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);
+}