mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
Merge remote-tracking branch 'origin/main' into channel-management-clean
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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||||";
|
||||
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\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\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";
|
||||
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||||",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
"||||",
|
||||
"",
|
||||
"||||",
|
||||
"||",
|
||||
"",
|
||||
"",
|
||||
"||",
|
||||
].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||||\n||||",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildOutgoingMessage: mediaTags mirror buildImetaTags output for non-empty pending", () => {
|
||||
const pending = [
|
||||
{
|
||||
|
||||
@@ -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 `` 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`;
|
||||
if (type.startsWith("image/")) return `\n`;
|
||||
export function formatImetaMediaLine(
|
||||
{ url, type, filename }: ImetaMedia,
|
||||
options: { spoiler?: boolean } = {},
|
||||
): string {
|
||||
if (type.startsWith("video/")) {
|
||||
const line = ``;
|
||||
return options.spoiler ? `\n||${line}||` : `\n${line}`;
|
||||
}
|
||||
if (type.startsWith("image/")) {
|
||||
const line = ``;
|
||||
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 };
|
||||
|
||||
69
desktop/src/features/messages/lib/spoilerFormatting.test.mjs
Normal file
69
desktop/src/features/messages/lib/spoilerFormatting.test.mjs
Normal 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");
|
||||
});
|
||||
55
desktop/src/features/messages/lib/spoilerFormatting.ts
Normal file
55
desktop/src/features/messages/lib/spoilerFormatting.ts
Normal 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);
|
||||
}
|
||||
16
desktop/src/features/messages/lib/spoilerMark.test.mjs
Normal file
16
desktop/src/features/messages/lib/spoilerMark.test.mjs
Normal 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);
|
||||
});
|
||||
111
desktop/src/features/messages/lib/spoilerMark.ts
Normal file
111
desktop/src/features/messages/lib/spoilerMark.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
180
desktop/src/features/messages/lib/useComposerSpoilerParticles.ts
Normal file
180
desktop/src/features/messages/lib/useComposerSpoilerParticles.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `` 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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
172
desktop/src/shared/lib/remarkSpoilers.test.mjs
Normal file
172
desktop/src/shared/lib/remarkSpoilers.test.mjs
Normal 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||" },
|
||||
]);
|
||||
});
|
||||
162
desktop/src/shared/lib/remarkSpoilers.ts
Normal file
162
desktop/src/shared/lib/remarkSpoilers.ts
Normal 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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
501
desktop/src/shared/ui/SpoilerParticles.tsx
Normal file
501
desktop/src/shared/ui/SpoilerParticles.tsx
Normal 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));
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
275
desktop/tests/e2e/spoiler.spec.ts
Normal file
275
desktop/tests/e2e/spoiler.spec.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user