Skip to content

Commit 27f405d

Browse files
Poll notifications and read syncs
Co-authored-by: yash-signal <[email protected]>
1 parent d62c397 commit 27f405d

File tree

15 files changed

+1185
-87
lines changed

15 files changed

+1185
-87
lines changed

_locales/en/messages.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,11 +1487,11 @@
14871487
"description": "Accessibility label for checkmark indicating user voted for this poll option"
14881488
},
14891489
"icu:PollTerminate--you": {
1490-
"messageformat": "You ended the poll: \"{poll}\"",
1490+
"messageformat": "You ended the poll: {poll}",
14911491
"description": "Chat event shown when you end a poll"
14921492
},
14931493
"icu:PollTerminate--other": {
1494-
"messageformat": "{name} ended the poll: \"{poll}\"",
1494+
"messageformat": "{name} ended the poll: {poll}",
14951495
"description": "Chat event shown when someone else ends a poll"
14961496
},
14971497
"icu:PollTerminate__view-poll": {
@@ -2636,6 +2636,10 @@
26362636
"icu:notificationReactionMessage": {
26372637
"messageformat": "{sender} reacted {emoji} to: {message}"
26382638
},
2639+
"icu:notificationPollVoteMessage": {
2640+
"messageformat": "{sender} voted in the poll \"{pollQuestion}\"",
2641+
"description": "Notification text when someone votes in your poll"
2642+
},
26392643
"icu:sendFailed": {
26402644
"messageformat": "Send failed",
26412645
"description": "Shown on outgoing message if it fails to send"

ts/messageModifiers/Polls.preload.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { isMe } from '../util/whatTypeOfConversation.dom.js';
2121

2222
import { strictAssert } from '../util/assert.std.js';
2323
import { getMessageIdForLogging } from '../util/idForLogging.preload.js';
24+
import { drop } from '../util/drop.std.js';
25+
import { maybeNotify } from '../messages/maybeNotify.preload.js';
2426

2527
const log = createLogger('Polls');
2628

@@ -369,10 +371,11 @@ export async function handlePollVote(
369371
return;
370372
}
371373

372-
const conversation = window.ConversationController.get(
374+
const conversationContainingThisPoll = window.ConversationController.get(
373375
message.attributes.conversationId
374376
);
375-
if (!conversation) {
377+
if (!conversationContainingThisPoll) {
378+
log.warn('handlePollVote: cannot find conversation containing this poll');
376379
return;
377380
}
378381

@@ -394,7 +397,7 @@ export async function handlePollVote(
394397
timestamp: vote.timestamp,
395398
sendStateByConversationId: isFromThisDevice
396399
? Object.fromEntries(
397-
Array.from(conversation.getMemberConversationIds())
400+
Array.from(conversationContainingThisPoll.getMemberConversationIds())
398401
.filter(id => id !== ourConversationId)
399402
.map(id => [
400403
id,
@@ -456,21 +459,39 @@ export async function handlePollVote(
456459
}
457460
}
458461

462+
// Set hasUnreadPollVotes flag if someone else voted on our poll
463+
const shouldMarkAsUnread =
464+
isOutgoing(message.attributes) && isFromSomeoneElse;
465+
459466
message.set({
460467
poll: {
461468
...poll,
462469
votes: updatedVotes,
463470
},
471+
...(shouldMarkAsUnread ? { hasUnreadPollVotes: true } : {}),
464472
});
465473

466474
log.info(
467475
'handlePollVote:',
468476
`Done processing vote for poll ${getMessageIdForLogging(message.attributes)}.`
469477
);
470478

479+
// Notify poll author when someone else votes
480+
if (shouldMarkAsUnread) {
481+
drop(
482+
maybeNotify({
483+
pollVote: vote,
484+
targetMessage: message.attributes,
485+
conversation: conversationContainingThisPoll,
486+
})
487+
);
488+
}
489+
471490
if (shouldPersist) {
472491
await window.MessageCache.saveMessage(message.attributes);
473-
window.reduxActions.conversations.markOpenConversationRead(conversation.id);
492+
window.reduxActions.conversations.markOpenConversationRead(
493+
conversationContainingThisPoll.id
494+
);
474495
}
475496
}
476497

@@ -507,6 +528,14 @@ export async function handlePollTerminate(
507528
return;
508529
}
509530

531+
const isFromThisDevice = terminate.source === PollSource.FromThisDevice;
532+
const isFromSync = terminate.source === PollSource.FromSync;
533+
const isFromSomeoneElse = terminate.source === PollSource.FromSomeoneElse;
534+
strictAssert(
535+
isFromThisDevice || isFromSync || isFromSomeoneElse,
536+
'Terminate can only be from this device, from sync, or from someone else'
537+
);
538+
510539
// Verify the terminator is the poll creator
511540
const author = getAuthor(attributes);
512541
const terminatorConversation = window.ConversationController.get(
@@ -524,8 +553,6 @@ export async function handlePollTerminate(
524553
return;
525554
}
526555

527-
const isFromThisDevice = terminate.source === PollSource.FromThisDevice;
528-
529556
message.set({
530557
poll: {
531558
...poll,

ts/messageModifiers/ReadSyncs.preload.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { DataReader, DataWriter } from '../sql/Client.preload.js';
2020
import { markRead } from '../services/MessageUpdater.preload.js';
2121
import { MessageModel } from '../models/messages.preload.js';
2222
import { itemStorage } from '../textsecure/Storage.preload.js';
23+
import { getMessageById } from '../messages/getMessageById.preload.js';
24+
import { getSourceServiceId } from '../messages/sources.preload.js';
2325

2426
const log = createLogger('ReadSyncs');
2527

@@ -52,7 +54,7 @@ async function remove(sync: ReadSyncAttributesType): Promise<void> {
5254

5355
async function maybeItIsAReactionReadSync(
5456
sync: ReadSyncAttributesType
55-
): Promise<void> {
57+
): Promise<boolean> {
5658
const { readSync } = sync;
5759
const logId = `ReadSyncs.onSync(timestamp=${readSync.timestamp})`;
5860

@@ -71,7 +73,7 @@ async function maybeItIsAReactionReadSync(
7173
readSync.sender,
7274
readSync.senderAci
7375
);
74-
return;
76+
return false;
7577
}
7678

7779
log.info(
@@ -82,14 +84,47 @@ async function maybeItIsAReactionReadSync(
8284
readSync.senderAci
8385
);
8486

85-
await remove(sync);
86-
8787
notificationService.removeBy({
8888
conversationId: readReaction.conversationId,
8989
emoji: readReaction.emoji,
9090
targetAuthorAci: readReaction.targetAuthorAci,
9191
targetTimestamp: readReaction.targetTimestamp,
9292
});
93+
94+
return true;
95+
}
96+
97+
async function maybeItIsAPollVoteReadSync(
98+
sync: ReadSyncAttributesType
99+
): Promise<boolean> {
100+
const { readSync } = sync;
101+
const logId = `ReadSyncs.onSync(timestamp=${readSync.timestamp})`;
102+
103+
const pollMessage = await DataWriter.markPollVoteAsRead(readSync.timestamp);
104+
105+
if (!pollMessage) {
106+
log.info(`${logId} poll vote read sync not found`);
107+
return false;
108+
}
109+
110+
const pollMessageModel = await getMessageById(pollMessage.id);
111+
if (!pollMessageModel) {
112+
log.warn(
113+
`${logId} found message for poll, but could not get the message model`
114+
);
115+
return false;
116+
}
117+
pollMessageModel.set({ hasUnreadPollVotes: false });
118+
drop(queueUpdateMessage(pollMessageModel.attributes));
119+
120+
notificationService.removeBy({
121+
conversationId: pollMessage.conversationId,
122+
targetAuthorAci: getSourceServiceId(pollMessageModel.attributes),
123+
targetTimestamp: pollMessage.sent_at,
124+
onlyRemoveAssociatedPollVotes: true,
125+
});
126+
127+
return true;
93128
}
94129

95130
export async function forMessage(
@@ -145,7 +180,13 @@ export async function onSync(sync: ReadSyncAttributesType): Promise<void> {
145180
});
146181

147182
if (!found) {
148-
await maybeItIsAReactionReadSync(sync);
183+
const foundReaction = await maybeItIsAReactionReadSync(sync);
184+
185+
const foundPollVote = await maybeItIsAPollVoteReadSync(sync);
186+
187+
if (foundReaction || foundPollVote) {
188+
await remove(sync);
189+
}
149190
return;
150191
}
151192

ts/messages/maybeNotify.preload.ts

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import { createLogger } from '../logging/log.std.js';
55

6-
import { isIncoming, isOutgoing } from './helpers.std.js';
6+
import { isOutgoing } from './helpers.std.js';
77
import { getAuthor } from './sources.preload.js';
88

99
import type { ConversationModel } from '../models/conversations.preload.js';
@@ -17,6 +17,10 @@ import { notificationService } from '../services/notifications.preload.js';
1717
import { getNotificationTextForMessage } from '../util/getNotificationTextForMessage.preload.js';
1818
import type { MessageAttributesType } from '../model-types.d.ts';
1919
import type { ReactionAttributesType } from '../messageModifiers/Reactions.preload.js';
20+
import {
21+
type PollVoteAttributesType,
22+
PollSource,
23+
} from '../messageModifiers/Polls.preload.js';
2024
import { shouldStoryReplyNotifyUser } from '../util/shouldStoryReplyNotifyUser.preload.js';
2125
import { ReactionSource } from '../reactions/ReactionSource.std.js';
2226

@@ -29,24 +33,53 @@ type MaybeNotifyArgs = {
2933
reaction: Readonly<ReactionAttributesType>;
3034
targetMessage: Readonly<MessageAttributesType>;
3135
}
32-
| { message: Readonly<MessageAttributesType>; reaction?: never }
36+
| {
37+
pollVote: Readonly<PollVoteAttributesType>;
38+
targetMessage: Readonly<MessageAttributesType>;
39+
}
40+
| {
41+
message: Readonly<MessageAttributesType>;
42+
reaction?: never;
43+
pollVote?: never;
44+
}
3345
);
3446

47+
function isMention(args: MaybeNotifyArgs): boolean {
48+
if ('reaction' in args || 'pollVote' in args) {
49+
return false;
50+
}
51+
return Boolean(args.message.mentionsMe);
52+
}
53+
3554
export async function maybeNotify(args: MaybeNotifyArgs): Promise<void> {
3655
if (!notificationService.isEnabled) {
3756
return;
3857
}
3958

4059
const { i18n } = window.SignalContext;
4160

42-
const { conversation, reaction } = args;
61+
const { conversation } = args;
62+
const reaction = 'reaction' in args ? args.reaction : undefined;
63+
const pollVote = 'pollVote' in args ? args.pollVote : undefined;
4364

4465
let warrantsNotification: boolean;
45-
if (reaction) {
46-
warrantsNotification = doesReactionWarrantNotification(args);
66+
if ('reaction' in args && 'targetMessage' in args) {
67+
warrantsNotification = doesReactionWarrantNotification({
68+
reaction: args.reaction,
69+
targetMessage: args.targetMessage,
70+
});
71+
} else if ('pollVote' in args && 'targetMessage' in args) {
72+
warrantsNotification = doesPollVoteWarrantNotification({
73+
pollVote: args.pollVote,
74+
targetMessage: args.targetMessage,
75+
});
4776
} else {
48-
warrantsNotification = await doesMessageWarrantNotification(args);
77+
warrantsNotification = await doesMessageWarrantNotification({
78+
message: args.message,
79+
conversation,
80+
});
4981
}
82+
5083
if (!warrantsNotification) {
5184
return;
5285
}
@@ -56,29 +89,34 @@ export async function maybeNotify(args: MaybeNotifyArgs): Promise<void> {
5689
}
5790

5891
const activeProfile = getActiveProfile(window.reduxStore.getState());
92+
5993
if (
6094
!shouldNotifyDuringNotificationProfile({
6195
activeProfile,
6296
conversationId: conversation.id,
6397
isCall: false,
64-
isMention: args.reaction ? false : Boolean(args.message.mentionsMe),
98+
isMention: isMention(args),
6599
})
66100
) {
67101
log.info('Would notify for message, but notification profile prevented it');
68102
return;
69103
}
70104

71105
const conversationId = conversation.get('id');
72-
const messageForNotification = args.reaction
73-
? args.targetMessage
74-
: args.message;
106+
const messageForNotification =
107+
'targetMessage' in args ? args.targetMessage : args.message;
75108
const isMessageInDirectConversation = isDirectConversation(
76109
conversation.attributes
77110
);
78111

79-
const sender = reaction
80-
? window.ConversationController.get(reaction.fromId)
81-
: getAuthor(args.message);
112+
let sender: ConversationModel | undefined;
113+
if (reaction) {
114+
sender = window.ConversationController.get(reaction.fromId);
115+
} else if (pollVote) {
116+
sender = window.ConversationController.get(pollVote.fromConversationId);
117+
} else if ('message' in args) {
118+
sender = getAuthor(args.message);
119+
}
82120
const senderName = sender ? sender.getTitle() : i18n('icu:unknownContact');
83121
const senderTitle = isMessageInDirectConversation
84122
? senderName
@@ -110,6 +148,13 @@ export async function maybeNotify(args: MaybeNotifyArgs): Promise<void> {
110148
targetTimestamp: reaction.targetTimestamp,
111149
}
112150
: undefined,
151+
pollVote: pollVote
152+
? {
153+
voterConversationId: pollVote.fromConversationId,
154+
targetAuthorAci: pollVote.targetAuthorAci,
155+
targetTimestamp: pollVote.targetTimestamp,
156+
}
157+
: undefined,
113158
sentAt: messageForNotification.timestamp,
114159
type: reaction ? NotificationType.Reaction : NotificationType.Message,
115160
});
@@ -128,14 +173,26 @@ function doesReactionWarrantNotification({
128173
);
129174
}
130175

176+
function doesPollVoteWarrantNotification({
177+
pollVote,
178+
targetMessage,
179+
}: {
180+
targetMessage: MessageAttributesType;
181+
pollVote: PollVoteAttributesType;
182+
}): boolean {
183+
return (
184+
pollVote.source === PollSource.FromSomeoneElse && isOutgoing(targetMessage)
185+
);
186+
}
187+
131188
async function doesMessageWarrantNotification({
132189
message,
133190
conversation,
134191
}: {
135192
message: MessageAttributesType;
136193
conversation: ConversationModel;
137194
}): Promise<boolean> {
138-
if (!isIncoming(message)) {
195+
if (!(message.type === 'incoming' || message.type === 'poll-terminate')) {
139196
return false;
140197
}
141198

@@ -154,19 +211,15 @@ async function doesMessageWarrantNotification({
154211
}
155212

156213
function isAllowedByConversation(args: MaybeNotifyArgs): boolean {
157-
const { conversation, reaction } = args;
214+
const { conversation } = args;
158215

159216
if (!conversation.isMuted()) {
160217
return true;
161218
}
162219

163-
if (reaction) {
164-
return false;
165-
}
166-
167220
if (conversation.get('dontNotifyForMentionsIfMuted')) {
168221
return false;
169222
}
170223

171-
return args.message.mentionsMe === true;
224+
return isMention(args);
172225
}

0 commit comments

Comments
 (0)