Skip to content

feat(frontend): support spoiler text in messages #2008

Description

@hmans

Context

Chatto stores and exchanges message bodies as plain text while bundled clients render the deliberately limited Markdown subset documented in FDR-032. People should be able to hide plot details, puzzle answers, sensitive-looking screenshots described in text, and similar content until each reader deliberately reveals it.

Use the familiar Discord-style syntax:

The answer is ||42||.

This remains a presentation feature. Spoiler markers do not encrypt content or change authorization: message source, APIs, exports, moderation tools, and explicit copy/reply actions can still expose the underlying text.

Syntax and parsing

  • Parse paired ||...|| delimiters outside inline code and fenced code.
  • An opening delimiter must be followed by non-whitespace and a closing delimiter must be preceded by non-whitespace. This keeps ordinary prose/code-like text such as a || b || c literal.
  • Empty, unmatched, and improperly spaced delimiters stay literal.
  • Do not nest spoilers or cross paragraph/block boundaries in the first version.
  • Inline Markdown inside a spoiler—emphasis, links, autolinks, mentions, and timestamps—should render normally once revealed.
  • Define and test precedence with GFM tables. A paired spoiler delimiter inside a cell must be treated as cell content rather than as column separators; existing empty-cell and escaped-pipe behavior must remain intact.
  • Preserve the exact source through compose, send, edit, copy, reply quoting, realtime delivery, history hydration, and third-party API use.

Reader experience

  • Initially render each spoiler as a clearly discoverable inline Reveal spoiler control without exposing its content through colour contrast, text selection, hover, focus, or the accessibility tree.
  • Reveal on pointer activation, Enter, or Space. The revealed content must become selectable and its links/mentions/timestamps must retain their normal behavior.
  • Give the control an accessible name/state and a visible keyboard focus indicator. Do not rely on colour alone.
  • Keep reveal state local to the rendered message. It need not be durable or synchronized between devices.
  • Support multiple independent spoilers in one message and every surface that renders message bodies, including room/thread timelines, reply and message preview cards, and search results.
  • Keep layout stable enough that revealing content does not cause an avoidable large timeline jump.

All user-visible control text must use the normal i18n catalogs.

Prevent accidental disclosure

Visual concealment is not useful if adjacent UI immediately repeats the hidden text.

  • Message/search excerpts and reply previews must preserve the spoiler boundary and start concealed.
  • Notification and push-notification text must replace concealed ranges with a neutral localized marker such as [spoiler] while retaining safe surrounding prose. If notification text is assembled outside the frontend, add the minimum shared/server-side source detection needed for consistent behavior.
  • URLs inside concealed ranges must not create visible link-preview cards or trigger preview fetches before reveal.
  • Mentions may retain their normal delivery semantics, but notification copy must not reveal the concealed text.
  • Search may continue indexing the underlying message because this is not an authorization boundary, but result snippets must not reveal matched spoiler text until the reader activates it.
  • Explicit actions that already copy or quote the original message body may retain the ||...|| source; document that behavior.

Implementation direction

Extend the owned markdown-it pipeline in apps/frontend/src/lib/markdown.ts with spoiler tokens and keep source HTML disabled. All generated tags and attributes must remain renderer-owned and flow through the audited MarkdownHtml.svelte boundary; do not add feature-level {@html}.

Interactive reveal behavior belongs at the reusable message-content boundary so it works consistently across callers. Concealed content should be genuinely excluded from selection and assistive-technology output until reveal rather than merely painted with matching foreground/background colours.

Keep parsing bounded by existing message-size limits and add targeted adversarial cases for long delimiter runs and many spoilers.

Acceptance criteria

  • ||spoiler|| renders as an initially concealed, explicitly revealable inline region.
  • Pointer, Enter, and Space activation work; focus, accessible name/state, and screen-reader behavior are verified.
  • Concealed text cannot be read accidentally through selection, hover/focus styling, or the accessibility tree.
  • Multiple spoilers reveal independently and remain responsive in narrow, light, dark, and RTL layouts.
  • Empty/unmatched/whitespace-adjacent delimiters and a || b || c remain literal.
  • Code spans and fenced code never parse spoiler markers.
  • Spoilers coexist with emphasis, links, mentions, timestamps, and GFM tables without corrupting syntax or layout.
  • Composer posting/editing, copy/reply actions, realtime delivery, and receiver-side rendering preserve the original Markdown source.
  • Room/thread messages, preview cards, reply previews, search results, and other message-rendering surfaces start concealed consistently.
  • Notifications and search excerpts redact concealed ranges; hidden URLs do not produce visible/fetched link previews before reveal.
  • The implementation adds no raw source-HTML support and preserves the current Trusted Types/CSP boundary.
  • Parser edge cases, table precedence, post-processing, notification/excerpt redaction, mounted interaction/accessibility tests, Storybook variants, and sender/receiver browser coverage are included.
  • FDR-032, public Message Body Tokens documentation, and user-facing formatting guidance explain the syntax and its non-security guarantee.

Out of scope

  • Encrypting spoiler content or changing who is authorized to read it.
  • Durable/per-user reveal preferences.
  • Spoilered attachments or link-preview cards.
  • A composer toolbar button; the first version can be source-syntax only.

Reference

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature or enhancement

    Projects

    Status
    Planned

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions