mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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>
168 lines
6.6 KiB
TypeScript
168 lines
6.6 KiB
TypeScript
/**
|
|
* 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<string, { name?: string | null; description?: string | null } | null> | null;
|
|
virtualLocations?: Record<string, { uri?: string | null } | null> | 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|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: /^\/./ },
|
|
{ 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 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 =>
|
|
!!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;
|
|
}
|
|
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. */
|
|
export function primaryLocationName(event: Pick<EventPlaces, 'locations'>): 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. 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 {
|
|
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 =
|
|
| { 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}`;
|
|
}
|