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({
+ // A div, not a
: the formatted body nests elements, and RichText + // handles line breaks itself (so no whitespace-pre-line either). +
{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}
+
+ );
+ })}
+ >
+ );
+}