From 9f67da37a6f83bf89111fa4613999ec1f838b86e Mon Sep 17 00:00:00 2001 From: gosangam Date: Fri, 9 Oct 2026 06:40:28 +0530 Subject: [PATCH] feedback formatter --- app/(app)/notifications/page.tsx | 13 +++- lib/rich-text.tsx | 115 +++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 lib/rich-text.tsx diff --git a/app/(app)/notifications/page.tsx b/app/(app)/notifications/page.tsx index 26ce2305..c76c3500 100644 --- a/app/(app)/notifications/page.tsx +++ b/app/(app)/notifications/page.tsx @@ -28,6 +28,7 @@ import { type AppNotification, type UserSummary, } from "@/lib/social"; +import { RichText } from "@/lib/rich-text"; import { UserAvatar } from "@/components/ui/user-avatar"; import { Button } from "@/components/ui/button"; import { timeAgo } from "@/lib/time"; @@ -275,10 +276,16 @@ function NotificationRow({
{isAdmin ? ( -

+ // A div, not a

: the formatted body nests elements, and RichText + // handles line breaks itself (so no whitespace-pre-line either). +

{display} - {n.body ? {n.body} : null} -

+ {n.body ? ( + + + + ) : null} +
) : (

{display} {message} diff --git a/lib/rich-text.tsx b/lib/rich-text.tsx new file mode 100644 index 00000000..b925f250 --- /dev/null +++ b/lib/rich-text.tsx @@ -0,0 +1,115 @@ +/** + * Renders the light formatting the admin message fields allow: + * + * *bold* _italic_ __underline__ ~strike~ [label](https://…) + * + * plus bare http(s) URLs. The rules mirror pkg/richtext in the BFF, which + * renders the same markers into email — so the preview shown here is what + * the recipient's inbox gets. + * + * Output is React nodes, never an HTML string: React escapes all text, so + * author input cannot become markup, and links are limited to http(s). + * + * NOTE: a copy of spsays-admin/lib/rich-text.tsx. The two are separate Next + * apps with no shared package — the same arrangement as compress-image.ts. + * Change both, and pkg/richtext in the BFF, together. + */ + +import type { ReactNode } from "react"; + +const LINK_RE = + /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<]+)/g; +const TRAILING_PUNCT = /[.,;:!?)]+$/; + +type Marker = { re: RegExp; wrap: (n: ReactNode, k: string) => ReactNode }; + +// Order matters: __underline__ before _italic_, so the double underscore +// isn't read as two italic markers. +const MARKERS: Marker[] = [ + { re: /__([^_\n]+?)__/, wrap: (n, k) => {n} }, + { re: /\*([^*\n]+?)\*/, wrap: (n, k) => {n} }, + { re: /~([^~\n]+?)~/, wrap: (n, k) => {n} }, + { re: /_([^_\n]+?)_/, wrap: (n, k) => {n} }, +]; + +const isWord = (c: string | undefined) => !!c && /\w/.test(c); + +/** Markers within a run of text that contains no links. */ +function renderMarkers(text: string, keyBase: string): ReactNode[] { + // Earliest match across all markers wins, so nesting resolves outside-in. + let best: { idx: number; len: number; inner: string; m: Marker } | null = + null; + for (const m of MARKERS) { + const re = new RegExp(m.re.source, "g"); + let hit: RegExpExecArray | null; + while ((hit = re.exec(text))) { + // Italic needs a non-word character either side, so snake_case and + // handles keep their underscores. + if (m.re.source.startsWith("_(")) { + const before = text[hit.index - 1]; + const after = text[hit.index + hit[0].length]; + if (isWord(before) || isWord(after)) continue; + } + if (!best || hit.index < best.idx) { + best = { idx: hit.index, len: hit[0].length, inner: hit[1], m }; + } + break; + } + } + if (!best) return text ? [text] : []; + + const before = text.slice(0, best.idx); + const after = text.slice(best.idx + best.len); + const k = `${keyBase}-${best.idx}`; + return [ + ...(before ? [before] : []), + best.m.wrap(renderMarkers(best.inner, `${k}i`), k), + ...renderMarkers(after, `${k}a`), + ]; +} + +/** Renders formatted text, newlines included. */ +export function RichText({ text }: { text: string }) { + const lines = text.replace(/\r\n/g, "\n").split("\n"); + return ( + <> + {lines.map((line, li) => { + const out: ReactNode[] = []; + let last = 0; + for (const m of line.matchAll(LINK_RE)) { + const idx = m.index ?? 0; + out.push(...renderMarkers(line.slice(last, idx), `${li}-${last}`)); + let href = m[2] ?? m[3]; + let label = m[1] ?? href; + let tail = ""; + if (!m[1]) { + // Sentence punctuation after a bare URL isn't part of it. + tail = href.match(TRAILING_PUNCT)?.[0] ?? ""; + href = href.slice(0, href.length - tail.length); + label = href; + } + out.push( + + {label} + , + ); + if (tail) out.push(tail); + last = idx + m[0].length; + } + out.push(...renderMarkers(line.slice(last), `${li}-${last}`)); + return ( + + {li > 0 &&
} + {out} +
+ ); + })} + + ); +}