mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
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:
co-authored by
Claude Opus 5
parent
6c82130267
commit
c2584c2bd5
@@ -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,
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user