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>
13 files changed,
+260
−27
changelog/2026-08-04-reaction-previews.md+5 −0ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift+18 −0ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+10 −2ios/LChatCore/Sources/LChatCore/Models.swift+17 −1ios/LChatCore/Sources/LChatCore/ThreadStore.swift+6 −1ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift+10 −2ios/LChatCore/Tests/LChatCoreTests/PreviewTextTests.swift+78 −0test/event-vectors/chat.json+6 −2test/format.test.mjs+34 −1test/markdown.test.mjs+14 −0web/static/apps/chat.js+11 −4web/static/format.js+19 −0web/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; |