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 link; script schemes never reach the
href. 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, and clear the copy-feedback timer on unmount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lucletoffe
2026-09-24 10:00:01 +02:00
co-authored by Claude Opus 5.5
parent 12410124ad
commit 8eaaa4edd8
3 changed files with 52 additions and 9 deletions
+6 -1
View File
@@ -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<ReturnType<typeof setTimeout> | 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
}
+18 -3
View File
@@ -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);
}
});
});
+28 -5
View File
@@ -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<EventPlaces, 'locations'>): 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 =