From bd1b2f3fd3d7e7933330faacc04f680bf3b61d19 Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:59:58 +0200 Subject: [PATCH] fix(calendar): keep app-scheme meeting links and match service labels exactly A virtual location in an app scheme (msteams:, zoommtg:) is kept as a last resort instead of hiding the meeting row; script schemes never are. A location only joins the meeting when the whole label is a service name, so "Salle Teams" still opens the maps. Recognise Zoom's /wc/ join path. Co-Authored-By: Claude Opus 5.5 --- src/lib/__tests__/event-links.test.ts | 21 ++++++++++-- src/lib/event-links.ts | 49 +++++++++++++++++++++------ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/lib/__tests__/event-links.test.ts b/src/lib/__tests__/event-links.test.ts index a987bc8f..72c78673 100644 --- a/src/lib/__tests__/event-links.test.ts +++ b/src/lib/__tests__/event-links.test.ts @@ -64,6 +64,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' } }, @@ -99,9 +110,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/src/lib/event-links.ts b/src/lib/event-links.ts index b7b94eff..1bc03ad6 100644 --- a/src/lib/event-links.ts +++ b/src/lib/event-links.ts @@ -10,7 +10,12 @@ * A physical address is handed to the platform's maps app instead. */ -import type { CalendarEvent } from '../api/types'; +/** The place fields read here, loose enough for any event shape. */ +export interface EventPlaces { + locations?: Record | null; + virtualLocations?: Record | null; + description?: string | null; +} export interface MeetingLink { uri: string; @@ -24,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: /^\/./ }, @@ -33,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 => @@ -84,10 +91,11 @@ function firstMeetingUrl(text: string | undefined | null): MeetingLink | null { * 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 { +export function findMeetingLink(event: EventPlaces): 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 uri = vl?.uri; + if (isHttpUrl(uri)) { + return { uri: uri.trim(), provider: meetingProviderOf(uri) ?? undefined, derived: false }; } } const locations = Object.values(event.locations ?? {}); @@ -95,22 +103,43 @@ export function findMeetingLink(event: Pick): string | undefined { +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; +// 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 =