Skip to content

Replies and reactions, Signal-style (0.3.0, qc 0.2.0) - #279

Merged
ralyodio merged 1 commit into
masterfrom
feat/replies-reactions
Oct 6, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/replies-reactions

Conversation

@ralyodio

@ralyodio ralyodio commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Signal-style reply-to and reactions, in the web app and in qc.

Privacy model

Reactions are messages of their own: message_type: 'reaction', with an end-to-end encrypted body of { target, emoji, remove }.

  • The server learns that someone reacted, never which message or which emoji.
  • One reaction per person per message: a new emoji replaces yours, and tapping yours removes it.

Replies use the existing reply_to_id. The server could already see that link.

  • The quote is drawn from the reader's own decrypted copy of the original, so quoted text is never stored a second time.
  • If the original isn't loaded, the quote says so.

Web

  • Message actions: hover a message, or tap the bubble on touch screens, to get an action bar with six quick reactions (❤️ 👍 😂 😮 😢 🙏), + for the full OpenEmoji picker, and Reply.
  • Reaction chips: shown under the bubble with OpenEmoji art and a count. The title lists who reacted, yours is highlighted, and tapping a chip toggles your reaction.
  • Quotes: a reply shows a quote block above the bubble, and clicking it scrolls to the original and flashes it. The snippet is rendered by React as text, never as HTML, and a test covers that.
  • Composer: a reply bar ("Replying to Bob" plus a snippet) can be cancelled with × or Escape, and the send carries replyToId.
  • Scrolling: the list no longer jumps to the bottom when someone reacts to an older message.

Server

  • messageType is checked against an allow-list (text, image, file, reaction).
  • replyToId must be a message in the same conversation. Before, it was stored unchecked.
  • /api/messages/load returned a chat's oldest N messages (it sorted ascending and then applied the limit), so in long chats the newest messages never showed. It now returns the newest N, default 200 and max 500, which also leaves room for reactions.
  • Migration 20261006180000_message_type_reaction lets the CHECK constraint accept reaction. It is already applied on dev2, and the schema cache is reloaded.

qc (0.2.0)

  • Messages fold reactions and replies through the same foldMessages the web app uses.
  • The TUI shows ┃ quote lines and reaction summaries.
  • qc read shows both, plus a short message id.
  • New commands: qc react <chat> <id> <emoji> [--remove] and qc send <chat> <text> --reply <id>.
  • MCP: a new react_to_message tool, reply_to on send_message, and read_chat now returns ids, reactions and reply quotes.

Not in this PR

  • Conversation-list previews and unread counts still count a reaction as a message.
  • The TUI can't react or reply interactively yet (the CLI and MCP can).

Tests

  • New tests:
    • the fold logic (6 tests)
    • MessageItem (4 tests)
    • the composer reply bar
    • qc folding a real ML-KEM-encrypted reaction and a reply
    • the MCP tools
  • test:ci: 636/636.
  • next build passes.

Messaging is Preshy's area. Anthony asked for this directly.

🤖 Generated with Claude Code

Reactions are messages of their own (message_type 'reaction') whose
END-TO-END ENCRYPTED body is { target, emoji, remove }: the server learns
that someone reacted, never to what or with which emoji. One reaction per
person per message; a new one replaces yours, tapping yours removes it.
Replies use the existing reply_to_id; the quote is drawn from the reader's
own decrypted copy, so quoted text is never stored twice.

- src/lib/chat/reactions.js: envelope + foldMessages (reactions into their
  targets, reply quotes), shared by the web app and qc.
- MessageItem: quote block (jumps to the original), reaction chips with
  OpenEmoji art and who-reacted titles, hover/tap action bar with six quick
  reactions, the full picker and Reply. MessageList folds and only
  auto-scrolls on new messages, not on a reaction.
- Composer: reply bar (Escape/x cancels), sends replyToId.
- Server: messageType allowlist; replyToId must be a message in the same
  conversation. /api/messages/load returned a chat's OLDEST N messages
  (ascending + limit); it now returns the newest N (default 200, max 500).
- Migration 20261006180000: message_type CHECK allows 'reaction' (applied
  on dev2).
- qc: messages fold reactions/replies, TUI shows quotes and reaction
  summaries, `qc react <chat> <id> <emoji> [--remove]`,
  `qc send --reply <id>`, MCP react_to_message + send_message reply_to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

13 finding(s)

MEDIUM: 9 | LOW: 4

Severity Rule Location
MEDIUM redos-nested-quantifier src/app/api/profile/update/route.js:73
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:38
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:66
MEDIUM js-unescaped-html-sink src/app/faq/page.jsx:57
MEDIUM js-unescaped-html-sink src/app/layout.jsx:137
MEDIUM js-unescaped-html-sink src/app/layout.jsx:141
MEDIUM js-unescaped-html-sink src/app/page.jsx:47
MEDIUM redos-nested-quantifier src/lib/auth/dns-name.js:88
MEDIUM js-unescaped-html-sink src/lib/components/chat/MessageItem.jsx:99
LOW secret-generic-credential src/app/api/auth/register-anon/route.test.js:32
LOW secret-jwt tests/debug-sms.js:10
LOW secret-generic-credential tests/private-key-import-export.test.js:252
LOW secret-generic-credential tests/private-key-import-export.test.js:264

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 83c02e7 into master Oct 6, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant