From 7c4b584d9c87f8bb7e88522a74cd1ef465c4a93b Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:47:15 +0200 Subject: [PATCH] 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 --- locales/rn/en.json | 3 + src/components/calendar/EventDetailSheet.tsx | 51 ++++++- src/lib/__tests__/event-links.test.ts | 123 ++++++++++++++++ src/lib/event-links.ts | 140 +++++++++++++++++++ 4 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 src/lib/__tests__/event-links.test.ts create mode 100644 src/lib/event-links.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index a629c21a..0cddce4a 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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.", diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index f5a5a839..ba1faf0b 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -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({ > } text={range} /> {location ? ( - } text={location} /> + + + + + + {location} + + ) : null} {videoUri ? ( @@ -278,7 +318,10 @@ export function EventDetailSheet({ style={styles.joinBtn} onPress={() => { void openExternalUrl(videoUri, { confirm: true }); }} > - {t('calendar.detail.open_link', 'Open link')} + + {t('calendar.detail.open_link', 'Open link')} + {meeting?.provider ? ` · ${meeting.provider}` : ''} + ) : null} diff --git a/src/lib/__tests__/event-links.test.ts b/src/lib/__tests__/event-links.test.ts new file mode 100644 index 00000000..a987bc8f --- /dev/null +++ b/src/lib/__tests__/event-links.test.ts @@ -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 | Rejoindre sur le web', + 'En savoir plus | Options de réunion', +].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'); + }); +}); diff --git a/src/lib/event-links.ts b/src/lib/event-links.ts new file mode 100644 index 00000000..b7b94eff --- /dev/null +++ b/src/lib/event-links.ts @@ -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): 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): 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}`; +}