From d37cc0b80d887923b073fcff3fdfe1535818de38 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 15 Jun 2026 14:50:09 +0100
Subject: [PATCH 1/8] Add composer spoiler formatting

---
 desktop/playwright.config.ts                  |   1 +
 .../messages/lib/imetaMediaMarkdown.test.mjs  |  79 +++
 .../messages/lib/imetaMediaMarkdown.ts        |  48 +-
 .../src/features/messages/lib/spoilerMark.ts  | 104 ++++
 .../lib/useComposerSpoilerParticles.ts        | 180 +++++++
 .../messages/lib/useRichTextEditor.ts         |   2 +
 .../messages/ui/ComposerAttachments.tsx       |  53 +-
 .../messages/ui/FormattingToolbar.tsx         |  67 +++
 .../features/messages/ui/MessageComposer.tsx  |  87 ++-
 .../messages/ui/MessageComposerToolbar.tsx    |  70 ++-
 .../messages/ui/useMentionSendFlow.ts         |  15 +
 .../src/shared/lib/remarkSpoilers.test.mjs    | 141 +++++
 desktop/src/shared/lib/remarkSpoilers.ts      | 160 ++++++
 desktop/src/shared/styles/globals.css         | 123 +++++
 desktop/src/shared/ui/SpoilerParticles.tsx    | 501 ++++++++++++++++++
 desktop/src/shared/ui/markdown.tsx            | 141 ++++-
 desktop/src/shared/ui/markdownUtils.ts        |  12 +-
 desktop/tests/e2e/spoiler.spec.ts             |  96 ++++
 desktop/tests/e2e/video-attachment.spec.ts    |   4 +-
 19 files changed, 1831 insertions(+), 53 deletions(-)
 create mode 100644 desktop/src/features/messages/lib/spoilerMark.ts
 create mode 100644 desktop/src/features/messages/lib/useComposerSpoilerParticles.ts
 create mode 100644 desktop/src/shared/lib/remarkSpoilers.test.mjs
 create mode 100644 desktop/src/shared/lib/remarkSpoilers.ts
 create mode 100644 desktop/src/shared/ui/SpoilerParticles.tsx
 create mode 100644 desktop/tests/e2e/spoiler.spec.ts

diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 3dd4e6c2f1..f70015433f 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -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",
diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs
index d91bd456a8..b68cde9564 100644
--- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs
+++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs
@@ -9,6 +9,7 @@ import test from "node:test";
 import {
   buildImetaTags,
   buildOutgoingMessage,
+  findSpoileredImetaMediaUrls,
   formatImetaMediaLine,
   imetaMediaFromTags,
   mergeOutgoingTags,
@@ -24,6 +25,14 @@ test("strip: removes trailing image line whose URL is in imetaMedia", () => {
   assert.equal(stripped, "Look at this");
 });
 
+test("strip: removes trailing spoilered image line whose URL is in imetaMedia", () => {
+  const body = "Look at this\n||![image](https://blossom/abc.png)||";
+  const stripped = stripImetaMediaLines(body, [
+    { url: "https://blossom/abc.png", type: "image/png" },
+  ]);
+  assert.equal(stripped, "Look at this");
+});
+
 test("strip: removes trailing video line", () => {
   const body = "Demo:\n![video](https://blossom/clip.mp4)";
   const stripped = stripImetaMediaLines(body, [
@@ -81,6 +90,16 @@ test("formatImetaMediaLine: image mime → ![image] line", () => {
   );
 });
 
+test("formatImetaMediaLine: spoilered image mime → wrapped ![image] line", () => {
+  assert.equal(
+    formatImetaMediaLine(
+      { url: "https://b/a.png", type: "image/png" },
+      { spoiler: true },
+    ),
+    "\n||![image](https://b/a.png)||",
+  );
+});
+
 test("buildImetaTags keeps media filenames in imeta", () => {
   // Filenames are included for every MIME type — the video review dialog
   // and file cards use them as display titles.
@@ -126,6 +145,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 +182,25 @@ test("strip: removes an escaped-bracket generic file line on edit", () => {
   assert.equal(stripped, "note");
 });
 
+test("findSpoileredImetaMediaUrls: extracts only spoilered matching media urls", () => {
+  const body = [
+    "note",
+    "||![image](https://b/a.png)||",
+    "![image](https://b/b.png)",
+    "||![video](https://b/c.mp4)||",
+  ].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/other.png", type: "image/png" },
+  ]);
+  assert.deepEqual([...spoilered].sort(), [
+    "https://b/a.png",
+    "https://b/c.mp4",
+  ]);
+});
+
 // ── imetaMediaFromTags (full BlobDescriptor projection) ───────────────
 
 test("imetaMediaFromTags: empty / undefined", () => {
@@ -327,6 +379,33 @@ test("buildOutgoingMessage: appends media markdown line per attachment, in order
   );
 });
 
+test("buildOutgoingMessage: wraps spoilered image and video attachments", () => {
+  const out = buildOutgoingMessage(
+    "hi",
+    [
+      {
+        url: "https://b/a.png",
+        type: "image/png",
+        sha256: "x",
+        size: 1,
+        uploaded: 0,
+      },
+      {
+        url: "https://b/v.mp4",
+        type: "video/mp4",
+        sha256: "y",
+        size: 2,
+        uploaded: 0,
+      },
+    ],
+    new Set(["https://b/a.png", "https://b/v.mp4"]),
+  );
+  assert.equal(
+    out.content,
+    "hi\n||![image](https://b/a.png)||\n||![video](https://b/v.mp4)||",
+  );
+});
+
 test("buildOutgoingMessage: mediaTags mirror buildImetaTags output for non-empty pending", () => {
   const pending = [
     {
diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts
index 6a3bea9c29..bc8498eed6 100644
--- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts
+++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts
@@ -101,7 +101,10 @@ 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*$/;
 /**
  * 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
@@ -143,6 +146,23 @@ 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>();
+  for (const line of body.split("\n")) {
+    const match = line.match(SPOILERED_MEDIA_LINE_RE);
+    if (match && urls.has(match[1])) {
+      spoileredUrls.add(match[1]);
+    }
+  }
+  return spoileredUrls;
+}
+
 /**
  * Format a single imeta entry as a leading-newline markdown line.
  *
@@ -151,13 +171,18 @@ export function stripImetaMediaLines(
  * href as a local media blob with a non-media MIME and upgrades it to a file
  * card. Mime-driven so the form is correct regardless of URL suffix.
  */
-export function formatImetaMediaLine({
-  url,
-  type,
-  filename,
-}: ImetaMedia): string {
-  if (type.startsWith("video/")) return `\n![video](${url})`;
-  if (type.startsWith("image/")) return `\n![image](${url})`;
+export function formatImetaMediaLine(
+  { url, type, filename }: ImetaMedia,
+  options: { spoiler?: boolean } = {},
+): string {
+  if (type.startsWith("video/")) {
+    const line = `![video](${url})`;
+    return options.spoiler ? `\n||${line}||` : `\n${line}`;
+  }
+  if (type.startsWith("image/")) {
+    const line = `![image](${url})`;
+    return options.spoiler ? `\n||${line}||` : `\n${line}`;
+  }
   // Generic file: plain link, label is the original filename (fallback to url tail).
   const label = filename || url.split("/").pop() || "file";
   // Escape markdown link-label metacharacters so filenames containing `[`, `]`,
@@ -181,9 +206,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 };
diff --git a/desktop/src/features/messages/lib/spoilerMark.ts b/desktop/src/features/messages/lib/spoilerMark.ts
new file mode 100644
index 0000000000..e7a5daf98a
--- /dev/null
+++ b/desktop/src/features/messages/lib/spoilerMark.ts
@@ -0,0 +1,104 @@
+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;
+  };
+
+  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>";
+}
+
+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);
+          },
+        },
+      },
+    };
+  },
+});
diff --git a/desktop/src/features/messages/lib/useComposerSpoilerParticles.ts b/desktop/src/features/messages/lib/useComposerSpoilerParticles.ts
new file mode 100644
index 0000000000..53ef0a5801
--- /dev/null
+++ b/desktop/src/features/messages/lib/useComposerSpoilerParticles.ts
@@ -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();
+  };
+}
diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts
index e293aa9ab8..f3ec5c7fe4 100644
--- a/desktop/src/features/messages/lib/useRichTextEditor.ts
+++ b/desktop/src/features/messages/lib/useRichTextEditor.ts
@@ -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({
diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx
index 2444895a22..c70fb9c1ef 100644
--- a/desktop/src/features/messages/ui/ComposerAttachments.tsx
+++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx
@@ -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 ? (
diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx
index fab4a1bfd9..c6ba3f1bc7 100644
--- a/desktop/src/features/messages/ui/FormattingToolbar.tsx
+++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx
@@ -14,12 +14,18 @@ import {
 
 import { cn } from "@/shared/lib/cn";
 import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+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 +52,67 @@ 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;
+}
+
+function isRangeFullySpoiled(
+  editor: Editor,
+  from: number,
+  to: number,
+): boolean {
+  const spoilerMark = editor.schema.marks[SPOILER_MARK_NAME];
+  if (!spoilerMark) return false;
+
+  let hasInlineContent = false;
+  let isFullySpoiled = true;
+
+  editor.state.doc.nodesBetween(from, to, (node) => {
+    if (!node.isText || node.textContent.length === 0) return;
+
+    hasInlineContent = true;
+    if (!spoilerMark.isInSet(node.marks)) {
+      isFullySpoiled = false;
+      return false;
+    }
+  });
+
+  return hasInlineContent && isFullySpoiled;
+}
+
+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 nextSpoilered = !isRangeFullySpoiled(editor, range.from, range.to);
+  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
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index 36f5985a0e..3d7bd2e64a 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -12,6 +12,7 @@ import { useCustomEmoji } from "@/features/custom-emoji/hooks";
 import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags";
 import {
   buildOutgoingMessage,
+  findSpoileredImetaMediaUrls,
   type ImetaMedia,
   mergeOutgoingTags,
   stripImetaMediaLines,
@@ -32,6 +33,7 @@ import {
   type AutocompleteEdit,
   useRichTextEditor,
 } from "@/features/messages/lib/useRichTextEditor";
+import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposerSpoilerParticles";
 import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
 import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
 import { cn } from "@/shared/lib/cn";
@@ -129,6 +131,9 @@ export function MessageComposer({
 
   const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
   const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
+  const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
+    Set<string>
+  >(() => new Set());
 
   const handleFormattingToggle = React.useCallback((pressed: boolean) => {
     if (pressed) setIsEmojiPickerOpen(false);
@@ -147,6 +152,7 @@ export function MessageComposer({
   const preEditSnapshotRef = React.useRef<{
     content: string;
     pendingImeta: ImetaMedia[];
+    spoileredAttachmentUrls: Set<string>;
   } | null>(null);
   const mentions = useMentions(channelId, undefined, profiles, {
     channelType,
@@ -239,6 +245,8 @@ export function MessageComposer({
     },
   });
 
+  useComposerSpoilerParticles(richText.editor, composerScrollRef);
+
   const mentionSendFlow = useMentionSendFlow({
     channelId,
     channelLinks,
@@ -253,6 +261,7 @@ export function MessageComposer({
     setContent,
     setIsEmojiPickerOpen,
     setPendingImeta: media.setPendingImeta,
+    setSpoileredAttachmentUrls,
   });
 
   // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
@@ -277,6 +286,7 @@ export function MessageComposer({
     }
 
     media.setPendingImeta([]);
+    setSpoileredAttachmentUrls(new Set());
     media.setUploadState({ status: "idle" });
     setIsEmojiPickerOpen(false);
     mentions.clearMentions();
@@ -299,6 +309,7 @@ export function MessageComposer({
       preEditSnapshotRef.current = {
         content: contentRef.current,
         pendingImeta: [...media.pendingImetaRef.current],
+        spoileredAttachmentUrls: new Set(spoileredAttachmentUrls),
       };
       // Strip the trailing `![image|video](url)` lines that correspond to
       // imeta attachments — the user manages those via the attachments row,
@@ -314,6 +325,12 @@ export function MessageComposer({
       // attachments so they show up in `ComposerAttachments` and the user
       // can remove existing ones / add new ones before saving.
       media.setPendingImeta(editTarget.imetaMedia ?? []);
+      setSpoileredAttachmentUrls(
+        findSpoileredImetaMediaUrls(
+          editTarget.body,
+          editTarget.imetaMedia ?? [],
+        ),
+      );
       // Defer focus to the next frame so it runs after any focus-
       // restoration the trigger UI (e.g. the message-row context menu)
       // fires on close. Without this, Radix-style focus-restoration races
@@ -323,8 +340,11 @@ export function MessageComposer({
       const rafId = requestAnimationFrame(() => richText.focusEnd());
       return () => cancelAnimationFrame(rafId);
     } else if (preEditSnapshotRef.current !== null) {
-      const { content: restoredContent, pendingImeta: restoredImeta } =
-        preEditSnapshotRef.current;
+      const {
+        content: restoredContent,
+        pendingImeta: restoredImeta,
+        spoileredAttachmentUrls: restoredSpoileredAttachmentUrls,
+      } = preEditSnapshotRef.current;
       preEditSnapshotRef.current = null;
       setContent(restoredContent);
       contentRef.current = restoredContent;
@@ -332,6 +352,7 @@ export function MessageComposer({
         ? richText.setContent(restoredContent)
         : richText.clearContent();
       media.setPendingImeta(restoredImeta);
+      setSpoileredAttachmentUrls(restoredSpoileredAttachmentUrls);
     }
   }, [editTarget?.id]);
 
@@ -485,6 +506,7 @@ export function MessageComposer({
       const { content: finalContent, mediaTags } = buildOutgoingMessage(
         trimmed,
         currentPendingImeta,
+        spoileredAttachmentUrls,
       );
 
       // NIP-30: attach `["emoji", shortcode, url]` tags for custom emoji in the
@@ -500,10 +522,12 @@ export function MessageComposer({
 
       const savedContent = trimmed;
       const savedImeta = [...currentPendingImeta];
+      const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls);
       setContent("");
       contentRef.current = "";
       richText.clearContent();
       media.setPendingImeta([]);
+      setSpoileredAttachmentUrls(new Set());
       mentions.clearMentions();
       channelLinks.clearChannels();
       emojiAutocomplete.clearEmojis();
@@ -516,6 +540,7 @@ export function MessageComposer({
         contentRef.current = savedContent;
         richText.setContent(savedContent);
         media.setPendingImeta(savedImeta);
+        setSpoileredAttachmentUrls(savedSpoileredAttachmentUrls);
       }
       return;
     }
@@ -536,6 +561,7 @@ export function MessageComposer({
     await mentionSendFlow.sendMessageWithMentionFlow({
       pendingImeta: currentPendingImeta,
       sentDraftKey: effectiveDraftKeyRef.current,
+      spoileredAttachmentUrls,
       trimmed,
     });
   }, [
@@ -549,6 +575,7 @@ export function MessageComposer({
     mentions.clearMentions,
     richText.clearContent,
     richText.setContent,
+    spoileredAttachmentUrls,
   ]);
   submitMessageRef.current = submitMessage;
 
@@ -711,6 +738,55 @@ export function MessageComposer({
     void media.handlePaperclip();
   }, [media.handlePaperclip]);
 
+  const handleRemoveAttachment = React.useCallback(
+    (url: string) => {
+      setSpoileredAttachmentUrls((current) => {
+        if (!current.has(url)) return current;
+        const next = new Set(current);
+        next.delete(url);
+        return next;
+      });
+      media.removeAttachment(url);
+    },
+    [media.removeAttachment],
+  );
+
+  const handleComposerSpoilerToggle = React.useCallback(
+    ({
+      emptySelection,
+      nextSpoilered,
+    }: {
+      emptySelection: boolean;
+      nextSpoilered?: boolean;
+    }) => {
+      if (!emptySelection) return;
+
+      const mediaUrls = media.pendingImetaRef.current
+        .filter(
+          (attachment) =>
+            attachment.type.startsWith("image/") ||
+            attachment.type.startsWith("video/"),
+        )
+        .map((attachment) => attachment.url);
+      if (mediaUrls.length === 0) return;
+
+      setSpoileredAttachmentUrls((current) => {
+        const shouldSpoiler =
+          nextSpoilered ?? mediaUrls.some((url) => !current.has(url));
+        const next = new Set(current);
+        for (const url of mediaUrls) {
+          if (shouldSpoiler) {
+            next.add(url);
+          } else {
+            next.delete(url);
+          }
+        }
+        return next;
+      });
+    },
+    [media.pendingImetaRef],
+  );
+
   // ── Render ──────────────────────────────────────────────────────────
   return (
     <>
@@ -839,14 +915,15 @@ export function MessageComposer({
                   onCancelUpload={media.cancelUpload}
                   uploadingCount={media.uploadingCount}
                   uploadingPreviews={media.uploadingPreviews}
-                  onRemove={media.removeAttachment}
+                  onRemove={handleRemoveAttachment}
+                  spoileredUrls={spoileredAttachmentUrls}
                 />
               </div>
             )}
 
             {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
             <div
-              className="rich-text-composer max-h-32 overflow-y-auto"
+              className="rich-text-composer relative max-h-32 overflow-y-auto"
               data-testid="message-input-scroll"
               ref={composerScrollRef}
               onKeyDown={handleEditorKeyDown}
@@ -869,7 +946,9 @@ export function MessageComposer({
               onFormattingToggle={handleFormattingToggle}
               onOpenMentionPicker={openMentionPicker}
               onPaperclip={handlePaperclipClick}
+              onSpoilerToggle={handleComposerSpoilerToggle}
               sendDisabled={sendDisabled}
+              spoilerActive={spoileredAttachmentUrls.size > 0}
             />
           </form>
         </div>
diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx
index 8d2d95d8cd..276758d3f9 100644
--- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx
+++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx
@@ -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}
+                      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 }}
diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts
index 5e571165e5..e60e988f4b 100644
--- a/desktop/src/features/messages/ui/useMentionSendFlow.ts
+++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts
@@ -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,
         };
 
diff --git a/desktop/src/shared/lib/remarkSpoilers.test.mjs b/desktop/src/shared/lib/remarkSpoilers.test.mjs
new file mode 100644
index 0000000000..c6f12e8a74
--- /dev/null
+++ b/desktop/src/shared/lib/remarkSpoilers.test.mjs
@@ -0,0 +1,141 @@
+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: 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.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||" },
+  ]);
+});
diff --git a/desktop/src/shared/lib/remarkSpoilers.ts b/desktop/src/shared/lib/remarkSpoilers.ts
new file mode 100644
index 0000000000..59be8d364c
--- /dev/null
+++ b/desktop/src/shared/lib/remarkSpoilers.ts
@@ -0,0 +1,160 @@
+/**
+ * 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[]): Node {
+  return {
+    type: "spoiler",
+    children,
+    data: {
+      hName: "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));
+        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 === "link" || node.type === "code" || node.type === "inlineCode"
+  );
+}
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index bb3fdb2492..bd2e013074 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -837,6 +837,129 @@
   }
 }
 
+.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"]):not(:hover):not(:focus-visible)
+  .buzz-spoiler__content,
+.buzz-spoiler:not([data-revealed="true"]):not(:hover):not(:focus-visible)
+  .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[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,
+.buzz-spoiler:hover .buzz-spoiler__particles,
+.buzz-spoiler:focus-visible .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-startup-shell {
     min-height: 100dvh;
diff --git a/desktop/src/shared/ui/SpoilerParticles.tsx b/desktop/src/shared/ui/SpoilerParticles.tsx
new file mode 100644
index 0000000000..0033a175b4
--- /dev/null
+++ b/desktop/src/shared/ui/SpoilerParticles.tsx
@@ -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));
+}
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index b21b45e732..6f10b25159 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -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
@@ -240,6 +249,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;
@@ -270,11 +322,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;
@@ -290,9 +350,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
@@ -729,6 +793,77 @@ function SyntaxHighlightedCode({
     </code>
   );
 }
+
+function SpoilerInline({ children }: { children?: React.ReactNode }) {
+  const [revealed, setRevealed] = React.useState(false);
+  const contentRef = React.useRef<HTMLElement | null>(null);
+  const isBlock = hasBlockMedia(React.Children.toArray(children));
+
+  const setContentElement = React.useCallback((node: HTMLElement | null) => {
+    contentRef.current = node;
+  }, []);
+
+  const toggleRevealed = React.useCallback(() => {
+    setRevealed((value) => !value);
+  }, []);
+
+  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,
+    onKeyDown: handleKeyDown,
+    role: "button",
+    tabIndex: 0,
+  } as const;
+
+  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,
   channels: Channel[],
@@ -753,6 +888,9 @@ function createMarkdownComponents(
       : "space-y-1 pl-6 marker:text-muted-foreground";
 
   return {
+    spoiler: ({ children }: { children?: React.ReactNode }) => (
+      <SpoilerInline>{children}</SpoilerInline>
+    ),
     a: ({ children, href, ...props }) => {
       if (!interactive) {
         return <span className="font-medium text-current">{children}</span>;
@@ -1156,6 +1294,7 @@ function MarkdownInner({
     () => [
       remarkGfm,
       remarkBreaks,
+      remarkSpoilers,
       remarkMessageLinks,
       [remarkMentions, { mentionNames }],
       [remarkChannelLinks, { channelNames }],
diff --git a/desktop/src/shared/ui/markdownUtils.ts b/desktop/src/shared/ui/markdownUtils.ts
index dab951285c..441a954996 100644
--- a/desktop/src/shared/ui/markdownUtils.ts
+++ b/desktop/src/shared/ui/markdownUtils.ts
@@ -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);
 }
 
 /**
diff --git a/desktop/tests/e2e/spoiler.spec.ts b/desktop/tests/e2e/spoiler.spec.ts
new file mode 100644
index 0000000000..7b8d6c9add
--- /dev/null
+++ b/desktop/tests/e2e/spoiler.spec.ts
@@ -0,0 +1,96 @@
+import { expect, test } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+
+const IMAGE_SHA = "c".repeat(64);
+const IMAGE_URL = "http://127.0.0.1:4173/buzz.svg";
+
+test.beforeEach(async ({ page }) => {
+  await installMockBridge(page, {
+    uploadDescriptors: [
+      {
+        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",
+      },
+    ],
+  });
+});
+
+test("no-selection spoiler applies to every composer paragraph", async ({
+  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 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);
+});
diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts
index cb2e9430eb..1d8394658f 100644
--- a/desktop/tests/e2e/video-attachment.spec.ts
+++ b/desktop/tests/e2e/video-attachment.spec.ts
@@ -125,8 +125,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);
 

From 6aa25232e35e89aa8b0acba03cb33f53fad7db66 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 15 Jun 2026 21:25:49 +0100
Subject: [PATCH 2/8] Disable spoiler toggle during uploads

---
 .../messages/ui/MessageComposerToolbar.tsx    |  2 +-
 desktop/src/testing/e2eBridge.ts              | 14 +++--
 desktop/tests/e2e/spoiler.spec.ts             | 59 ++++++++++++++-----
 desktop/tests/helpers/bridge.ts               |  1 +
 4 files changed, 56 insertions(+), 20 deletions(-)

diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx
index 276758d3f9..153fa82107 100644
--- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx
+++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx
@@ -243,7 +243,7 @@ export const MessageComposerToolbar = React.memo(
                         isSpoilerActive &&
                           "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground",
                       )}
-                      disabled={composerDisabled || !editor}
+                      disabled={composerDisabled || !editor || isUploading}
                       onClick={handleSpoilerClick}
                       onMouseDown={onCaptureSelection}
                       size="icon"
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index b8afdef205..0e2729f095 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -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;
@@ -5082,9 +5083,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
@@ -6415,9 +6421,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
diff --git a/desktop/tests/e2e/spoiler.spec.ts b/desktop/tests/e2e/spoiler.spec.ts
index 7b8d6c9add..956c12adcf 100644
--- a/desktop/tests/e2e/spoiler.spec.ts
+++ b/desktop/tests/e2e/spoiler.spec.ts
@@ -1,30 +1,35 @@
 import { expect, test } from "@playwright/test";
+import type { Page } from "@playwright/test";
 
 import { installMockBridge } from "../helpers/bridge";
 
 const IMAGE_SHA = "c".repeat(64);
 const IMAGE_URL = "http://127.0.0.1:4173/buzz.svg";
-
-test.beforeEach(async ({ page }) => {
+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",
+};
+
+async function installSpoilerBridge(
+  page: Page,
+  mock: Parameters<typeof installMockBridge>[1] = {},
+) {
   await installMockBridge(page, {
-    uploadDescriptors: [
-      {
-        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",
-      },
-    ],
+    ...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",
   });
@@ -67,6 +72,7 @@ test("no-selection spoiler applies to every composer paragraph", async ({
 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");
@@ -94,3 +100,26 @@ test("image attachments can be marked and sent as hidden spoilers", async ({
   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();
+});
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index a4d4489dc1..eb43cad8b5 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -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;

From 0d11c29a7f5f0ca9059ddd1c32d2f235b89d8234 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 15 Jun 2026 21:48:35 +0100
Subject: [PATCH 3/8] Address spoiler review feedback

---
 .../src/features/messages/lib/hasMention.ts   | 11 ++++----
 .../messages/lib/spoilerMark.test.mjs         | 16 +++++++++++
 .../src/features/messages/lib/spoilerMark.ts  |  9 +++++-
 .../messages/lib/useMentions.test.mjs         |  8 ++++++
 desktop/src/shared/ui/markdown.tsx            | 20 +++++++++++++
 desktop/tests/e2e/spoiler.spec.ts             | 28 +++++++++++++++++++
 6 files changed, 86 insertions(+), 6 deletions(-)
 create mode 100644 desktop/src/features/messages/lib/spoilerMark.test.mjs

diff --git a/desktop/src/features/messages/lib/hasMention.ts b/desktop/src/features/messages/lib/hasMention.ts
index 453a0e50db..d736f96463 100644
--- a/desktop/src/features/messages/lib/hasMention.ts
+++ b/desktop/src/features/messages/lib/hasMention.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);
diff --git a/desktop/src/features/messages/lib/spoilerMark.test.mjs b/desktop/src/features/messages/lib/spoilerMark.test.mjs
new file mode 100644
index 0000000000..9c548779ad
--- /dev/null
+++ b/desktop/src/features/messages/lib/spoilerMark.test.mjs
@@ -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);
+});
diff --git a/desktop/src/features/messages/lib/spoilerMark.ts b/desktop/src/features/messages/lib/spoilerMark.ts
index e7a5daf98a..a8afba2025 100644
--- a/desktop/src/features/messages/lib/spoilerMark.ts
+++ b/desktop/src/features/messages/lib/spoilerMark.ts
@@ -44,13 +44,20 @@ export function registerSpoilerMarkdownIt(
     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>";
 }
 
-function findClosingDelimiter(
+/**
+ * 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,
diff --git a/desktop/src/features/messages/lib/useMentions.test.mjs b/desktop/src/features/messages/lib/useMentions.test.mjs
index e60aa7a077..e1a6eca908 100644
--- a/desktop/src/features/messages/lib/useMentions.test.mjs
+++ b/desktop/src/features/messages/lib/useMentions.test.mjs
@@ -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", () => {
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index 44ea867b86..d095040049 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -807,6 +807,24 @@ function SpoilerInline({ children }: { children?: React.ReactNode }) {
     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;
@@ -828,7 +846,9 @@ function SpoilerInline({ children }: { children?: React.ReactNode }) {
     "aria-label": revealed ? "Hide spoiler" : "Reveal spoiler",
     "aria-pressed": revealed,
     onClick: handleClick,
+    onClickCapture: handleClickCapture,
     onKeyDown: handleKeyDown,
+    onPointerDownCapture: handlePointerDownCapture,
     role: "button",
     tabIndex: 0,
   } as const;
diff --git a/desktop/tests/e2e/spoiler.spec.ts b/desktop/tests/e2e/spoiler.spec.ts
index 956c12adcf..f5257e316d 100644
--- a/desktop/tests/e2e/spoiler.spec.ts
+++ b/desktop/tests/e2e/spoiler.spec.ts
@@ -123,3 +123,31 @@ test("spoiler button is disabled while attachment upload is pending", async ({
   ).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");
+});

From 4ab66d6354e22d76bc3d49592b3e4634d26286a9 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 15 Jun 2026 22:10:41 +0100
Subject: [PATCH 4/8] Fix spoiler toggles around code blocks

---
 .../messages/lib/spoilerFormatting.test.mjs   | 69 +++++++++++++++++++
 .../messages/lib/spoilerFormatting.ts         | 55 +++++++++++++++
 .../messages/ui/FormattingToolbar.tsx         | 37 ++++------
 3 files changed, 136 insertions(+), 25 deletions(-)
 create mode 100644 desktop/src/features/messages/lib/spoilerFormatting.test.mjs
 create mode 100644 desktop/src/features/messages/lib/spoilerFormatting.ts

diff --git a/desktop/src/features/messages/lib/spoilerFormatting.test.mjs b/desktop/src/features/messages/lib/spoilerFormatting.test.mjs
new file mode 100644
index 0000000000..162d1f50b7
--- /dev/null
+++ b/desktop/src/features/messages/lib/spoilerFormatting.test.mjs
@@ -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");
+});
diff --git a/desktop/src/features/messages/lib/spoilerFormatting.ts b/desktop/src/features/messages/lib/spoilerFormatting.ts
new file mode 100644
index 0000000000..bc0832f394
--- /dev/null
+++ b/desktop/src/features/messages/lib/spoilerFormatting.ts
@@ -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);
+}
diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx
index 59a698e133..e1b5762d76 100644
--- a/desktop/src/features/messages/ui/FormattingToolbar.tsx
+++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx
@@ -14,6 +14,7 @@ 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 = {
@@ -70,30 +71,6 @@ function documentRangeForEmptySelection(editor: Editor): {
   return from < to ? { from, to } : null;
 }
 
-function isRangeFullySpoiled(
-  editor: Editor,
-  from: number,
-  to: number,
-): boolean {
-  const spoilerMark = editor.schema.marks[SPOILER_MARK_NAME];
-  if (!spoilerMark) return false;
-
-  let hasInlineContent = false;
-  let isFullySpoiled = true;
-
-  editor.state.doc.nodesBetween(from, to, (node) => {
-    if (!node.isText || node.textContent.length === 0) return;
-
-    hasInlineContent = true;
-    if (!spoilerMark.isInSet(node.marks)) {
-      isFullySpoiled = false;
-      return false;
-    }
-  });
-
-  return hasInlineContent && isFullySpoiled;
-}
-
 export function toggleSpoilerFormatting(editor: Editor): SpoilerToggleState {
   const emptySelection = editor.state.selection.empty;
   const range = documentRangeForEmptySelection(editor);
@@ -104,7 +81,17 @@ export function toggleSpoilerFormatting(editor: Editor): SpoilerToggleState {
 
   const cursorPosition = editor.state.selection.from;
   const chain = editor.chain().focus().setTextSelection(range);
-  const nextSpoilered = !isRangeFullySpoiled(editor, range.from, range.to);
+  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 {

From 7145ad148f78f9c866978326639f0a622913d675 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 15 Jun 2026 22:27:56 +0100
Subject: [PATCH 5/8] Fix block spoiler attachment handling

---
 .../messages/lib/imetaMediaMarkdown.test.mjs  | 25 +++++++
 .../messages/lib/imetaMediaMarkdown.ts        | 68 ++++++++++++++++++-
 .../src/shared/lib/remarkSpoilers.test.mjs    |  4 ++
 desktop/src/shared/lib/remarkSpoilers.ts      |  8 ++-
 desktop/src/shared/ui/markdown.test.mjs       | 22 ++++++
 desktop/src/shared/ui/markdown.tsx            | 22 ++++--
 6 files changed, 142 insertions(+), 7 deletions(-)

diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs
index b68cde9564..1bbbdce9f9 100644
--- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs
+++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs
@@ -33,6 +33,23 @@ test("strip: removes trailing spoilered image line whose URL is in imetaMedia",
   assert.equal(stripped, "Look at this");
 });
 
+test("strip: removes trailing block-spoilered image line", () => {
+  const body = "Look at this\n||\n![image](https://blossom/abc.png)\n||";
+  const stripped = stripImetaMediaLines(body, [
+    { url: "https://blossom/abc.png", type: "image/png" },
+  ]);
+  assert.equal(stripped, "Look at this");
+});
+
+test("strip: keeps block spoiler text while stripping imeta media", () => {
+  const body =
+    "Look at this\n||\nsecret\n![image](https://blossom/abc.png)\n||";
+  const stripped = stripImetaMediaLines(body, [
+    { url: "https://blossom/abc.png", type: "image/png" },
+  ]);
+  assert.equal(stripped, body);
+});
+
 test("strip: removes trailing video line", () => {
   const body = "Demo:\n![video](https://blossom/clip.mp4)";
   const stripped = stripImetaMediaLines(body, [
@@ -188,16 +205,24 @@ test("findSpoileredImetaMediaUrls: extracts only spoilered matching media urls",
     "||![image](https://b/a.png)||",
     "![image](https://b/b.png)",
     "||![video](https://b/c.mp4)||",
+    "||",
+    "![image](https://b/d.png)",
+    "![video](https://b/e.mp4)",
+    "||",
   ].join("\n");
   const spoilered = findSpoileredImetaMediaUrls(body, [
     { url: "https://b/a.png", type: "image/png" },
     { url: "https://b/b.png", type: "image/png" },
     { url: "https://b/c.mp4", type: "video/mp4" },
+    { url: "https://b/d.png", type: "image/png" },
+    { url: "https://b/e.mp4", type: "video/mp4" },
     { url: "https://b/other.png", type: "image/png" },
   ]);
   assert.deepEqual([...spoilered].sort(), [
     "https://b/a.png",
     "https://b/c.mp4",
+    "https://b/d.png",
+    "https://b/e.mp4",
   ]);
 });
 
diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts
index bc8498eed6..10516555d5 100644
--- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts
+++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts
@@ -105,6 +105,7 @@ 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
@@ -113,6 +114,35 @@ const SPOILERED_MEDIA_LINE_RE =
  */
 const FILE_LINE_RE = /^\[(?:\\.|[^\]\\])*\]\(([^)\s]+)\)\s*$/;
 
+function findTrailingBlockSpoilerMediaStart(
+  lines: string[],
+  closingDelimiterIndex: number,
+  urls: ReadonlySet<string>,
+): number | null {
+  let index = closingDelimiterIndex - 1;
+  let hasMatchingMedia = false;
+
+  while (index >= 0) {
+    const line = lines[index];
+    if (line.trim() === "") {
+      index -= 1;
+      continue;
+    }
+
+    if (BLOCK_SPOILER_DELIMITER_RE.test(line)) {
+      return hasMatchingMedia ? index : null;
+    }
+
+    const match = line.match(MEDIA_LINE_RE);
+    if (!match || !urls.has(match[1])) return null;
+
+    hasMatchingMedia = true;
+    index -= 1;
+  }
+
+  return null;
+}
+
 /**
  * Remove trailing `![image|video](url)` lines whose URL matches an entry in
  * `imetaMedia`. Stops at the first non-matching/non-blank line so attachments
@@ -135,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;
@@ -154,10 +191,39 @@ export function findSpoileredImetaMediaUrls(
 
   const urls = new Set(imetaMedia.map((m) => m.url));
   const spoileredUrls = new Set<string>();
-  for (const line of body.split("\n")) {
+  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;
diff --git a/desktop/src/shared/lib/remarkSpoilers.test.mjs b/desktop/src/shared/lib/remarkSpoilers.test.mjs
index c6f12e8a74..c1c5fe3677 100644
--- a/desktop/src/shared/lib/remarkSpoilers.test.mjs
+++ b/desktop/src/shared/lib/remarkSpoilers.test.mjs
@@ -103,6 +103,10 @@ test("remarkSpoilers: groups block nodes between delimiter paragraphs", () => {
 
   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");
 });
 
diff --git a/desktop/src/shared/lib/remarkSpoilers.ts b/desktop/src/shared/lib/remarkSpoilers.ts
index 59be8d364c..75289d2a2d 100644
--- a/desktop/src/shared/lib/remarkSpoilers.ts
+++ b/desktop/src/shared/lib/remarkSpoilers.ts
@@ -100,12 +100,16 @@ function splitDelimiterParts(children: Node[]): Part[] {
   return parts;
 }
 
-function buildSpoilerNode(children: Node[]): Node {
+function buildSpoilerNode(
+  children: Node[],
+  options: { block?: boolean } = {},
+): Node {
   return {
     type: "spoiler",
     children,
     data: {
       hName: "spoiler",
+      ...(options.block ? { hProperties: { "data-block-spoiler": "" } } : {}),
     },
   };
 }
@@ -118,7 +122,7 @@ function groupBlockSpoilers(children: Node[]): Node[] {
   for (const child of children) {
     if (isBlockDelimiter(child)) {
       if (spoilerBuffer) {
-        output.push(buildSpoilerNode(spoilerBuffer));
+        output.push(buildSpoilerNode(spoilerBuffer, { block: true }));
         spoilerBuffer = null;
         openingDelimiter = null;
       } else {
diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs
index 80171341ab..952d101015 100644
--- a/desktop/src/shared/ui/markdown.test.mjs
+++ b/desktop/src/shared/ui/markdown.test.mjs
@@ -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.
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index d095040049..f2e32c9eb8 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -794,10 +794,16 @@ function SyntaxHighlightedCode({
   );
 }
 
-function SpoilerInline({ children }: { children?: React.ReactNode }) {
+function SpoilerInline({
+  block = false,
+  children,
+}: {
+  block?: boolean;
+  children?: React.ReactNode;
+}) {
   const [revealed, setRevealed] = React.useState(false);
   const contentRef = React.useRef<HTMLElement | null>(null);
-  const isBlock = hasBlockMedia(React.Children.toArray(children));
+  const isBlock = block || hasBlockMedia(React.Children.toArray(children));
 
   const setContentElement = React.useCallback((node: HTMLElement | null) => {
     contentRef.current = node;
@@ -908,8 +914,16 @@ function createMarkdownComponents(
       : "space-y-1 pl-6 marker:text-muted-foreground";
 
   return {
-    spoiler: ({ children }: { children?: React.ReactNode }) => (
-      <SpoilerInline>{children}</SpoilerInline>
+    spoiler: ({
+      children,
+      ...props
+    }: {
+      "data-block-spoiler"?: string;
+      children?: React.ReactNode;
+    }) => (
+      <SpoilerInline block={props["data-block-spoiler"] != null}>
+        {children}
+      </SpoilerInline>
     ),
     a: ({ children, href, ...props }) => {
       if (!interactive) {

From 4d9215ecef2d0582a0e179fc60d1c9195ba8283d Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 15 Jun 2026 22:41:11 +0100
Subject: [PATCH 6/8] Respect non-interactive spoiler previews

---
 desktop/src/shared/styles/globals.css |  6 ++
 desktop/src/shared/ui/markdown.tsx    | 37 +++++++++++-
 desktop/tests/e2e/spoiler.spec.ts     | 84 ++++++++++++++++++++++++++-
 3 files changed, 125 insertions(+), 2 deletions(-)

diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index 39f4ae3c8a..ac85b34b39 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -1041,6 +1041,12 @@
   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 {
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index f2e32c9eb8..626e2a5ae1 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -797,9 +797,11 @@ function SyntaxHighlightedCode({
 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);
@@ -859,6 +861,36 @@ function SpoilerInline({
     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
@@ -921,7 +953,10 @@ function createMarkdownComponents(
       "data-block-spoiler"?: string;
       children?: React.ReactNode;
     }) => (
-      <SpoilerInline block={props["data-block-spoiler"] != null}>
+      <SpoilerInline
+        block={props["data-block-spoiler"] != null}
+        interactive={interactive}
+      >
         {children}
       </SpoilerInline>
     ),
diff --git a/desktop/tests/e2e/spoiler.spec.ts b/desktop/tests/e2e/spoiler.spec.ts
index f5257e316d..12af7a82ba 100644
--- a/desktop/tests/e2e/spoiler.spec.ts
+++ b/desktop/tests/e2e/spoiler.spec.ts
@@ -1,7 +1,7 @@
 import { expect, test } from "@playwright/test";
 import type { Page } from "@playwright/test";
 
-import { installMockBridge } from "../helpers/bridge";
+import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
 
 const IMAGE_SHA = "c".repeat(64);
 const IMAGE_URL = "http://127.0.0.1:4173/buzz.svg";
@@ -15,6 +15,24 @@ const IMAGE_DESCRIPTOR = {
   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,
@@ -151,3 +169,67 @@ test("hidden spoiler links reveal without opening on the first click", async ({
   expect(popup).toBeNull();
   await expect(spoiler).toHaveAttribute("data-revealed", "true");
 });
+
+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",
+  );
+});

From 30da3af5cf5d577e9dbc161e122f888ffcc9f3b1 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 15 Jun 2026 22:57:07 +0100
Subject: [PATCH 7/8] Parse spoilers inside link labels

---
 .../src/shared/lib/remarkSpoilers.test.mjs    | 27 +++++++++++++++++++
 desktop/src/shared/lib/remarkSpoilers.ts      |  4 +--
 2 files changed, 28 insertions(+), 3 deletions(-)

diff --git a/desktop/src/shared/lib/remarkSpoilers.test.mjs b/desktop/src/shared/lib/remarkSpoilers.test.mjs
index c1c5fe3677..5ac848a1bb 100644
--- a/desktop/src/shared/lib/remarkSpoilers.test.mjs
+++ b/desktop/src/shared/lib/remarkSpoilers.test.mjs
@@ -72,6 +72,33 @@ test("remarkSpoilers: groups inline images between delimiters", () => {
   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",
diff --git a/desktop/src/shared/lib/remarkSpoilers.ts b/desktop/src/shared/lib/remarkSpoilers.ts
index 75289d2a2d..501d7c2d16 100644
--- a/desktop/src/shared/lib/remarkSpoilers.ts
+++ b/desktop/src/shared/lib/remarkSpoilers.ts
@@ -158,7 +158,5 @@ function isBlockDelimiter(node: Node): boolean {
 }
 
 function shouldSkipNode(node: Node): boolean {
-  return (
-    node.type === "link" || node.type === "code" || node.type === "inlineCode"
-  );
+  return node.type === "code" || node.type === "inlineCode";
 }

From 7b3479dd34597b88f68968f7339d50ff658510d7 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Tue, 16 Jun 2026 07:10:58 +0100
Subject: [PATCH 8/8] Keep spoilers masked until reveal

---
 desktop/src/shared/styles/globals.css | 11 ++------
 desktop/tests/e2e/spoiler.spec.ts     | 40 +++++++++++++++++++++++++++
 2 files changed, 43 insertions(+), 8 deletions(-)

diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index ac85b34b39..85ae1ba780 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -992,11 +992,8 @@
   z-index: 0;
 }
 
-.buzz-spoiler:not([data-revealed="true"]):not(:hover):not(:focus-visible)
-  .buzz-spoiler__content,
-.buzz-spoiler:not([data-revealed="true"]):not(:hover):not(:focus-visible)
-  .buzz-spoiler__content
-  * {
+.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;
@@ -1056,9 +1053,7 @@
   text-decoration-color: currentColor;
 }
 
-.buzz-spoiler[data-revealed="true"] .buzz-spoiler__particles,
-.buzz-spoiler:hover .buzz-spoiler__particles,
-.buzz-spoiler:focus-visible .buzz-spoiler__particles {
+.buzz-spoiler[data-revealed="true"] .buzz-spoiler__particles {
   opacity: 0;
 }
 
diff --git a/desktop/tests/e2e/spoiler.spec.ts b/desktop/tests/e2e/spoiler.spec.ts
index 12af7a82ba..11a336f3e1 100644
--- a/desktop/tests/e2e/spoiler.spec.ts
+++ b/desktop/tests/e2e/spoiler.spec.ts
@@ -170,6 +170,46 @@ test("hidden spoiler links reveal without opening on the first click", async ({
   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,
 }) => {
