mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-19 17:58:48 +00:00
Poll notifications and read syncs
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
2
ts/model-types.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
18
ts/sql/migrations/1520-poll-votes-unread.std.ts
Normal file
18
ts/sql/migrations/1520-poll-votes-unread.std.ts
Normal 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';
|
||||
`);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
640
ts/test-electron/sql/pollVoteMarkRead_test.preload.ts
Normal file
640
ts/test-electron/sql/pollVoteMarkRead_test.preload.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
135
ts/test-node/sql/migration_1520_test.node.ts
Normal file
135
ts/test-node/sql/migration_1520_test.node.ts
Normal 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'");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user