From 012c7bde50d07b901809c9b43dcb5640be86f58f Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 18 Mar 2026 00:05:56 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20mobile=20timeline=20=E2=80=94=20render?= =?UTF-8?q?=20links=20as=20tappable=20[link]=20in=20notes=20and=20time=20e?= =?UTF-8?q?ntries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace stripHtml() with renderContent() that: 1. Extracts ... anchors before stripping HTML 2. Strips remaining HTML tags cleanly 3. Detects bare https?:// URLs in plain text 4. Renders each as a tappable [link] with text-primary underline - Collapsed note preview still shows plain text (no link clutter) - Expanded note/time-entry body shows inline [link] elements --- app/mobile/tickets/[id]/page.tsx | 47 ++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/app/mobile/tickets/[id]/page.tsx b/app/mobile/tickets/[id]/page.tsx index 0911096..2b1c1c8 100644 --- a/app/mobile/tickets/[id]/page.tsx +++ b/app/mobile/tickets/[id]/page.tsx @@ -53,8 +53,42 @@ function fmtHours(h: string | number) { if (n < 1) return `${Math.round(n * 60)}m`; return `${n.toFixed(1)}h`; } -function stripHtml(s: string) { - return s?.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() ?? ''; +function renderContent(s: string): React.ReactNode[] { + if (!s) return []; + + // Step 1: convert ... → placeholder §URL§ + // Collect links in order + const links: string[] = []; + const withPlaceholders = s.replace(/]*\bhref=["']([^"']+)["'][^>]*>.*?<\/a>/gi, (_, href) => { + links.push(href); + return `\x00LINK${links.length - 1}\x00`; + }); + + // Step 2: strip remaining HTML tags + const plain = withPlaceholders.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + + // Step 3: split on LINK placeholders and bare URLs + const URL_RE = /\x00LINK(\d+)\x00|(https?:\/\/[^\s<>"']+)/g; + const nodes: React.ReactNode[] = []; + let last = 0; + let match: RegExpExecArray | null; + let key = 0; + + while ((match = URL_RE.exec(plain)) !== null) { + if (match.index > last) { + nodes.push(plain.slice(last, match.index)); + } + const href = match[1] !== undefined ? links[parseInt(match[1])] : match[2]; + nodes.push( + e.stopPropagation()}> + [link] + + ); + last = match.index + match[0].length; + } + if (last < plain.length) nodes.push(plain.slice(last)); + return nodes; } function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defaultOpen?: boolean }) { @@ -118,7 +152,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau

{te.resource_name}

- {te.notes &&

{stripHtml(te.notes)}

} + {te.notes &&

{renderContent(te.notes)}

} )} @@ -130,7 +164,8 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau const note = item.data; const isInternal = note.publish === 1; const isSystem = item.is_system; - const body = stripHtml(note.description ?? ''); + const bodyNodes = renderContent(note.description ?? ''); + const bodyPlain = (note.description ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); const iconColor = isSystem ? 'text-muted-foreground' : isInternal ? 'text-slate-400' : 'text-amber-500'; const iconBg = isSystem ? 'bg-muted' : isInternal ? 'bg-muted' : 'bg-amber-500/10'; return ( @@ -149,7 +184,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau

{note.title || (isInternal ? 'Internal Note' : 'Customer Note')}

- {!open && body &&

{body}

} + {!open && bodyPlain &&

{bodyPlain}

} {open ? : } @@ -159,7 +194,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau

{note.author || 'System'} · {isSystem ? 'System' : isInternal ? 'Internal' : 'Client-visible'}

-

{body}

+

{bodyNodes}

)}