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}`; +}