diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 0e7ac01f0..98d08e4c7 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -165,12 +165,17 @@ export function EventDetailPopover({ return action.kind === "url" ? action.uri : mapsUrl(action.query); }, [locationName, meeting]); const [locationCopied, setLocationCopied] = useState(false); + const copiedTimer = useRef | null>(null); + useEffect(() => () => { + if (copiedTimer.current) clearTimeout(copiedTimer.current); + }, []); const copyLocation = useCallback(async () => { if (!locationName) return; try { await navigator.clipboard.writeText(locationName); setLocationCopied(true); - setTimeout(() => setLocationCopied(false), 1500); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setLocationCopied(false), 1500); } catch { // clipboard refused (insecure context or permission) - nothing to do } diff --git a/lib/__tests__/event-links.test.ts b/lib/__tests__/event-links.test.ts index 90df14caf..fba4eb554 100644 --- a/lib/__tests__/event-links.test.ts +++ b/lib/__tests__/event-links.test.ts @@ -63,6 +63,17 @@ describe('findMeetingLink', () => { 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' } }, @@ -98,9 +109,13 @@ describe('locationAction', () => { 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); + 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); + } }); }); diff --git a/lib/event-links.ts b/lib/event-links.ts index c539414a9..8c350c37d 100644 --- a/lib/event-links.ts +++ b/lib/event-links.ts @@ -29,7 +29,7 @@ export interface MeetingLink { // "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: '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: /^\/./ }, @@ -38,6 +38,8 @@ const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [ { 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 => @@ -101,7 +103,16 @@ export function findMeetingLink(event: EventPlaces): MeetingLink | null { const found = firstMeetingUrl(loc?.name) ?? firstMeetingUrl(loc?.description); if (found) return found; } - return firstMeetingUrl(event.description); + 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. */ @@ -112,11 +123,23 @@ export function primaryLocationName(event: Pick): stri // "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; +// 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 { - return MEETING_LABEL_RE.test(location) && !/\d/.test(location); + 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 =