Merge pull request #59 from lucletoffe/fix/calendar-location-links

fix(calendar): join the meeting and open the location from the event sheet
This commit is contained in:
Linus Rath
2026-10-06 23:30:07 +02:00
4 changed files with 357 additions and 4 deletions
+3
View File
@@ -13,6 +13,9 @@
"indigo": "Indigo",
"teal": "Teal"
},
"detail": {
"location_hint": "Opens the location; long-press to copy it"
},
"drawer": {
"my_calendars": "My calendars",
"no_calendars": "No calendars yet.",
+47 -4
View File
@@ -4,14 +4,18 @@ import {
Dimensions,
Easing,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
ToastAndroid,
View,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { openExternalUrl } from '../../lib/open-url';
import { splitTextLinks } from '../../lib/linkify-text';
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from '../../lib/event-links';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
AlignLeft,
@@ -184,8 +188,11 @@ export function EventDetailSheet({
const recurrence = recurrenceLabel(event, t, locale);
const reminders = alertsToReminders(event.alerts);
const participants = getParticipantList(event, { resolveName: resolveContactName });
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
const location = primaryLocationName(event);
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; surface it as the meeting link anyway.
const meeting = findMeetingLink(event);
const videoUri = meeting?.uri;
const isCancelled = event.status === 'cancelled';
// Can the signed-in user RSVP? Only when they appear as a non-organizer
@@ -207,6 +214,25 @@ export function EventDetailSheet({
);
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
const openLocation = () => {
if (!location) return;
const action = locationAction(location, meeting);
if (action.kind === 'url') {
void openExternalUrl(action.uri, { confirm: true });
} else {
void openExternalUrl(mapsUrl(action.query, Platform.OS));
}
};
const copyLocation = () => {
if (!location) return;
void Clipboard.setStringAsync(location).then(() => {
if (Platform.OS === 'android') {
ToastAndroid.show(t('notifications.copied_to_clipboard', 'Copied to clipboard'), ToastAndroid.SHORT);
}
}).catch(() => {});
};
const doRsvp = async (status: RsvpStatus) => {
if (!onRsvp || !myParticipantId || rsvpBusy) return;
setRsvpBusy(true);
@@ -263,7 +289,21 @@ export function EventDetailSheet({
>
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
{location ? (
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
<View style={styles.detailRow}>
<View style={styles.detailIcon}>
<MapPin size={16} color={c.textMuted} />
</View>
<Text
style={[styles.detailText, styles.detailLink]}
numberOfLines={3}
onPress={openLocation}
onLongPress={copyLocation}
accessibilityRole="link"
accessibilityHint={t('calendar.detail.location_hint', 'Opens the location; long-press to copy it')}
>
{location}
</Text>
</View>
) : null}
{videoUri ? (
<View style={styles.detailRow}>
@@ -274,7 +314,10 @@ export function EventDetailSheet({
style={styles.joinBtn}
onPress={() => { void openExternalUrl(videoUri, { confirm: true }); }}
>
<Text style={styles.joinBtnText}>{t('calendar.detail.open_link', 'Open link')}</Text>
<Text style={styles.joinBtnText}>
{t('calendar.detail.open_link', 'Open link')}
{meeting?.provider ? ` · ${meeting.provider}` : ''}
</Text>
</Pressable>
</View>
) : null}
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect } from 'vitest';
import {
findMeetingLink,
isMeetingLabel,
locationAction,
mapsUrl,
meetingProviderOf,
primaryLocationName,
unwrapSafeLink,
} from '../event-links';
/**
* A Teams invitation fills LOCATION with "Microsoft Teams Meeting" and leaves
* the join URL in the description, next to help and dial-in links. The sheet
* must still offer a single "Join" button, and a real address must open the
* maps app.
*/
const TEAMS_JOIN = 'https://teams.microsoft.com/l/meetup-join/19%3ameeting_NjQ5%40thread.v2/0?context=%7b%22Tid%22%3a%22abc%22%7d';
const TEAMS_DESCRIPTION = [
'________________________________________________________________________________',
'Réunion Microsoft Teams',
'Rejoindre sur votre ordinateur, application mobile ou appareil de salle',
`Cliquez ici pour participer à la réunion<${TEAMS_JOIN}>`,
'ID de la réunion : 319 560 123 45',
'Code secret : CTYa9',
'Télécharger Teams<https://www.microsoft.com/fr-fr/microsoft-teams/download-app> | Rejoindre sur le web<https://www.microsoft.com/microsoft-teams/join-a-meeting>',
'En savoir plus<https://aka.ms/JoinTeamsMeeting> | Options de réunion<https://teams.microsoft.com/meetingOptions/?organizerId=1>',
].join('\n');
describe('findMeetingLink', () => {
it('prefers the structured virtual location', () => {
expect(findMeetingLink({
virtualLocations: { v: { uri: 'https://zoom.us/j/123' } },
description: TEAMS_DESCRIPTION,
})).toEqual({ uri: 'https://zoom.us/j/123', provider: 'Zoom', derived: false });
});
it('digs the Teams join URL out of the description, skipping help links', () => {
expect(findMeetingLink({
locations: { l: { name: 'Réunion Microsoft Teams' } },
description: TEAMS_DESCRIPTION,
})).toEqual({ uri: TEAMS_JOIN, provider: 'Teams', derived: true });
});
it('recognises the short Teams, Meet and Zoom forms', () => {
expect(findMeetingLink({ description: 'Join: https://teams.microsoft.com/meet/31956012345?p=CTYa9' })?.provider).toBe('Teams');
expect(findMeetingLink({ description: 'Meet: https://meet.google.com/abc-defg-hij.' })?.uri).toBe('https://meet.google.com/abc-defg-hij');
expect(findMeetingLink({ description: 'https://us02web.zoom.us/j/8812345?pwd=xyz' })?.provider).toBe('Zoom');
});
it('takes a meeting URL used as the location', () => {
expect(findMeetingLink({ locations: { l: { name: 'https://meet.jit.si/standup' } } })?.uri).toBe('https://meet.jit.si/standup');
});
it('unwraps Outlook Safe Links', () => {
const wrapped = `https://eur01.safelinks.protection.outlook.com/?url=${encodeURIComponent(TEAMS_JOIN)}&data=05`;
expect(findMeetingLink({ description: `Join<${wrapped}>` })?.uri).toBe(TEAMS_JOIN);
});
it('ignores ordinary links', () => {
expect(findMeetingLink({ description: 'Agenda: https://docs.example.com/agenda https://aka.ms/JoinTeamsMeeting' })).toBeNull();
expect(findMeetingLink({})).toBeNull();
});
it('falls back to an app-scheme virtual location, never a script one', () => {
expect(findMeetingLink({ virtualLocations: { v: { uri: 'msteams:/l/meetup-join/19%3a1' } } }))
.toEqual({ uri: 'msteams:/l/meetup-join/19%3a1', derived: false });
expect(findMeetingLink({ virtualLocations: { v: { uri: 'javascript:alert(1)' } } })).toBeNull();
expect(findMeetingLink({ virtualLocations: { v: { uri: 'data:text/html,x' } } })).toBeNull();
});
it('recognises the Zoom web-client join path', () => {
expect(findMeetingLink({ description: 'https://acme.zoom.us/wc/join/8812345?pwd=x' })?.provider).toBe('Zoom');
});
it('skips a virtual location that is not a web URL', () => {
expect(findMeetingLink({
virtualLocations: { v: { uri: 'tel:+33123456789' } },
description: 'https://meet.google.com/abc-defg-hij',
})?.provider).toBe('Google Meet');
});
});
describe('meetingProviderOf / unwrapSafeLink', () => {
it('rejects look-alike hosts', () => {
expect(meetingProviderOf('https://teams.microsoft.com.evil.example/l/meetup-join/x')).toBeNull();
expect(meetingProviderOf('https://notzoom.us/j/1')).toBeNull();
});
it('leaves non-wrapped URLs alone', () => {
expect(unwrapSafeLink('https://example.com/?url=https://x.y')).toBe('https://example.com/?url=https://x.y');
});
});
describe('locationAction', () => {
const meeting = { uri: TEAMS_JOIN, provider: 'Teams', derived: true };
it('searches the maps for a postal address', () => {
expect(locationAction('12 rue de la Paix, 75002 Paris', meeting)).toEqual({ kind: 'maps', query: '12 rue de la Paix, 75002 Paris' });
});
it('joins the meeting when the location only names the service', () => {
expect(locationAction('Réunion Microsoft Teams', meeting)).toEqual({ kind: 'url', uri: TEAMS_JOIN });
expect(locationAction('Microsoft Teams Meeting', null)).toEqual({ kind: 'maps', query: 'Microsoft Teams Meeting' });
});
it('opens a URL location', () => {
expect(locationAction(' https://example.com/room ', null)).toEqual({ kind: 'url', uri: 'https://example.com/room' });
});
it('only treats a bare service name as a meeting', () => {
for (const label of ['Zoom', 'Réunion Microsoft Teams', 'Microsoft Teams Meeting', 'Google Meet', 'En ligne', 'Visioconférence']) {
expect(isMeetingLabel(label)).toBe(true);
}
for (const label of ['Salle Teams 3', 'Salle Teams', 'Studio Zoom', 'Salle Visio', 'Café en ligne droite, Paris']) {
expect(isMeetingLabel(label)).toBe(false);
}
});
});
describe('mapsUrl', () => {
it('uses the geo: intent on Android', () => {
expect(mapsUrl('12 rue de la Paix,\n75002 Paris', 'android')).toBe('geo:0,0?q=12%20rue%20de%20la%20Paix%2C%2075002%20Paris');
});
it('uses the Google Maps universal link elsewhere', () => {
expect(mapsUrl('Gare de Lyon', 'ios')).toBe('https://www.google.com/maps/search/?api=1&query=Gare%20de%20Lyon');
});
});
describe('primaryLocationName', () => {
it('trims and drops blank names', () => {
expect(primaryLocationName({ locations: { a: { name: ' ' } } })).toBeUndefined();
expect(primaryLocationName({ locations: { a: { name: ' Lyon ' } } })).toBe('Lyon');
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* What an event's place fields should do when tapped.
*
* Meeting invitations rarely fill `virtualLocations`: a Teams invite arrives
* with LOCATION set to "Microsoft Teams Meeting" and the join URL buried in
* the description, among dial-in numbers and help links. `findMeetingLink`
* digs it out so the sheet can offer one "Join" button without touching the
* event on the server (it is someone else's invitation).
*
* A physical address is handed to the platform's maps app instead.
*/
/** The place fields read here, loose enough for any event shape. */
export interface EventPlaces {
locations?: Record<string, { name?: string | null; description?: string | null } | null> | null;
virtualLocations?: Record<string, { uri?: string | null } | null> | null;
description?: string | null;
}
export interface MeetingLink {
uri: string;
/** Display name of the conferencing service, when recognised. */
provider?: string;
/** True when the link was found in the description, not a structured field. */
derived: boolean;
}
// Hosts whose URLs are joinable meetings. The path test keeps help pages,
// "learn more" and dial-in links on the same host out of the result.
const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [
{ name: 'Teams', host: /^teams\.(microsoft|live)\.com$/, path: /^\/(l\/meetup-join|meet)\//i },
{ name: 'Zoom', host: /(^|\.)zoom\.us$/, path: /^\/(j|w|wc|my|s)\//i },
{ name: 'Google Meet', host: /^meet\.google\.com$/, path: /^\/[a-z]{3}-[a-z]{4}-[a-z]{3}/i },
{ name: 'Webex', host: /(^|\.)webex\.com$/, path: /^\/(meet|join|[^/]+\/j\.php)/i },
{ name: 'Jitsi', host: /^meet\.jit\.si$/, path: /^\/./ },
{ name: 'Whereby', host: /^whereby\.com$/, path: /^\/./ },
{ name: 'GoTo', host: /^(meet\.goto\.com|global\.gotomeeting\.com|app\.gotomeeting\.com)$/, path: /^\/./ },
{ name: 'Visio', host: /^(visio|webconf)\.numerique\.gouv\.fr$/, path: /^\/./ },
];
const UNSAFE_SCHEMES = new Set(['javascript', 'data', 'vbscript', 'file', 'blob', 'about']);
const URL_RE = /https?:\/\/[^\s<>"']+/g;
const isHttpUrl = (value: string | undefined | null): value is string =>
!!value && /^https?:\/\/\S+$/i.test(value.trim());
/**
* Outlook's Safe Links and similar gateways wrap the real URL in a query
* parameter; unwrap one level so the provider can be recognised.
*/
export function unwrapSafeLink(uri: string): string {
try {
const u = new URL(uri);
if (/(^|\.)safelinks\.protection\.outlook\.com$/i.test(u.hostname)) {
const inner = u.searchParams.get('url');
if (inner && isHttpUrl(inner)) return inner;
}
} catch {
// not a parseable URL - keep as is
}
return uri;
}
/** The conferencing service a URL joins, or null for any other link. */
export function meetingProviderOf(uri: string): string | null {
let u: URL;
try {
u = new URL(unwrapSafeLink(uri));
} catch {
return null;
}
const host = u.hostname.toLowerCase();
const provider = PROVIDERS.find((p) => p.host.test(host) && (!p.path || p.path.test(u.pathname)));
return provider ? provider.name : null;
}
function firstMeetingUrl(text: string | undefined | null): MeetingLink | null {
if (!text) return null;
for (const raw of text.match(URL_RE) ?? []) {
// Trailing punctuation belongs to the sentence, not the URL.
const candidate = unwrapSafeLink(raw.replace(/[).,;:!?\]]+$/, ''));
const provider = meetingProviderOf(candidate);
if (provider) return { uri: candidate, provider, derived: true };
}
return null;
}
/**
* The link that joins the meeting: the event's own virtual location first,
* then a URL used as the location, then a known conferencing URL found in the
* location or the description.
*/
export function findMeetingLink(event: EventPlaces): MeetingLink | null {
for (const vl of Object.values(event.virtualLocations ?? {})) {
const uri = vl?.uri;
if (isHttpUrl(uri)) {
return { uri: uri.trim(), provider: meetingProviderOf(uri) ?? undefined, derived: false };
}
}
const locations = Object.values(event.locations ?? {});
for (const loc of locations) {
const found = firstMeetingUrl(loc?.name) ?? firstMeetingUrl(loc?.description);
if (found) return found;
}
const inDescription = firstMeetingUrl(event.description);
if (inDescription) return inDescription;
// Last resort: a virtual location in an app scheme (msteams:, zoommtg:,
// sip:…) still beats no link at all. Never a scheme that runs code.
for (const vl of Object.values(event.virtualLocations ?? {})) {
const uri = vl?.uri?.trim();
const scheme = uri ? /^([a-z][a-z0-9+.-]*):/i.exec(uri)?.[1].toLowerCase() : undefined;
if (uri && scheme && !UNSAFE_SCHEMES.has(scheme)) return { uri, derived: false };
}
return null;
}
/** The first location's display name, if any. */
export function primaryLocationName(event: Pick<EventPlaces, 'locations'>): string | undefined {
const name = Object.values(event.locations ?? {})[0]?.name?.trim();
return name || undefined;
}
// "Microsoft Teams Meeting", "Réunion Microsoft Teams", "Zoom", "Google Meet"…
// A location like this names the service, not a place: tapping it should join
// the meeting, never search the maps for it. The whole label must be a service
// name, so "Salle Teams" or "Studio Zoom" stay places.
const MEETING_LABELS = new Set([
'microsoft teams', 'teams', 'zoom', 'google meet', 'meet', 'webex', 'jitsi', 'whereby',
'gotomeeting', 'goto', 'visio', 'visioconference', 'online', 'en ligne',
]);
export function isMeetingLabel(location: string): boolean {
const label = location
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z ]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^(reunion|meeting|online meeting|appel|call) /, '')
.replace(/ (meeting|reunion|call)$/, '');
return MEETING_LABELS.has(label);
}
export type LocationAction =
| { kind: 'url'; uri: string }
| { kind: 'maps'; query: string };
/**
* What tapping the location does: open it when it is a URL, join the meeting
* when it only names the service, search the maps otherwise.
*/
export function locationAction(location: string, meeting: MeetingLink | null): LocationAction {
const value = location.trim();
if (isHttpUrl(value)) return { kind: 'url', uri: value };
if (meeting && isMeetingLabel(value)) return { kind: 'url', uri: meeting.uri };
return { kind: 'maps', query: value };
}
/**
* A maps URL for a free-text address. Android's `geo:` intent opens Google
* Maps (or the user's chosen maps app) straight on the search; elsewhere the
* Google Maps universal link opens the app when installed, the site otherwise.
*/
export function mapsUrl(query: string, platform: string): string {
const q = encodeURIComponent(query.replace(/\s+/g, ' ').trim());
if (platform === 'android') return `geo:0,0?q=${q}`;
return `https://www.google.com/maps/search/?api=1&query=${q}`;
}