From 7dd865904e5bfb33155f44497043305448a37549 Mon Sep 17 00:00:00 2001 From: yash-signal Date: Wed, 12 Nov 2025 09:26:16 -0600 Subject: [PATCH] Poll notifications and read syncs --- _locales/en/messages.json | 8 +- ts/messageModifiers/Polls.preload.ts | 39 +- ts/messageModifiers/ReadSyncs.preload.ts | 51 +- ts/messages/maybeNotify.preload.ts | 93 ++- ts/model-types.d.ts | 2 + ts/services/backups/import.preload.ts | 3 + ts/services/notifications.preload.ts | 87 ++- ts/sql/Interface.std.ts | 16 + ts/sql/Server.node.ts | 68 ++ ts/sql/hydration.std.ts | 20 +- .../migrations/1520-poll-votes-unread.std.ts | 18 + ts/sql/migrations/index.node.ts | 2 + .../sql/pollVoteMarkRead_test.preload.ts | 640 ++++++++++++++++++ ts/test-node/sql/migration_1520_test.node.ts | 135 ++++ ts/util/markConversationRead.preload.ts | 90 ++- 15 files changed, 1185 insertions(+), 87 deletions(-) create mode 100644 ts/sql/migrations/1520-poll-votes-unread.std.ts create mode 100644 ts/test-electron/sql/pollVoteMarkRead_test.preload.ts create mode 100644 ts/test-node/sql/migration_1520_test.node.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a7cfc70140..b0123bf822 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1487,11 +1487,11 @@ "description": "Accessibility label for checkmark indicating user voted for this poll option" }, "icu:PollTerminate--you": { - "messageformat": "You ended the poll: \"{poll}\"", + "messageformat": "You ended the poll: {poll}", "description": "Chat event shown when you end a poll" }, "icu:PollTerminate--other": { - "messageformat": "{name} ended the poll: \"{poll}\"", + "messageformat": "{name} ended the poll: {poll}", "description": "Chat event shown when someone else ends a poll" }, "icu:PollTerminate__view-poll": { @@ -2636,6 +2636,10 @@ "icu:notificationReactionMessage": { "messageformat": "{sender} reacted {emoji} to: {message}" }, + "icu:notificationPollVoteMessage": { + "messageformat": "{sender} voted in the poll \"{pollQuestion}\"", + "description": "Notification text when someone votes in your poll" + }, "icu:sendFailed": { "messageformat": "Send failed", "description": "Shown on outgoing message if it fails to send" diff --git a/ts/messageModifiers/Polls.preload.ts b/ts/messageModifiers/Polls.preload.ts index 4a98f73bc8..8b57082744 100644 --- a/ts/messageModifiers/Polls.preload.ts +++ b/ts/messageModifiers/Polls.preload.ts @@ -21,6 +21,8 @@ import { isMe } from '../util/whatTypeOfConversation.dom.js'; import { strictAssert } from '../util/assert.std.js'; import { getMessageIdForLogging } from '../util/idForLogging.preload.js'; +import { drop } from '../util/drop.std.js'; +import { maybeNotify } from '../messages/maybeNotify.preload.js'; const log = createLogger('Polls'); @@ -369,10 +371,11 @@ export async function handlePollVote( return; } - const conversation = window.ConversationController.get( + const conversationContainingThisPoll = window.ConversationController.get( message.attributes.conversationId ); - if (!conversation) { + if (!conversationContainingThisPoll) { + log.warn('handlePollVote: cannot find conversation containing this poll'); return; } @@ -394,7 +397,7 @@ export async function handlePollVote( timestamp: vote.timestamp, sendStateByConversationId: isFromThisDevice ? Object.fromEntries( - Array.from(conversation.getMemberConversationIds()) + Array.from(conversationContainingThisPoll.getMemberConversationIds()) .filter(id => id !== ourConversationId) .map(id => [ id, @@ -456,11 +459,16 @@ export async function handlePollVote( } } + // Set hasUnreadPollVotes flag if someone else voted on our poll + const shouldMarkAsUnread = + isOutgoing(message.attributes) && isFromSomeoneElse; + message.set({ poll: { ...poll, votes: updatedVotes, }, + ...(shouldMarkAsUnread ? { hasUnreadPollVotes: true } : {}), }); log.info( @@ -468,9 +476,22 @@ export async function handlePollVote( `Done processing vote for poll ${getMessageIdForLogging(message.attributes)}.` ); + // Notify poll author when someone else votes + if (shouldMarkAsUnread) { + drop( + maybeNotify({ + pollVote: vote, + targetMessage: message.attributes, + conversation: conversationContainingThisPoll, + }) + ); + } + if (shouldPersist) { await window.MessageCache.saveMessage(message.attributes); - window.reduxActions.conversations.markOpenConversationRead(conversation.id); + window.reduxActions.conversations.markOpenConversationRead( + conversationContainingThisPoll.id + ); } } @@ -507,6 +528,14 @@ export async function handlePollTerminate( return; } + const isFromThisDevice = terminate.source === PollSource.FromThisDevice; + const isFromSync = terminate.source === PollSource.FromSync; + const isFromSomeoneElse = terminate.source === PollSource.FromSomeoneElse; + strictAssert( + isFromThisDevice || isFromSync || isFromSomeoneElse, + 'Terminate can only be from this device, from sync, or from someone else' + ); + // Verify the terminator is the poll creator const author = getAuthor(attributes); const terminatorConversation = window.ConversationController.get( @@ -524,8 +553,6 @@ export async function handlePollTerminate( return; } - const isFromThisDevice = terminate.source === PollSource.FromThisDevice; - message.set({ poll: { ...poll, diff --git a/ts/messageModifiers/ReadSyncs.preload.ts b/ts/messageModifiers/ReadSyncs.preload.ts index 034bfbc1dc..d4e71b5b92 100644 --- a/ts/messageModifiers/ReadSyncs.preload.ts +++ b/ts/messageModifiers/ReadSyncs.preload.ts @@ -20,6 +20,8 @@ import { DataReader, DataWriter } from '../sql/Client.preload.js'; import { markRead } from '../services/MessageUpdater.preload.js'; import { MessageModel } from '../models/messages.preload.js'; import { itemStorage } from '../textsecure/Storage.preload.js'; +import { getMessageById } from '../messages/getMessageById.preload.js'; +import { getSourceServiceId } from '../messages/sources.preload.js'; const log = createLogger('ReadSyncs'); @@ -52,7 +54,7 @@ async function remove(sync: ReadSyncAttributesType): Promise { async function maybeItIsAReactionReadSync( sync: ReadSyncAttributesType -): Promise { +): Promise { const { readSync } = sync; const logId = `ReadSyncs.onSync(timestamp=${readSync.timestamp})`; @@ -71,7 +73,7 @@ async function maybeItIsAReactionReadSync( readSync.sender, readSync.senderAci ); - return; + return false; } log.info( @@ -82,14 +84,47 @@ async function maybeItIsAReactionReadSync( readSync.senderAci ); - await remove(sync); - notificationService.removeBy({ conversationId: readReaction.conversationId, emoji: readReaction.emoji, targetAuthorAci: readReaction.targetAuthorAci, targetTimestamp: readReaction.targetTimestamp, }); + + return true; +} + +async function maybeItIsAPollVoteReadSync( + sync: ReadSyncAttributesType +): Promise { + const { readSync } = sync; + const logId = `ReadSyncs.onSync(timestamp=${readSync.timestamp})`; + + const pollMessage = await DataWriter.markPollVoteAsRead(readSync.timestamp); + + if (!pollMessage) { + log.info(`${logId} poll vote read sync not found`); + return false; + } + + const pollMessageModel = await getMessageById(pollMessage.id); + if (!pollMessageModel) { + log.warn( + `${logId} found message for poll, but could not get the message model` + ); + return false; + } + pollMessageModel.set({ hasUnreadPollVotes: false }); + drop(queueUpdateMessage(pollMessageModel.attributes)); + + notificationService.removeBy({ + conversationId: pollMessage.conversationId, + targetAuthorAci: getSourceServiceId(pollMessageModel.attributes), + targetTimestamp: pollMessage.sent_at, + onlyRemoveAssociatedPollVotes: true, + }); + + return true; } export async function forMessage( @@ -145,7 +180,13 @@ export async function onSync(sync: ReadSyncAttributesType): Promise { }); if (!found) { - await maybeItIsAReactionReadSync(sync); + const foundReaction = await maybeItIsAReactionReadSync(sync); + + const foundPollVote = await maybeItIsAPollVoteReadSync(sync); + + if (foundReaction || foundPollVote) { + await remove(sync); + } return; } diff --git a/ts/messages/maybeNotify.preload.ts b/ts/messages/maybeNotify.preload.ts index 657b263c7f..ca089c9973 100644 --- a/ts/messages/maybeNotify.preload.ts +++ b/ts/messages/maybeNotify.preload.ts @@ -3,7 +3,7 @@ import { createLogger } from '../logging/log.std.js'; -import { isIncoming, isOutgoing } from './helpers.std.js'; +import { isOutgoing } from './helpers.std.js'; import { getAuthor } from './sources.preload.js'; import type { ConversationModel } from '../models/conversations.preload.js'; @@ -17,6 +17,10 @@ import { notificationService } from '../services/notifications.preload.js'; import { getNotificationTextForMessage } from '../util/getNotificationTextForMessage.preload.js'; import type { MessageAttributesType } from '../model-types.d.ts'; import type { ReactionAttributesType } from '../messageModifiers/Reactions.preload.js'; +import { + type PollVoteAttributesType, + PollSource, +} from '../messageModifiers/Polls.preload.js'; import { shouldStoryReplyNotifyUser } from '../util/shouldStoryReplyNotifyUser.preload.js'; import { ReactionSource } from '../reactions/ReactionSource.std.js'; @@ -29,9 +33,24 @@ type MaybeNotifyArgs = { reaction: Readonly; targetMessage: Readonly; } - | { message: Readonly; reaction?: never } + | { + pollVote: Readonly; + targetMessage: Readonly; + } + | { + message: Readonly; + reaction?: never; + pollVote?: never; + } ); +function isMention(args: MaybeNotifyArgs): boolean { + if ('reaction' in args || 'pollVote' in args) { + return false; + } + return Boolean(args.message.mentionsMe); +} + export async function maybeNotify(args: MaybeNotifyArgs): Promise { if (!notificationService.isEnabled) { return; @@ -39,14 +58,28 @@ export async function maybeNotify(args: MaybeNotifyArgs): Promise { const { i18n } = window.SignalContext; - const { conversation, reaction } = args; + const { conversation } = args; + const reaction = 'reaction' in args ? args.reaction : undefined; + const pollVote = 'pollVote' in args ? args.pollVote : undefined; let warrantsNotification: boolean; - if (reaction) { - warrantsNotification = doesReactionWarrantNotification(args); + if ('reaction' in args && 'targetMessage' in args) { + warrantsNotification = doesReactionWarrantNotification({ + reaction: args.reaction, + targetMessage: args.targetMessage, + }); + } else if ('pollVote' in args && 'targetMessage' in args) { + warrantsNotification = doesPollVoteWarrantNotification({ + pollVote: args.pollVote, + targetMessage: args.targetMessage, + }); } else { - warrantsNotification = await doesMessageWarrantNotification(args); + warrantsNotification = await doesMessageWarrantNotification({ + message: args.message, + conversation, + }); } + if (!warrantsNotification) { return; } @@ -56,12 +89,13 @@ export async function maybeNotify(args: MaybeNotifyArgs): Promise { } const activeProfile = getActiveProfile(window.reduxStore.getState()); + if ( !shouldNotifyDuringNotificationProfile({ activeProfile, conversationId: conversation.id, isCall: false, - isMention: args.reaction ? false : Boolean(args.message.mentionsMe), + isMention: isMention(args), }) ) { log.info('Would notify for message, but notification profile prevented it'); @@ -69,16 +103,20 @@ export async function maybeNotify(args: MaybeNotifyArgs): Promise { } const conversationId = conversation.get('id'); - const messageForNotification = args.reaction - ? args.targetMessage - : args.message; + const messageForNotification = + 'targetMessage' in args ? args.targetMessage : args.message; const isMessageInDirectConversation = isDirectConversation( conversation.attributes ); - const sender = reaction - ? window.ConversationController.get(reaction.fromId) - : getAuthor(args.message); + let sender: ConversationModel | undefined; + if (reaction) { + sender = window.ConversationController.get(reaction.fromId); + } else if (pollVote) { + sender = window.ConversationController.get(pollVote.fromConversationId); + } else if ('message' in args) { + sender = getAuthor(args.message); + } const senderName = sender ? sender.getTitle() : i18n('icu:unknownContact'); const senderTitle = isMessageInDirectConversation ? senderName @@ -110,6 +148,13 @@ export async function maybeNotify(args: MaybeNotifyArgs): Promise { targetTimestamp: reaction.targetTimestamp, } : undefined, + pollVote: pollVote + ? { + voterConversationId: pollVote.fromConversationId, + targetAuthorAci: pollVote.targetAuthorAci, + targetTimestamp: pollVote.targetTimestamp, + } + : undefined, sentAt: messageForNotification.timestamp, type: reaction ? NotificationType.Reaction : NotificationType.Message, }); @@ -128,6 +173,18 @@ function doesReactionWarrantNotification({ ); } +function doesPollVoteWarrantNotification({ + pollVote, + targetMessage, +}: { + targetMessage: MessageAttributesType; + pollVote: PollVoteAttributesType; +}): boolean { + return ( + pollVote.source === PollSource.FromSomeoneElse && isOutgoing(targetMessage) + ); +} + async function doesMessageWarrantNotification({ message, conversation, @@ -135,7 +192,7 @@ async function doesMessageWarrantNotification({ message: MessageAttributesType; conversation: ConversationModel; }): Promise { - if (!isIncoming(message)) { + if (!(message.type === 'incoming' || message.type === 'poll-terminate')) { return false; } @@ -154,19 +211,15 @@ async function doesMessageWarrantNotification({ } function isAllowedByConversation(args: MaybeNotifyArgs): boolean { - const { conversation, reaction } = args; + const { conversation } = args; if (!conversation.isMuted()) { return true; } - if (reaction) { - return false; - } - if (conversation.get('dontNotifyForMentionsIfMuted')) { return false; } - return args.message.mentionsMe === true; + return isMention(args); } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 3877bb9aa9..4eadc070c4 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -217,6 +217,8 @@ export type MessageAttributesType = { question: string; pollMessageId: string; }; + // This field will only be set to true for outgoing messages + hasUnreadPollVotes?: boolean; requiredProtocolVersion?: number; sms?: boolean; sourceDevice?: number; diff --git a/ts/services/backups/import.preload.ts b/ts/services/backups/import.preload.ts index 07fb9e5a58..ce0b21390b 100644 --- a/ts/services/backups/import.preload.ts +++ b/ts/services/backups/import.preload.ts @@ -1851,6 +1851,7 @@ export class BackupImportStream extends Writable { patch: { readStatus: ReadStatus.Read, seenStatus: SeenStatus.Seen, + hasUnreadPollVotes: false, received_at_ms: receivedAtMs, serverTimestamp, unidentifiedDeliveryReceived, @@ -1863,6 +1864,7 @@ export class BackupImportStream extends Writable { patch: { readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, + hasUnreadPollVotes: false, received_at_ms: receivedAtMs, serverTimestamp, unidentifiedDeliveryReceived, @@ -1877,6 +1879,7 @@ export class BackupImportStream extends Writable { patch: { readStatus: ReadStatus.Read, seenStatus: SeenStatus.Seen, + hasUnreadPollVotes: false, }, newActiveAt: timestamp, }; diff --git a/ts/services/notifications.preload.ts b/ts/services/notifications.preload.ts index 4c4995bf55..1cd47c82a6 100644 --- a/ts/services/notifications.preload.ts +++ b/ts/services/notifications.preload.ts @@ -35,6 +35,11 @@ type NotificationDataType = Readonly<{ targetAuthorAci: string; targetTimestamp: number; }; + pollVote?: { + voterConversationId: string; + targetAuthorAci: string; + targetTimestamp: number; + }; senderTitle: string; sentAt: number; storyId?: string; @@ -244,20 +249,29 @@ class NotificationService extends EventEmitter { // Remove the last notification if both conditions hold: // // 1. Either `conversationId` or `messageId` matches (if present) - // 2. `emoji`, `targetAuthorAci`, `targetTimestamp` matches (if present) - public removeBy({ - conversationId, - messageId, - emoji, - targetAuthorAci, - targetTimestamp, - }: Readonly<{ - conversationId?: string; - messageId?: string; - emoji?: string; - targetAuthorAci?: string; - targetTimestamp?: number; - }>): void { + // 2. Reaction: `emoji`, `targetAuthorAci`, `targetTimestamp` matches + // 3. Poll vote: `onlyRemoveAssociatedPollVotes` flag is true + public removeBy( + options: Readonly< + { + emoji?: string; + targetAuthorAci?: string; + targetTimestamp?: number; + onlyRemoveAssociatedPollVotes?: boolean; + } & ( + | { conversationId: string; messageId?: string } + | { messageId: string; conversationId?: string } + ) + > + ): void { + const { + conversationId, + messageId, + emoji, + targetAuthorAci, + targetTimestamp, + onlyRemoveAssociatedPollVotes, + } = options; if (!this.#notificationData) { log.info('NotificationService#removeBy: no notification data'); return; @@ -280,17 +294,38 @@ class NotificationService extends EventEmitter { return; } + // If reaction filters are provided, only remove reaction notifications that match const { reaction } = this.#notificationData; - if ( - reaction && - emoji && - targetAuthorAci && - targetTimestamp && - (reaction.emoji !== emoji || + const hasReactionFilters = Boolean( + emoji && targetAuthorAci && targetTimestamp + ); + if (hasReactionFilters) { + if (!reaction) { + // Looking for reactions but this isn't one + return; + } + if ( + reaction.emoji !== emoji || reaction.targetAuthorAci !== targetAuthorAci || - reaction.targetTimestamp !== targetTimestamp) - ) { - return; + reaction.targetTimestamp !== targetTimestamp + ) { + // Reaction doesn't match the filter + return; + } + } + + // If onlyRemoveAssociatedPollVotes is true, only remove poll vote notifications + // that match the targetAuthorAci and targetTimestamp + if (onlyRemoveAssociatedPollVotes && targetAuthorAci && targetTimestamp) { + const { pollVote } = this.#notificationData; + if ( + !pollVote || + pollVote.targetAuthorAci !== targetAuthorAci || + pollVote.targetTimestamp !== targetTimestamp + ) { + // Looking for poll votes but this isn't one + return; + } } this.clear(); @@ -360,6 +395,7 @@ class NotificationService extends EventEmitter { message, messageId, reaction, + pollVote, senderTitle, storyId, sentAt, @@ -402,6 +438,11 @@ class NotificationService extends EventEmitter { emoji: reaction.emoji, message, }); + } else if (pollVote) { + notificationMessage = i18n('icu:notificationPollVoteMessage', { + sender: senderTitle, + pollQuestion: message, + }); } else { notificationMessage = message; } diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index 326b14dcb4..6fb68094a2 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -153,6 +153,7 @@ export const MESSAGE_NON_PRIMARY_KEY_COLUMNS = [ 'hasAttachments', 'hasFileAttachments', 'hasVisualMediaAttachments', + 'hasUnreadPollVotes', 'isChangeCreatedByUs', 'isErased', 'isViewOnce', @@ -190,6 +191,7 @@ export type MessageTypeUnhydrated = { hasAttachments: 0 | 1 | null; hasFileAttachments: 0 | 1 | null; hasVisualMediaAttachments: 0 | 1 | null; + hasUnreadPollVotes: 0 | 1 | null; isChangeCreatedByUs: 0 | 1 | null; isErased: 0 | 1 | null; isViewOnce: 0 | 1 | null; @@ -481,6 +483,13 @@ export type ReactionResultType = Pick< 'targetAuthorAci' | 'targetTimestamp' | 'messageId' > & { rowid: number }; +export type PollVoteReadResultType = { + id: string; + conversationId: string; + targetTimestamp: number; + type: MessageType['type']; +}; + export type GetUnreadByConversationAndMarkReadResultType = Array< { originalReadStatus: ReadStatus | undefined } & Pick< MessageType, @@ -1069,10 +1078,17 @@ type WritableInterface = { readMessageReceivedAt: number; storyId?: string; }) => Array; + getUnreadPollVotesAndMarkRead: (options: { + conversationId: string; + readMessageReceivedAt: number; + }) => Array; markReactionAsRead: ( targetAuthorServiceId: ServiceIdString, targetTimestamp: number ) => ReactionType | undefined; + markPollVoteAsRead: ( + targetTimestamp: number + ) => MessageAttributesType | undefined; removeReactionFromConversation: (reaction: { emoji: string; fromId: string; diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index 86312bf37e..864e077f9f 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -149,6 +149,7 @@ import type { PageMessagesCursorType, PageMessagesResultType, PreKeyIdType, + PollVoteReadResultType, ReactionResultType, ReadableDB, SenderKeyIdType, @@ -587,6 +588,7 @@ export const DataWriter: ServerWritableInterface = { removeAllProfileKeyCredentials, getUnreadByConversationAndMarkRead, getUnreadReactionsAndMarkRead, + getUnreadPollVotesAndMarkRead, replaceAllEndorsementsForGroup, deleteAllEndorsementsForGroup, @@ -597,6 +599,7 @@ export const DataWriter: ServerWritableInterface = { removeMessage, removeMessages, markReactionAsRead, + markPollVoteAsRead, addReaction, removeReactionFromConversation, _removeAllReactions, @@ -2913,6 +2916,7 @@ function saveMessage( seenStatus: originalSeenStatus, serverTimestamp, unidentifiedDeliveryReceived, + hasUnreadPollVotes, ...json } = message; @@ -3026,6 +3030,7 @@ function saveMessage( hasVisualMediaAttachments: downloadedAttachments?.some(isVisualMedia) ? 1 : 0, + hasUnreadPollVotes: hasUnreadPollVotes ? 1 : 0, isChangeCreatedByUs: groupV2Change?.from === ourAci ? 1 : 0, isErased: isErased ? 1 : 0, isViewOnce: isViewOnce ? 1 : 0, @@ -3516,6 +3521,69 @@ function markReactionAsRead( })(); } +function getUnreadPollVotesAndMarkRead( + db: WritableDB, + { + conversationId, + readMessageReceivedAt, + }: { + conversationId: string; + readMessageReceivedAt: number; + } +): Array { + return db.transaction(() => { + const unreadPollVoteMessages: Array = db + .prepare( + ` + UPDATE messages + INDEXED BY messages_unread_poll_votes + SET hasUnreadPollVotes = 0 + WHERE + conversationId = $conversationId AND + hasUnreadPollVotes = 1 AND + received_at <= $readMessageReceivedAt AND + type IS 'outgoing' + RETURNING id, conversationId, sent_at AS targetTimestamp, type; + ` + ) + .all({ + conversationId, + readMessageReceivedAt, + }); + + return unreadPollVoteMessages; + })(); +} + +function markPollVoteAsRead( + db: WritableDB, + targetTimestamp: number +): MessageAttributesType | undefined { + return db.transaction(() => { + const row = db + .prepare( + ` + UPDATE messages + SET hasUnreadPollVotes = 0 + WHERE + sent_at = $sent_at AND + hasUnreadPollVotes = 1 AND + type IS 'outgoing' + RETURNING ${MESSAGE_COLUMNS.join(', ')}; + ` + ) + .get({ + sent_at: targetTimestamp, + }); + + if (!row) { + return undefined; + } + + return hydrateMessage(db, row); + })(); +} + function getReactionByTimestamp( db: ReadableDB, fromId: string, diff --git a/ts/sql/hydration.std.ts b/ts/sql/hydration.std.ts index e1c33c6075..8b90b2246a 100644 --- a/ts/sql/hydration.std.ts +++ b/ts/sql/hydration.std.ts @@ -57,12 +57,20 @@ export function hydrateMessages( db: ReadableDB, unhydratedMessages: Array ): Array { - const messagesWithColumnsHydrated = unhydratedMessages.map(msg => ({ - ...hydrateMessageTableColumns(msg), - hasAttachments: msg.hasAttachments === 1, - hasFileAttachments: msg.hasFileAttachments === 1, - hasVisualMediaAttachments: msg.hasVisualMediaAttachments === 1, - })); + const messagesWithColumnsHydrated = unhydratedMessages.map(msg => { + const base = { + ...hydrateMessageTableColumns(msg), + hasAttachments: msg.hasAttachments === 1, + hasFileAttachments: msg.hasFileAttachments === 1, + hasVisualMediaAttachments: msg.hasVisualMediaAttachments === 1, + }; + + if (msg.hasUnreadPollVotes === 1) { + return { ...base, hasUnreadPollVotes: true }; + } + + return base; + }); return hydrateMessagesWithAttachments(db, messagesWithColumnsHydrated); } diff --git a/ts/sql/migrations/1520-poll-votes-unread.std.ts b/ts/sql/migrations/1520-poll-votes-unread.std.ts new file mode 100644 index 0000000000..6db99223a1 --- /dev/null +++ b/ts/sql/migrations/1520-poll-votes-unread.std.ts @@ -0,0 +1,18 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from '@signalapp/sqlcipher'; + +export default function updateToSchemaVersion1510(db: Database): void { + db.exec(` + -- Add hasUnreadPollVotes column to messages table + ALTER TABLE messages ADD COLUMN hasUnreadPollVotes INTEGER NOT NULL DEFAULT 0; + + -- Create partial index for efficient queries + -- Only indexes rows where hasUnreadPollVotes = 1 + CREATE INDEX messages_unread_poll_votes ON messages ( + conversationId, + received_at + ) WHERE hasUnreadPollVotes = 1 AND type IS 'outgoing'; + `); +} diff --git a/ts/sql/migrations/index.node.ts b/ts/sql/migrations/index.node.ts index 0305a93a05..90477eae05 100644 --- a/ts/sql/migrations/index.node.ts +++ b/ts/sql/migrations/index.node.ts @@ -127,6 +127,7 @@ import updateToSchemaVersion1480 from './1480-chat-folders-remove-duplicates.std import updateToSchemaVersion1490 from './1490-lowercase-notification-profiles.std.js'; import updateToSchemaVersion1500 from './1500-search-polls.std.js'; import updateToSchemaVersion1510 from './1510-chat-folders-normalize-all-chats.std.js'; +import updateToSchemaVersion1520 from './1520-poll-votes-unread.std.js'; import { DataWriter } from '../Server.node.js'; @@ -1612,6 +1613,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1500, update: updateToSchemaVersion1500 }, { version: 1510, update: updateToSchemaVersion1510 }, + { version: 1520, update: updateToSchemaVersion1520 }, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/test-electron/sql/pollVoteMarkRead_test.preload.ts b/ts/test-electron/sql/pollVoteMarkRead_test.preload.ts new file mode 100644 index 0000000000..3195b47531 --- /dev/null +++ b/ts/test-electron/sql/pollVoteMarkRead_test.preload.ts @@ -0,0 +1,640 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as generateUuid } from 'uuid'; + +import { DataReader, DataWriter } from '../../sql/Client.preload.js'; +import { generateAci } from '../../types/ServiceId.std.js'; +import type { MessageAttributesType } from '../../model-types.d.ts'; +import { postSaveUpdates } from '../../util/cleanup.preload.js'; + +const { _getAllMessages } = DataReader; + +const { + _removeAllMessages, + saveMessages, + getUnreadPollVotesAndMarkRead, + markPollVoteAsRead, +} = DataWriter; + +describe('sql/pollVoteMarkRead', () => { + beforeEach(async () => { + await _removeAllMessages(); + }); + + describe('getUnreadPollVotesAndMarkRead', () => { + it('finds and marks unread poll votes in conversation', async () => { + const start = Date.now(); + const conversationId = generateUuid(); + const ourAci = generateAci(); + + const pollMessage1: MessageAttributesType = { + id: generateUuid(), + body: 'poll 1', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 1, + received_at: start + 1, + timestamp: start + 1, + hasUnreadPollVotes: true, + poll: { + question: 'Test 1?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + const pollMessage2: MessageAttributesType = { + id: generateUuid(), + body: 'poll 2', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 2, + received_at: start + 2, + timestamp: start + 2, + hasUnreadPollVotes: true, + poll: { + question: 'Test 2?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + const pollMessage3: MessageAttributesType = { + id: generateUuid(), + body: 'poll 3', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 3, + received_at: start + 3, + timestamp: start + 3, + hasUnreadPollVotes: true, + poll: { + question: 'Test 3?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + await saveMessages([pollMessage1, pollMessage2, pollMessage3], { + forceSave: true, + ourAci, + postSaveUpdates, + }); + + assert.lengthOf(await _getAllMessages(), 3); + + const markedRead = await getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: pollMessage2.received_at, + }); + + assert.lengthOf(markedRead, 2, 'two poll votes marked read'); + + // Verify correct messages were marked + const markedIds = markedRead.map(m => m.id); + assert.include(markedIds, pollMessage1.id); + assert.include(markedIds, pollMessage2.id); + + // Verify they were actually marked read + const markedRead2 = await getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: pollMessage3.received_at, + }); + + assert.lengthOf(markedRead2, 1, 'only one poll vote remains unread'); + assert.strictEqual(markedRead2[0].id, pollMessage3.id); + }); + + it('respects received_at cutoff', async () => { + const start = Date.now(); + const conversationId = generateUuid(); + const ourAci = generateAci(); + + const pollMessage1: MessageAttributesType = { + id: generateUuid(), + body: 'poll 1', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 1, + received_at: start + 1, + timestamp: start + 1, + hasUnreadPollVotes: true, + poll: { + question: 'Test 1?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + const pollMessage2: MessageAttributesType = { + id: generateUuid(), + body: 'poll 2', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 1000, + received_at: start + 1000, + timestamp: start + 1000, + hasUnreadPollVotes: true, + poll: { + question: 'Test 2?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + await saveMessages([pollMessage1, pollMessage2], { + forceSave: true, + ourAci, + postSaveUpdates, + }); + + // Only mark messages received before start + 500 + const markedRead = await getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: start + 500, + }); + + assert.lengthOf(markedRead, 1, 'only one poll vote within cutoff'); + assert.strictEqual(markedRead[0].id, pollMessage1.id); + }); + + it('filters by conversationId correctly', async () => { + const start = Date.now(); + const conversationId1 = generateUuid(); + const conversationId2 = generateUuid(); + const ourAci = generateAci(); + + const pollMessage1: MessageAttributesType = { + id: generateUuid(), + body: 'poll 1', + type: 'outgoing', + conversationId: conversationId1, + sourceServiceId: ourAci, + sent_at: start + 1, + received_at: start + 1, + timestamp: start + 1, + hasUnreadPollVotes: true, + poll: { + question: 'Test 1?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + const pollMessage2: MessageAttributesType = { + id: generateUuid(), + body: 'poll 2', + type: 'outgoing', + conversationId: conversationId2, + sourceServiceId: ourAci, + sent_at: start + 2, + received_at: start + 2, + timestamp: start + 2, + hasUnreadPollVotes: true, + poll: { + question: 'Test 2?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + await saveMessages([pollMessage1, pollMessage2], { + forceSave: true, + ourAci, + postSaveUpdates, + }); + + const markedRead = await getUnreadPollVotesAndMarkRead({ + conversationId: conversationId1, + readMessageReceivedAt: start + 10000, + }); + + assert.lengthOf(markedRead, 1, 'only polls from conversationId1'); + assert.strictEqual(markedRead[0].id, pollMessage1.id); + }); + + it('only returns messages with hasUnreadPollVotes = true', async () => { + const start = Date.now(); + const conversationId = generateUuid(); + const ourAci = generateAci(); + + const pollMessage1: MessageAttributesType = { + id: generateUuid(), + body: 'poll 1', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 1, + received_at: start + 1, + timestamp: start + 1, + hasUnreadPollVotes: true, + poll: { + question: 'Test 1?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + const pollMessage2: MessageAttributesType = { + id: generateUuid(), + body: 'poll 2', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 2, + received_at: start + 2, + timestamp: start + 2, + hasUnreadPollVotes: false, // Already read + poll: { + question: 'Test 2?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + await saveMessages([pollMessage1, pollMessage2], { + forceSave: true, + ourAci, + postSaveUpdates, + }); + + const markedRead = await getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: start + 10000, + }); + + assert.lengthOf(markedRead, 1, 'only unread poll votes'); + assert.strictEqual(markedRead[0].id, pollMessage1.id); + }); + + it('marks multiple poll votes as read in single call', async () => { + const start = Date.now(); + const conversationId = generateUuid(); + const ourAci = generateAci(); + + const pollMessages: Array = []; + for (let i = 0; i < 10; i += 1) { + pollMessages.push({ + id: generateUuid(), + body: `poll ${i}`, + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + i, + received_at: start + i, + timestamp: start + i, + hasUnreadPollVotes: true, + poll: { + question: `Test ${i}?`, + options: [], + votes: [], + allowMultiple: false, + }, + }); + } + + await saveMessages(pollMessages, { + forceSave: true, + ourAci, + postSaveUpdates, + }); + + const markedRead = await getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: start + 10000, + }); + + assert.lengthOf(markedRead, 10, 'all 10 polls marked read'); + + // Verify all were actually marked + const markedRead2 = await getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: start + 10000, + }); + + assert.lengthOf(markedRead2, 0, 'no unread polls remaining'); + }); + + it('does not return already read poll votes on second call', async () => { + const start = Date.now(); + const conversationId = generateUuid(); + const ourAci = generateAci(); + + const pollMessage: MessageAttributesType = { + id: generateUuid(), + body: 'poll', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 1, + received_at: start + 1, + timestamp: start + 1, + hasUnreadPollVotes: true, + poll: { + question: 'Test?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + await saveMessages([pollMessage], { + forceSave: true, + ourAci, + postSaveUpdates, + }); + + // First call marks as read + const markedRead1 = await getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: start + 10000, + }); + + assert.lengthOf(markedRead1, 1); + + // Second call should return empty + const markedRead2 = await getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: start + 10000, + }); + + assert.lengthOf(markedRead2, 0, 'idempotent - no polls on second call'); + }); + + it('handles empty result set gracefully', async () => { + const conversationId = generateUuid(); + const start = Date.now(); + + const markedRead = await getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: start, + }); + + assert.isArray(markedRead); + assert.lengthOf(markedRead, 0); + }); + }); + + describe('markPollVoteAsRead', () => { + it('finds and marks specific poll by author and timestamp', async () => { + const start = Date.now(); + const conversationId = generateUuid(); + const ourAci = generateAci(); + + const pollMessage: MessageAttributesType = { + id: generateUuid(), + body: 'poll', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 1, + received_at: start + 1, + timestamp: start + 1, + hasUnreadPollVotes: true, + poll: { + question: 'Test?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + await saveMessages([pollMessage], { + forceSave: true, + ourAci, + postSaveUpdates, + }); + + const result = await markPollVoteAsRead(pollMessage.sent_at); + + assert.isDefined(result); + assert.strictEqual(result?.id, pollMessage.id); + + // Verify it was marked read + const result2 = await markPollVoteAsRead(pollMessage.sent_at); + assert.isUndefined( + result2, + 'should return undefined after already marked read' + ); + }); + + it('returns undefined when no matching poll found', async () => { + const start = Date.now(); + + const result = await markPollVoteAsRead(start + 1); + + assert.isUndefined(result); + }); + + it('returns undefined when poll already read', async () => { + const start = Date.now(); + const conversationId = generateUuid(); + const ourAci = generateAci(); + + const pollMessage: MessageAttributesType = { + id: generateUuid(), + body: 'poll', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 1, + received_at: start + 1, + timestamp: start + 1, + hasUnreadPollVotes: false, // Already read + poll: { + question: 'Test?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + await saveMessages([pollMessage], { + forceSave: true, + ourAci, + postSaveUpdates, + }); + + const result = await markPollVoteAsRead(start + 1); + + assert.isUndefined(result, 'should return undefined when already read'); + }); + + it('marks only the specific poll, not others', async () => { + const start = Date.now(); + const conversationId = generateUuid(); + const ourAci = generateAci(); + + const pollMessage1: MessageAttributesType = { + id: generateUuid(), + body: 'poll 1', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 1, + received_at: start + 1, + timestamp: start + 1, + hasUnreadPollVotes: true, + poll: { + question: 'Test 1?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + const pollMessage2: MessageAttributesType = { + id: generateUuid(), + body: 'poll 2', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 2, + received_at: start + 2, + timestamp: start + 2, + hasUnreadPollVotes: true, + poll: { + question: 'Test 2?', + options: [], + votes: [], + allowMultiple: false, + }, + }; + + await saveMessages([pollMessage1, pollMessage2], { + forceSave: true, + ourAci, + postSaveUpdates, + }); + + // Mark only the first poll + const result = await markPollVoteAsRead(start + 1); + + assert.isNotNull(result); + assert.strictEqual(result?.id, pollMessage1.id); + + // Second poll should still be unread + const markedRead = await getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: start + 10000, + }); + + assert.lengthOf(markedRead, 1, 'second poll still unread'); + assert.strictEqual(markedRead[0].id, pollMessage2.id); + }); + + it('returns full MessageAttributesType on success', async () => { + const start = Date.now(); + const conversationId = generateUuid(); + const ourAci = generateAci(); + + const pollMessage: MessageAttributesType = { + id: generateUuid(), + body: 'poll', + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + 1, + received_at: start + 1, + timestamp: start + 1, + hasUnreadPollVotes: true, + poll: { + question: 'Test?', + options: ['Option 1'], + votes: [], + allowMultiple: false, + }, + }; + + await saveMessages([pollMessage], { + forceSave: true, + ourAci, + postSaveUpdates, + }); + + const result = await markPollVoteAsRead(start + 1); + + assert.isNotNull(result, 'result should not be null'); + assert.strictEqual(result?.id, pollMessage.id, 'message id should match'); + assert.strictEqual(result?.body, 'poll', 'message body should be "poll"'); + assert.strictEqual( + result?.type, + 'outgoing', + 'message type should be "outgoing"' + ); + assert.ok( + result?.hasUnreadPollVotes == null || + result?.hasUnreadPollVotes === false, + 'hasUnreadPollVotes should be false or null/undefined after marking as read' + ); + assert.deepEqual( + result?.poll?.options, + ['Option 1'], + 'poll options should match' + ); + }); + + it('handles multiple polls from same author', async () => { + const start = Date.now(); + const conversationId = generateUuid(); + const ourAci = generateAci(); + + const pollMessages: Array = []; + for (let i = 0; i < 5; i += 1) { + pollMessages.push({ + id: generateUuid(), + body: `poll ${i}`, + type: 'outgoing', + conversationId, + sourceServiceId: ourAci, + sent_at: start + i * 1000, + received_at: start + i * 1000, + timestamp: start + i * 1000, + hasUnreadPollVotes: true, + poll: { + question: `Test ${i}?`, + options: [], + votes: [], + allowMultiple: false, + }, + }); + } + + await saveMessages(pollMessages, { + forceSave: true, + ourAci, + postSaveUpdates, + }); + + // Mark specific poll by timestamp + const result = await markPollVoteAsRead(start + 2000); + + assert.isNotNull(result); + assert.strictEqual(result?.id, pollMessages[2].id); + + // Other polls should still be unread + const markedRead = await getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: start + 10000, + }); + + assert.lengthOf(markedRead, 4, 'four polls still unread'); + }); + }); +}); diff --git a/ts/test-node/sql/migration_1520_test.node.ts b/ts/test-node/sql/migration_1520_test.node.ts new file mode 100644 index 0000000000..bab54bf881 --- /dev/null +++ b/ts/test-node/sql/migration_1520_test.node.ts @@ -0,0 +1,135 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import type { WritableDB } from '../../sql/Interface.std.js'; +import { sql } from '../../sql/util.std.js'; +import { + createDB, + updateToVersion, + insertData, + explain, +} from './helpers.node.js'; + +describe('SQL/updateToSchemaVersion1520', () => { + let db: WritableDB; + + afterEach(() => { + db.close(); + }); + + describe('hasUnreadPollVotes column', () => { + it('adds hasUnreadPollVotes column with default value 0', () => { + db = createDB(); + updateToVersion(db, 1510); + + const messages = [ + { + id: 'msg1', + conversationId: 'conv1', + type: 'outgoing', + received_at: 1000, + sent_at: 1000, + timestamp: 1000, + json: JSON.stringify({ + poll: { question: 'Test?' }, + }), + }, + ]; + + insertData(db, 'messages', messages); + updateToVersion(db, 1520); + + const result = db + .prepare("SELECT hasUnreadPollVotes FROM messages WHERE id = 'msg1'") + .get<{ hasUnreadPollVotes: number }>(); + + assert.strictEqual(result?.hasUnreadPollVotes, 0); + }); + }); + + describe('messages_unread_poll_votes index', () => { + it('creates messages_unread_poll_votes index', () => { + db = createDB(); + updateToVersion(db, 1520); + + const indexes = db + .prepare( + ` + SELECT name FROM sqlite_master + WHERE type = 'index' AND name = 'messages_unread_poll_votes' + ` + ) + .all(); + + assert.lengthOf(indexes, 1, 'index should exist'); + }); + + it('uses index for getUnreadPollVotesAndMarkRead UPDATE query', () => { + db = createDB(); + updateToVersion(db, 1520); + + const details = explain( + db, + sql` + UPDATE messages + INDEXED BY messages_unread_poll_votes + SET hasUnreadPollVotes = 0 + WHERE + conversationId = ${'test-conv'} AND + hasUnreadPollVotes = 1 AND + received_at <= ${5000} AND + type IS 'outgoing' + RETURNING id, conversationId, sent_at AS targetTimestamp, type; + ` + ); + + assert.strictEqual( + details, + 'SEARCH messages USING COVERING INDEX messages_unread_poll_votes (conversationId=? AND received_at { + db = createDB(); + updateToVersion(db, 1520); + + const detailsWithIndex = explain( + db, + sql` + SELECT id FROM messages + WHERE + conversationId = ${'test-conv'} AND + hasUnreadPollVotes = 1 AND + type IS 'outgoing' AND + received_at <= ${5000}; + ` + ); + + assert.include( + detailsWithIndex, + 'messages_unread_poll_votes', + 'should use partial index when hasUnreadPollVotes = 1' + ); + }); + + it('index includes all required columns and conditions', () => { + db = createDB(); + updateToVersion(db, 1520); + + const indexInfo = db + .prepare( + ` + SELECT sql FROM sqlite_master + WHERE type = 'index' AND name = 'messages_unread_poll_votes' + ` + ) + .get() as { sql: string }; + + assert.include(indexInfo.sql, 'conversationId'); + assert.include(indexInfo.sql, 'received_at'); + assert.include(indexInfo.sql, 'WHERE hasUnreadPollVotes = 1'); + assert.include(indexInfo.sql, "type IS 'outgoing'"); + }); + }); +}); diff --git a/ts/util/markConversationRead.preload.ts b/ts/util/markConversationRead.preload.ts index d793d72175..303f28c006 100644 --- a/ts/util/markConversationRead.preload.ts +++ b/ts/util/markConversationRead.preload.ts @@ -45,23 +45,31 @@ export async function markConversationRead( ): Promise { const { id: conversationId } = conversationAttrs; - const [unreadMessages, unreadEditedMessages, unreadReactions] = - await Promise.all([ - DataWriter.getUnreadByConversationAndMarkRead({ - conversationId, - readMessageReceivedAt: readMessage.received_at, - readAt: options.readAt, - includeStoryReplies: !isGroup(conversationAttrs), - }), - DataWriter.getUnreadEditedMessagesAndMarkRead({ - conversationId, - readMessageReceivedAt: readMessage.received_at, - }), - DataWriter.getUnreadReactionsAndMarkRead({ - conversationId, - readMessageReceivedAt: readMessage.received_at, - }), - ]); + const [ + unreadMessages, + unreadEditedMessages, + unreadReactions, + unreadPollVotes, + ] = await Promise.all([ + DataWriter.getUnreadByConversationAndMarkRead({ + conversationId, + readMessageReceivedAt: readMessage.received_at, + readAt: options.readAt, + includeStoryReplies: !isGroup(conversationAttrs), + }), + DataWriter.getUnreadEditedMessagesAndMarkRead({ + conversationId, + readMessageReceivedAt: readMessage.received_at, + }), + DataWriter.getUnreadReactionsAndMarkRead({ + conversationId, + readMessageReceivedAt: readMessage.received_at, + }), + DataWriter.getUnreadPollVotesAndMarkRead({ + conversationId, + readMessageReceivedAt: readMessage.received_at, + }), + ]); const convoId = getConversationIdForLogging(conversationAttrs); const logId = `(${convoId})`; @@ -73,33 +81,36 @@ export async function markConversationRead( }, unreadMessages: unreadMessages.length, unreadReactions: unreadReactions.length, + unreadPollVotes: unreadPollVotes.length, }); if ( !unreadMessages.length && !unreadEditedMessages.length && - !unreadReactions.length + !unreadReactions.length && + !unreadPollVotes.length ) { return false; } notificationService.removeBy({ conversationId }); - const unreadReactionSyncData = new Map< + const unreadReadSyncData = new Map< string, { messageId?: string; - senderAci?: AciString; - senderE164?: string; timestamp: number; - } + } & ( + | { senderAci: AciString; senderE164?: string } + | { senderE164: string; senderAci?: AciString } + ) >(); unreadReactions.forEach(reaction => { const targetKey = `${reaction.targetAuthorAci}/${reaction.targetTimestamp}`; - if (unreadReactionSyncData.has(targetKey)) { + if (unreadReadSyncData.has(targetKey)) { return; } - unreadReactionSyncData.set(targetKey, { + unreadReadSyncData.set(targetKey, { messageId: reaction.messageId, senderE164: undefined, senderAci: reaction.targetAuthorAci, @@ -107,9 +118,38 @@ export async function markConversationRead( }); }); + unreadPollVotes.forEach(pollVote => { + if (pollVote.type !== 'outgoing') { + log.warn( + 'Found a message with unread poll votes that is not outgoing, not sending read sync' + ); + return; + } + const targetAuthorAci = itemStorage.user.getCheckedAci(); + const targetKey = `${targetAuthorAci}/${pollVote.targetTimestamp}`; + if (unreadReadSyncData.has(targetKey)) { + return; + } + unreadReadSyncData.set(targetKey, { + messageId: pollVote.id, + senderE164: undefined, + senderAci: targetAuthorAci, + timestamp: pollVote.targetTimestamp, + }); + }); + const allUnreadMessages = [...unreadMessages, ...unreadEditedMessages]; const updatedMessages: Array = []; + + // Update in-memory MessageModels for poll votes + unreadPollVotes.forEach(pollVote => { + const message = window.MessageCache.getById(pollVote.id); + if (message) { + message.set({ hasUnreadPollVotes: false }); + updatedMessages.push(message); + } + }); const allReadMessagesSync = allUnreadMessages .map(messageSyncData => { const message = window.MessageCache.getById(messageSyncData.id); @@ -200,7 +240,7 @@ export async function markConversationRead( senderId?: string; timestamp: number; hasErrors?: string; - }> = [...unreadMessagesSyncData, ...unreadReactionSyncData.values()]; + }> = [...unreadMessagesSyncData, ...unreadReadSyncData.values()]; if (readSyncs.length && options.sendReadReceipts) { log.info(logId, `Sending ${readSyncs.length} read syncs`);