fix(calendar): join the meeting and open the location from the event sheet

Invitations - Teams above all - leave virtualLocations empty: LOCATION reads
"Microsoft Teams Meeting" and the join URL sits in the description between
help and dial-in links, so the sheet showed no meeting button. Find the
join URL of known conferencing services (Teams, Zoom, Meet, Webex, Jitsi,
Whereby, GoTo) in the location or description, unwrapping Outlook Safe
Links, and offer it as the meeting link. Nothing is written to the event.

The location was plain text. Tapping it now opens it: a URL opens as a
link, a location that only names the service joins the meeting, anything
else is searched in the maps app (geo: intent on Android, Google Maps
universal link elsewhere). Long-press copies it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lucletoffe
2026-09-24 09:47:15 +02:00
co-authored by Claude Opus 5.5
parent 5b8c4353d8
commit 7c4b584d9c
4 changed files with 313 additions and 4 deletions
+3
View File
@@ -10,6 +10,9 @@
"days_clear": "The next {count} days are clear.",
"no_upcoming": "No upcoming events"
},
"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,
@@ -194,8 +198,11 @@ export function EventDetailSheet({
const recurrence = recurrenceLabel(event, t, locale);
const reminders = alertsToReminders(event.alerts);
const participants = getParticipantList(event);
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
@@ -217,6 +224,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);
@@ -267,7 +293,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}>
@@ -278,7 +318,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}
+123
View File
@@ -0,0 +1,123 @@
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('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('keeps a numbered room as a place', () => {
expect(isMeetingLabel('Salle Teams 3')).toBe(false);
expect(isMeetingLabel('Zoom')).toBe(true);
});
});
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');
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* 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.
*/
import type { CalendarEvent } from '../api/types';
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|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 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: Pick<CalendarEvent, 'virtualLocations' | 'locations' | 'description'>): MeetingLink | null {
for (const vl of Object.values(event.virtualLocations ?? {})) {
if (isHttpUrl(vl?.uri)) {
return { uri: vl.uri.trim(), provider: meetingProviderOf(vl.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;
}
return firstMeetingUrl(event.description);
}
/** The first location's display name, if any. */
export function primaryLocationName(event: Pick<CalendarEvent, '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.
const MEETING_LABEL_RE = /\b(microsoft teams|teams|zoom|google meet|webex|jitsi|whereby|gotomeeting|visio(conf[ée]rence)?|online|en ligne)\b/i;
export function isMeetingLabel(location: string): boolean {
return MEETING_LABEL_RE.test(location) && !/\d/.test(location);
}
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}`;
}