eleven / ocho Public

web+native: a trailing reaction speaks in the chat list (#1418)

* web+native: a trailing reaction speaks in the chat list

When the newest event in a thread was an emoji reaction, the chat-list row
showed nothing under the name β€” blank on web (#520 made a trailing reaction
silent to stop it rendering as "James: " with nothing after) and blank-ish
on native, where previewText saw a payload with no text.

Say what happened instead: "James reacted πŸ‘ to β€œbuy milk”" β€” a new pure
helper (format.js reactionLine + its Swift twin), called from previewText on
both platforms. It names the actor itself, so it returns before the group
prefix and never doubles up.

The quote comes from targetText, a replySnippet of the reacted-to message
that the reacting client stamps inside the ciphertext β€” the same trick
targetUid already uses. The row is built from the thread's last_blob alone,
so there is nothing local to look the target up in for a thread this device
has never opened. Reactions that predate it (i.e. everything already stored)
fall back to the bare "James reacted πŸ‘".

Un-reacts speak too: on reload that event is all the list has, so silence
would mean the row said one thing live and another after a refresh.

Reactions still never light the unread dot or fire a native notify.

Co-Authored-By: Claude <noreply@anthropic.com>

* web+native: stamp the reaction quote through the plain markdown projection

Quoting the reacted-to message raw would show the marks the bubble renders
as styling ("**buy** milk") and the composer's opt-out escapes as visible
backslashes β€” the rule previewText already states for every other preview
line. Projected at the stamp (chat.js toggleReaction, ChatFeedModel), so no
payload ever carries them.

Co-Authored-By: Claude <noreply@anthropic.com>

* native: resolve the reaction's actor by uid, not just the payload's name

On real data a reaction row often read "Someone reacted πŸ˜‚": native took the
actor from the payload's `name` snapshot alone, and that field is EMPTY
whenever the reacting device had no screen name to stamp. The web never had
this β€” its previewText resolves by uid through nicknames/roster.

ThreadStore already holds displayLabels (my name for them, #577), the twin of
that resolution. Pass it in; the payload's name stays the fallback, and
"Someone" now only means nobody to resolve at all.

Co-Authored-By: Claude <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam pushed by codex agent, operated by paul@keymail.dev 9e24de6e9b82d80a8742676018ff3d5d9343b0fd parent 8b2b19f
13 files changed, +260 −27
  • changelog/2026-08-04-reaction-previews.md +5 −0
  • ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift +18 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +10 −2
  • ios/LChatCore/Sources/LChatCore/Models.swift +17 −1
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +6 −1
  • ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift +10 −2
  • ios/LChatCore/Tests/LChatCoreTests/PreviewTextTests.swift +78 −0
  • test/event-vectors/chat.json +6 −2
  • test/format.test.mjs +34 −1
  • test/markdown.test.mjs +14 −0
  • web/static/apps/chat.js +11 −4
  • web/static/format.js +19 −0
  • web/static/shell.js +32 −14
diff --git a/changelog/2026-08-04-reaction-previews.md b/changelog/2026-08-04-reaction-previews.md
new file mode 100644
index 0000000..f51b521
--- /dev/null
+++ b/changelog/2026-08-04-reaction-previews.md
@@ -0,0 +1,5 @@
+πŸ€– **Reactions now show up in the chat list.** When the most recent thing in a
+chat was someone's emoji reaction, the chat list showed a blank line under the
+chat's name. It now says what happened β€” "James reacted πŸ‘ to β€œcan you grab
+milk?”" β€” so you can tell at a glance who reacted to what without opening the
+chat. Reactions still never mark a chat unread.
diff --git a/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift b/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift
index 2b15ab3..69e3ea3 100644
--- a/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift
+++ b/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift
@@ -64,6 +64,24 @@ public func replySnippet(_ payload: MessagePayload, max: Int = 140) -> String {
return ""
}
+/// format.js reactionLine: the sidebar line for a reaction event β€”
+/// "James reacted πŸ‘ to β€œbuy milk”". A reaction has no text of its own, so
+/// without this the row went blank. `who` is resolved by the caller so it can
+/// say "You"; the quoted snippet is the payload's `targetText`, absent on
+/// reactions from older clients (then the bare form stands on its own).
+public func reactionLine(_ who: String, _ payload: MessagePayload, max: Int = 40) -> String {
+ let actor = who.trimmingCharacters(in: .whitespaces).isEmpty ? "Someone" : who
+ let emoji = (payload.emoji ?? "").trimmingCharacters(in: .whitespaces)
+ // on == false is an un-react. It still speaks: on reload this event is all
+ // the list has, so silence here would mean a different row after a refresh.
+ let off = payload.on == false
+ let head = "\(actor) \(off ? "removed" : "reacted")" + (emoji.isEmpty ? "" : " \(emoji)")
+ var quoted = MessagePayload()
+ quoted.text = payload.targetText
+ let snip = replySnippet(quoted, max: max)
+ return snip.isEmpty ? head : "\(head) \(off ? "from" : "to") β€œ\(snip)”"
+}
+
/// format.js typingSummary: who's typing, as a subtle one-liner that never
/// grows unbounded past two names.
public func typingSummary(_ names: [String]) -> String {
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 958b5f7..8e25e71 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -819,19 +819,27 @@ public final class ChatFeedModel: ObservableObject {
/// (type="reaction") sent as an ordinary message β€” the server never sees
/// the emoji or the target; every client folds the event into the target's
/// tally. targetUid (the reacted-to message's author) rides inside the
- /// ciphertext so push handlers can show the notification only to the author.
+ /// ciphertext so push handlers can show the notification only to the author;
+ /// targetText (its snippet) rides along so the sidebar can quote what was
+ /// reacted to without loading the thread.
public func toggleReaction(_ id: Int64, emoji: String) {
guard Self.allowedReactions.contains(emoji), key != nil,
let socket, socket.isLive,
let m = messages.first(where: { $0.id == id }) else { return }
let mine = m.reactions.first { $0.emoji == emoji }?.isMine ?? false
+ // The quote goes through the markdown plain projection, like every
+ // other preview line β€” raw text would show the marks the bubble renders
+ // as styling. Projected at the stamp, so no payload carries them.
+ var quoted = m.raw
+ quoted.text = markdownPlainText(m.raw.text ?? "")
var payload = MessagePayload(type: "reaction",
name: instance.screenName,
ts: Int64(Date().timeIntervalSince1970 * 1000),
targetSid: id,
targetUid: m.raw.uid,
emoji: emoji,
- on: !mine)
+ on: !mine,
+ targetText: replySnippet(quoted))
if !myUserId.isEmpty { payload.uid = myUserId }
encryptAndSend(payload, via: socket)
// Optimistic local fold so the chip responds instantly; the echo frame
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index 5901c8f..8a9c32d 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -175,6 +175,10 @@ public struct MessagePayload: Codable, Equatable, Sendable {
// only to the author; nil for legacy uid-less targets
public var emoji: String? // the reaction emoji
public var on: Bool? // true to add, false to remove
+ public var targetText: String? // a replySnippet of the reacted-to message, stamped by the
+ // reacting client (inside the ciphertext) so the sidebar can
+ // say "reacted πŸ‘ to β€œbuy milk”" for a thread built from
+ // last_blob alone; nil on reactions sent before this shipped
// Tamper-evidence gossip (docs/tamper-evidence.md, #573): the sender's
// verified checkpoint head, `[seqTo, chain]`, riding inside the encrypted
@@ -409,7 +413,19 @@ public struct ChatThread: Identifiable, Equatable, Codable {
// A one-line snippet for the sidebar: "Name: text" in groups, just text in DMs β€”
// mirrors previewText in web/static/chat.js.
-public func previewText(kind: String, payload: MessagePayload) -> String {
+public func previewText(kind: String, payload: MessagePayload, mine: Bool = false,
+ actor: String = "") -> String {
+ // A reaction event carries no text, so say what happened instead of
+ // rendering an empty row (web twin: the reaction branch in previewText).
+ // It names the actor itself, so it returns before the group prefix below.
+ // `actor` is the caller's by-uid resolution β€” my name for them (#577), the
+ // way the web resolves it; the payload's own `name` is only the fallback,
+ // and it is EMPTY whenever the reacting device had no screen name to stamp
+ // (which is what made these rows read "Someone reacted πŸ‘").
+ if payload.isReactionEvent {
+ let who = actor.isEmpty ? (payload.name ?? "") : actor
+ return reactionLine(mine ? "You" : who, payload)
+ }
// Through the markdown plain projection, never raw: raw text leaks the
// composer's opt-out escapes as visible backslashes (\*lit\*), and shows
// the marks the bubble renders as styling.
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index f028f9b..e71dbfc 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -656,7 +656,12 @@ public final class ThreadStore: ObservableObject {
lastMine = n == rosterLabels[myUserId]
}
if p.isRenderableChat || p.isReactionEvent {
- preview = previewText(kind: t.kind, payload: p)
+ // displayLabels: my name for them (#577) β€” the same by-uid
+ // resolution the web's previewText does, so a reaction from
+ // a device that stamped no screen name still names its
+ // sender instead of "Someone".
+ let actor = p.uid.flatMap { displayLabels[$0] } ?? ""
+ preview = previewText(kind: t.kind, payload: p, mine: lastMine, actor: actor)
} else {
// An APP EVENT (docs/native-apps.md voice surfaces): the
// raw payload text is app data, not a sidebar line ("buy
diff --git a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift
index f3c8ddf..ede0cc0 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift
@@ -169,7 +169,8 @@ final class EventVectorTests: XCTestCase {
targetSid: 12345,
targetUid: "user-eve-1",
emoji: "πŸ‘",
- on: true
+ on: true,
+ targetText: "buy milk"
)
XCTAssertTrue(reactionAdd.isReactionEvent, "should be identified as a reaction event")
@@ -187,6 +188,11 @@ final class EventVectorTests: XCTestCase {
XCTAssertEqual(reactionAdd.targetUid, "user-eve-1")
XCTAssertNotEqual(reactionAdd.targetUid, reactionAdd.uid, "reactor and author differ in this vector")
+ // The sidebar-side contract: targetText is a snippet of the reacted-to
+ // message, stamped by the reacting client so the chat list can quote it
+ // from last_blob alone. The fold ignores it (see expected_fold_event).
+ XCTAssertEqual(reactionAdd.targetText, "buy milk", "targetText carries the reacted-to snippet")
+
let reactionRemove = MessagePayload(
type: "reaction",
uid: "user-bob-1",
@@ -199,12 +205,14 @@ final class EventVectorTests: XCTestCase {
targetSid: 12346,
targetUid: "user-eve-1",
emoji: "❀️",
- on: false
+ on: false,
+ targetText: "shipped it πŸŽ‰"
)
XCTAssertTrue(reactionRemove.isReactionEvent, "should be identified as a reaction event")
XCTAssertFalse(reactionRemove.on ?? true, "on should be false for removing")
XCTAssertEqual(reactionRemove.targetUid, "user-eve-1", "targetUid rides on removals too")
+ XCTAssertEqual(reactionRemove.targetText, "shipped it πŸŽ‰", "so does targetText β€” an un-react still speaks in the sidebar")
}
func testReactionFold() {
diff --git a/ios/LChatCore/Tests/LChatCoreTests/PreviewTextTests.swift b/ios/LChatCore/Tests/LChatCoreTests/PreviewTextTests.swift
index 5ab4dbc..fe7120f 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/PreviewTextTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/PreviewTextTests.swift
@@ -58,4 +58,82 @@ final class PreviewTextTests: XCTestCase {
p.attachments = [att("report.docx", "application/msword")]
XCTAssertEqual(previewText(kind: "group", payload: p), "Ada: πŸ“Ž report.docx")
}
+
+ // A reaction has no text of its own β€” the row used to go blank (in a group,
+ // a bare "Ada: "). It says what happened instead, quoting the snippet the
+ // reacting client stamped inside the ciphertext. Web twin: reactionLine in
+ // format.js + the reaction branch in shell.js previewText.
+ private func reaction(_ name: String, _ emoji: String, on: Bool, target: String?) -> MessagePayload {
+ var p = MessagePayload()
+ p.type = "reaction"
+ p.name = name
+ p.emoji = emoji
+ p.on = on
+ p.targetSid = 12345
+ p.targetText = target
+ return p
+ }
+
+ func testReactionQuotesItsTarget() {
+ let p = reaction("Ada", "πŸ‘", on: true, target: "buy milk")
+ XCTAssertEqual(previewText(kind: "dm", payload: p), "Ada reacted πŸ‘ to β€œbuy milk”")
+ }
+
+ // It names the actor itself, so the group prefix never doubles up.
+ func testReactionTakesNoGroupPrefix() {
+ let p = reaction("Ada", "πŸ‘", on: true, target: "buy milk")
+ XCTAssertEqual(previewText(kind: "group", payload: p), "Ada reacted πŸ‘ to β€œbuy milk”")
+ }
+
+ func testMyOwnReactionSaysYou() {
+ let p = reaction("Ada", "πŸŽ‰", on: true, target: "shipped it")
+ XCTAssertEqual(previewText(kind: "group", payload: p, mine: true), "You reacted πŸŽ‰ to β€œshipped it”")
+ }
+
+ // An un-react speaks too: on reload this event is all the sidebar has, so
+ // silence would mean a different row after a refresh than the one shown live.
+ func testUnReactSpeaks() {
+ let p = reaction("Bob", "❀️", on: false, target: "shipped it πŸŽ‰")
+ XCTAssertEqual(previewText(kind: "dm", payload: p), "Bob removed ❀️ from β€œshipped it πŸŽ‰β€")
+ }
+
+ // Reactions from clients that predate targetText (and every reaction
+ // already in a database) carry no snippet β€” the bare form still reads.
+ func testReactionWithoutASnippet() {
+ XCTAssertEqual(previewText(kind: "dm", payload: reaction("Ada", "πŸ‘", on: true, target: nil)),
+ "Ada reacted πŸ‘")
+ XCTAssertEqual(previewText(kind: "dm", payload: reaction("Ada", "πŸ‘", on: false, target: nil)),
+ "Ada removed πŸ‘")
+ }
+
+ func testReactionQuoteIsClamped() {
+ let p = reaction("Ada", "πŸ‘", on: true, target: String(repeating: "a", count: 200))
+ let line = previewText(kind: "dm", payload: p)
+ XCTAssertTrue(line.hasPrefix("Ada reacted πŸ‘ to β€œ"))
+ XCTAssertTrue(line.hasSuffix("…”"))
+ XCTAssertLessThan(line.count, 70, "the sidebar line stays one line's worth")
+ }
+
+ // The caller resolves the actor by uid (ThreadStore's displayLabels β€” my
+ // name for them, #577), and that wins over the payload's snapshot. Without
+ // it, a reaction from a device that stamped no screen name β€” common on real
+ // data β€” reads "Someone reacted πŸ‘".
+ func testActorResolvesByUidOverThePayloadName() {
+ var p = reaction("", "πŸ˜‚", on: true, target: nil)
+ p.name = ""
+ XCTAssertEqual(previewText(kind: "group", payload: p, actor: "Paul"), "Paul reacted πŸ˜‚")
+ }
+
+ func testResolvedActorBeatsAStalePayloadName() {
+ let p = reaction("Old Name", "πŸ‘", on: true, target: "buy milk")
+ XCTAssertEqual(previewText(kind: "dm", payload: p, actor: "My Name For Them"),
+ "My Name For Them reacted πŸ‘ to β€œbuy milk”")
+ }
+
+ // Nobody to resolve (they left the space, and the payload named no one).
+ func testReactionWithoutANameSaysSomeone() {
+ var p = reaction("", "πŸ‘", on: true, target: "buy milk")
+ p.name = nil
+ XCTAssertEqual(previewText(kind: "dm", payload: p), "Someone reacted πŸ‘ to β€œbuy milk”")
+ }
}
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json
index a75e82a..c2933a7 100644
--- a/test/event-vectors/chat.json
+++ b/test/event-vectors/chat.json
@@ -324,6 +324,7 @@
"targetSid": 12345,
"targetUid": "user-eve-1",
"emoji": "πŸ‘",
+ "targetText": "buy milk",
"name": "Alice",
"uid": "user-alice-1",
"ts": 1700000010000,
@@ -346,10 +347,11 @@
"targetSid": 12345,
"targetUid": "user-eve-1",
"emoji": "πŸ‘",
+ "targetText": "buy milk",
"uid": "user-alice-1",
"on": true
},
- "note": "Reactions are encrypted events stored as messages. Clients decrypt and fold them into the target message's reactions array. Count and mine-flag are derived client-side from the accumulated reaction events. on=true adds the reaction; on=false removes it. The mine-flag is true if uid===me, false otherwise. targetUid is the reacted-to message's author, stamped by the reacting client so push handlers (sw.js, the iOS NSE) can show the notification only to the author; the fold ignores it. Omitted when the target is a legacy uid-less message β€” then no one is notified."
+ "note": "Reactions are encrypted events stored as messages. Clients decrypt and fold them into the target message's reactions array. Count and mine-flag are derived client-side from the accumulated reaction events. on=true adds the reaction; on=false removes it. The mine-flag is true if uid===me, false otherwise. targetUid is the reacted-to message's author, stamped by the reacting client so push handlers (sw.js, the iOS NSE) can show the notification only to the author; the fold ignores it. Omitted when the target is a legacy uid-less message β€” then no one is notified. targetText is a replySnippet of the reacted-to message, likewise stamped by the reacting client and likewise ignored by the fold: it exists so the chat list can say 'Alice reacted πŸ‘ to β€œbuy milk”' for a thread built from last_blob alone (format.js reactionLine / the Swift twin). Optional β€” reactions from clients that predate it carry none, and the line falls back to 'Alice reacted πŸ‘'."
},
{
"name": "reaction_remove",
@@ -359,6 +361,7 @@
"targetSid": 12346,
"targetUid": "user-eve-1",
"emoji": "❀️",
+ "targetText": "shipped it πŸŽ‰",
"name": "Bob",
"uid": "user-bob-1",
"ts": 1700000015000,
@@ -381,10 +384,11 @@
"targetSid": 12346,
"targetUid": "user-eve-1",
"emoji": "❀️",
+ "targetText": "shipped it πŸŽ‰",
"uid": "user-bob-1",
"on": false
},
- "note": "Removal is signaled by on=false. Clients fold both add and remove into the target message's reaction array; the current state is derived by filtering for on=true and counting. Removals never notify anyone (push handlers suppress on=false)."
+ "note": "Removal is signaled by on=false. Clients fold both add and remove into the target message's reaction array; the current state is derived by filtering for on=true and counting. Removals never notify anyone (push handlers suppress on=false) β€” but they DO speak in the chat list ('Bob removed ❀️ from β€œshipped it πŸŽ‰β€'), because on reload this event is all the list has to render the row from."
},
{
"name": "edit",
diff --git a/test/format.test.mjs b/test/format.test.mjs
index 5980410..8b5053b 100644
--- a/test/format.test.mjs
+++ b/test/format.test.mjs
@@ -1,6 +1,6 @@
// Tests for text formatting (link detection, file sizes). node test/format.test.mjs
import assert from "node:assert/strict";
-import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs, formatCountdown } from "../web/static/format.js";
+import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, reactionLine, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs, formatCountdown } from "../web/static/format.js";
import { emojify, emojiMatches } from "../web/static/emoji.js";
let failures = 0;
@@ -127,6 +127,39 @@ await test("replySnippet is empty for an empty message", () => {
assert.equal(replySnippet(null), "");
});
+await test("reactionLine quotes what was reacted to", () => {
+ assert.equal(
+ reactionLine("James", { type: "reaction", emoji: "πŸ‘", on: true, targetText: "buy milk" }),
+ "James reacted πŸ‘ to β€œbuy milk”",
+ );
+});
+
+await test("reactionLine speaks for an un-react too", () => {
+ // The list must say the same thing after a reload as it did live, and on
+ // reload the un-react event is all it has.
+ assert.equal(
+ reactionLine("James", { type: "reaction", emoji: "❀️", on: false, targetText: "shipped it" }),
+ "James removed ❀️ from β€œshipped it”",
+ );
+});
+
+await test("reactionLine falls back to the bare form without a snippet", () => {
+ // Reactions sent before targetText shipped β€” the primary path on rollout.
+ assert.equal(reactionLine("James", { type: "reaction", emoji: "πŸ‘", on: true }), "James reacted πŸ‘");
+ assert.equal(reactionLine("James", { type: "reaction", emoji: "πŸ‘", on: false }), "James removed πŸ‘");
+});
+
+await test("reactionLine clamps a long quote", () => {
+ const line = reactionLine("Jo", { emoji: "πŸ‘", on: true, targetText: "a".repeat(200) }, 10);
+ assert.ok(line.startsWith("Jo reacted πŸ‘ to β€œ"));
+ assert.ok(line.endsWith("…”"));
+});
+
+await test("reactionLine takes the caller's actor, including β€œYou”", () => {
+ assert.equal(reactionLine("You", { emoji: "πŸŽ‰", on: true, targetText: "hi" }), "You reacted πŸŽ‰ to β€œhi”");
+ assert.equal(reactionLine("", { emoji: "πŸŽ‰", on: true }), "Someone reacted πŸŽ‰");
+});
+
await test("emojiOnlyCount counts 1–3 all-emoji messages", () => {
assert.equal(emojiOnlyCount("πŸ‘"), 1);
assert.equal(emojiOnlyCount("πŸ˜€πŸ˜‚"), 2);
diff --git a/test/markdown.test.mjs b/test/markdown.test.mjs
index b5a6bce..2795b83 100644
--- a/test/markdown.test.mjs
+++ b/test/markdown.test.mjs
@@ -11,6 +11,7 @@ import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseMessage, houseParser, plainText } from "../web/static/markdown.js";
+import { replySnippet } from "../web/static/format.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -127,6 +128,19 @@ await test("link hrefs are never non-http(s)", () => {
walk(parseMessage("[a](data:text/html,x) [b](vbscript:x) [c](file:///etc) <ftp://x.com>"));
});
+// The reaction quote a chat-list row shows ("James reacted πŸ‘ to β€œbuy milk”")
+// is stamped at react time as replySnippet(plainText(text)) β€” the same plain
+// projection every other preview line uses. Pinned here because the stamp
+// itself lives in DOM code (chat.js toggleReaction, ChatFeedModel on native):
+// quoting raw text would show the marks the bubble renders as styling, and the
+// composer's opt-out escapes as visible backslashes.
+await test("a reaction quote is stamped through the plain projection", () => {
+ assert.equal(replySnippet({ text: plainText("**buy** milk") }), "buy milk");
+ assert.equal(replySnippet({ text: plainText("\\*lit\\*") }), "*lit*");
+ // No text (a photo someone reacted to) still labels itself.
+ assert.equal(replySnippet({ text: plainText(""), attachments: [{ type: "image/png" }] }), "πŸ“· Photo");
+});
+
if (failures) {
console.error(`\nmarkdown: ${failures} test(s) failed`);
process.exit(1);
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index c6998d9..37417d2 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -25,7 +25,7 @@
import { defineApp, namespace, keyedList, runCommand, runAppMention, messageActionsFor, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, cancelAnswer, setAnswerCancelledHook, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js";
import { replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js";
import { openLightbox } from "../lightbox.js";
-import { parseMessage, renderTree } from "../markdown.js";
+import { parseMessage, renderTree, plainText as mdPlainText } from "../markdown.js";
import { createComposer, adoptComposer, classicComposerPref, formatAsYouTypePref } from "../composer.js";
// The rich (ProseMirror) composer backend loads via a primed dynamic import β€”
@@ -45,7 +45,8 @@ import { decodeBlurhash } from "../blurhash.js";
// The host bridge: chat.js injects the pieces that are server mechanisms,
// key-holding machinery, or host state β€” the app never touches crypto/tokens.
-// react(threadId, sid, emoji, on) original react/unreact WS frames
+// react(threadId, sid, emoji, on, targetUid, targetText)
+// encrypted react/unreact event
// del(threadId, sid) original delete frame (server-verified)
// edit(threadId, sid, payload) re-encrypt + original edit frame
// loadBlob(threadId, att) β†’ url fetch + decrypt an attachment blob
@@ -1469,9 +1470,15 @@ function toggleReaction(m, emoji, ctx) {
// Reactions are now encrypted events. Optimistically update local state, but
// if the send fails, revert on the next render. host.react is now async.
// m.uid (the reacted-to message's author) rides inside the encrypted payload
- // so push handlers can show the reaction only to the author.
+ // so push handlers can show the reaction only to the author; its snippet
+ // rides along too so the chat list can say what was reacted to.
+ // Through the markdown plain projection, like every other preview line β€”
+ // quoting raw text would show the marks the bubble renders as styling, and
+ // the composer's opt-out escapes as visible backslashes. Projected here, at
+ // the stamp, so no payload ever carries them.
const onVal = !mine;
- host.react(ctx.threadId, sid, emoji, onVal, m.uid || "").then((sent) => {
+ const quote = replySnippet({ ...m.raw, text: mdPlainText(m.raw.text || "") });
+ host.react(ctx.threadId, sid, emoji, onVal, m.uid || "", quote).then((sent) => {
if (!sent) {
if (onVal) myReactions.delete(key);
else myReactions.add(key);
diff --git a/web/static/format.js b/web/static/format.js
index 4465d20..e53dd03 100644
--- a/web/static/format.js
+++ b/web/static/format.js
@@ -244,6 +244,25 @@ export function replySnippet(payload, max = 140) {
return "";
}
+// The chat-list line for a reaction event β€” "James reacted πŸ‘ to β€œbuy milk”".
+// A reaction is an ordinary encrypted message, so it can be the newest thing in
+// a thread; before this the row simply went blank (previewText saw a payload
+// with no text). `who` is resolved by the caller, so it can say "You".
+// The quoted snippet rides inside the ciphertext as `targetText`, stamped by
+// the reacting client β€” reactions sent before that shipped (and by older
+// clients) carry none, so the bare "James reacted πŸ‘" is the fallback, not an
+// error. Pure so both the sidebar and its tests can use it.
+export function reactionLine(who, payload, max = 40) {
+ const actor = String(who || "").trim() || "Someone";
+ const emoji = (payload && payload.emoji ? String(payload.emoji) : "").trim();
+ // on === false is an un-react. It still speaks: the list has to say the same
+ // thing after a reload as it did live, and on reload all we have is this event.
+ const off = !!payload && payload.on === false;
+ const head = `${actor} ${off ? "removed" : "reacted"}${emoji ? " " + emoji : ""}`;
+ const snip = replySnippet({ text: payload && payload.targetText }, max);
+ return snip ? `${head} ${off ? "from" : "to"} β€œ${snip}”` : head;
+}
+
// Counts the emoji in a message whose only content is emoji (plus whitespace),
// returning 0 for anything else. Used to render short all-emoji messages large
// and bubble-less (Γ  la Messages). Grapheme-segmented so ZWJ families (πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦),
diff --git a/web/static/shell.js b/web/static/shell.js
index 19018fc..f50df7f 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -48,7 +48,7 @@ import {
capAllows,
appReloadMessage,
} from "/static/ui.js";
-import { richTokens, blockTokens, firstLink, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId, relTime, formatCountdown } from "/static/format.js";
+import { richTokens, blockTokens, firstLink, replySnippet, reactionLine, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId, relTime, formatCountdown } from "/static/format.js";
import { emojify, emojiMatches } from "/static/emoji.js";
import { startBootScene, finishBootScene } from "/static/bootscene.js";
import { processImage, videoMeta } from "/static/media.js";
@@ -2372,9 +2372,10 @@ async function applyThreads(r) {
if (isAppType(payload.type)) {
const pv = appPreview(payload, { you: payload.uid && payload.uid === myUserId });
if (pv) threadPreview[t.id] = pv;
- } else if (payload.type !== "reaction") {
- // A trailing reaction event stays silent (like the live path) β€”
- // previewText would render it as "Name: " with nothing after.
+ } else {
+ // Anything else β€” a chat message, or a trailing reaction event, which
+ // previewText speaks as "James reacted πŸ‘ to …" rather than the blank
+ // row it used to leave behind.
previewPayload[t.id] = payload;
threadPreview[t.id] = previewText(t, payload);
}
@@ -2653,6 +2654,14 @@ window.lchatPaletteRun = (kind, id) => {
// A one-line snippet for the chat list: "Name: text" in groups, just text in DMs.
function previewText(t, data) {
+ // The actor, resolved by uid like the bubble headers (#592) β€” my name for
+ // them, their current name, then the payload's snapshot. Both the reaction
+ // line and the group prefix below name them.
+ const who = (data.uid && displayNameFor(nicknames[data.uid], (roster.find((x) => x.user_id === data.uid) || {}).label)) || data.name;
+ // A reaction event: it has no text of its own, so say what happened β€”
+ // "James reacted πŸ‘ to β€œbuy milk”" (#520 left this blank). It names the
+ // actor itself, so it returns before the group prefix below.
+ if (data.type === "reaction") return reactionLine(data.uid && data.uid === myUserId ? "You" : who, data);
// Through the markdown plain projection, never raw: raw text leaks the
// composer's opt-out escapes as visible backslashes (\*lit\*), and shows
// the marks the bubble renders as styling.
@@ -2670,10 +2679,7 @@ function previewText(t, data) {
}
}
// An action line ("Alice waves") already names the actor β€” skip the group
- // prefix so it doesn't double up ("Alice: Alice waves"). The prefix resolves
- // by uid like the bubble headers (#592) β€” my name for them, their current
- // name, then the payload snapshot.
- const who = (data.uid && displayNameFor(nicknames[data.uid], (roster.find((x) => x.user_id === data.uid) || {}).label)) || data.name;
+ // prefix so it doesn't double up ("Alice: Alice waves").
return t.kind === "group" && who && !data.action ? `${who}: ${body}` : body;
}
@@ -5240,7 +5246,7 @@ setChatAppHost({
const t = threads.find((x) => x.id === threadId);
return !!t && t.kind === "self";
},
- react: async (threadId, targetSid, emoji, on, targetUid) => {
+ react: async (threadId, targetSid, emoji, on, targetUid, targetText) => {
const key = threadKeys[threadId];
if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false;
const payload = {
@@ -5261,6 +5267,12 @@ setChatAppHost({
// author β€” the server never learns whose message it was. Absent for
// legacy uid-less messages (then no one is notified).
if (targetUid) payload.targetUid = targetUid;
+ // A snippet of the reacted-to message, likewise inside the ciphertext: the
+ // chat list needs it to say "reacted πŸ‘ to β€œbuy milk”" for a thread it has
+ // never opened (the row is built from last_blob alone, so there is nothing
+ // to look the target up in). A snapshot, like a reply quote's β€” it outlives
+ // an edit or a delete of the original.
+ if (targetText) payload.targetText = targetText;
try {
const blob = await encrypt(key, payload);
ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
@@ -7717,15 +7729,21 @@ function connectWS() {
// Own echoes still compare (per-device split view) but don't count as peers.
takeGossip(payload, payload && payload.uid && payload.uid !== myUserId);
// A reaction event: fold it so the target bubble's chips update live, then
- // stop β€” reactions stay silent in the thread list (no preview, no unread
- // dot, no native notify), like the plaintext reaction frames they replace.
- // last_at still advances (the server counts the event as the thread's
- // latest message, so a reload would order the list the same way).
+ // stop β€” a reaction never lights the unread dot or fires a native notify
+ // (the author's own push says it instead). It DOES retitle the chat-list
+ // row ("James reacted πŸ‘ to …"): the server counts the event as the
+ // thread's latest message, so last_at advances either way and a reload
+ // would render exactly this line β€” staying silent here would mean the list
+ // said one thing live and another after a refresh.
if (payload && payload.type === "reaction") {
foldHostEvent(tid, "chat", { type: "chat.reaction", targetSid: payload.targetSid, emoji: payload.emoji, uid: payload.uid, on: payload.on, ts: payload.ts });
if (tid === activeId) renderActiveSurfaces();
const rt = threads.find((x) => x.id === tid);
- if (rt) rt.last_at = msg.created_at || Math.floor(Date.now() / 1000);
+ if (rt) {
+ rt.last_at = msg.created_at || Math.floor(Date.now() / 1000);
+ previewPayload[tid] = payload;
+ threadPreview[tid] = previewText(rt, payload);
+ }
if (tid === activeId && document.hasFocus()) markThreadRead(tid);
renderThreadList();
return;