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
1 change: 1 addition & 0 deletions .agents/skills/imsg/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Only after `imsg status` confirms the bridge is loaded (`imsg launch` injects it
```bash
imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi' --reply-to MSG_GUID # replies, effects, subjects
imsg poll send --chat GUID --question 'Dinner?' --option 'Pizza' --option 'Sushi' --comment 'Vote by 5pm'
imsg poll unvote --chat GUID --poll POLL_GUID --option-index 1
imsg edit --chat GUID --message MSG_GUID --new-text 'updated' # macOS 13+
imsg chat-create --addresses '+15551234567,+15559876543' --name 'Crew'
```
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

### Native Polls
- fix: match native poll vote envelopes, participant handles, and summary metadata so votes render participant markers and correct notifications (#162, thanks @omarshahine).
- feat: add selective native poll unvoting through CLI and JSON-RPC while preserving the sender's other selected options (#162, thanks @omarshahine).

## 0.12.3 - 2026-07-06

Expand Down
3 changes: 2 additions & 1 deletion Sources/IMsgCore/IMsgBridgeProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public enum IMsgBridgeProtocol {
public static func defaultResponseTimeout(for action: BridgeAction) -> TimeInterval {
switch action {
case .sendMessage, .sendMultipart, .sendAttachment, .sendPoll, .sendPollVote,
.sendReaction, .createChat:
.sendPollUnvote, .sendReaction, .createChat:
return defaultSendResponseTimeout
default:
return defaultResponseTimeout
Expand Down Expand Up @@ -66,6 +66,7 @@ public enum BridgeAction: String, Sendable, CaseIterable {
case sendAttachment = "send-attachment"
case sendPoll = "send-poll"
case sendPollVote = "send-poll-vote"
case sendPollUnvote = "send-poll-unvote"
case sendReaction = "send-reaction"
case notifyAnyways = "notify-anyways"

Expand Down
17 changes: 15 additions & 2 deletions Sources/IMsgCore/MessagePolls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ public enum MessagePollDecoder {

let scan = PayloadScan(payloadData: payloadData, summaryData: messageSummaryInfo)
let facts = PollFacts(objects: scan.objects)
let hasPollPayloadEvidence = !facts.votes.isEmpty || scan.hasPollURLHint
let hasPollPayloadEvidence = !facts.votes.isEmpty || facts.hasEmptyVotes || scan.hasPollURLHint
guard isPollBundle || hasPollPayloadEvidence else { return nil }

let metadata = MessagePollMetadata(
Expand Down Expand Up @@ -208,7 +208,7 @@ public enum MessagePollDecoder {
if isVoteAssociation || !votes.isEmpty {
let pollGUID = firstNonEmpty(facts.pollGUID, originalGUID, messageGUID)
return MessagePollEvent(
kind: votes.isEmpty ? .unknown : .vote,
kind: votes.isEmpty && !facts.hasEmptyVotes ? .unknown : .vote,
pollGUID: pollGUID,
question: facts.question,
options: facts.options,
Expand Down Expand Up @@ -351,6 +351,7 @@ private struct PollFacts {
var question: String?
var options: [MessagePollOption] = []
var votes: [MessagePollVote] = []
var hasEmptyVotes = false
var pollGUID: String?
var creator: String?
var participants: [String] = []
Expand All @@ -363,6 +364,7 @@ private struct PollFacts {
self.question = state.question
self.options = state.options
self.votes = state.votes
self.hasEmptyVotes = state.hasEmptyVotes
self.pollGUID = state.pollGUID
self.creator = state.creator
self.participants = state.participants
Expand Down Expand Up @@ -415,6 +417,8 @@ private struct PollFacts {
if !parsedVotes.isEmpty {
state.appendVotes(parsedVotes)
state.participants.append(contentsOf: parsedVotes.compactMap { $0.participant })
} else if hasEmptyVotesArray(in: dict) {
state.hasEmptyVotes = true
}

for child in dict.values {
Expand Down Expand Up @@ -483,6 +487,14 @@ private struct PollFacts {
return []
}

private static func hasEmptyVotesArray(in dict: [String: Any]) -> Bool {
for key in ["votes", "pollVotes", "responses"] {
guard let array = arrayValue(dict[key]) else { continue }
if array.isEmpty { return true }
}
return false
}

private static func vote(from value: Any) -> MessagePollVote? {
guard let dict = stringDictionary(value) else { return nil }
guard
Expand Down Expand Up @@ -562,6 +574,7 @@ private struct PollFactsState {
var question: String?
var options: [MessagePollOption] = []
var votes: [MessagePollVote] = []
var hasEmptyVotes = false
var pollGUID: String?
var creator: String?
var participants: [String] = []
Expand Down
41 changes: 41 additions & 0 deletions Sources/IMsgCore/MessageStore+Polls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ extension MessageStore {
}
}

/// Latest outbound vote snapshot for a poll. Native poll vote rows carry the
/// sender's full selected-option set, not a single delta, so selective unvote
/// must remove one option from this current snapshot and resend the remainder.
public func pollSelectedOptionIDs(guid: String) throws -> [String] {
let normalized = normalizeAssociatedGUID(guid)
let target = normalized.isEmpty ? guid : normalized
guard !target.isEmpty else { return [] }
return try withConnection { db in
try latestOutboundPollVoteOptionIDs(guid: target, db: db)
}
}

private func pollOptionTextsByID(
pollGUID: String,
db: Connection,
Expand Down Expand Up @@ -133,6 +145,35 @@ extension MessageStore {
return options
}

private func latestOutboundPollVoteOptionIDs(guid: String, db: Connection) throws -> [String] {
guard schema.hasReactionColumns else { return [] }
let selection = MessageRowSelection(store: self, includeChatID: false)
let rows = try db.prepareRowIterator(
"""
SELECT \(selection.selectList)
FROM message m
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE m.is_from_me = 1
AND m.associated_message_type = ?
AND (
m.associated_message_guid = ?
OR m.associated_message_guid LIKE '%/' || ?
)
ORDER BY m.date DESC, m.ROWID DESC
LIMIT 1
""",
bindings: [MessagePollDecoder.voteAssociatedMessageType, guid, guid]
)
guard let row = try rows.failableNext() else { return [] }
let decoded = try decodeMessageRow(
row,
columns: selection.columns,
fallbackChatID: nil
)
guard decoded.poll?.kind == .vote else { return [] }
return decoded.poll?.votes?.map(\.optionID) ?? []
}

private func sourcePollGUID(forAny candidates: [String], db: Connection) throws -> String? {
guard schema.hasReactionColumns else { return nil }
for candidate in candidates {
Expand Down
81 changes: 67 additions & 14 deletions Sources/IMsgHelper/IMsgInjected.m
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,7 @@ static id findChat(NSString *identifier) {
@"sendMessageReason": @(gHasSendMessageReason),
@"pollPayloadMessage": @(pollPayloadMessageInitializerAvailable()),
@"pollVoteMessage": @(pollVoteMessageInitializerAvailable()),
@"pollUpdateMessage": @(pollVoteMessageInitializerAvailable()),
@"deleteChat": @(hasRegistry &&
[registryClass instancesRespondToSelector:NSSelectorFromString(@"deleteChat:")]),
@"removeChat": @(hasRegistry &&
Expand Down Expand Up @@ -2971,14 +2972,28 @@ static void appendEvent(NSDictionary *evt) {
/// creation the data URL carries no `?src=p&c=` suffix.
static NSData *buildPollVotePayloadData(NSString *optionIdentifier,
NSString *voterHandle,
NSArray<NSString *> *remainingOptionIdentifiers,
BOOL removed,
NSString **outError) {
NSDictionary *vote = @{
@"voteOptionIdentifier": optionIdentifier,
@"participantHandle": pollParticipantHandle(voterHandle) ?: @""
};
NSArray<NSString *> *voteOptionIdentifiers = removed
? (remainingOptionIdentifiers ?: @[])
: @[(optionIdentifier ?: @"")];
NSMutableArray *votes = [NSMutableArray arrayWithCapacity:voteOptionIdentifiers.count];
NSString *participantHandle = pollParticipantHandle(voterHandle) ?: @"";
for (NSString *identifierValue in voteOptionIdentifiers) {
NSString *identifier = trimmedPollString(identifierValue);
if (!identifier.length) {
if (outError) *outError = @"Poll vote payload contains an empty option identifier";
return nil;
}
[votes addObject:@{
@"voteOptionIdentifier": identifier,
@"participantHandle": participantHandle
}];
}
NSDictionary *root = @{
@"version": @1,
@"item": @{ @"votes": @[vote] }
@"item": @{ @"votes": votes }
};
NSError *jsonError = nil;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:root options:0 error:&jsonError];
Expand Down Expand Up @@ -3017,7 +3032,8 @@ static void appendEvent(NSDictionary *evt) {
static id buildPollVoteIMMessage(NSAttributedString *body,
NSData *payloadData,
NSDictionary *summaryInfo,
NSString *pollMessageGuid) {
NSString *pollMessageGuid,
long long associatedType) {
Class messageClass = NSClassFromString(@"IMMessage");
if (!messageClass) return nil;

Expand All @@ -3037,7 +3053,6 @@ static id buildPollVoteIMMessage(NSAttributedString *body,
NSDate *now = [NSDate date];
NSArray *fileTransferGuids = @[];
unsigned long long flags = flagsForAssociatedMessagePayload(nil, fileTransferGuids, NO);
long long associatedType = 4000;
NSRange associatedRange = NSMakeRange(0, 1);

[inv setArgument:&nilObj atIndex:2]; // sender
Expand Down Expand Up @@ -3081,19 +3096,36 @@ static id buildPollVoteIMMessage(NSAttributedString *body,
return (balloonStamped && payloadStamped) ? result : nil;
}

/// `send-poll-vote`: cast a vote on an existing poll. Builds a Polls balloon
/// carrying the vote payload, associated to the poll message via type 4000.
/// The caller passes the original poll message GUID and the chosen option's
/// UUID (resolved CLI-side from the poll's decoded options).
static NSDictionary *handleSendPollVote(NSInteger requestId, NSDictionary *params) {
static NSDictionary *handleSendPollVoteMutation(NSInteger requestId,
NSDictionary *params,
BOOL removed) {
NSString *chatGuid = params[@"chatGuid"];
NSString *pollMessageGuid = trimmedPollString(params[@"pollMessageGuid"]);
NSString *optionIdentifier = trimmedPollString(params[@"optionIdentifier"]);
NSString *optionText = trimmedPollString(params[@"optionText"]);
NSArray *remainingOptionIdentifiers = @[];

if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
if (!pollMessageGuid.length) return errorResponse(requestId, @"Missing pollMessageGuid");
if (!optionIdentifier.length) return errorResponse(requestId, @"Missing optionIdentifier");
if (removed) {
id rawRemaining = params[@"remainingOptionIdentifiers"];
if (rawRemaining && ![rawRemaining isKindOfClass:[NSArray class]]) {
return errorResponse(requestId, @"remainingOptionIdentifiers must be an array");
}
NSMutableArray *normalizedRemaining = [NSMutableArray array];
for (id rawIdentifier in (NSArray *)(rawRemaining ?: @[])) {
if (![rawIdentifier isKindOfClass:[NSString class]]) {
return errorResponse(requestId, @"remainingOptionIdentifiers must contain strings");
}
NSString *identifier = trimmedPollString(rawIdentifier);
if (!identifier.length) {
return errorResponse(requestId, @"remainingOptionIdentifiers must not contain empty values");
}
[normalizedRemaining addObject:identifier];
}
remainingOptionIdentifiers = [normalizedRemaining copy];
}
if (!pollVoteMessageInitializerAvailable()) {
return errorResponse(requestId, @"Poll vote IMMessage initializer unavailable on this macOS");
}
Expand All @@ -3111,7 +3143,11 @@ static id buildPollVoteIMMessage(NSAttributedString *body,
}

NSString *payloadError = nil;
NSData *payloadData = buildPollVotePayloadData(optionIdentifier, voterHandle, &payloadError);
NSData *payloadData = buildPollVotePayloadData(optionIdentifier,
voterHandle,
remainingOptionIdentifiers,
removed,
&payloadError);
if (!payloadData) {
return errorResponse(requestId, payloadError ?: @"Could not build vote payload");
}
Expand All @@ -3128,7 +3164,7 @@ static id buildPollVoteIMMessage(NSAttributedString *body,

@try {
clearThreadContextForChat(chat, nil);
id imMessage = buildPollVoteIMMessage(body, payloadData, summary, pollMessageGuid);
id imMessage = buildPollVoteIMMessage(body, payloadData, summary, pollMessageGuid, 4000);
if (!imMessage) {
return errorResponse(requestId, @"Could not construct vote IMMessage");
}
Expand All @@ -3140,6 +3176,8 @@ static id buildPollVoteIMMessage(NSAttributedString *body,
@"pollMessageGuid": pollMessageGuid,
@"optionIdentifier": optionIdentifier,
@"optionText": optionText ?: @"",
@"remainingOptionIdentifiers": remainingOptionIdentifiers,
@"removed": @(removed),
@"balloonBundleID": pollsBalloonBundleIdentifier()
});
} @catch (NSException *exception) {
Expand All @@ -3148,6 +3186,20 @@ static id buildPollVoteIMMessage(NSAttributedString *body,
}
}

/// `send-poll-vote`: cast a vote on an existing poll. Builds a Polls balloon
/// carrying the vote payload, associated to the poll message via type 4000.
/// The caller passes the original poll message GUID and the chosen option's
/// UUID (resolved CLI-side from the poll's decoded options).
static NSDictionary *handleSendPollVote(NSInteger requestId, NSDictionary *params) {
return handleSendPollVoteMutation(requestId, params, NO);
}

/// `send-poll-unvote`: remove one selected option by sending the same native
/// vote payload shape with the sender's remaining selected options.
static NSDictionary *handleSendPollUnvote(NSInteger requestId, NSDictionary *params) {
return handleSendPollVoteMutation(requestId, params, YES);
}

/// `send-multipart`: at minimum, sends an attributedBody composed of multiple
/// text parts. v1 supports text-only multipart; mention/file parts can land in
/// a follow-up.
Expand Down Expand Up @@ -4438,6 +4490,7 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer,
if ([action isEqualToString:@"send-attachment"]) return handleSendAttachment(legacyId, params);
if ([action isEqualToString:@"send-poll"]) return handleSendPoll(legacyId, params);
if ([action isEqualToString:@"send-poll-vote"]) return handleSendPollVote(legacyId, params);
if ([action isEqualToString:@"send-poll-unvote"]) return handleSendPollUnvote(legacyId, params);
if ([action isEqualToString:@"send-reaction"]) return handleSendReaction(legacyId, params);
if ([action isEqualToString:@"notify-anyways"]) return handleNotifyAnyways(legacyId, params);
if ([action isEqualToString:@"edit-message"]) return handleEditMessage(legacyId, params);
Expand Down
Loading