mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
co-authored by
Claude Opus 5.5
parent
12410124ad
commit
8eaaa4edd8
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user