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 <Text>. 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QPPAw4SK4M9Z7euVPM2cL
This commit is contained in:
lucletoffe
2026-09-04 09:49:44 +02:00
co-authored by Claude Opus 5
parent 6c82130267
commit c2584c2bd5
3 changed files with 119 additions and 1 deletions
+24 -1
View File
@@ -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={<AlignLeft size={16} color={c.textMuted} />}
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 (
<View style={styles.detailRow}>
<View style={styles.detailIcon}>{icon}</View>
<Text style={styles.detailText} numberOfLines={multiline ? undefined : 2}>
{text}
{segments
? segments.map((segment, i) => (segment.url ? (
<Text
key={i}
style={styles.detailLink}
onPress={() => { void openExternalUrl(segment.url as string, { confirm: true }); }}
>
{segment.text}
</Text>
) : (
<Text key={i}>{segment.text}</Text>
)))
: text}
</Text>
</View>
);
@@ -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,
+53
View File
@@ -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 <https://...> form', () => {
const segments = splitTextLinks('Need help? <https://aka.example/Join?omkt=fr-FR> | 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? <https://aka.example/Join?omkt=fr-FR> | 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 }]);
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Split plain text into runs, marking the http(s) URLs inside it.
*
* For text rendered as React Native <Text>, where an <a> is not an option:
* the caller maps each segment to a <Text>, giving the ones carrying a `url`
* an onPress. The URL terminates at whitespace or any of `<>"'`, so the
* `<https://…>` 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);
}