diff --git a/CHANGELOG.md b/CHANGELOG.md index a751244..3830eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ ### Reliability - fix: bound osascript send, reaction, and helper-process waits with process-tree cleanup so stalled subprocesses cannot hang CLI or RPC work (#197, thanks @SebTardif). +### Native Polls +- fix: render complete native poll selection snapshots in human-readable history and watch output while preserving the existing poll-vote prefix (#198, thanks @clawSean). + ## 0.13.4 - 2026-07-27 ### Highlights diff --git a/Sources/imsg/AttachmentDisplay.swift b/Sources/imsg/AttachmentDisplay.swift index 700977a..2750302 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 vote] \(snapshots.joined(separator: "; "))" + } + return "[poll vote] no options selected" + } + guard let vote = poll.vote else { + return "[poll vote] 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 5bdf801..2c775d4 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 vote] +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 vote] +15550002000 selected Beta") +} + +@Test +func pollDisplayHandlesAnEmptySelectionSnapshot() { + let poll = MessagePollEvent(kind: .vote, votes: []) + #expect(pollDisplayText(for: poll) == "[poll vote] 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 e5c1a4a..8256ad3 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 ca4bb72..7663776 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. |