Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 31 additions & 3 deletions Sources/imsg/AttachmentDisplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down
51 changes: 51 additions & 0 deletions Tests/imsgTests/UtilitiesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
4 changes: 2 additions & 2 deletions docs/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 <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 <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 <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 <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 <id> --poll <poll-guid> --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.
Expand Down
4 changes: 2 additions & 2 deletions docs/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down