From c2584c2bd531972d3a0ae243592d0bb30f35ee66 Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:49:40 +0200 Subject: [PATCH] fix(calendar): make URLs in the event description tappable A meeting invitation puts the join URL in the description - Teams, Meet, Zoom and the plain iTIP fallbacks all do - and the event detail sheet rendered it as bare . The one actionable thing in the event could not be tapped: you had to select the URL by hand and paste it into a browser. The mail body already linkifies (plainTextToSafeHtml, rendered in the WebView); only the calendar was left out. `splitTextLinks` (lib/linkify-text) splits the text into runs and marks the http(s) URLs, using the same rule as plainTextToSafeHtml so a message linkifies identically in the body and in the calendar. DetailRow gains an opt-in `linkify` prop - set on the description row only, not on rows the app composed itself - and hands each URL to `openExternalUrl` with `confirm: true`, the same guard the "Open link" button on the video row already uses: scheme allow-list plus the host confirmation, since this text arrives in someone else's data. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018QPPAw4SK4M9Z7euVPM2cL --- src/components/calendar/EventDetailSheet.tsx | 25 ++++++++- src/lib/__tests__/linkify-text.test.ts | 53 ++++++++++++++++++++ src/lib/linkify-text.ts | 42 ++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/lib/__tests__/linkify-text.test.ts create mode 100644 src/lib/linkify-text.ts diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index 959fc9d0..f5a5a839 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -11,6 +11,7 @@ import { View, } from 'react-native'; import { openExternalUrl } from '../../lib/open-url'; +import { splitTextLinks } from '../../lib/linkify-text'; import { SafeAreaView } from 'react-native-safe-area-context'; import { AlignLeft, @@ -286,6 +287,7 @@ export function EventDetailSheet({ icon={} text={event.description} multiline + linkify /> ) : null} {recurrence && ( @@ -424,18 +426,38 @@ function DetailRow({ icon, text, multiline = false, + linkify = false, }: { icon: React.ReactNode; text: string; multiline?: boolean; + /** + * Render the http(s) URLs inside `text` as tappable links. For rows whose + * text comes from the invitation itself - a meeting description carries the + * join URL - never for text the app composed. + */ + linkify?: boolean; }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const segments = React.useMemo(() => (linkify ? splitTextLinks(text) : null), [linkify, text]); return ( {icon} - {text} + {segments + ? segments.map((segment, i) => (segment.url ? ( + { void openExternalUrl(segment.url as string, { confirm: true }); }} + > + {segment.text} + + ) : ( + {segment.text} + ))) + : text} ); @@ -586,6 +608,7 @@ function makeStyles(c: ThemePalette) { detailRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'flex-start' }, detailIcon: { width: 16, paddingTop: 2 }, detailText: { flex: 1, ...typography.body, color: c.text }, + detailLink: { color: c.primary, textDecorationLine: 'underline' }, joinBtn: { flex: 1, diff --git a/src/lib/__tests__/linkify-text.test.ts b/src/lib/__tests__/linkify-text.test.ts new file mode 100644 index 00000000..f46123f8 --- /dev/null +++ b/src/lib/__tests__/linkify-text.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { splitTextLinks, hasTextLink } from '../linkify-text'; + +/** + * A meeting invitation carries the join URL in its description, so that text + * has to expose its URLs as something tappable. The split must agree with + * `plainTextToSafeHtml` (lib/email-html), or the same message linkifies + * differently in the mail body and in the calendar. + */ + +describe('splitTextLinks', () => { + it('marks the URL and keeps the text around it', () => { + expect(splitTextLinks('Join: https://teams.example.com/meet/319560?p=CTYa9 Meeting ID: 319')) + .toEqual([ + { text: 'Join: ' }, + { text: 'https://teams.example.com/meet/319560?p=CTYa9', url: 'https://teams.example.com/meet/319560?p=CTYa9' }, + { text: ' Meeting ID: 319' }, + ]); + }); + + it('stops at the closing bracket of the form', () => { + const segments = splitTextLinks('Need help? | System'); + expect(segments.filter((s) => s.url).map((s) => s.url)).toEqual(['https://aka.example/Join?omkt=fr-FR']); + expect(segments.map((s) => s.text).join('')).toBe('Need help? | System'); + }); + + it('finds every URL in a multi-line description', () => { + const text = 'Microsoft Teams meeting\nJoin: https://teams.example.com/meet/1\n\nDial in: https://dialin.example/2'; + expect(splitTextLinks(text).filter((s) => s.url).map((s) => s.url)) + .toEqual(['https://teams.example.com/meet/1', 'https://dialin.example/2']); + }); + + it('leaves non-http schemes alone', () => { + const text = 'try javascript:alert(1) or file:///etc/passwd or tel:+33123456789'; + expect(splitTextLinks(text)).toEqual([{ text }]); + expect(hasTextLink(text)).toBe(false); + }); + + it('returns the text unchanged when there is no URL, and never loses characters', () => { + expect(splitTextLinks('no links here')).toEqual([{ text: 'no links here' }]); + expect(splitTextLinks('')).toEqual([]); + const text = 'a https://x.example b https://y.example'; + expect(splitTextLinks(text).map((s) => s.text).join('')).toBe(text); + }); + + it('is not confused by repeated calls (no lastIndex leak)', () => { + const text = 'https://x.example'; + expect(hasTextLink(text)).toBe(true); + expect(hasTextLink(text)).toBe(true); + expect(splitTextLinks(text)).toEqual([{ text, url: text }]); + expect(splitTextLinks(text)).toEqual([{ text, url: text }]); + }); +}); diff --git a/src/lib/linkify-text.ts b/src/lib/linkify-text.ts new file mode 100644 index 00000000..54737fae --- /dev/null +++ b/src/lib/linkify-text.ts @@ -0,0 +1,42 @@ +/** + * Split plain text into runs, marking the http(s) URLs inside it. + * + * For text rendered as React Native , where an is not an option: + * the caller maps each segment to a , giving the ones carrying a `url` + * an onPress. The URL terminates at whitespace or any of `<>"'`, so the + * `` form mail clients emit yields a bare URL rather than one with + * a trailing bracket. + * + * Same rule as `plainTextToSafeHtml` in lib/email-html - the two must agree, + * or the same message linkifies differently in the body and in the calendar. + * The result is still only a hint: hand every URL to `openExternalUrl`, which + * is what actually checks the scheme. + */ + +export interface TextSegment { + text: string; + /** Set when this run is an http(s) URL. */ + url?: string; +} + +const URL_RE = /(https?:\/\/[^\s<>"']+)/g; + +export function splitTextLinks(text: string): TextSegment[] { + const segments: TextSegment[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + URL_RE.lastIndex = 0; + while ((match = URL_RE.exec(text)) !== null) { + if (match.index > lastIndex) segments.push({ text: text.slice(lastIndex, match.index) }); + segments.push({ text: match[0], url: match[0] }); + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) segments.push({ text: text.slice(lastIndex) }); + return segments; +} + +/** True when the text holds at least one http(s) URL. */ +export function hasTextLink(text: string): boolean { + URL_RE.lastIndex = 0; + return URL_RE.test(text); +}