Merge remote-tracking branch 'origin/main' into channel-management-clean

This commit is contained in:
Thomas Petersen
2026-06-16 07:19:20 -04:00
27 changed files with 2385 additions and 62 deletions

View File

@@ -35,6 +35,7 @@ export default defineConfig({
"**/profile-active-turn-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/video-attachment.spec.ts",
"**/spoiler.spec.ts",
"**/mentions.spec.ts",
"**/relay-reconnect.spec.ts",
"**/workflows.spec.ts",

View File

@@ -8,17 +8,18 @@ function escapeRegExp(str: string): string {
/**
* Check whether `text` contains an @mention of `name`.
*
* Matches `@Name` preceded by start-of-string, whitespace, or markdown
* bold/italic markers (`*`, `**`, `***`, `_`, `__`, `___`). This handles
* the case where a mention is pasted from the chat area and TipTap's Bold
* extension wraps it in bold marks (font-weight >= 500 → bold).
* Matches `@Name` preceded by start-of-string, whitespace, markdown
* bold/italic markers (`*`, `**`, `***`, `_`, `__`, `___`), or spoiler
* delimiters (`||`). This handles the case where a mention is pasted from the
* chat area and TipTap's Bold extension wraps it in bold marks (font-weight >=
* 500 -> bold), plus messages whose visible mention text is spoilered.
*
* Exported separately so it can be unit-tested without importing React.
*/
export function hasMention(text: string, name: string): boolean {
const escaped = escapeRegExp(name);
const pattern = new RegExp(
`(?:^|\\s|[*_]{1,3})@${escaped}(?=[\\s,;.!?:)\\]}*_]|$)`,
`(?:^|\\s|[*_]{1,3}|\\|\\|)@${escaped}(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
"i",
);
return pattern.test(text);

View File

@@ -9,6 +9,7 @@ import test from "node:test";
import {
buildImetaTags,
buildOutgoingMessage,
findSpoileredImetaMediaUrls,
formatImetaMediaLine,
imetaMediaFromTags,
mergeOutgoingTags,
@@ -24,6 +25,31 @@ test("strip: removes trailing image line whose URL is in imetaMedia", () => {
assert.equal(stripped, "Look at this");
});
test("strip: removes trailing spoilered image line whose URL is in imetaMedia", () => {
const body = "Look at this\n||![image](https://blossom/abc.png)||";
const stripped = stripImetaMediaLines(body, [
{ url: "https://blossom/abc.png", type: "image/png" },
]);
assert.equal(stripped, "Look at this");
});
test("strip: removes trailing block-spoilered image line", () => {
const body = "Look at this\n||\n![image](https://blossom/abc.png)\n||";
const stripped = stripImetaMediaLines(body, [
{ url: "https://blossom/abc.png", type: "image/png" },
]);
assert.equal(stripped, "Look at this");
});
test("strip: keeps block spoiler text while stripping imeta media", () => {
const body =
"Look at this\n||\nsecret\n![image](https://blossom/abc.png)\n||";
const stripped = stripImetaMediaLines(body, [
{ url: "https://blossom/abc.png", type: "image/png" },
]);
assert.equal(stripped, body);
});
test("strip: removes trailing video line", () => {
const body = "Demo:\n![video](https://blossom/clip.mp4)";
const stripped = stripImetaMediaLines(body, [
@@ -81,6 +107,16 @@ test("formatImetaMediaLine: image mime → ![image] line", () => {
);
});
test("formatImetaMediaLine: spoilered image mime → wrapped ![image] line", () => {
assert.equal(
formatImetaMediaLine(
{ url: "https://b/a.png", type: "image/png" },
{ spoiler: true },
),
"\n||![image](https://b/a.png)||",
);
});
test("buildImetaTags keeps media filenames in imeta", () => {
// Filenames are included for every MIME type — the video review dialog
// and file cards use them as display titles.
@@ -126,6 +162,20 @@ test("formatImetaMediaLine: generic mime → [filename](url) link", () => {
);
});
test("formatImetaMediaLine: spoiler option does not wrap generic files", () => {
assert.equal(
formatImetaMediaLine(
{
url: "https://b/blob",
type: "application/pdf",
filename: "report.pdf",
},
{ spoiler: true },
),
"\n[report.pdf](https://b/blob)",
);
});
test("formatImetaMediaLine: escapes markdown brackets/backslash in filename", () => {
// `a].pdf` would otherwise close the link label early and break the FileCard.
assert.equal(
@@ -149,6 +199,33 @@ test("strip: removes an escaped-bracket generic file line on edit", () => {
assert.equal(stripped, "note");
});
test("findSpoileredImetaMediaUrls: extracts only spoilered matching media urls", () => {
const body = [
"note",
"||![image](https://b/a.png)||",
"![image](https://b/b.png)",
"||![video](https://b/c.mp4)||",
"||",
"![image](https://b/d.png)",
"![video](https://b/e.mp4)",
"||",
].join("\n");
const spoilered = findSpoileredImetaMediaUrls(body, [
{ url: "https://b/a.png", type: "image/png" },
{ url: "https://b/b.png", type: "image/png" },
{ url: "https://b/c.mp4", type: "video/mp4" },
{ url: "https://b/d.png", type: "image/png" },
{ url: "https://b/e.mp4", type: "video/mp4" },
{ url: "https://b/other.png", type: "image/png" },
]);
assert.deepEqual([...spoilered].sort(), [
"https://b/a.png",
"https://b/c.mp4",
"https://b/d.png",
"https://b/e.mp4",
]);
});
// ── imetaMediaFromTags (full BlobDescriptor projection) ───────────────
test("imetaMediaFromTags: empty / undefined", () => {
@@ -327,6 +404,33 @@ test("buildOutgoingMessage: appends media markdown line per attachment, in order
);
});
test("buildOutgoingMessage: wraps spoilered image and video attachments", () => {
const out = buildOutgoingMessage(
"hi",
[
{
url: "https://b/a.png",
type: "image/png",
sha256: "x",
size: 1,
uploaded: 0,
},
{
url: "https://b/v.mp4",
type: "video/mp4",
sha256: "y",
size: 2,
uploaded: 0,
},
],
new Set(["https://b/a.png", "https://b/v.mp4"]),
);
assert.equal(
out.content,
"hi\n||![image](https://b/a.png)||\n||![video](https://b/v.mp4)||",
);
});
test("buildOutgoingMessage: mediaTags mirror buildImetaTags output for non-empty pending", () => {
const pending = [
{

View File

@@ -101,7 +101,11 @@ export function buildImetaTags(
]);
}
const MEDIA_LINE_RE = /^!\[(?:image|video)\]\(([^)\s]+)\)\s*$/;
const MEDIA_LINE_RE =
/^(?:\|\|)?!\[(?:image|video)\]\(([^)\s]+)\)(?:\|\|)?\s*$/;
const SPOILERED_MEDIA_LINE_RE =
/^\|\|!\[(?:image|video)\]\(([^)\s]+)\)\|\|\s*$/;
const BLOCK_SPOILER_DELIMITER_RE = /^\s*\|\|\s*$/;
/**
* Matches a generic file-attachment line `[label](url)` (no leading `!`, so it's
* a link not an image). The label can contain spaces and backslash-escaped
@@ -110,6 +114,35 @@ const MEDIA_LINE_RE = /^!\[(?:image|video)\]\(([^)\s]+)\)\s*$/;
*/
const FILE_LINE_RE = /^\[(?:\\.|[^\]\\])*\]\(([^)\s]+)\)\s*$/;
function findTrailingBlockSpoilerMediaStart(
lines: string[],
closingDelimiterIndex: number,
urls: ReadonlySet<string>,
): number | null {
let index = closingDelimiterIndex - 1;
let hasMatchingMedia = false;
while (index >= 0) {
const line = lines[index];
if (line.trim() === "") {
index -= 1;
continue;
}
if (BLOCK_SPOILER_DELIMITER_RE.test(line)) {
return hasMatchingMedia ? index : null;
}
const match = line.match(MEDIA_LINE_RE);
if (!match || !urls.has(match[1])) return null;
hasMatchingMedia = true;
index -= 1;
}
return null;
}
/**
* Remove trailing `![image|video](url)` lines whose URL matches an entry in
* `imetaMedia`. Stops at the first non-matching/non-blank line so attachments
@@ -132,6 +165,13 @@ export function stripImetaMediaLines(
end -= 1;
continue;
}
if (BLOCK_SPOILER_DELIMITER_RE.test(line)) {
const start = findTrailingBlockSpoilerMediaStart(lines, end - 1, urls);
if (start != null) {
end = start;
continue;
}
}
const match = line.match(MEDIA_LINE_RE) ?? line.match(FILE_LINE_RE);
if (match && urls.has(match[1])) {
end -= 1;
@@ -143,6 +183,52 @@ export function stripImetaMediaLines(
return lines.slice(0, end).join("\n").replace(/\s+$/, "");
}
export function findSpoileredImetaMediaUrls(
body: string,
imetaMedia: ReadonlyArray<ImetaMedia>,
): Set<string> {
if (imetaMedia.length === 0) return new Set();
const urls = new Set(imetaMedia.map((m) => m.url));
const spoileredUrls = new Set<string>();
const lines = body.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const match = line.match(SPOILERED_MEDIA_LINE_RE);
if (match && urls.has(match[1])) {
spoileredUrls.add(match[1]);
continue;
}
if (!BLOCK_SPOILER_DELIMITER_RE.test(line)) continue;
const blockSpoileredUrls = new Set<string>();
let closingDelimiterIndex = -1;
for (
let blockIndex = index + 1;
blockIndex < lines.length;
blockIndex += 1
) {
const blockLine = lines[blockIndex];
if (BLOCK_SPOILER_DELIMITER_RE.test(blockLine)) {
closingDelimiterIndex = blockIndex;
break;
}
const blockMatch = blockLine.match(MEDIA_LINE_RE);
if (blockMatch && urls.has(blockMatch[1])) {
blockSpoileredUrls.add(blockMatch[1]);
}
}
if (closingDelimiterIndex !== -1) {
for (const url of blockSpoileredUrls) spoileredUrls.add(url);
index = closingDelimiterIndex;
}
}
return spoileredUrls;
}
/**
* Format a single imeta entry as a leading-newline markdown line.
*
@@ -151,13 +237,18 @@ export function stripImetaMediaLines(
* href as a local media blob with a non-media MIME and upgrades it to a file
* card. Mime-driven so the form is correct regardless of URL suffix.
*/
export function formatImetaMediaLine({
url,
type,
filename,
}: ImetaMedia): string {
if (type.startsWith("video/")) return `\n![video](${url})`;
if (type.startsWith("image/")) return `\n![image](${url})`;
export function formatImetaMediaLine(
{ url, type, filename }: ImetaMedia,
options: { spoiler?: boolean } = {},
): string {
if (type.startsWith("video/")) {
const line = `![video](${url})`;
return options.spoiler ? `\n||${line}||` : `\n${line}`;
}
if (type.startsWith("image/")) {
const line = `![image](${url})`;
return options.spoiler ? `\n||${line}||` : `\n${line}`;
}
// Generic file: plain link, label is the original filename (fallback to url tail).
const label = filename || url.split("/").pop() || "file";
// Escape markdown link-label metacharacters so filenames containing `[`, `]`,
@@ -181,9 +272,14 @@ export function formatImetaMediaLine({
export function buildOutgoingMessage(
body: string,
pendingImeta: ReadonlyArray<ImetaMedia>,
spoileredMediaUrls: ReadonlySet<string> = new Set(),
): { content: string; mediaTags: string[][] | undefined } {
let content = body;
for (const d of pendingImeta) content += formatImetaMediaLine(d);
for (const d of pendingImeta) {
content += formatImetaMediaLine(d, {
spoiler: spoileredMediaUrls.has(d.url),
});
}
const mediaTags =
pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined;
return { content, mediaTags };

View File

@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getSchema } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { getSpoilerRangeState } from "./spoilerFormatting.ts";
import { SpoilerMark, SPOILER_MARK_NAME } from "./spoilerMark.ts";
const schema = getSchema([
StarterKit.configure({
hardBreak: { keepMarks: true },
heading: false,
trailingNode: false,
link: false,
}),
SpoilerMark,
]);
const spoilerMark = schema.marks[SPOILER_MARK_NAME];
const codeMark = schema.marks.code;
const para = (...content) => schema.nodes.paragraph.create(null, content);
const codeBlock = (content) => schema.nodes.codeBlock.create(null, content);
const t = (text, marks = []) => schema.text(text, marks);
function doc(...content) {
return schema.nodes.doc.create(null, content);
}
function wholeDocState(d) {
return getSpoilerRangeState(d, spoilerMark, 1, d.content.size - 1);
}
test("getSpoilerRangeState: ignores inline code when surrounding text is spoilered", () => {
const d = doc(
para(
t("hidden ", [spoilerMark.create()]),
t("literal", [codeMark.create()]),
t(" text", [spoilerMark.create()]),
),
);
assert.equal(wholeDocState(d), "fully-spoiled");
});
test("getSpoilerRangeState: ignores code blocks when surrounding text is spoilered", () => {
const d = doc(
para(t("hidden", [spoilerMark.create()])),
codeBlock(t("const secret = true;")),
);
assert.equal(wholeDocState(d), "fully-spoiled");
});
test("getSpoilerRangeState: reports unspoilered markable text as partial", () => {
const d = doc(
para(t("hidden", [spoilerMark.create()])),
codeBlock(t("const secret = true;")),
para(t("visible")),
);
assert.equal(wholeDocState(d), "partially-spoiled");
});
test("getSpoilerRangeState: returns no markable content for code-only ranges", () => {
const d = doc(codeBlock(t("const secret = true;")));
assert.equal(wholeDocState(d), "no-markable-content");
});

View File

@@ -0,0 +1,55 @@
import type { Editor } from "@tiptap/react";
import type { MarkType, Node as ProseMirrorNode } from "@tiptap/pm/model";
import { SPOILER_MARK_NAME } from "./spoilerMark";
export type SpoilerRangeState =
| "fully-spoiled"
| "partially-spoiled"
| "no-markable-content";
function canTextNodeHoldMark(
node: ProseMirrorNode,
parent: ProseMirrorNode | null,
markType: MarkType,
): boolean {
if (!node.isText || node.textContent.length === 0) return false;
if (parent && !parent.type.allowsMarkType(markType)) return false;
if (markType.isInSet(node.marks)) return true;
return node.marks.every((mark) => !mark.type.excludes(markType));
}
export function getSpoilerRangeState(
doc: ProseMirrorNode,
spoilerMark: MarkType,
from: number,
to: number,
): SpoilerRangeState {
let hasMarkableContent = false;
let isFullySpoiled = true;
doc.nodesBetween(from, to, (node, _pos, parent) => {
if (!canTextNodeHoldMark(node, parent, spoilerMark)) return;
hasMarkableContent = true;
if (!spoilerMark.isInSet(node.marks)) {
isFullySpoiled = false;
return false;
}
});
if (!hasMarkableContent) return "no-markable-content";
return isFullySpoiled ? "fully-spoiled" : "partially-spoiled";
}
export function getEditorSpoilerRangeState(
editor: Editor,
from: number,
to: number,
): SpoilerRangeState {
const spoilerMark = editor.schema.marks[SPOILER_MARK_NAME];
if (!spoilerMark) return "no-markable-content";
return getSpoilerRangeState(editor.state.doc, spoilerMark, from, to);
}

View File

@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";
import { findClosingDelimiter } from "./spoilerMark.ts";
test("findClosingDelimiter: closes on the first inner delimiter", () => {
const source = "||a || b||";
assert.equal(findClosingDelimiter(source, 2, source.length), 4);
});
test("findClosingDelimiter: returns -1 when no closing delimiter exists", () => {
const source = "||open spoiler";
assert.equal(findClosingDelimiter(source, 2, source.length), -1);
});

View File

@@ -0,0 +1,111 @@
import { Mark, mergeAttributes } from "@tiptap/core";
export const SPOILER_MARK_NAME = "spoiler";
const SPOILER_MARKDOWN_RULE = "buzz_spoiler";
const SPOILER_OPEN_TOKEN = "buzz_spoiler_open";
const SPOILER_CLOSE_TOKEN = "buzz_spoiler_close";
const PIPE_CHAR = 0x7c;
export function registerSpoilerMarkdownIt(
// biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here
md: any,
): void {
if (md.renderer.rules[SPOILER_OPEN_TOKEN]) return;
// biome-ignore lint/suspicious/noExplicitAny: markdown-it state/silent
const rule = (state: any, silent: boolean): boolean => {
const start = state.pos;
if (
state.src.charCodeAt(start) !== PIPE_CHAR ||
state.src.charCodeAt(start + 1) !== PIPE_CHAR
) {
return false;
}
const contentStart = start + 2;
const contentEnd = findClosingDelimiter(
state.src,
contentStart,
state.posMax,
);
if (contentEnd <= contentStart) return false;
if (!silent) {
const previousPosMax = state.posMax;
state.push(SPOILER_OPEN_TOKEN, "span", 1);
state.pos = contentStart;
state.posMax = contentEnd;
state.md.inline.tokenize(state);
state.push(SPOILER_CLOSE_TOKEN, "span", -1);
state.posMax = previousPosMax;
}
state.pos = contentEnd + 2;
return true;
};
// Composer parsing is intentionally inline-only. Block delimiter spoilers
// are rendered by remarkSpoilers as receive-only interop, but are not part of
// the composer WYSIWYG surface.
md.inline.ruler.before("emphasis", SPOILER_MARKDOWN_RULE, rule);
md.renderer.rules[SPOILER_OPEN_TOKEN] = () =>
'<span data-spoiler="" class="buzz-spoiler">';
md.renderer.rules[SPOILER_CLOSE_TOKEN] = () => "</span>";
}
/**
* Match the first closing delimiter, mirroring Discord-style spoiler parsing.
* Inner `||` text closes the spoiler instead of nesting or scanning greedily.
*/
export function findClosingDelimiter(
source: string,
start: number,
end: number,
): number {
for (let index = start; index < end - 1; index++) {
if (
source.charCodeAt(index) === PIPE_CHAR &&
source.charCodeAt(index + 1) === PIPE_CHAR
) {
return index;
}
}
return -1;
}
export const SpoilerMark = Mark.create({
name: SPOILER_MARK_NAME,
parseHTML() {
return [{ tag: "span[data-spoiler]" }];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(HTMLAttributes, {
"data-spoiler": "",
class: "buzz-spoiler",
}),
0,
];
},
addStorage() {
return {
markdown: {
serialize: {
open: "||",
close: "||",
expelEnclosingWhitespace: true,
},
parse: {
// biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here
setup(_md: any) {
registerSpoilerMarkdownIt(_md);
},
},
},
};
},
});

View File

@@ -0,0 +1,180 @@
import * as React from "react";
import type { Editor } from "@tiptap/react";
import { mountSpoilerParticleCanvas } from "@/shared/ui/SpoilerParticles";
type ComposerSpoilerMount = {
canvas: HTMLCanvasElement;
cleanup: () => void;
};
const SPOILER_SELECTOR = ".buzz-spoiler[data-spoiler]";
export function useComposerSpoilerParticles(
editor: Editor | null,
scrollRef: React.RefObject<HTMLElement | null>,
) {
React.useEffect(() => {
if (!editor) return;
let cancelled = false;
let setupFrame = 0;
let teardown: (() => void) | undefined;
const setup = () => {
let editorRoot: HTMLElement;
try {
editorRoot = editor.view.dom as HTMLElement;
} catch {
if (!cancelled) setupFrame = window.requestAnimationFrame(setup);
return;
}
const scrollRoot = scrollRef.current ?? editorRoot.parentElement;
if (!scrollRoot) {
if (!cancelled) setupFrame = window.requestAnimationFrame(setup);
return;
}
teardown = mountComposerSpoilerParticles(editor, editorRoot, scrollRoot);
};
setup();
return () => {
cancelled = true;
if (setupFrame) {
window.cancelAnimationFrame(setupFrame);
}
teardown?.();
};
}, [editor, scrollRef]);
}
function mountComposerSpoilerParticles(
editor: Editor,
editorRoot: HTMLElement,
scrollRoot: HTMLElement,
): () => void {
const document = editorRoot.ownerDocument;
const overlayRoot = document.createElement("div");
overlayRoot.className = "buzz-spoiler-composer-particles";
scrollRoot.appendChild(overlayRoot);
const mounts = new Map<HTMLElement, ComposerSpoilerMount>();
let syncFrame = 0;
const syncCanvasGeometry = (
spoiler: HTMLElement,
canvas: HTMLCanvasElement,
) => {
if (
!spoiler.isConnected ||
!scrollRoot.isConnected ||
!editorRoot.contains(spoiler)
) {
canvas.style.display = "none";
return false;
}
const spoilerRect = spoiler.getBoundingClientRect();
const scrollRect = scrollRoot.getBoundingClientRect();
const fontSize = Number.parseFloat(getComputedStyle(spoiler).fontSize);
const padding = Number.isFinite(fontSize) ? fontSize * 0.1 : 1.6;
const width = spoilerRect.width + padding * 2;
const height = spoilerRect.height + padding * 2;
if (width <= 0 || height <= 0) {
canvas.style.display = "none";
return false;
}
canvas.style.display = "block";
canvas.style.left = `${
spoilerRect.left - scrollRect.left + scrollRoot.scrollLeft - padding
}px`;
canvas.style.top = `${
spoilerRect.top - scrollRect.top + scrollRoot.scrollTop - padding
}px`;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
return true;
};
const mountSpoiler = (spoiler: HTMLElement) => {
const canvas = document.createElement("canvas");
canvas.className =
"buzz-spoiler__particles buzz-spoiler__particles--composer";
overlayRoot.appendChild(canvas);
const cleanup = mountSpoilerParticleCanvas({
canvas,
content: spoiler,
syncCanvas: () => syncCanvasGeometry(spoiler, canvas),
});
mounts.set(spoiler, { canvas, cleanup });
};
const syncSpoilers = () => {
syncFrame = 0;
const spoilers = new Set(
Array.from(editorRoot.querySelectorAll<HTMLElement>(SPOILER_SELECTOR)),
);
for (const spoiler of spoilers) {
if (!mounts.has(spoiler)) {
mountSpoiler(spoiler);
}
}
for (const [spoiler, mount] of mounts) {
if (!spoilers.has(spoiler)) {
mount.cleanup();
mount.canvas.remove();
mounts.delete(spoiler);
continue;
}
syncCanvasGeometry(spoiler, mount.canvas);
}
};
const scheduleSync = () => {
if (syncFrame) return;
syncFrame = window.requestAnimationFrame(syncSpoilers);
};
const mutationObserver = new MutationObserver(scheduleSync);
mutationObserver.observe(editorRoot, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
const handleTransaction = () => scheduleSync();
editor.on("transaction", handleTransaction);
scrollRoot.addEventListener("scroll", scheduleSync, { passive: true });
window.addEventListener("resize", scheduleSync);
window.addEventListener("scroll", scheduleSync, true);
scheduleSync();
return () => {
if (syncFrame) {
window.cancelAnimationFrame(syncFrame);
}
editor.off("transaction", handleTransaction);
mutationObserver.disconnect();
scrollRoot.removeEventListener("scroll", scheduleSync);
window.removeEventListener("resize", scheduleSync);
window.removeEventListener("scroll", scheduleSync, true);
for (const mount of mounts.values()) {
mount.cleanup();
mount.canvas.remove();
}
overlayRoot.remove();
};
}

View File

@@ -56,6 +56,14 @@ test("matches _@Name_ (underscore italic-wrapped)", () => {
assert.equal(hasMention("_@Alice_", "Alice"), true);
});
test("matches ||@Name|| (spoiler-wrapped)", () => {
assert.equal(hasMention("||@Alice||", "Alice"), true);
});
test("matches @Name at the end of spoiler content", () => {
assert.equal(hasMention("||hi @Alice||", "Alice"), true);
});
// ── Boundary conditions ───────────────────────────────────────────────
test("matches @Name followed by punctuation", () => {

View File

@@ -23,6 +23,7 @@ import {
handleCodeFenceEnter,
insertNewlineInCodeBlock,
} from "./codeBlockExtensions";
import { SpoilerMark } from "./spoilerMark";
/**
* Plain-text edit descriptor returned by autocomplete hooks
@@ -291,6 +292,7 @@ export function useRichTextEditor({
},
}),
CodeBlockAfterHardBreak,
SpoilerMark,
MentionHighlightExtension,
customEmojiWiring.extension,
Placeholder.configure({

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { FileText, Play, X } from "lucide-react";
import { FileText, HatGlasses, Play, X } from "lucide-react";
import type { BlobDescriptor } from "@/shared/api/tauri";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
@@ -36,39 +36,16 @@ type ComposerAttachmentsProps = {
uploadingCount?: number;
uploadingPreviews?: UploadingAttachmentPreview[];
onRemove: (url: string) => void;
spoileredUrls?: ReadonlySet<string>;
};
const COMPOSER_MEDIA_HEIGHT_PX = 55;
const COMPOSER_MEDIA_MAX_WIDTH_PX = 129;
const COMPOSER_MEDIA_MIN_WIDTH_PX = 64;
const COMPOSER_MEDIA_WIDTH_PX = 55;
function aspectRatioFromDim(dim?: string): number | undefined {
if (!dim) return undefined;
const match = dim.match(/^(\d+)x(\d+)$/i);
if (!match) return undefined;
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) {
return undefined;
}
return width / height;
}
function composerMediaStyle(dim?: string): React.CSSProperties {
const aspectRatio = aspectRatioFromDim(dim) ?? 16 / 9;
const widthPx = Math.round(
Math.min(
COMPOSER_MEDIA_MAX_WIDTH_PX,
Math.max(
COMPOSER_MEDIA_MIN_WIDTH_PX,
aspectRatio * COMPOSER_MEDIA_HEIGHT_PX,
),
),
);
function composerMediaStyle(): React.CSSProperties {
return {
aspectRatio: String(aspectRatio),
height: COMPOSER_MEDIA_HEIGHT_PX,
width: widthPx,
width: COMPOSER_MEDIA_WIDTH_PX,
};
}
@@ -84,6 +61,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
uploadingPreviews = [],
onCancelUpload,
onRemove,
spoileredUrls,
}: ComposerAttachmentsProps) {
if (attachments.length === 0 && !isUploading) return null;
@@ -107,6 +85,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
const isVideo = attachment.type.startsWith("video/");
const isImage = attachment.type.startsWith("image/");
const isFile = !isVideo && !isImage;
const isSpoilered = spoileredUrls?.has(attachment.url) ?? false;
const thumbUrl = attachment.thumb
? rewriteRelayUrl(attachment.thumb)
: rewriteRelayUrl(attachment.url);
@@ -115,7 +94,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
: attachment.thumb
? rewriteRelayUrl(attachment.thumb)
: undefined;
const mediaStyle = composerMediaStyle(attachment.dim);
const mediaStyle = composerMediaStyle();
// Generic file: compact chip with a file icon + filename, plus the
// same remove button. No lightbox (nothing to preview).
@@ -167,7 +146,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
className="group relative"
>
<div
className="relative h-[55px] max-w-[129px]"
className="relative h-[55px] max-w-[55px]"
style={mediaStyle}
>
<DialogPrimitive.Root>
@@ -193,9 +172,17 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
<img
src={thumbUrl}
alt={`Attachment ${hash}`}
className="h-full w-full object-contain"
className="h-full w-full object-cover"
/>
)}
{isSpoilered ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
data-composer-media-spoiler=""
>
<HatGlasses className="h-5 w-5" />
</div>
) : null}
</div>
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
@@ -265,8 +252,8 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
className="group relative"
>
<div
className="relative h-[55px] max-w-[129px]"
style={composerMediaStyle(preview.dim)}
className="relative h-[55px] max-w-[55px]"
style={composerMediaStyle()}
>
<div className="h-full w-full overflow-hidden rounded-2xl border border-border/70 bg-muted">
{preview.posterUrl ? (

View File

@@ -14,12 +14,19 @@ import {
import { cn } from "@/shared/lib/cn";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { getEditorSpoilerRangeState } from "@/features/messages/lib/spoilerFormatting";
import { SPOILER_MARK_NAME } from "@/features/messages/lib/spoilerMark";
type FormattingToolbarProps = {
editor: Editor | null;
disabled?: boolean;
};
export type SpoilerToggleState = {
emptySelection: boolean;
nextSpoilered?: boolean;
};
type ActiveStates = {
bold: boolean;
italic: boolean;
@@ -46,6 +53,53 @@ function getActiveStates(editor: Editor): ActiveStates {
};
}
export function isSpoilerFormattingActive(editor: Editor): boolean {
return editor.isActive(SPOILER_MARK_NAME);
}
function documentRangeForEmptySelection(editor: Editor): {
from: number;
to: number;
} | null {
const { doc, selection } = editor.state;
if (!selection.empty) return null;
if (doc.textContent.trim().length === 0) return null;
const from = 1;
const to = doc.content.size - 1;
return from < to ? { from, to } : null;
}
export function toggleSpoilerFormatting(editor: Editor): SpoilerToggleState {
const emptySelection = editor.state.selection.empty;
const range = documentRangeForEmptySelection(editor);
if (!range) {
editor.chain().focus().toggleMark(SPOILER_MARK_NAME).run();
return { emptySelection };
}
const cursorPosition = editor.state.selection.from;
const chain = editor.chain().focus().setTextSelection(range);
const rangeSpoilerState = getEditorSpoilerRangeState(
editor,
range.from,
range.to,
);
if (rangeSpoilerState === "no-markable-content") {
chain.setTextSelection(cursorPosition).run();
return { emptySelection };
}
const nextSpoilered = rangeSpoilerState !== "fully-spoiled";
if (nextSpoilered) {
chain.setMark(SPOILER_MARK_NAME).setTextSelection(cursorPosition).run();
} else {
chain.unsetMark(SPOILER_MARK_NAME).setTextSelection(cursorPosition).run();
}
return { emptySelection, nextSpoilered };
}
/**
* Formatting bar shown above the editor when the format toggle is active.
* Uses plain buttons with explicit active-class toggling instead of

View File

@@ -12,6 +12,7 @@ import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags";
import {
buildOutgoingMessage,
findSpoileredImetaMediaUrls,
type ImetaMedia,
mergeOutgoingTags,
stripImetaMediaLines,
@@ -32,6 +33,7 @@ import {
type AutocompleteEdit,
useRichTextEditor,
} from "@/features/messages/lib/useRichTextEditor";
import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposerSpoilerParticles";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
import { cn } from "@/shared/lib/cn";
@@ -129,6 +131,9 @@ export function MessageComposer({
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
Set<string>
>(() => new Set());
const handleFormattingToggle = React.useCallback((pressed: boolean) => {
if (pressed) setIsEmojiPickerOpen(false);
@@ -147,6 +152,7 @@ export function MessageComposer({
const preEditSnapshotRef = React.useRef<{
content: string;
pendingImeta: ImetaMedia[];
spoileredAttachmentUrls: Set<string>;
} | null>(null);
const mentions = useMentions(channelId, undefined, profiles, {
channelType,
@@ -239,6 +245,8 @@ export function MessageComposer({
},
});
useComposerSpoilerParticles(richText.editor, composerScrollRef);
const mentionSendFlow = useMentionSendFlow({
channelId,
channelLinks,
@@ -253,6 +261,7 @@ export function MessageComposer({
setContent,
setIsEmojiPickerOpen,
setPendingImeta: media.setPendingImeta,
setSpoileredAttachmentUrls,
});
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
@@ -277,6 +286,7 @@ export function MessageComposer({
}
media.setPendingImeta([]);
setSpoileredAttachmentUrls(new Set());
media.setUploadState({ status: "idle" });
setIsEmojiPickerOpen(false);
mentions.clearMentions();
@@ -299,6 +309,7 @@ export function MessageComposer({
preEditSnapshotRef.current = {
content: contentRef.current,
pendingImeta: [...media.pendingImetaRef.current],
spoileredAttachmentUrls: new Set(spoileredAttachmentUrls),
};
// Strip the trailing `![image|video](url)` lines that correspond to
// imeta attachments — the user manages those via the attachments row,
@@ -314,6 +325,12 @@ export function MessageComposer({
// attachments so they show up in `ComposerAttachments` and the user
// can remove existing ones / add new ones before saving.
media.setPendingImeta(editTarget.imetaMedia ?? []);
setSpoileredAttachmentUrls(
findSpoileredImetaMediaUrls(
editTarget.body,
editTarget.imetaMedia ?? [],
),
);
// Defer focus to the next frame so it runs after any focus-
// restoration the trigger UI (e.g. the message-row context menu)
// fires on close. Without this, Radix-style focus-restoration races
@@ -323,8 +340,11 @@ export function MessageComposer({
const rafId = requestAnimationFrame(() => richText.focusEnd());
return () => cancelAnimationFrame(rafId);
} else if (preEditSnapshotRef.current !== null) {
const { content: restoredContent, pendingImeta: restoredImeta } =
preEditSnapshotRef.current;
const {
content: restoredContent,
pendingImeta: restoredImeta,
spoileredAttachmentUrls: restoredSpoileredAttachmentUrls,
} = preEditSnapshotRef.current;
preEditSnapshotRef.current = null;
setContent(restoredContent);
contentRef.current = restoredContent;
@@ -332,6 +352,7 @@ export function MessageComposer({
? richText.setContent(restoredContent)
: richText.clearContent();
media.setPendingImeta(restoredImeta);
setSpoileredAttachmentUrls(restoredSpoileredAttachmentUrls);
}
}, [editTarget?.id]);
@@ -485,6 +506,7 @@ export function MessageComposer({
const { content: finalContent, mediaTags } = buildOutgoingMessage(
trimmed,
currentPendingImeta,
spoileredAttachmentUrls,
);
// NIP-30: attach `["emoji", shortcode, url]` tags for custom emoji in the
@@ -500,10 +522,12 @@ export function MessageComposer({
const savedContent = trimmed;
const savedImeta = [...currentPendingImeta];
const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls);
setContent("");
contentRef.current = "";
richText.clearContent();
media.setPendingImeta([]);
setSpoileredAttachmentUrls(new Set());
mentions.clearMentions();
channelLinks.clearChannels();
emojiAutocomplete.clearEmojis();
@@ -516,6 +540,7 @@ export function MessageComposer({
contentRef.current = savedContent;
richText.setContent(savedContent);
media.setPendingImeta(savedImeta);
setSpoileredAttachmentUrls(savedSpoileredAttachmentUrls);
}
return;
}
@@ -536,6 +561,7 @@ export function MessageComposer({
await mentionSendFlow.sendMessageWithMentionFlow({
pendingImeta: currentPendingImeta,
sentDraftKey: effectiveDraftKeyRef.current,
spoileredAttachmentUrls,
trimmed,
});
}, [
@@ -549,6 +575,7 @@ export function MessageComposer({
mentions.clearMentions,
richText.clearContent,
richText.setContent,
spoileredAttachmentUrls,
]);
submitMessageRef.current = submitMessage;
@@ -711,6 +738,55 @@ export function MessageComposer({
void media.handlePaperclip();
}, [media.handlePaperclip]);
const handleRemoveAttachment = React.useCallback(
(url: string) => {
setSpoileredAttachmentUrls((current) => {
if (!current.has(url)) return current;
const next = new Set(current);
next.delete(url);
return next;
});
media.removeAttachment(url);
},
[media.removeAttachment],
);
const handleComposerSpoilerToggle = React.useCallback(
({
emptySelection,
nextSpoilered,
}: {
emptySelection: boolean;
nextSpoilered?: boolean;
}) => {
if (!emptySelection) return;
const mediaUrls = media.pendingImetaRef.current
.filter(
(attachment) =>
attachment.type.startsWith("image/") ||
attachment.type.startsWith("video/"),
)
.map((attachment) => attachment.url);
if (mediaUrls.length === 0) return;
setSpoileredAttachmentUrls((current) => {
const shouldSpoiler =
nextSpoilered ?? mediaUrls.some((url) => !current.has(url));
const next = new Set(current);
for (const url of mediaUrls) {
if (shouldSpoiler) {
next.add(url);
} else {
next.delete(url);
}
}
return next;
});
},
[media.pendingImetaRef],
);
// ── Render ──────────────────────────────────────────────────────────
return (
<>
@@ -839,14 +915,15 @@ export function MessageComposer({
onCancelUpload={media.cancelUpload}
uploadingCount={media.uploadingCount}
uploadingPreviews={media.uploadingPreviews}
onRemove={media.removeAttachment}
onRemove={handleRemoveAttachment}
spoileredUrls={spoileredAttachmentUrls}
/>
</div>
)}
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className="rich-text-composer max-h-32 overflow-y-auto"
className="rich-text-composer relative max-h-32 overflow-y-auto"
data-testid="message-input-scroll"
ref={composerScrollRef}
onKeyDown={handleEditorKeyDown}
@@ -869,7 +946,9 @@ export function MessageComposer({
onFormattingToggle={handleFormattingToggle}
onOpenMentionPicker={openMentionPicker}
onPaperclip={handlePaperclipClick}
onSpoilerToggle={handleComposerSpoilerToggle}
sendDisabled={sendDisabled}
spoilerActive={spoileredAttachmentUrls.size > 0}
/>
</form>
</div>

View File

@@ -1,12 +1,25 @@
import * as React from "react";
import type { Editor } from "@tiptap/react";
import { AnimatePresence, motion } from "motion/react";
import { ALargeSmall, ArrowUp, AtSign, Paperclip, X } from "lucide-react";
import {
ALargeSmall,
ArrowUp,
AtSign,
HatGlasses,
Paperclip,
X,
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
import { FormattingToolbar } from "./FormattingToolbar";
import {
FormattingToolbar,
isSpoilerFormattingActive,
type SpoilerToggleState,
toggleSpoilerFormatting,
} from "./FormattingToolbar";
/** Spring for enter/exit of button groups — all fire simultaneously. */
const presenceSpring = {
@@ -31,7 +44,9 @@ export const MessageComposerToolbar = React.memo(
onFormattingToggle,
onOpenMentionPicker,
onPaperclip,
onSpoilerToggle,
sendDisabled,
spoilerActive,
}: {
composerDisabled: boolean;
editor: Editor | null;
@@ -47,8 +62,38 @@ export const MessageComposerToolbar = React.memo(
onFormattingToggle: (pressed: boolean) => void;
onOpenMentionPicker: () => void;
onPaperclip: () => void;
onSpoilerToggle?: (state: SpoilerToggleState) => void;
sendDisabled: boolean;
spoilerActive?: boolean;
}) {
const [spoilerFormattingActive, setSpoilerFormattingActive] =
React.useState(() =>
editor ? isSpoilerFormattingActive(editor) : false,
);
React.useEffect(() => {
if (!editor) {
setSpoilerFormattingActive(false);
return;
}
const update = () => {
setSpoilerFormattingActive(isSpoilerFormattingActive(editor));
};
update();
editor.on("transaction", update);
return () => {
editor.off("transaction", update);
};
}, [editor]);
const isSpoilerActive = spoilerFormattingActive || Boolean(spoilerActive);
const handleSpoilerClick = React.useCallback(() => {
if (!editor) return;
onSpoilerToggle?.(toggleSpoilerFormatting(editor));
}, [editor, onSpoilerToggle]);
return (
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
<div className="flex min-h-10 min-w-0 flex-1 items-center gap-1 py-1">
@@ -189,6 +234,27 @@ export const MessageComposerToolbar = React.memo(
onTriggerMouseDown={onCaptureSelection}
open={isEmojiPickerOpen}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Spoiler"
aria-pressed={isSpoilerActive}
className={cn(
isSpoilerActive &&
"bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground",
)}
disabled={composerDisabled || !editor || isUploading}
onClick={handleSpoilerClick}
onMouseDown={onCaptureSelection}
size="icon"
type="button"
variant={isSpoilerActive ? "default" : "ghost"}
>
<HatGlasses />
</Button>
</TooltipTrigger>
<TooltipContent>Spoiler</TooltipContent>
</Tooltip>
<motion.div
initial={{ x: -8, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}

View File

@@ -34,12 +34,14 @@ type PendingNonMemberMentionSend = {
readyAgentPubkeys?: string[];
savedContent: string;
savedImeta: ImetaMedia[];
savedSpoileredAttachmentUrls: Set<string>;
sentDraftKey: string | null | undefined;
};
type SendMessageWithMentionFlowInput = {
pendingImeta: ImetaMedia[];
sentDraftKey: string | null | undefined;
spoileredAttachmentUrls?: ReadonlySet<string>;
trimmed: string;
};
@@ -63,6 +65,9 @@ type UseMentionSendFlowOptions = {
setContent: React.Dispatch<React.SetStateAction<string>>;
setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
setPendingImeta: (pendingImeta: ImetaMedia[]) => void;
setSpoileredAttachmentUrls?: React.Dispatch<
React.SetStateAction<Set<string>>
>;
};
function mergeOutgoingTagsWithReferenceMentions(
@@ -110,6 +115,7 @@ export function useMentionSendFlow({
setContent,
setIsEmojiPickerOpen,
setPendingImeta,
setSpoileredAttachmentUrls,
}: UseMentionSendFlowOptions) {
const [pendingNonMemberSend, setPendingNonMemberSend] =
React.useState<PendingNonMemberMentionSend | null>(null);
@@ -293,6 +299,7 @@ export function useMentionSendFlow({
contentRef.current = "";
richText.clearContent();
setPendingImeta([]);
setSpoileredAttachmentUrls?.(new Set());
mentions.clearMentions();
channelLinks.clearChannels();
emojiAutocomplete.clearEmojis();
@@ -306,6 +313,7 @@ export function useMentionSendFlow({
setContent,
setIsEmojiPickerOpen,
setPendingImeta,
setSpoileredAttachmentUrls,
]);
React.useEffect(() => {
@@ -367,6 +375,9 @@ export function useMentionSendFlow({
contentRef.current = draft.savedContent;
richText.setContent(draft.savedContent);
setPendingImeta(draft.savedImeta);
setSpoileredAttachmentUrls?.(
new Set(draft.savedSpoileredAttachmentUrls),
);
}
} finally {
isCompleteSendPendingRef.current = false;
@@ -382,6 +393,7 @@ export function useMentionSendFlow({
richText.setContent,
setContent,
setPendingImeta,
setSpoileredAttachmentUrls,
],
);
@@ -406,6 +418,7 @@ export function useMentionSendFlow({
async ({
pendingImeta,
sentDraftKey,
spoileredAttachmentUrls = new Set(),
trimmed,
}: SendMessageWithMentionFlowInput) => {
if (isMentionSendPendingRef.current) {
@@ -440,6 +453,7 @@ export function useMentionSendFlow({
const { content: finalContent, mediaTags } = buildOutgoingMessage(
trimmed,
pendingImeta,
spoileredAttachmentUrls,
);
const outgoingTags = mergeOutgoingTags(
mediaTags,
@@ -472,6 +486,7 @@ export function useMentionSendFlow({
readyAgentPubkeys: createdPersonaAgentPubkeys,
savedContent: trimmed,
savedImeta: [...pendingImeta],
savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls),
sentDraftKey,
};

View File

@@ -0,0 +1,172 @@
import assert from "node:assert/strict";
import test from "node:test";
import remarkSpoilers from "./remarkSpoilers.ts";
function runPlugin(tree) {
remarkSpoilers()(tree);
return tree;
}
test("remarkSpoilers: wraps text between double-pipe delimiters", () => {
const tree = runPlugin({
type: "root",
children: [
{
type: "paragraph",
children: [{ type: "text", value: "keep ||secret|| visible" }],
},
],
});
const children = tree.children[0].children;
assert.deepEqual(children[0], { type: "text", value: "keep " });
assert.equal(children[1].type, "spoiler");
assert.deepEqual(children[1].data, { hName: "spoiler" });
assert.deepEqual(children[1].children, [{ type: "text", value: "secret" }]);
assert.deepEqual(children[2], { type: "text", value: " visible" });
});
test("remarkSpoilers: groups formatted inline nodes between delimiters", () => {
const tree = runPlugin({
type: "root",
children: [
{
type: "paragraph",
children: [
{ type: "text", value: "||" },
{
type: "strong",
children: [{ type: "text", value: "secret" }],
},
{ type: "text", value: "||" },
],
},
],
});
const children = tree.children[0].children;
assert.equal(children.length, 1);
assert.equal(children[0].type, "spoiler");
assert.equal(children[0].children[0].type, "strong");
});
test("remarkSpoilers: groups inline images between delimiters", () => {
const tree = runPlugin({
type: "root",
children: [
{
type: "paragraph",
children: [
{ type: "text", value: "||" },
{ type: "image", url: "https://example.com/secret.png", alt: "" },
{ type: "text", value: "||" },
],
},
],
});
const children = tree.children[0].children;
assert.equal(children.length, 1);
assert.equal(children[0].type, "spoiler");
assert.equal(children[0].children[0].type, "image");
});
test("remarkSpoilers: parses spoiler delimiters inside link labels", () => {
const tree = runPlugin({
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "link",
url: "https://example.com/a||b",
children: [{ type: "text", value: "||secret||" }],
},
],
},
],
});
const link = tree.children[0].children[0];
assert.equal(link.type, "link");
assert.equal(link.url, "https://example.com/a||b");
assert.equal(link.children.length, 1);
assert.equal(link.children[0].type, "spoiler");
assert.deepEqual(link.children[0].children, [
{ type: "text", value: "secret" },
]);
});
test("remarkSpoilers: groups block nodes between delimiter paragraphs", () => {
const tree = runPlugin({
type: "root",
children: [
{
type: "paragraph",
children: [{ type: "text", value: "before" }],
},
{
type: "paragraph",
children: [{ type: "text", value: "||" }],
},
{
type: "paragraph",
children: [
{ type: "image", url: "https://example.com/secret.png", alt: "" },
],
},
{
type: "paragraph",
children: [{ type: "text", value: "||" }],
},
{
type: "paragraph",
children: [{ type: "text", value: "after" }],
},
],
});
assert.equal(tree.children.length, 3);
assert.equal(tree.children[1].type, "spoiler");
assert.deepEqual(tree.children[1].data, {
hName: "spoiler",
hProperties: { "data-block-spoiler": "" },
});
assert.equal(tree.children[1].children[0].children[0].type, "image");
});
test("remarkSpoilers: leaves unmatched delimiters as text", () => {
const tree = runPlugin({
type: "root",
children: [
{
type: "paragraph",
children: [{ type: "text", value: "keep ||plain" }],
},
],
});
assert.deepEqual(tree.children[0].children, [
{ type: "text", value: "keep " },
{ type: "text", value: "||" },
{ type: "text", value: "plain" },
]);
});
test("remarkSpoilers: does not parse delimiters inside inline code", () => {
const tree = runPlugin({
type: "root",
children: [
{
type: "paragraph",
children: [{ type: "inlineCode", value: "||secret||" }],
},
],
});
assert.deepEqual(tree.children[0].children, [
{ type: "inlineCode", value: "||secret||" },
]);
});

View File

@@ -0,0 +1,162 @@
/**
* Remark plugin that turns chat-style spoiler spans (`||secret||`) into a
* custom HAST element rendered by `markdown.tsx`.
*/
type Node = {
// biome-ignore lint/suspicious/noExplicitAny: building mdast-compatible nodes
[key: string]: any;
};
type Part =
| { type: "delimiter" }
| {
type: "node";
node: Node;
};
export default function remarkSpoilers() {
return (
// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available
tree: any,
) => {
transformNode(tree);
};
}
function transformNode(node: Node) {
if (
!node?.children ||
!Array.isArray(node.children) ||
shouldSkipNode(node)
) {
return;
}
for (const child of node.children) {
transformNode(child);
}
node.children = groupBlockSpoilers(groupSpoilers(node.children));
}
function groupSpoilers(children: Node[]): Node[] {
const output: Node[] = [];
let spoilerBuffer: Node[] | null = null;
for (const part of splitDelimiterParts(children)) {
if (part.type === "delimiter") {
if (spoilerBuffer) {
output.push(buildSpoilerNode(spoilerBuffer));
spoilerBuffer = null;
} else {
spoilerBuffer = [];
}
continue;
}
if (spoilerBuffer) {
spoilerBuffer.push(part.node);
} else {
output.push(part.node);
}
}
if (spoilerBuffer) {
output.push({ type: "text", value: "||" }, ...spoilerBuffer);
}
return output;
}
function splitDelimiterParts(children: Node[]): Part[] {
const parts: Part[] = [];
for (const child of children) {
if (child.type !== "text") {
parts.push({ type: "node", node: child });
continue;
}
const text = String(child.value ?? "");
let cursor = 0;
while (cursor < text.length) {
const delimiterIndex = text.indexOf("||", cursor);
if (delimiterIndex === -1) {
const value = text.slice(cursor);
if (value) parts.push({ type: "node", node: { ...child, value } });
break;
}
const before = text.slice(cursor, delimiterIndex);
if (before)
parts.push({ type: "node", node: { ...child, value: before } });
parts.push({ type: "delimiter" });
cursor = delimiterIndex + 2;
}
}
return parts;
}
function buildSpoilerNode(
children: Node[],
options: { block?: boolean } = {},
): Node {
return {
type: "spoiler",
children,
data: {
hName: "spoiler",
...(options.block ? { hProperties: { "data-block-spoiler": "" } } : {}),
},
};
}
function groupBlockSpoilers(children: Node[]): Node[] {
const output: Node[] = [];
let spoilerBuffer: Node[] | null = null;
let openingDelimiter: Node | null = null;
for (const child of children) {
if (isBlockDelimiter(child)) {
if (spoilerBuffer) {
output.push(buildSpoilerNode(spoilerBuffer, { block: true }));
spoilerBuffer = null;
openingDelimiter = null;
} else {
spoilerBuffer = [];
openingDelimiter = child;
}
continue;
}
if (spoilerBuffer) {
spoilerBuffer.push(child);
} else {
output.push(child);
}
}
if (spoilerBuffer) {
if (openingDelimiter) output.push(openingDelimiter);
output.push(...spoilerBuffer);
}
return output;
}
function isBlockDelimiter(node: Node): boolean {
return (
node.type === "paragraph" &&
Array.isArray(node.children) &&
node.children.length === 1 &&
node.children[0]?.type === "text" &&
String(node.children[0].value ?? "").trim() === "||"
);
}
function shouldSkipNode(node: Node): boolean {
return node.type === "code" || node.type === "inlineCode";
}

View File

@@ -982,6 +982,130 @@
}
}
.buzz-spoiler {
--buzz-spoiler-accent: #333;
-webkit-box-decoration-break: clone;
background: transparent;
border-radius: 0.3rem;
box-decoration-break: clone;
cursor: pointer;
display: inline-block;
line-height: inherit;
padding: 0 0.12em;
position: relative;
transition:
background-color 500ms ease-out,
color 500ms ease-out;
vertical-align: baseline;
}
.dark .buzz-spoiler {
--buzz-spoiler-accent: #fff;
}
.buzz-spoiler__content {
position: relative;
transition:
opacity 500ms ease-out,
filter 500ms ease-out;
z-index: 0;
}
.buzz-spoiler:not([data-revealed="true"]) .buzz-spoiler__content,
.buzz-spoiler:not([data-revealed="true"]) .buzz-spoiler__content * {
color: transparent;
filter: blur(0.5px);
opacity: 0;
text-decoration-color: transparent;
text-shadow: none;
transition:
opacity 200ms ease-out,
filter 200ms ease-out;
}
.buzz-spoiler__particles {
background: transparent;
border-radius: inherit;
color: var(--buzz-spoiler-accent, #333);
height: calc(100% + 0.2em);
inset: -0.1em -0.1em;
opacity: 1;
pointer-events: none;
position: absolute;
transition: opacity 500ms ease-out;
width: calc(100% + 0.2em);
z-index: 1;
}
.dark .buzz-spoiler__particles {
--buzz-spoiler-accent: #fff;
}
.buzz-spoiler--block {
display: inline-block;
max-width: 100%;
}
.buzz-spoiler--block .buzz-spoiler__content,
.buzz-spoiler--block [data-block-media] {
display: inline-block;
max-width: 100%;
}
.buzz-spoiler--block img[data-spoiler-media-size] {
height: var(--buzz-spoiler-media-height);
width: var(--buzz-spoiler-media-width);
}
.buzz-spoiler--inert,
.buzz-spoiler--inert * {
cursor: inherit;
pointer-events: none;
}
.buzz-spoiler[data-revealed="true"],
.buzz-spoiler:hover,
.buzz-spoiler:focus-visible {
animation: none;
background: hsl(var(--muted) / 0.5);
color: inherit;
text-decoration-color: currentColor;
}
.buzz-spoiler[data-revealed="true"] .buzz-spoiler__particles {
opacity: 0;
}
.ProseMirror .buzz-spoiler {
background: transparent;
color: transparent;
cursor: text;
text-decoration-color: transparent;
}
.buzz-spoiler-composer-particles {
inset: 0;
pointer-events: none;
position: absolute;
z-index: 1;
}
.buzz-spoiler__particles--composer {
border-radius: 0.3rem;
height: auto;
inset: auto;
position: absolute;
transition: none;
width: auto;
}
@media (prefers-reduced-motion: reduce) {
.buzz-spoiler__particles {
transition: none;
}
}
@layer components {
.buzz-huddle-shell {
--buzz-huddle-drawer-height: 5rem;

View File

@@ -0,0 +1,501 @@
import * as React from "react";
type Hsl = {
h: number;
s: number;
l: number;
};
type Point = {
x: number;
y: number;
};
type Particle = {
originX: number;
originY: number;
vx: number;
vy: number;
size: number;
lifetime: number;
respawn: number;
phase: number;
lightness: number;
shape: "circle" | "square";
};
type ParticleScene = {
accent: Hsl;
cssHeight: number;
cssWidth: number;
dpr: number;
particles: Particle[];
};
type SpoilerParticlesProps = {
active: boolean;
contentRef: React.RefObject<HTMLElement | null>;
};
type SpoilerParticleCanvasOptions = {
canvas: HTMLCanvasElement;
content: HTMLElement;
syncCanvas?: () => boolean;
};
const DEFAULT_ACCENT: Hsl = { h: 0, s: 0, l: 100 };
const DEFAULT_DENSITY = 0.12;
const DEFAULT_FPS = 24;
const DEFAULT_GAP = 6;
const GAP_RATIO = 8;
const MAX_PARTICLES = 5000;
const RANDOM_SEED = 4011505;
const V_MIN = 2;
const V_MAX = 12;
export function SpoilerParticles({
active,
contentRef,
}: SpoilerParticlesProps) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
React.useEffect(() => {
if (!active) return;
const canvas = canvasRef.current;
const content = contentRef.current;
if (!canvas || !content) return;
return mountSpoilerParticleCanvas({ canvas, content });
}, [active, contentRef]);
return <canvas className="buzz-spoiler__particles" ref={canvasRef} />;
}
export function mountSpoilerParticleCanvas({
canvas,
content,
syncCanvas,
}: SpoilerParticleCanvasOptions): () => void {
const context = canvas.getContext("2d");
if (!context) return () => {};
let animationFrame = 0;
let lastFrameTime = 0;
let scene: ParticleScene | null = null;
let sceneIsStale = true;
let visible = true;
let reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const ensureScene = () => {
if (syncCanvas && !syncCanvas()) {
context.clearRect(0, 0, canvas.width, canvas.height);
return null;
}
const rect = canvas.getBoundingClientRect();
const dpr = Math.max(1, window.devicePixelRatio || 1);
const cssWidth = Math.max(1, rect.width);
const cssHeight = Math.max(1, rect.height);
if (
!scene ||
sceneIsStale ||
scene.cssWidth !== cssWidth ||
scene.cssHeight !== cssHeight ||
scene.dpr !== dpr
) {
scene = buildScene(canvas, content, cssWidth, cssHeight, dpr);
sceneIsStale = false;
}
return scene;
};
const draw = (timeMs: number) => {
const currentScene = ensureScene();
if (!currentScene) return;
drawParticles(context, currentScene, reducedMotion ? 0 : timeMs / 1000);
};
const stop = () => {
if (animationFrame) {
window.cancelAnimationFrame(animationFrame);
animationFrame = 0;
}
};
const frame = (timeMs: number) => {
if (lastFrameTime === 0 || timeMs - lastFrameTime >= 1000 / DEFAULT_FPS) {
draw(timeMs);
lastFrameTime = timeMs;
}
animationFrame = window.requestAnimationFrame(frame);
};
const start = () => {
if (!visible || animationFrame) return;
if (reducedMotion) {
draw(0);
return;
}
animationFrame = window.requestAnimationFrame(frame);
};
const resizeObserver = new ResizeObserver(() => {
sceneIsStale = true;
draw(performance.now());
});
resizeObserver.observe(content);
const contentObserver = new MutationObserver(() => {
sceneIsStale = true;
draw(performance.now());
});
contentObserver.observe(content, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
const themeObserver = new MutationObserver(() => {
sceneIsStale = true;
draw(performance.now());
});
themeObserver.observe(content.ownerDocument.documentElement, {
attributeFilter: ["class", "style"],
attributes: true,
});
const intersectionObserver = new IntersectionObserver(
([entry]) => {
visible = entry?.isIntersecting ?? true;
if (visible) {
start();
} else {
stop();
}
},
{ rootMargin: "120px" },
);
intersectionObserver.observe(content);
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const handleMotionChange = () => {
reducedMotion = motionQuery.matches;
stop();
start();
};
motionQuery.addEventListener("change", handleMotionChange);
draw(performance.now());
start();
return () => {
stop();
resizeObserver.disconnect();
contentObserver.disconnect();
themeObserver.disconnect();
intersectionObserver.disconnect();
motionQuery.removeEventListener("change", handleMotionChange);
};
}
function buildScene(
canvas: HTMLCanvasElement,
content: HTMLElement,
cssWidth: number,
cssHeight: number,
dpr: number,
): ParticleScene | null {
const pixelWidth = Math.max(1, Math.ceil(cssWidth * dpr));
const pixelHeight = Math.max(1, Math.ceil(cssHeight * dpr));
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
const accent = readAccent(canvas);
const maskPoints = buildWordMaskPoints(content, canvas, cssWidth, cssHeight);
const points =
maskPoints.length > 0
? maskPoints
: buildFallbackPoints(cssWidth, cssHeight);
const particleCount = Math.round(
Math.min(
MAX_PARTICLES,
Math.max(16, DEFAULT_DENSITY * cssWidth * cssHeight),
),
);
const rand = lcgrand(RANDOM_SEED);
const ldir = accent.l > 50 ? -1 : 1;
const particles: Particle[] = Array.from({ length: particleCount }, () => {
const point = points[Math.floor(rand(0, points.length))] ?? {
x: rand(0, cssWidth),
y: rand(0, cssHeight),
};
const speed = rand(V_MIN, V_MAX);
const angle = rand(0, Math.PI * 2);
const lifetime = rand(0.3, 1.5);
const respawn = rand(0, 1);
return {
originX: point.x + rand(-0.35, 0.35),
originY: point.y + rand(-0.35, 0.35),
vx: speed * Math.cos(angle),
vy: speed * Math.sin(angle),
size: rand(1, 1 + (dpr > 1 ? 0.5 : 0)),
lifetime,
respawn,
phase: rand(0, lifetime + respawn),
lightness: clamp(accent.l + ldir * rand(0, 30), 0, 100),
shape: rand() > 0.5 ? "square" : "circle",
};
});
return {
accent,
cssHeight,
cssWidth,
dpr,
particles,
};
}
function buildWordMaskPoints(
content: HTMLElement,
canvas: HTMLCanvasElement,
cssWidth: number,
cssHeight: number,
): Point[] {
const canvasRect = canvas.getBoundingClientRect();
const document = content.ownerDocument;
const range = document.createRange();
const points: Point[] = [];
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
return node.textContent?.trim()
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
while (walker.nextNode()) {
const node = walker.currentNode;
const text = node.textContent ?? "";
const words = text.matchAll(/\S+/g);
for (const word of words) {
const start = word.index ?? 0;
const end = start + word[0].length;
range.setStart(node, start);
range.setEnd(node, end);
for (const rect of range.getClientRects()) {
addRectPoints(points, {
x: rect.left - canvasRect.left,
y: rect.top - canvasRect.top,
width: rect.width,
height: rect.height,
});
}
}
}
range.detach();
for (const media of content.querySelectorAll("img, video")) {
const rect = media.getBoundingClientRect();
addRectPoints(points, {
height: rect.height,
width: rect.width,
x: rect.left - canvasRect.left,
y: rect.top - canvasRect.top,
});
}
return points.filter(
(point) =>
point.x >= 0 &&
point.y >= 0 &&
point.x <= cssWidth &&
point.y <= cssHeight,
);
}
function addRectPoints(
points: Point[],
rect: { x: number; y: number; width: number; height: number },
) {
if (rect.width <= 0 || rect.height <= 0) return;
const verticalGap = Math.min(DEFAULT_GAP, rect.height / GAP_RATIO);
const yStart = rect.y + verticalGap;
const yEnd = rect.y + Math.max(verticalGap, rect.height - verticalGap);
const step = 1;
for (let y = yStart; y <= yEnd; y += step) {
for (let x = rect.x; x <= rect.x + rect.width; x += step) {
points.push({ x, y });
}
}
}
function buildFallbackPoints(cssWidth: number, cssHeight: number): Point[] {
const points: Point[] = [];
const verticalGap = Math.min(DEFAULT_GAP, cssHeight / GAP_RATIO);
for (let y = verticalGap; y <= cssHeight - verticalGap; y += 1) {
for (let x = 0; x <= cssWidth; x += 1) {
points.push({ x, y });
}
}
return points;
}
function drawParticles(
context: CanvasRenderingContext2D,
scene: ParticleScene,
time: number,
) {
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(
0,
0,
Math.ceil(scene.cssWidth * scene.dpr),
Math.ceil(scene.cssHeight * scene.dpr),
);
context.setTransform(scene.dpr, 0, 0, scene.dpr, 0, 0);
for (const particle of scene.particles) {
const cycle = particle.lifetime + particle.respawn;
const t = Math.min(particle.lifetime, (time + particle.phase) % cycle);
const visibility = trapezoidalWave(particle.lifetime, 0.15, 0.3)(t);
const alpha = clamp(1 - t / particle.lifetime, 0, 1);
const size = particle.size * visibility;
if (size <= 0) continue;
const x = particle.originX + particle.vx * t;
const y = particle.originY + particle.vy * t;
context.fillStyle = `hsl(${scene.accent.h} ${scene.accent.s}% ${
particle.lightness
}% / ${Math.round(alpha * 100)}%)`;
for (const [wrappedX, wrappedY] of cycleBounds(
x,
y,
scene.cssWidth,
scene.cssHeight,
size / 2,
)) {
context.beginPath();
if (particle.shape === "square") {
context.rect(wrappedX, wrappedY, size, size);
} else {
context.arc(wrappedX, wrappedY, size / 2, 0, Math.PI * 2);
}
context.fill();
}
}
}
function readAccent(canvas: HTMLCanvasElement): Hsl {
const parsed = parseRgb(window.getComputedStyle(canvas).color);
return parsed ? rgbToHsl(parsed.r, parsed.g, parsed.b) : DEFAULT_ACCENT;
}
function parseRgb(value: string): { r: number; g: number; b: number } | null {
const match = value.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
if (!match) return null;
return {
b: Number(match[3]),
g: Number(match[2]),
r: Number(match[1]),
};
}
function rgbToHsl(r: number, g: number, b: number): Hsl {
const r1 = r / 255;
const g1 = g / 255;
const b1 = b / 255;
const max = Math.max(r1, g1, b1);
const min = Math.min(r1, g1, b1);
const delta = max - min;
const l = (max + min) / 2;
if (delta === 0) {
return { h: 0, s: 0, l: Math.round(l * 100) };
}
const s = delta / (1 - Math.abs(2 * l - 1));
let h = 0;
if (max === r1) {
h = 60 * (((g1 - b1) / delta) % 6);
} else if (max === g1) {
h = 60 * ((b1 - r1) / delta + 2);
} else {
h = 60 * ((r1 - g1) / delta + 4);
}
return {
h: Math.round((h + 360) % 360),
l: Math.round(l * 100),
s: Math.round(s * 100),
};
}
function cycleBounds(
x: number,
y: number,
width: number,
height: number,
radius: number,
): [number, number][] {
const wrappedX = cycle(x, width);
const wrappedY = cycle(y, height);
return [
[wrappedX, wrappedY],
[mirror(wrappedX, width, radius), mirror(wrappedY, height, radius)],
];
}
function trapezoidalWave(length: number, a: number, b: number) {
const stop = Math.max(a, length - b);
return (time: number) => {
if (time < a) return Math.max(0, time / a);
if (time > stop) return Math.max(0, 1 - (time - stop) / (length - stop));
return 1;
};
}
function lcgrand(seed = 1) {
let currentSeed = seed;
return (a = 0, b = 1) => {
currentSeed = Math.imul(214013, currentSeed) + 2531011;
const next = (Math.imul(48271, currentSeed) & 0x7fffffff) / 0x7fffffff;
return a + Math.abs(b - a) * next;
};
}
function cycle(value: number, max: number): number {
if (max <= 0) return 0;
return ((value % max) + max) % max;
}
function mirror(value: number, max: number, radius: number): number {
if (value < radius) return max + value;
if (value > max - radius) return value - max;
return value;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}

View File

@@ -444,6 +444,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import { isMessageLink } from "../../features/messages/lib/messageLink.ts";
import remarkSpoilers from "../lib/remarkSpoilers.ts";
function messageLinkUrlTransform(value, key) {
if (key === "href" && isMessageLink(value)) {
@@ -506,6 +507,27 @@ test("messageLinkUrlTransform: leaves non-message buzz:// schemes to default", (
assert.match(html, /href=""/);
});
test("remarkSpoilers: block delimiter spoilers expose a block prop to React", () => {
let spoilerProps;
renderToStaticMarkup(
React.createElement(
ReactMarkdown,
{
components: {
spoiler: (props) => {
spoilerProps = props;
return React.createElement("div", null, props.children);
},
},
remarkPlugins: [remarkSpoilers],
},
"||\n\nsecret\n\n||",
),
);
assert.equal(spoilerProps?.["data-block-spoiler"], "");
});
// ── remarkMessageLinks: bare-URL → message-link node ──────────────────
// `remark-gfm`'s autolinker only covers http(s)://, so bare `buzz://message`
// URLs in plain text never reach any rendering path without this plugin.

View File

@@ -38,6 +38,7 @@ import remarkCustomEmoji, {
type CustomEmoji,
} from "@/shared/lib/remarkCustomEmoji";
import remarkMentions from "@/shared/lib/remarkMentions";
import remarkSpoilers from "@/shared/lib/remarkSpoilers";
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
import { Button } from "@/shared/ui/button";
import {
@@ -45,6 +46,7 @@ import {
MENTION_CHIP_HOVER_CLASSES,
} from "@/shared/ui/mentionChip";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { SpoilerParticles } from "@/shared/ui/SpoilerParticles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import {
@@ -133,6 +135,13 @@ function aspectRatioFromDim(dim?: string): number | undefined {
return width / height;
}
function isInsideHiddenSpoiler(element: Element): boolean {
return (
element.closest('.buzz-spoiler[data-spoiler][data-revealed="false"]') !==
null
);
}
/**
* Video review context flows through React context instead of
* `createMarkdownComponents` arguments. The component map must keep a stable
@@ -255,6 +264,49 @@ function ImageBlock({
}) {
const [lightboxOpen, setLightboxOpen] = React.useState(false);
const [menu, setMenu] = React.useState<{ x: number; y: number } | null>(null);
const [spoilerMediaSize, setSpoilerMediaSize] = React.useState<{
height: number;
src: string;
width: number;
} | null>(null);
const updateSpoilerMediaSize = React.useCallback(
(image: HTMLImageElement) => {
const { naturalHeight, naturalWidth } = image;
if (naturalHeight <= 0 || naturalWidth <= 0) return;
const maxWidth = 384;
const maxHeight = 256;
const scale = Math.min(
1,
maxWidth / naturalWidth,
maxHeight / naturalHeight,
);
setSpoilerMediaSize({
height: Math.max(1, Math.round(naturalHeight * scale)),
src: resolvedSrc ?? image.currentSrc,
width: Math.max(1, Math.round(naturalWidth * scale)),
});
},
[resolvedSrc],
);
const imageRef = React.useCallback(
(image: HTMLImageElement | null) => {
if (image?.complete) updateSpoilerMediaSize(image);
},
[updateSpoilerMediaSize],
);
const currentSpoilerMediaSize =
spoilerMediaSize?.src === resolvedSrc ? spoilerMediaSize : null;
const spoilerMediaStyle = currentSpoilerMediaSize
? ({
"--buzz-spoiler-media-height": `${currentSpoilerMediaSize.height}px`,
"--buzz-spoiler-media-width": `${currentSpoilerMediaSize.width}px`,
} as React.CSSProperties)
: undefined;
React.useEffect(() => {
if (!menu) return;
@@ -285,11 +337,19 @@ function ImageBlock({
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
if (isInsideHiddenSpoiler(e.currentTarget)) return;
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setMenu({ x: e.clientX, y: e.clientY });
};
const handleImageClick = (event: React.MouseEvent<HTMLImageElement>) => {
if (isInsideHiddenSpoiler(event.currentTarget)) {
return;
}
setLightboxOpen(true);
};
const handleDownload = () => {
setMenu(null);
if (!src) return;
@@ -305,9 +365,13 @@ function ImageBlock({
<img
alt={alt}
className="mt-1 block max-h-64 max-w-sm cursor-pointer rounded-xl object-contain"
data-spoiler-media-size={currentSpoilerMediaSize ? "" : undefined}
ref={imageRef}
src={resolvedSrc}
onClick={() => setLightboxOpen(true)}
style={spoilerMediaStyle}
onClick={handleImageClick}
onContextMenuCapture={handleContextMenu}
onLoad={(event) => updateSpoilerMediaSize(event.currentTarget)}
/>
{menu && src ? (
<div
@@ -744,6 +808,135 @@ function SyntaxHighlightedCode({
</code>
);
}
function SpoilerInline({
block = false,
children,
interactive = true,
}: {
block?: boolean;
children?: React.ReactNode;
interactive?: boolean;
}) {
const [revealed, setRevealed] = React.useState(false);
const contentRef = React.useRef<HTMLElement | null>(null);
const isBlock = block || hasBlockMedia(React.Children.toArray(children));
const setContentElement = React.useCallback((node: HTMLElement | null) => {
contentRef.current = node;
}, []);
const toggleRevealed = React.useCallback(() => {
setRevealed((value) => !value);
}, []);
const handlePointerDownCapture = React.useCallback(
(event: React.PointerEvent<HTMLElement>) => {
if (revealed) return;
event.stopPropagation();
},
[revealed],
);
const handleClickCapture = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (revealed) return;
event.preventDefault();
event.stopPropagation();
toggleRevealed();
},
[revealed, toggleRevealed],
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (revealed && isBlock && event.target !== event.currentTarget) return;
toggleRevealed();
},
[isBlock, revealed, toggleRevealed],
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
toggleRevealed();
},
[toggleRevealed],
);
const revealProps = {
"aria-label": revealed ? "Hide spoiler" : "Reveal spoiler",
"aria-pressed": revealed,
onClick: handleClick,
onClickCapture: handleClickCapture,
onKeyDown: handleKeyDown,
onPointerDownCapture: handlePointerDownCapture,
role: "button",
tabIndex: 0,
} as const;
if (!interactive) {
if (isBlock) {
return (
<div
className="buzz-spoiler buzz-spoiler--block buzz-spoiler--inert"
data-revealed="false"
data-spoiler=""
>
<SpoilerParticles active contentRef={contentRef} />
<div className="buzz-spoiler__content" ref={setContentElement}>
{children}
</div>
</div>
);
}
return (
<span
className="buzz-spoiler buzz-spoiler--inert"
data-revealed="false"
data-spoiler=""
>
<SpoilerParticles active contentRef={contentRef} />
<span className="buzz-spoiler__content" ref={setContentElement}>
{children}
</span>
</span>
);
}
if (isBlock) {
return (
<div
{...revealProps}
className="buzz-spoiler buzz-spoiler--block"
data-revealed={revealed ? "true" : "false"}
data-spoiler=""
>
<SpoilerParticles active={!revealed} contentRef={contentRef} />
<div className="buzz-spoiler__content" ref={setContentElement}>
{children}
</div>
</div>
);
}
return (
<span
{...revealProps}
className="buzz-spoiler"
data-revealed={revealed ? "true" : "false"}
data-spoiler=""
>
<SpoilerParticles active={!revealed} contentRef={contentRef} />
<span className="buzz-spoiler__content" ref={setContentElement}>
{children}
</span>
</span>
);
}
function createMarkdownComponents(
variant: MarkdownVariant,
runtimeRef: React.RefObject<MarkdownRuntime>,
@@ -763,6 +956,20 @@ function createMarkdownComponents(
: "space-y-1 pl-6 marker:text-muted-foreground";
return {
spoiler: ({
children,
...props
}: {
"data-block-spoiler"?: string;
children?: React.ReactNode;
}) => (
<SpoilerInline
block={props["data-block-spoiler"] != null}
interactive={interactive}
>
{children}
</SpoilerInline>
),
a: ({ children, href, ...props }) => {
const { imetaByUrl, onOpenMessageLink } = runtimeRef.current;
if (!interactive) {
@@ -1170,6 +1377,7 @@ function MarkdownInner({
() => [
remarkGfm,
remarkBreaks,
remarkSpoilers,
remarkMessageLinks,
[remarkMentions, { mentionNames }],
[remarkChannelLinks, { channelNames }],

View File

@@ -10,10 +10,18 @@ import * as React from "react";
function isBlockMedia(child: React.ReactNode): boolean {
if (!React.isValidElement(child)) return false;
const props = child.props as Record<string, unknown>;
const props = child.props as {
children?: React.ReactNode;
node?: { tagName?: unknown };
[key: string]: unknown;
};
const node = props?.node as { tagName?: unknown } | undefined;
return props?.["data-block-media"] != null || node?.tagName === "img";
if (props?.["data-block-media"] != null || node?.tagName === "img") {
return true;
}
return React.Children.toArray(props.children).some(isBlockMedia);
}
/**

View File

@@ -91,6 +91,7 @@ type E2eConfig = {
// (e.g. a generic PDF) without a real upload pipeline. See
// tests/helpers/bridge.ts:MockBridgeOptions.uploadDescriptors.
meshReporterPubkey?: string;
uploadDelayMs?: number;
uploadDescriptors?: RawBlobDescriptor[];
};
relayHttpUrl?: string;
@@ -5122,9 +5123,14 @@ async function handleSearchMessages(
* PDF so the file-attachment flow (chip → send → FileCard) can be exercised
* out of the box.
*/
function resolveMockUploadDescriptors(
async function resolveMockUploadDescriptors(
config: E2eConfig | undefined,
): RawBlobDescriptor[] {
): Promise<RawBlobDescriptor[]> {
const delayMs = config?.mock?.uploadDelayMs ?? 0;
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
const configured = config?.mock?.uploadDescriptors;
// `undefined` means "not configured" → default PDF. An explicit `[]` is a
// valid override (e.g. modelling a picker cancel / no-files-selected), so it
@@ -6457,9 +6463,9 @@ export function maybeInstallE2eTauriMocks() {
case "get_media_proxy_port":
return MOCK_MEDIA_PROXY_PORT;
case "pick_and_upload_media":
return resolveMockUploadDescriptors(activeConfig);
return await resolveMockUploadDescriptors(activeConfig);
case "upload_media_bytes":
return resolveMockUploadDescriptors(activeConfig)[0];
return (await resolveMockUploadDescriptors(activeConfig))[0];
case "download_image":
case "download_file":
// The save dialog can't run headlessly; report a successful save so the

View File

@@ -0,0 +1,275 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const IMAGE_SHA = "c".repeat(64);
const IMAGE_URL = "http://127.0.0.1:4173/buzz.svg";
const IMAGE_DESCRIPTOR = {
url: IMAGE_URL,
sha256: IMAGE_SHA,
size: 646,
type: "image/svg+xml",
uploaded: Math.floor(Date.now() / 1000),
thumb: IMAGE_URL,
dim: "64x64",
filename: "buzz.svg",
};
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
type MockFeedItem = {
id: string;
kind: number;
pubkey: string;
content: string;
created_at: number;
channel_id: string | null;
channel_name: string;
tags: string[][];
category: "mention" | "needs_action" | "activity" | "agent_activity";
};
type MockFeedWindow = Window &
typeof globalThis & {
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: MockFeedItem) => MockFeedItem;
};
async function installSpoilerBridge(
page: Page,
mock: Parameters<typeof installMockBridge>[1] = {},
) {
await installMockBridge(page, {
...mock,
uploadDescriptors: [IMAGE_DESCRIPTOR],
});
}
test("no-selection spoiler applies to every composer paragraph", async ({
page,
}) => {
await installSpoilerBridge(page);
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
origin: "http://127.0.0.1:4173",
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
const paragraphs = [
"First hidden paragraph",
"Second hidden paragraph",
"Third hidden paragraph",
];
await page.evaluate(
(text) => navigator.clipboard.writeText(text),
paragraphs.join("\n\n"),
);
await input.click();
await page.keyboard.press("ControlOrMeta+V");
await expect(input.locator("p")).toHaveCount(paragraphs.length);
await page.getByRole("button", { name: "Spoiler", exact: true }).click();
await expect
.poll(() =>
input.evaluate(() =>
Array.from(
document.querySelectorAll(
'[data-testid="message-input"] .buzz-spoiler[data-spoiler]',
),
(node) => node.textContent,
),
),
)
.toEqual(paragraphs);
});
test("image attachments can be marked and sent as hidden spoilers", async ({
page,
}) => {
await installSpoilerBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByRole("button", { name: "Attach image" }).click();
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment cccc")).toBeVisible();
await page.getByRole("button", { name: "Spoiler", exact: true }).click();
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();
await page.getByTestId("send-message").click();
const lastMessage = page.getByTestId("message-row").last();
const spoilerBlock = lastMessage.locator(".buzz-spoiler--block");
await expect(spoilerBlock).toBeVisible();
await expect(spoilerBlock).toHaveAttribute("data-revealed", "false");
await expect(spoilerBlock.locator("[data-block-media] img")).toHaveAttribute(
"src",
IMAGE_URL,
);
await spoilerBlock.click();
await expect(spoilerBlock).toHaveAttribute("data-revealed", "true");
await expect(page.getByRole("dialog", { name: "image" })).toHaveCount(0);
});
test("spoiler button is disabled while attachment upload is pending", async ({
page,
}) => {
await installSpoilerBridge(page, { uploadDelayMs: 1_000 });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const spoilerButton = page.getByRole("button", {
name: "Spoiler",
exact: true,
});
await expect(spoilerButton).toBeEnabled();
await page.getByRole("button", { name: "Attach image" }).click();
await expect(spoilerButton).toBeDisabled({ timeout: 500 });
await expect(
page.getByTestId("message-composer").getByAltText("Attachment cccc"),
).toBeVisible();
await expect(spoilerButton).toBeEnabled();
});
test("hidden spoiler links reveal without opening on the first click", async ({
page,
}) => {
await installSpoilerBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.click();
await page.keyboard.type("||[secret](https://example.com)||");
await page.getByTestId("send-message").click();
const lastMessage = page.getByTestId("message-row").last();
const spoiler = lastMessage.locator(".buzz-spoiler").first();
await expect(spoiler).toHaveAttribute("data-revealed", "false");
const popupPromise = page
.waitForEvent("popup", { timeout: 500 })
.catch(() => null);
await spoiler.getByRole("link", { name: "secret" }).click({ force: true });
const popup = await popupPromise;
await popup?.close();
expect(popup).toBeNull();
await expect(spoiler).toHaveAttribute("data-revealed", "true");
});
test("hidden spoilers stay masked on hover and focus until reveal", async ({
page,
}) => {
await installSpoilerBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.click();
await page.keyboard.type("||secret hover focus note||");
await page.getByTestId("send-message").click();
const lastMessage = page.getByTestId("message-row").last();
const spoiler = lastMessage.locator(".buzz-spoiler").first();
const content = spoiler.locator(".buzz-spoiler__content");
const particles = spoiler.locator(".buzz-spoiler__particles");
await expect(spoiler).toHaveAttribute("data-revealed", "false");
await expect(content).toHaveCSS("opacity", "0");
await expect(particles).toHaveCSS("opacity", "1");
await spoiler.hover();
await expect(spoiler).toHaveAttribute("data-revealed", "false");
await expect(content).toHaveCSS("opacity", "0");
await expect(particles).toHaveCSS("opacity", "1");
await page.mouse.move(0, 0);
await spoiler.focus();
await expect(spoiler).toBeFocused();
await expect(spoiler).toHaveAttribute("data-revealed", "false");
await expect(content).toHaveCSS("opacity", "0");
await expect(particles).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(spoiler).toHaveAttribute("data-revealed", "true");
await expect(content).toHaveCSS("opacity", "1");
await expect(particles).toHaveCSS("opacity", "0");
});
test("non-interactive inbox preview spoilers let row clicks pass through", async ({
page,
}) => {
await installSpoilerBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(
() =>
typeof (window as MockFeedWindow).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ ===
"function",
);
await page.evaluate(
({ channelId, createdAt, currentPubkey, senderPubkey }) => {
const pushFeedItem = (window as MockFeedWindow)
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) {
throw new Error("Mock feed injection helper is not installed.");
}
pushFeedItem({
id: "mock-feed-spoiler-preview",
kind: 9,
pubkey: senderPubkey,
content: "Preview contains ||hidden launch note|| for review.",
created_at: createdAt,
channel_id: channelId,
channel_name: "general",
tags: [
["e", channelId],
["p", currentPubkey],
],
category: "mention",
});
},
{
channelId: GENERAL_CHANNEL_ID,
createdAt: Math.floor(Date.now() / 1000),
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
const item = page.getByTestId("home-inbox-item-mock-feed-spoiler-preview");
await expect(item).toContainText("Preview contains");
const spoiler = item.locator(".buzz-spoiler").first();
await expect(spoiler).toBeVisible();
await expect(spoiler).not.toHaveAttribute("role", "button");
await expect(spoiler).not.toHaveAttribute("tabindex", "0");
await expect(spoiler).toHaveCSS("pointer-events", "none");
const box = await spoiler.boundingBox();
expect(box).not.toBeNull();
if (!box) {
throw new Error("Expected inbox preview spoiler to have a bounding box.");
}
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
await expect(page.getByTestId("home-inbox-detail")).toContainText(
"Preview contains",
);
});

View File

@@ -140,8 +140,8 @@ test("video upload previews use poster frames and inline videos open review mode
await expect(composerPoster).toHaveAttribute("src", POSTER_DATA_URL);
const box = await composerPoster.boundingBox();
expect(box?.width).toBeGreaterThanOrEqual(104);
expect(box?.width).toBeLessThanOrEqual(132);
expect(box?.width).toBeGreaterThanOrEqual(53);
expect(box?.width).toBeLessThanOrEqual(58);
expect(box?.height).toBeGreaterThanOrEqual(52);
expect(box?.height).toBeLessThanOrEqual(58);

View File

@@ -132,6 +132,7 @@ type MockBridgeOptions = {
* generic PDF so the file-attachment flow can be exercised by default. An
* explicit `[]` is honoured (models a picker cancel / no files selected).
*/
uploadDelayMs?: number;
uploadDescriptors?: {
url: string;
sha256: string;