feedback formatter
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
|||||||
type AppNotification,
|
type AppNotification,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
} from "@/lib/social";
|
} from "@/lib/social";
|
||||||
|
import { RichText } from "@/lib/rich-text";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
@@ -275,10 +276,16 @@ function NotificationRow({
|
|||||||
<UserAvatar src={isAdmin ? null : n.actor_avatar_url} name={display} />
|
<UserAvatar src={isAdmin ? null : n.actor_avatar_url} name={display} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<p className="text-sm text-foreground leading-snug whitespace-pre-line">
|
// A div, not a <p>: the formatted body nests elements, and RichText
|
||||||
|
// handles line breaks itself (so no whitespace-pre-line either).
|
||||||
|
<div className="text-sm text-foreground leading-snug">
|
||||||
<span className="font-semibold">{display}</span>
|
<span className="font-semibold">{display}</span>
|
||||||
{n.body ? <span className="block mt-0.5">{n.body}</span> : null}
|
{n.body ? (
|
||||||
</p>
|
<span className="block mt-0.5">
|
||||||
|
<RichText text={n.body} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-foreground leading-snug">
|
<p className="text-sm text-foreground leading-snug">
|
||||||
<span className="font-semibold">{display}</span> {message}
|
<span className="font-semibold">{display}</span> {message}
|
||||||
|
|||||||
@@ -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) => <u key={k}>{n}</u> },
|
||||||
|
{ re: /\*([^*\n]+?)\*/, wrap: (n, k) => <strong key={k}>{n}</strong> },
|
||||||
|
{ re: /~([^~\n]+?)~/, wrap: (n, k) => <s key={k}>{n}</s> },
|
||||||
|
{ re: /_([^_\n]+?)_/, wrap: (n, k) => <em key={k}>{n}</em> },
|
||||||
|
];
|
||||||
|
|
||||||
|
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(
|
||||||
|
<a
|
||||||
|
key={`${li}-l${idx}`}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
if (tail) out.push(tail);
|
||||||
|
last = idx + m[0].length;
|
||||||
|
}
|
||||||
|
out.push(...renderMarkers(line.slice(last), `${li}-${last}`));
|
||||||
|
return (
|
||||||
|
<span key={li}>
|
||||||
|
{li > 0 && <br />}
|
||||||
|
{out}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user