Poll notifications and read syncs

This commit is contained in:
yash-signal
2025-11-12 09:26:16 -06:00
committed by GitHub
parent 7a8f208854
commit 7dd865904e
15 changed files with 1185 additions and 87 deletions

View File

@@ -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"

View File

@@ -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,

View File

@@ -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<void> {
async function maybeItIsAReactionReadSync(
sync: ReadSyncAttributesType
): Promise<void> {
): Promise<boolean> {
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<boolean> {
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<void> {
});
if (!found) {
await maybeItIsAReactionReadSync(sync);
const foundReaction = await maybeItIsAReactionReadSync(sync);
const foundPollVote = await maybeItIsAPollVoteReadSync(sync);
if (foundReaction || foundPollVote) {
await remove(sync);
}
return;
}

View File

@@ -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<ReactionAttributesType>;
targetMessage: Readonly<MessageAttributesType>;
}
| { message: Readonly<MessageAttributesType>; reaction?: never }
| {
pollVote: Readonly<PollVoteAttributesType>;
targetMessage: Readonly<MessageAttributesType>;
}
| {
message: Readonly<MessageAttributesType>;
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<void> {
if (!notificationService.isEnabled) {
return;
@@ -39,14 +58,28 @@ export async function maybeNotify(args: MaybeNotifyArgs): Promise<void> {
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<void> {
}
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<void> {
}
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<void> {
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<boolean> {
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);
}

2
ts/model-types.d.ts vendored
View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -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<ReactionResultType>;
getUnreadPollVotesAndMarkRead: (options: {
conversationId: string;
readMessageReceivedAt: number;
}) => Array<PollVoteReadResultType>;
markReactionAsRead: (
targetAuthorServiceId: ServiceIdString,
targetTimestamp: number
) => ReactionType | undefined;
markPollVoteAsRead: (
targetTimestamp: number
) => MessageAttributesType | undefined;
removeReactionFromConversation: (reaction: {
emoji: string;
fromId: string;

View File

@@ -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<PollVoteReadResultType> {
return db.transaction(() => {
const unreadPollVoteMessages: Array<PollVoteReadResultType> = 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<MessageTypeUnhydrated>({
sent_at: targetTimestamp,
});
if (!row) {
return undefined;
}
return hydrateMessage(db, row);
})();
}
function getReactionByTimestamp(
db: ReadableDB,
fromId: string,

View File

@@ -57,12 +57,20 @@ export function hydrateMessages(
db: ReadableDB,
unhydratedMessages: Array<MessageTypeUnhydrated>
): Array<MessageType> {
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);
}

View File

@@ -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';
`);
}

View File

@@ -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<SchemaUpdateType> = [
{ version: 1500, update: updateToSchemaVersion1500 },
{ version: 1510, update: updateToSchemaVersion1510 },
{ version: 1520, update: updateToSchemaVersion1520 },
];
export class DBVersionFromFutureError extends Error {

View File

@@ -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<MessageAttributesType> = [];
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<MessageAttributesType> = [];
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');
});
});
});

View File

@@ -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<?)'
);
});
it('uses index when hasUnreadPollVotes = 1', () => {
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'");
});
});
});

View File

@@ -45,23 +45,31 @@ export async function markConversationRead(
): Promise<boolean> {
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<MessageModel> = [];
// 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`);