From 12410124ad091750ce47aae7a6d1deeab9689a49 Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:52:56 +0200 Subject: [PATCH 1/2] fix(calendar): surface the meeting link and open the location in maps Invitations - Teams above all - leave virtualLocations empty: LOCATION reads "Microsoft Teams Meeting" and the join URL sits in the description between help and dial-in links, so the popover showed no meeting link and "Copy meeting link" was not offered. Find the join URL of known conferencing services (Teams, Zoom, Meet, Webex, Jitsi, Whereby, GoTo) in the location or description, unwrapping Outlook Safe Links. Nothing is written to the event. The location was plain text unless it was a URL. It is now always a link: a URL opens as is, a location that only names the service joins the meeting, anything else opens a Google Maps search (the Maps app on a phone). A copy button sits next to it. Co-Authored-By: Claude Opus 5.5 --- components/calendar/calendar-app.tsx | 5 +- components/calendar/event-context-menu.tsx | 5 +- components/calendar/event-detail-popover.tsx | 69 ++++++--- lib/__tests__/event-links.test.ts | 118 +++++++++++++++ lib/event-links.ts | 144 +++++++++++++++++++ locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 32 files changed, 339 insertions(+), 29 deletions(-) create mode 100644 lib/__tests__/event-links.test.ts create mode 100644 lib/event-links.ts diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index ab2085588..c7a0ff914 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -89,6 +89,7 @@ import { type ScrollWindowState, type ScrollWindowViewProps, } from "@/lib/calendar-scroll-window"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; +import { findMeetingLink } from "@/lib/event-links"; type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean } @@ -1242,9 +1243,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = }, [t]); const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => { - const uri = event.virtualLocations - ? Object.values(event.virtualLocations).find((v) => v.uri)?.uri - : undefined; + const uri = findMeetingLink(event)?.uri; if (!uri) return; try { await navigator.clipboard.writeText(uri); diff --git a/components/calendar/event-context-menu.tsx b/components/calendar/event-context-menu.tsx index 9bc109a3c..e85873edb 100644 --- a/components/calendar/event-context-menu.tsx +++ b/components/calendar/event-context-menu.tsx @@ -18,6 +18,7 @@ import { import type { CalendarEvent } from "@/lib/jmap/types"; import { buildCalendarPath } from "@/lib/deep-links"; import { useCopyLink } from "@/hooks/use-copy-link"; +import { findMeetingLink } from "@/lib/event-links"; interface Position { x: number; @@ -60,9 +61,7 @@ export function EventContextMenu({ onClose(); }; - const hasMeetingLink = !!( - event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri) - ); + const hasMeetingLink = !!findMeetingLink(event); return ( diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index fc458f9c7..0e7ac01f0 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -23,6 +23,7 @@ import { import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; +import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; interface EventDetailPopoverProps { event: CalendarEvent; @@ -149,16 +150,31 @@ export function EventDetailPopover({ const displayEndDate = getEventDisplayEndDate(event); const isMultiDay = !isSameDay(startDate, displayEndDate); - const locationName = useMemo(() => { - if (!event.locations) return null; - return Object.values(event.locations)[0]?.name || null; - }, [event.locations]); + const locationName = useMemo(() => primaryLocationName(event) ?? null, [event]); - const virtualLocation = useMemo(() => { - if (!event.virtualLocations) return null; - const first = Object.values(event.virtualLocations)[0]; - return first?.uri || null; - }, [event.virtualLocations]); + // Invitations (Teams above all) leave virtualLocations empty and bury the + // join URL in the description; surface it as the meeting link anyway. + const meeting = useMemo(() => findMeetingLink(event), [event]); + const virtualLocation = meeting?.uri ?? null; + + // A URL opens as a link, a location that only names the service joins the + // meeting, a postal address opens in the maps. + const locationHref = useMemo(() => { + if (!locationName) return null; + const action = locationAction(locationName, meeting); + return action.kind === "url" ? action.uri : mapsUrl(action.query); + }, [locationName, meeting]); + const [locationCopied, setLocationCopied] = useState(false); + const copyLocation = useCallback(async () => { + if (!locationName) return; + try { + await navigator.clipboard.writeText(locationName); + setLocationCopied(true); + setTimeout(() => setLocationCopied(false), 1500); + } catch { + // clipboard refused (insecure context or permission) - nothing to do + } + }, [locationName]); const participants = useMemo( () => getParticipantList(event, { resolveName: resolveContactName }), @@ -407,21 +423,28 @@ export function EventDetailPopover({ {locationName && (
- {/^https?:\/\//i.test(locationName) ? ( - - {(() => { + + {/^https?:\/\//i.test(locationName) + ? (() => { try { return new URL(locationName).hostname; } catch { return locationName; } - })()} - - ) : ( - {locationName} - )} + })() + : locationName} + +
)} diff --git a/lib/__tests__/event-links.test.ts b/lib/__tests__/event-links.test.ts new file mode 100644 index 000000000..90df14caf --- /dev/null +++ b/lib/__tests__/event-links.test.ts @@ -0,0 +1,118 @@ +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 popover + * must still offer the join link, and a real address must open the maps. + */ + +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('builds a Google Maps search, collapsing line breaks', () => { + expect(mapsUrl('12 rue de la Paix,\n75002 Paris')).toBe('https://www.google.com/maps/search/?api=1&query=12%20rue%20de%20la%20Paix%2C%2075002%20Paris'); + }); +}); + +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/lib/event-links.ts b/lib/event-links.ts new file mode 100644 index 000000000..c539414a9 --- /dev/null +++ b/lib/event-links.ts @@ -0,0 +1,144 @@ +/** + * 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 popover can offer the meeting link without touching the + * event on the server (it is someone else's invitation). + * + * A physical address opens in the maps instead. + */ + +/** 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; + /** 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: EventPlaces): MeetingLink | null { + for (const vl of Object.values(event.virtualLocations ?? {})) { + const uri = vl?.uri; + if (isHttpUrl(uri)) { + return { uri: uri.trim(), provider: meetingProviderOf(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. The Google Maps universal link opens + * the Maps app on a phone when it is installed, the site otherwise. + */ +export function mapsUrl(query: string): string { + const q = encodeURIComponent(query.replace(/\s+/g, ' ').trim()); + return `https://www.google.com/maps/search/?api=1&query=${q}`; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index beb8ce1d2..25f30a459 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2825,6 +2825,7 @@ "save_note": "حفظ", "note_saved": "تمت إضافة الملاحظة", "open_link": "فتح الرابط", + "copy_location": "نسخ الموقع", "meeting_link": "رابط الاجتماع", "tentative": "مبدئي", "cancelled": "ملغى", diff --git a/locales/ca/common.json b/locales/ca/common.json index 473d564ce..0ca4bdbd2 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2825,6 +2825,7 @@ "save_note": "Desa", "note_saved": "Nota afegida", "open_link": "Obre l'enllaç", + "copy_location": "Copia la ubicació", "meeting_link": "Enllaç de la reunió", "tentative": "Provisional", "cancelled": "Cancel·lat", diff --git a/locales/cs/common.json b/locales/cs/common.json index d56568d41..b6c3ec0a4 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2825,6 +2825,7 @@ "save_note": "Uložit", "note_saved": "Poznámka přidána", "open_link": "Otevřít odkaz", + "copy_location": "Kopírovat místo", "meeting_link": "Odkaz na schůzku", "tentative": "Nezávazně", "cancelled": "Zrušeno", diff --git a/locales/da/common.json b/locales/da/common.json index eb1c70afd..5e58b49a4 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2825,6 +2825,7 @@ "save_note": "Gem", "note_saved": "Note tilføjet", "open_link": "Åbn link", + "copy_location": "Kopiér sted", "meeting_link": "Mødelink", "tentative": "Foreløbig", "cancelled": "Aflyst", diff --git a/locales/de/common.json b/locales/de/common.json index f60f704d9..d8aac43a5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2825,6 +2825,7 @@ "save_note": "Speichern", "note_saved": "Notiz hinzugefügt", "open_link": "Link öffnen", + "copy_location": "Ort kopieren", "meeting_link": "Besprechungslink", "tentative": "Vorläufig", "cancelled": "Abgesagt", diff --git a/locales/en/common.json b/locales/en/common.json index fd6b0748f..e18ecb915 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2825,6 +2825,7 @@ "save_note": "Save", "note_saved": "Note added", "open_link": "Open link", + "copy_location": "Copy location", "meeting_link": "Meeting link", "tentative": "Tentative", "cancelled": "Cancelled", diff --git a/locales/es/common.json b/locales/es/common.json index 68ab42dc8..861a14e9f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2825,6 +2825,7 @@ "save_note": "Guardar", "note_saved": "Nota añadida", "open_link": "Abrir enlace", + "copy_location": "Copiar ubicación", "meeting_link": "Enlace de reunión", "tentative": "Provisional", "cancelled": "Cancelado", diff --git a/locales/fa/common.json b/locales/fa/common.json index 234cf81dd..42b28797e 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2825,6 +2825,7 @@ "save_note": "ذخیره", "note_saved": "یادداشت اضافه شد", "open_link": "باز کردن لینک", + "copy_location": "کپی مکان", "meeting_link": "لینک جلسه", "tentative": "احتمالی", "cancelled": "لغو شده", diff --git a/locales/fr/common.json b/locales/fr/common.json index 95310d185..dc2adcb69 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2825,6 +2825,7 @@ "save_note": "Enregistrer", "note_saved": "Note ajoutée", "open_link": "Ouvrir le lien", + "copy_location": "Copier le lieu", "meeting_link": "Lien de réunion", "tentative": "Provisoire", "cancelled": "Annulé", diff --git a/locales/he/common.json b/locales/he/common.json index 0897f879b..21f2a5190 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2749,6 +2749,7 @@ "save_note": "שמור", "note_saved": "הערה נוספה", "open_link": "פתח קישור", + "copy_location": "העתק מיקום", "meeting_link": "קישור לפגישה", "tentative": "טנטטיבי", "cancelled": "בוטל", diff --git a/locales/hu/common.json b/locales/hu/common.json index 7e71f32d5..a264bfdde 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2825,6 +2825,7 @@ "save_note": "Mentés", "note_saved": "Megjegyzés hozzáadva", "open_link": "Link megnyitása", + "copy_location": "Helyszín másolása", "meeting_link": "Találkozó link", "tentative": "Előzetes", "cancelled": "Lemondva", diff --git a/locales/it/common.json b/locales/it/common.json index 1e337883c..76155fff0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2825,6 +2825,7 @@ "save_note": "Salva", "note_saved": "Nota aggiunta", "open_link": "Apri link", + "copy_location": "Copia luogo", "meeting_link": "Link riunione", "tentative": "Provvisorio", "cancelled": "Annullato", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3675f2deb..3993c193a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2825,6 +2825,7 @@ "save_note": "保存", "note_saved": "メモを追加しました", "open_link": "リンクを開く", + "copy_location": "場所をコピー", "meeting_link": "会議リンク", "tentative": "仮", "cancelled": "キャンセル済み", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6e1295922..277d4a6b0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2825,6 +2825,7 @@ "save_note": "저장", "note_saved": "메모가 추가되었어요", "open_link": "링크 열기", + "copy_location": "위치 복사", "meeting_link": "화상 회의 링크", "tentative": "미정", "cancelled": "취소됨", diff --git a/locales/lv/common.json b/locales/lv/common.json index 9166d4894..68a09fcdc 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2824,6 +2824,7 @@ "save_note": "Saglabāt", "note_saved": "Piezīme pievienota", "open_link": "Atvērt saiti", + "copy_location": "Kopēt vietu", "meeting_link": "Sapulces saite", "tentative": "Pagaidām", "cancelled": "Atcelts", diff --git a/locales/mn/common.json b/locales/mn/common.json index 7bd45981f..9dbcc99d5 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2825,6 +2825,7 @@ "save_note": "Хадгалах", "note_saved": "Тэмдэглэл нэмсэн", "open_link": "Нээлттэй холбоос", + "copy_location": "Байршлыг хуулах", "meeting_link": "Уулзалтын холбоос", "tentative": "Урьдчилсан", "cancelled": "Цуцлагдсан", diff --git a/locales/nb/common.json b/locales/nb/common.json index cb379e3e7..92f8334da 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2825,6 +2825,7 @@ "save_note": "Lagre", "note_saved": "Notatet er lagret", "open_link": "Åpne lenken", + "copy_location": "Kopier sted", "meeting_link": "Møtelenke", "tentative": "Foreløpig", "cancelled": "Avlyst", diff --git a/locales/nl/common.json b/locales/nl/common.json index 710d595f9..cc47b3835 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2825,6 +2825,7 @@ "save_note": "Opslaan", "note_saved": "Notitie toegevoegd", "open_link": "Link openen", + "copy_location": "Locatie kopiëren", "meeting_link": "Vergaderlink", "tentative": "Voorlopig", "cancelled": "Geannuleerd", diff --git a/locales/pl/common.json b/locales/pl/common.json index 824f2508a..60bb7adb1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2825,6 +2825,7 @@ "save_note": "Zapisz", "note_saved": "Dodano notatkę", "open_link": "Otwórz link", + "copy_location": "Kopiuj lokalizację", "meeting_link": "Link do spotkania", "tentative": "Wstępnie", "cancelled": "Odwołane", diff --git a/locales/pt/common.json b/locales/pt/common.json index 7643b23df..b3dc6ba6e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2825,6 +2825,7 @@ "save_note": "Salvar", "note_saved": "Nota adicionada", "open_link": "Abrir link", + "copy_location": "Copiar local", "meeting_link": "Link da reunião", "tentative": "Provisório", "cancelled": "Cancelado", diff --git a/locales/ro/common.json b/locales/ro/common.json index edcc46676..065abeb8a 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2825,6 +2825,7 @@ "save_note": "Salvare", "note_saved": "Notă adăugată", "open_link": "Deschideți linkul", + "copy_location": "Copiază locația", "meeting_link": "Link către întâlnire", "tentative": "Provizoriu", "cancelled": "Anulat", diff --git a/locales/ru/common.json b/locales/ru/common.json index 8cb74803a..dd5ce2a38 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2825,6 +2825,7 @@ "save_note": "Сохранить", "note_saved": "Заметка добавлена", "open_link": "Открыть ссылку", + "copy_location": "Копировать место", "meeting_link": "Ссылка на встречу", "tentative": "Предварительно", "cancelled": "Отменено", diff --git a/locales/sk/common.json b/locales/sk/common.json index 31ca9f633..481092b04 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2825,6 +2825,7 @@ "save_note": "Uložiť", "note_saved": "Poznámka pridaná", "open_link": "Otvoriť odkaz", + "copy_location": "Kopírovať miesto", "meeting_link": "Odkaz na stretnutie", "tentative": "Nezáväzne", "cancelled": "Zrušené", diff --git a/locales/tr/common.json b/locales/tr/common.json index d69b6f487..a96ad17a0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2825,6 +2825,7 @@ "save_note": "Kaydet", "note_saved": "Not eklendi", "open_link": "Bağlantıyı aç", + "copy_location": "Konumu kopyala", "meeting_link": "Toplantı bağlantısı", "tentative": "Geçici", "cancelled": "İptal edildi", diff --git a/locales/uk/common.json b/locales/uk/common.json index 335d9b084..e97353361 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2825,6 +2825,7 @@ "save_note": "зберегти", "note_saved": "Примітка додана", "open_link": "Відкрити посилання", + "copy_location": "Копіювати місце", "meeting_link": "Посилання на зустріч", "tentative": "Орієнтовний", "cancelled": "Скасовано", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 4b4d98001..10d25a9aa 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2825,6 +2825,7 @@ "save_note": "儲存", "note_saved": "已新增備註", "open_link": "開啟連結", + "copy_location": "複製地點", "meeting_link": "會議連結", "tentative": "暫定", "cancelled": "已取消", diff --git a/locales/zh/common.json b/locales/zh/common.json index bd387bb01..668711288 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2825,6 +2825,7 @@ "save_note": "保存", "note_saved": "注释已添加", "open_link": "打开链接", + "copy_location": "复制地点", "meeting_link": "会议链接", "tentative": "暂定", "cancelled": "已取消", From 8eaaa4edd813480f294eedf7e20b4a229afb0673 Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:00:01 +0200 Subject: [PATCH 2/2] 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 --- components/calendar/event-detail-popover.tsx | 7 ++++- lib/__tests__/event-links.test.ts | 21 +++++++++++-- lib/event-links.ts | 33 +++++++++++++++++--- 3 files changed, 52 insertions(+), 9 deletions(-) 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 =