From 62676f5c9da471018b12b32e44fa91857f745025 Mon Sep 17 00:00:00 2001 From: clawSean <260045960+clawSean@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:22:41 +0000 Subject: [PATCH 1/2] fix(polls): render selection snapshots --- Sources/imsg/AttachmentDisplay.swift | 34 +++++++++++++++++-- Tests/imsgTests/UtilitiesTests.swift | 51 ++++++++++++++++++++++++++++ docs/history.md | 4 +-- docs/json.md | 4 +-- 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/Sources/imsg/AttachmentDisplay.swift b/Sources/imsg/AttachmentDisplay.swift index 700977ad..22e840d7 100644 --- a/Sources/imsg/AttachmentDisplay.swift +++ b/Sources/imsg/AttachmentDisplay.swift @@ -28,9 +28,37 @@ func pollDisplayText(for poll: MessagePollEvent) -> String { let options = poll.options?.map(\.text).joined(separator: " / ") ?? "" return options.isEmpty ? "[poll created] \(question)" : "[poll created] \(question): \(options)" case .vote: - let participant = poll.vote?.participant ?? "someone" - let option = poll.vote?.optionText ?? poll.vote?.optionID ?? "unknown option" - let action = poll.vote?.eventType ?? "selected" + if let votes = poll.votes { + var participantOrder: [String] = [] + var selectionsByParticipant: [String: [String]] = [:] + for vote in votes where vote.eventType != "removed" { + let participant = vote.participant ?? "someone" + let option = vote.optionText ?? vote.optionID + if selectionsByParticipant[participant] == nil { + participantOrder.append(participant) + selectionsByParticipant[participant] = [] + } + if selectionsByParticipant[participant]?.contains(option) == false { + selectionsByParticipant[participant]?.append(option) + } + } + let snapshots = participantOrder.compactMap { participant -> String? in + guard let selections = selectionsByParticipant[participant], !selections.isEmpty else { + return nil + } + return "\(participant) selected \(selections.joined(separator: " / "))" + } + if !snapshots.isEmpty { + return "[poll selections] \(snapshots.joined(separator: "; "))" + } + return "[poll selections] no options selected" + } + guard let vote = poll.vote else { + return "[poll selections] no options selected" + } + let participant = vote.participant ?? "someone" + let option = vote.optionText ?? vote.optionID + let action = vote.eventType ?? "selected" return "[poll vote] \(participant) \(action) \(option)" case .unknown: return "[poll unknown]" diff --git a/Tests/imsgTests/UtilitiesTests.swift b/Tests/imsgTests/UtilitiesTests.swift index 5bdf8013..f92a3657 100644 --- a/Tests/imsgTests/UtilitiesTests.swift +++ b/Tests/imsgTests/UtilitiesTests.swift @@ -54,6 +54,57 @@ func attachmentDisplayPrefersTransferName() { #expect(pluralSuffix(for: 2) == "s") } +@Test +func pollDisplayUsesTheFullSelectionSnapshot() { + let firstVote = MessagePollVote( + optionID: "choice-a", + optionText: "Lobster", + participant: "+15550002000", + eventType: "selected" + ) + let poll = MessagePollEvent( + kind: .vote, + vote: firstVote, + votes: [ + firstVote, + MessagePollVote( + optionID: "choice-b", + optionText: "Also lobster", + participant: "+15550002000", + eventType: "selected" + ), + ] + ) + + let display = pollDisplayText(for: poll) + #expect(display == "[poll selections] +15550002000 selected Lobster / Also lobster") + #expect(display.contains("Lobster / Also lobster")) + #expect(display != "[poll vote] +15550002000 selected Lobster") +} + +@Test +func pollDisplayDoesNotReportARemainingSelectionAsANewVote() { + let remainingVote = MessagePollVote( + optionID: "choice-b", + optionText: "Beta", + participant: "+15550002000", + eventType: "selected" + ) + let poll = MessagePollEvent( + kind: .vote, + vote: remainingVote, + votes: [remainingVote] + ) + + #expect(pollDisplayText(for: poll) == "[poll selections] +15550002000 selected Beta") +} + +@Test +func pollDisplayHandlesAnEmptySelectionSnapshot() { + let poll = MessagePollEvent(kind: .vote, votes: []) + #expect(pollDisplayText(for: poll) == "[poll selections] no options selected") +} + @Test func jsonLinesPrintsSingleLineJSON() throws { let line = try JSONLines.encode(["status": "ok"]) diff --git a/docs/history.md b/docs/history.md index e5c1a4a3..8256ad33 100644 --- a/docs/history.md +++ b/docs/history.md @@ -65,7 +65,7 @@ Tapback rows (`Liked "..."`, `Loved "..."`, etc.) are hidden from `history` outp ## Native polls -Native Apple Messages polls are decoded when Messages stores them as the Polls extension balloon (`com.apple.messages.Polls`). Creation rows include `poll.kind == "created"` with the question and options when available. Native poll payload titles are often empty because Messages shows the question as a separate caption row; imsg backfills an empty created-poll `question` from the earliest clean caption that replies to the poll. Vote update rows include `poll.kind == "vote"` and `poll.original_guid` pointing back to the poll message. +Native Apple Messages polls are decoded when Messages stores them as the Polls extension balloon (`com.apple.messages.Polls`). Creation rows include `poll.kind == "created"` with the question and options when available. Native poll payload titles are often empty because Messages shows the question as a separate caption row; imsg backfills an empty created-poll `question` from the earliest clean caption that replies to the poll. Vote update rows include `poll.kind == "vote"` and `poll.original_guid` pointing back to the poll message. Their `poll.votes` array is the participant's full selected-option snapshot, not necessarily the option that changed. ```bash imsg history --chat-id 42 --json \ @@ -103,7 +103,7 @@ vote row without the Polls payload, so the recipient's poll did not update. 1. Create a native poll in Messages from an iPhone or Mac. 2. Run `imsg history --chat-id --json | jq -c 'select(.poll != null) | {id, guid, poll}'` and verify the creation row has `poll.kind == "created"` with decoded question/options. 3. Vote on the poll from another participant/device. -4. Run `imsg watch --chat-id --json | jq -c 'select(.poll != null)'` while the vote happens, or re-run history, and verify the vote row has `poll.kind == "vote"`, `poll.original_guid` set to the original poll GUID, and `poll.vote.option_id` set. +4. Run `imsg watch --chat-id --json | jq -c 'select(.poll != null)'` while the vote happens, or re-run history, and verify the vote row has `poll.kind == "vote"`, `poll.original_guid` set to the original poll GUID, and `poll.votes` containing the participant's current selected options. 5. Send a poll with `imsg poll send --chat-id --question "..." --option "A" --option "B"` and verify it renders as a native Messages poll on iOS/macOS with the question visible as the plain caption below it. 6. Vote with `imsg poll vote --chat-id --poll --option-index 2`, then verify the new row has `poll.kind == "vote"`, the original GUID, and the selected option. 7. If Apple changes the private Polls payload shape, verify the row still emits `poll.kind == "unknown"` with metadata and no raw payload bytes. diff --git a/docs/json.md b/docs/json.md index ca4bb72f..7663776b 100644 --- a/docs/json.md +++ b/docs/json.md @@ -144,8 +144,8 @@ Native Apple Messages polls are emitted as normal messages with a `poll` object. | `poll_guid` | string | The poll's source message GUID when known. | | `question` | string | Poll title or question when decoded. For native created polls with an empty payload title, this may be backfilled from the poll's plain caption row. | | `options` | array | Poll options, each with `id` and `text`. | -| `vote` | object | First decoded vote update, with `option_id`, `participant`, and `event_type` when present. | -| `votes` | array | All decoded vote entries when the payload carries more than one. | +| `vote` | object | First decoded vote entry for compatibility. This is not necessarily the option that changed. | +| `votes` | array | Authoritative full selected-option snapshot carried by the update payload. | | `original_guid` | string | For vote rows, the original poll message GUID from `associated_message_guid`. | | `creator` | string | Creator handle when the payload includes it. Creation rows may fall back to the sender handle. | | `participants` | array | Handles seen in decoded poll metadata. | From bd539a5a404c241de5500ad7bdbe8b998467a38d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 22:35:29 -0700 Subject: [PATCH 2/2] fix: preserve poll vote output prefix --- Sources/imsg/AttachmentDisplay.swift | 6 +++--- Tests/imsgTests/UtilitiesTests.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/imsg/AttachmentDisplay.swift b/Sources/imsg/AttachmentDisplay.swift index 22e840d7..2750302c 100644 --- a/Sources/imsg/AttachmentDisplay.swift +++ b/Sources/imsg/AttachmentDisplay.swift @@ -49,12 +49,12 @@ func pollDisplayText(for poll: MessagePollEvent) -> String { return "\(participant) selected \(selections.joined(separator: " / "))" } if !snapshots.isEmpty { - return "[poll selections] \(snapshots.joined(separator: "; "))" + return "[poll vote] \(snapshots.joined(separator: "; "))" } - return "[poll selections] no options selected" + return "[poll vote] no options selected" } guard let vote = poll.vote else { - return "[poll selections] no options selected" + return "[poll vote] no options selected" } let participant = vote.participant ?? "someone" let option = vote.optionText ?? vote.optionID diff --git a/Tests/imsgTests/UtilitiesTests.swift b/Tests/imsgTests/UtilitiesTests.swift index f92a3657..2c775d4c 100644 --- a/Tests/imsgTests/UtilitiesTests.swift +++ b/Tests/imsgTests/UtilitiesTests.swift @@ -77,7 +77,7 @@ func pollDisplayUsesTheFullSelectionSnapshot() { ) let display = pollDisplayText(for: poll) - #expect(display == "[poll selections] +15550002000 selected Lobster / Also lobster") + #expect(display == "[poll vote] +15550002000 selected Lobster / Also lobster") #expect(display.contains("Lobster / Also lobster")) #expect(display != "[poll vote] +15550002000 selected Lobster") } @@ -96,13 +96,13 @@ func pollDisplayDoesNotReportARemainingSelectionAsANewVote() { votes: [remainingVote] ) - #expect(pollDisplayText(for: poll) == "[poll selections] +15550002000 selected Beta") + #expect(pollDisplayText(for: poll) == "[poll vote] +15550002000 selected Beta") } @Test func pollDisplayHandlesAnEmptySelectionSnapshot() { let poll = MessagePollEvent(kind: .vote, votes: []) - #expect(pollDisplayText(for: poll) == "[poll selections] no options selected") + #expect(pollDisplayText(for: poll) == "[poll vote] no options selected") } @Test