Send/Receive support for reaction read syncs
This commit is contained in:
parent
82a9705010
commit
e0c324e4ba
23 changed files with 1188 additions and 498 deletions
|
@ -97,6 +97,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
if (message.isExpiring() && !expirationStartTimestamp) {
|
if (message.isExpiring() && !expirationStartTimestamp) {
|
||||||
|
// TODO DESKTOP-1509: use setToExpire once this is TS
|
||||||
await message.setToExpire(false, { skipSave: true });
|
await message.setToExpire(false, { skipSave: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -67,7 +67,7 @@
|
||||||
|
|
||||||
function getById(id) {
|
function getById(id) {
|
||||||
const existing = messageLookup[id];
|
const existing = messageLookup[id];
|
||||||
return existing && existing.message ? existing.message : null;
|
return existing && existing.message ? existing.message : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findBySentAt(sentAt) {
|
function findBySentAt(sentAt) {
|
||||||
|
|
|
@ -60,8 +60,14 @@
|
||||||
// Remove the last notification if both conditions hold:
|
// Remove the last notification if both conditions hold:
|
||||||
//
|
//
|
||||||
// 1. Either `conversationId` or `messageId` matches (if present)
|
// 1. Either `conversationId` or `messageId` matches (if present)
|
||||||
// 2. `reactionFromId` matches (if present)
|
// 2. `emoji`, `targetAuthorUuid`, `targetTimestamp` matches (if present)
|
||||||
removeBy({ conversationId, messageId, reactionFromId }) {
|
removeBy({
|
||||||
|
conversationId,
|
||||||
|
messageId,
|
||||||
|
emoji,
|
||||||
|
targetAuthorUuid,
|
||||||
|
targetTimestamp,
|
||||||
|
}) {
|
||||||
if (!this.notificationData) {
|
if (!this.notificationData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -81,10 +87,15 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { reaction } = this.notificationData;
|
||||||
if (
|
if (
|
||||||
reactionFromId &&
|
reaction &&
|
||||||
this.notificationData.reaction &&
|
emoji &&
|
||||||
this.notificationData.reaction.fromId !== reactionFromId
|
targetAuthorUuid &&
|
||||||
|
targetTimestamp &&
|
||||||
|
(reaction.emoji !== emoji ||
|
||||||
|
reaction.targetAuthorUuid !== targetAuthorUuid ||
|
||||||
|
reaction.targetTimestamp !== targetTimestamp)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,6 +98,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
if (message.isExpiring() && !expirationStartTimestamp) {
|
if (message.isExpiring() && !expirationStartTimestamp) {
|
||||||
|
// TODO DESKTOP-1509: use setToExpire once this is TS
|
||||||
await message.setToExpire(false, { skipSave: true });
|
await message.setToExpire(false, { skipSave: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,30 @@
|
||||||
|
|
||||||
// eslint-disable-next-line func-names
|
// eslint-disable-next-line func-names
|
||||||
(function () {
|
(function () {
|
||||||
|
async function maybeItIsAReactionReadSync(receipt) {
|
||||||
|
const readReaction = await window.Signal.Data.markReactionAsRead(
|
||||||
|
receipt.get('senderUuid'),
|
||||||
|
Number(receipt.get('timestamp'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!readReaction) {
|
||||||
|
window.log.info(
|
||||||
|
'Nothing found for read sync',
|
||||||
|
receipt.get('senderId'),
|
||||||
|
receipt.get('sender'),
|
||||||
|
receipt.get('senderUuid'),
|
||||||
|
receipt.get('timestamp')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Whisper.Notifications.removeBy({
|
||||||
|
conversationId: readReaction.conversationId,
|
||||||
|
emoji: readReaction.emoji,
|
||||||
|
targetAuthorUuid: readReaction.targetAuthorUuid,
|
||||||
|
targetTimestamp: readReaction.targetTimestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
window.Whisper = window.Whisper || {};
|
window.Whisper = window.Whisper || {};
|
||||||
Whisper.ReadSyncs = new (Backbone.Collection.extend({
|
Whisper.ReadSyncs = new (Backbone.Collection.extend({
|
||||||
forMessage(message) {
|
forMessage(message) {
|
||||||
|
@ -47,19 +71,14 @@
|
||||||
|
|
||||||
return item.isIncoming() && senderId === receipt.get('senderId');
|
return item.isIncoming() && senderId === receipt.get('senderId');
|
||||||
});
|
});
|
||||||
if (found) {
|
|
||||||
Whisper.Notifications.removeBy({ messageId: found.id });
|
if (!found) {
|
||||||
} else {
|
await maybeItIsAReactionReadSync(receipt);
|
||||||
window.log.info(
|
|
||||||
'No message for read sync',
|
|
||||||
receipt.get('senderId'),
|
|
||||||
receipt.get('sender'),
|
|
||||||
receipt.get('senderUuid'),
|
|
||||||
receipt.get('timestamp')
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Whisper.Notifications.removeBy({ messageId: found.id });
|
||||||
|
|
||||||
const message = MessageController.register(found.id, found);
|
const message = MessageController.register(found.id, found);
|
||||||
const readAt = receipt.get('read_at');
|
const readAt = receipt.get('read_at');
|
||||||
|
|
||||||
|
@ -67,7 +86,8 @@
|
||||||
// timer to the time specified by the read sync if it's earlier than
|
// timer to the time specified by the read sync if it's earlier than
|
||||||
// the previous read time.
|
// the previous read time.
|
||||||
if (message.isUnread()) {
|
if (message.isUnread()) {
|
||||||
await message.markRead(readAt, { skipSave: true });
|
// TODO DESKTOP-1509: use MessageUpdater.markRead once this is TS
|
||||||
|
message.markRead(readAt, { skipSave: true });
|
||||||
|
|
||||||
const updateConversation = () => {
|
const updateConversation = () => {
|
||||||
// onReadMessage may result in messages older than this one being
|
// onReadMessage may result in messages older than this one being
|
||||||
|
@ -100,6 +120,7 @@
|
||||||
message.set({ expirationStartTimestamp });
|
message.set({ expirationStartTimestamp });
|
||||||
|
|
||||||
const force = true;
|
const force = true;
|
||||||
|
// TODO DESKTOP-1509: use setToExpire once this is TS
|
||||||
await message.setToExpire(force, { skipSave: true });
|
await message.setToExpire(force, { skipSave: true });
|
||||||
|
|
||||||
const conversation = message.getConversation();
|
const conversation = message.getConversation();
|
||||||
|
|
|
@ -723,7 +723,7 @@ export class ConversationController {
|
||||||
|
|
||||||
async prepareForSend(
|
async prepareForSend(
|
||||||
id: string | undefined,
|
id: string | undefined,
|
||||||
options?: { syncMessage?: boolean; disableMeCheck?: boolean }
|
options?: { syncMessage?: boolean }
|
||||||
): Promise<{
|
): Promise<{
|
||||||
wrap: (
|
wrap: (
|
||||||
promise: Promise<CallbackResultType | void | null>
|
promise: Promise<CallbackResultType | void | null>
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { initializeAllJobQueues } from './jobs/initializeAllJobQueues';
|
||||||
import { removeStorageKeyJobQueue } from './jobs/removeStorageKeyJobQueue';
|
import { removeStorageKeyJobQueue } from './jobs/removeStorageKeyJobQueue';
|
||||||
import { ourProfileKeyService } from './services/ourProfileKey';
|
import { ourProfileKeyService } from './services/ourProfileKey';
|
||||||
import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey';
|
import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey';
|
||||||
|
import { setToExpire } from './services/MessageUpdater';
|
||||||
|
|
||||||
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
|
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
|
||||||
|
|
||||||
|
@ -1525,10 +1526,11 @@ export async function startApp(): Promise<void> {
|
||||||
`Cleanup: Starting timer for delivered message ${sentAt}`
|
`Cleanup: Starting timer for delivered message ${sentAt}`
|
||||||
);
|
);
|
||||||
message.set(
|
message.set(
|
||||||
'expirationStartTimestamp',
|
setToExpire({
|
||||||
expirationStartTimestamp || sentAt
|
...message.attributes,
|
||||||
|
expirationStartTimestamp: expirationStartTimestamp || sentAt,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
await message.setToExpire();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
6
ts/model-types.d.ts
vendored
6
ts/model-types.d.ts
vendored
|
@ -109,8 +109,6 @@ export type MessageAttributesType = {
|
||||||
quote?: QuotedMessageType;
|
quote?: QuotedMessageType;
|
||||||
reactions?: Array<{
|
reactions?: Array<{
|
||||||
emoji: string;
|
emoji: string;
|
||||||
timestamp: number;
|
|
||||||
fromId: string;
|
|
||||||
from: {
|
from: {
|
||||||
id: string;
|
id: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
@ -120,6 +118,10 @@ export type MessageAttributesType = {
|
||||||
isMe?: boolean;
|
isMe?: boolean;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
};
|
};
|
||||||
|
fromId: string;
|
||||||
|
targetAuthorUuid: string;
|
||||||
|
targetTimestamp: number;
|
||||||
|
timestamp: number;
|
||||||
}>;
|
}>;
|
||||||
read_by: Array<string | null>;
|
read_by: Array<string | null>;
|
||||||
requiredProtocolVersion: number;
|
requiredProtocolVersion: number;
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
/* eslint-disable class-methods-use-this */
|
/* eslint-disable class-methods-use-this */
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
|
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
|
||||||
|
import { compact } from 'lodash';
|
||||||
import {
|
import {
|
||||||
MessageModelCollectionType,
|
MessageModelCollectionType,
|
||||||
WhatIsThis,
|
WhatIsThis,
|
||||||
|
@ -15,7 +16,6 @@ import { CallMode, CallHistoryDetailsType } from '../types/Calling';
|
||||||
import {
|
import {
|
||||||
CallbackResultType,
|
CallbackResultType,
|
||||||
GroupV2InfoType,
|
GroupV2InfoType,
|
||||||
SendMetadataType,
|
|
||||||
SendOptionsType,
|
SendOptionsType,
|
||||||
} from '../textsecure/SendMessage';
|
} from '../textsecure/SendMessage';
|
||||||
import {
|
import {
|
||||||
|
@ -26,7 +26,6 @@ import { ColorType } from '../types/Colors';
|
||||||
import { MessageModel } from './messages';
|
import { MessageModel } from './messages';
|
||||||
import { isMuted } from '../util/isMuted';
|
import { isMuted } from '../util/isMuted';
|
||||||
import { isConversationUnregistered } from '../util/isConversationUnregistered';
|
import { isConversationUnregistered } from '../util/isConversationUnregistered';
|
||||||
import { assert } from '../util/assert';
|
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
||||||
import { MIMEType, IMAGE_WEBP } from '../types/MIME';
|
import { MIMEType, IMAGE_WEBP } from '../types/MIME';
|
||||||
|
@ -35,7 +34,6 @@ import {
|
||||||
base64ToArrayBuffer,
|
base64ToArrayBuffer,
|
||||||
deriveAccessKey,
|
deriveAccessKey,
|
||||||
fromEncodedBinaryToArrayBuffer,
|
fromEncodedBinaryToArrayBuffer,
|
||||||
getRandomBytes,
|
|
||||||
stringFromBytes,
|
stringFromBytes,
|
||||||
trimForDisplay,
|
trimForDisplay,
|
||||||
verifyAccessKey,
|
verifyAccessKey,
|
||||||
|
@ -45,16 +43,13 @@ import { BodyRangesType } from '../types/Util';
|
||||||
import { getTextWithMentions } from '../util';
|
import { getTextWithMentions } from '../util';
|
||||||
import { migrateColor } from '../util/migrateColor';
|
import { migrateColor } from '../util/migrateColor';
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
import {
|
|
||||||
PhoneNumberSharingMode,
|
|
||||||
parsePhoneNumberSharingMode,
|
|
||||||
} from '../util/phoneNumberSharingMode';
|
|
||||||
import {
|
|
||||||
SenderCertificateMode,
|
|
||||||
SerializedCertificateType,
|
|
||||||
} from '../textsecure/OutgoingMessage';
|
|
||||||
import { senderCertificateService } from '../services/senderCertificate';
|
|
||||||
import { ourProfileKeyService } from '../services/ourProfileKey';
|
import { ourProfileKeyService } from '../services/ourProfileKey';
|
||||||
|
import { getSendOptions } from '../util/getSendOptions';
|
||||||
|
import { isConversationAccepted } from '../util/isConversationAccepted';
|
||||||
|
import { markConversationRead } from '../util/markConversationRead';
|
||||||
|
import { handleMessageSend } from '../util/handleMessageSend';
|
||||||
|
import { getConversationMembers } from '../util/getConversationMembers';
|
||||||
|
import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor';
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
window.Whisper = window.Whisper || {};
|
window.Whisper = window.Whisper || {};
|
||||||
|
@ -1211,6 +1206,7 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const model = this.messageCollection!.add(message, { merge: true });
|
const model = this.messageCollection!.add(message, { merge: true });
|
||||||
|
// TODO use MessageUpdater.setToExpire
|
||||||
model.setToExpire();
|
model.setToExpire();
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
@ -1535,7 +1531,7 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
if (isLocalAction) {
|
if (isLocalAction) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await this.sendReadReceiptsFor(receiptSpecs);
|
await sendReadReceiptsFor(this.attributes, receiptSpecs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
@ -2304,43 +2300,7 @@ export class ConversationModel extends window.Backbone
|
||||||
* of message requests
|
* of message requests
|
||||||
*/
|
*/
|
||||||
getAccepted(): boolean {
|
getAccepted(): boolean {
|
||||||
const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled(
|
return isConversationAccepted(this.attributes);
|
||||||
'desktop.messageRequests'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!messageRequestsEnabled) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isMe()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageRequestEnum =
|
|
||||||
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
|
|
||||||
if (this.getMessageRequestResponseType() === messageRequestEnum.ACCEPT) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFromOrAddedByTrustedContact = this.isFromOrAddedByTrustedContact();
|
|
||||||
const hasSentMessages = this.getSentMessageCount() > 0;
|
|
||||||
const hasMessagesBeforeMessageRequests =
|
|
||||||
(this.get('messageCountBeforeMessageRequests') || 0) > 0;
|
|
||||||
const hasNoMessages = (this.get('messageCount') || 0) === 0;
|
|
||||||
|
|
||||||
const isEmptyPrivateConvo = hasNoMessages && this.isPrivate();
|
|
||||||
const isEmptyWhitelistedGroup =
|
|
||||||
hasNoMessages && !this.isPrivate() && this.get('profileSharing');
|
|
||||||
|
|
||||||
return (
|
|
||||||
isFromOrAddedByTrustedContact ||
|
|
||||||
hasSentMessages ||
|
|
||||||
hasMessagesBeforeMessageRequests ||
|
|
||||||
// an empty group is the scenario where we need to rely on
|
|
||||||
// whether the profile has already been shared or not
|
|
||||||
isEmptyPrivateConvo ||
|
|
||||||
isEmptyWhitelistedGroup
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMemberVerifiedChange(): void {
|
onMemberVerifiedChange(): void {
|
||||||
|
@ -2631,12 +2591,6 @@ export class ConversationModel extends window.Backbone
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getUnread(): Promise<MessageModelCollectionType> {
|
|
||||||
return window.Signal.Data.getUnreadByConversation(this.id, {
|
|
||||||
MessageCollection: window.Whisper.MessageCollection,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
validate(attributes = this.attributes): string | null {
|
validate(attributes = this.attributes): string | null {
|
||||||
const required = ['type'];
|
const required = ['type'];
|
||||||
const missing = window._.filter(required, attr => !attributes[attr]);
|
const missing = window._.filter(required, attr => !attributes[attr]);
|
||||||
|
@ -2785,50 +2739,11 @@ export class ConversationModel extends window.Backbone
|
||||||
getMembers(
|
getMembers(
|
||||||
options: { includePendingMembers?: boolean } = {}
|
options: { includePendingMembers?: boolean } = {}
|
||||||
): Array<ConversationModel> {
|
): Array<ConversationModel> {
|
||||||
if (this.isPrivate()) {
|
return compact(
|
||||||
return [this];
|
getConversationMembers(this.attributes, options).map(conversationAttrs =>
|
||||||
}
|
window.ConversationController.get(conversationAttrs.id)
|
||||||
|
)
|
||||||
if (this.get('membersV2')) {
|
);
|
||||||
const { includePendingMembers } = options;
|
|
||||||
const members: Array<{ conversationId: string }> = includePendingMembers
|
|
||||||
? [
|
|
||||||
...(this.get('membersV2') || []),
|
|
||||||
...(this.get('pendingMembersV2') || []),
|
|
||||||
]
|
|
||||||
: this.get('membersV2') || [];
|
|
||||||
|
|
||||||
return window._.compact(
|
|
||||||
members.map(member => {
|
|
||||||
const c = window.ConversationController.get(member.conversationId);
|
|
||||||
|
|
||||||
// In groups we won't sent to contacts we believe are unregistered
|
|
||||||
if (c && c.isUnregistered()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return c;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.get('members')) {
|
|
||||||
return window._.compact(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
this.get('members')!.map(id => {
|
|
||||||
const c = window.ConversationController.get(id);
|
|
||||||
|
|
||||||
// In groups we won't send to contacts we believe are unregistered
|
|
||||||
if (c && c.isUnregistered()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return c;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getMemberIds(): Array<string> {
|
getMemberIds(): Array<string> {
|
||||||
|
@ -3477,198 +3392,13 @@ export class ConversationModel extends window.Backbone
|
||||||
async wrapSend(
|
async wrapSend(
|
||||||
promise: Promise<CallbackResultType | void | null>
|
promise: Promise<CallbackResultType | void | null>
|
||||||
): Promise<CallbackResultType | void | null> {
|
): Promise<CallbackResultType | void | null> {
|
||||||
return promise.then(
|
return handleMessageSend(promise);
|
||||||
async result => {
|
|
||||||
// success
|
|
||||||
if (result) {
|
|
||||||
await this.handleMessageSendResult(
|
|
||||||
result.failoverIdentifiers,
|
|
||||||
result.unidentifiedDeliveries
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
async result => {
|
|
||||||
// failure
|
|
||||||
if (result) {
|
|
||||||
await this.handleMessageSendResult(
|
|
||||||
result.failoverIdentifiers,
|
|
||||||
result.unidentifiedDeliveries
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw result;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleMessageSendResult(
|
async getSendOptions(
|
||||||
failoverIdentifiers: Array<string> | undefined,
|
options: { syncMessage?: boolean } = {}
|
||||||
unidentifiedDeliveries: Array<string> | undefined
|
): Promise<SendOptionsType> {
|
||||||
): Promise<void> {
|
return getSendOptions(this.attributes, options);
|
||||||
await Promise.all(
|
|
||||||
(failoverIdentifiers || []).map(async identifier => {
|
|
||||||
const conversation = window.ConversationController.get(identifier);
|
|
||||||
|
|
||||||
if (
|
|
||||||
conversation &&
|
|
||||||
conversation.get('sealedSender') !== SEALED_SENDER.DISABLED
|
|
||||||
) {
|
|
||||||
window.log.info(
|
|
||||||
`Setting sealedSender to DISABLED for conversation ${conversation.idForLogging()}`
|
|
||||||
);
|
|
||||||
conversation.set({
|
|
||||||
sealedSender: SEALED_SENDER.DISABLED,
|
|
||||||
});
|
|
||||||
window.Signal.Data.updateConversation(conversation.attributes);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
(unidentifiedDeliveries || []).map(async identifier => {
|
|
||||||
const conversation = window.ConversationController.get(identifier);
|
|
||||||
|
|
||||||
if (
|
|
||||||
conversation &&
|
|
||||||
conversation.get('sealedSender') === SEALED_SENDER.UNKNOWN
|
|
||||||
) {
|
|
||||||
if (conversation.get('accessKey')) {
|
|
||||||
window.log.info(
|
|
||||||
`Setting sealedSender to ENABLED for conversation ${conversation.idForLogging()}`
|
|
||||||
);
|
|
||||||
conversation.set({
|
|
||||||
sealedSender: SEALED_SENDER.ENABLED,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
window.log.info(
|
|
||||||
`Setting sealedSender to UNRESTRICTED for conversation ${conversation.idForLogging()}`
|
|
||||||
);
|
|
||||||
conversation.set({
|
|
||||||
sealedSender: SEALED_SENDER.UNRESTRICTED,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
window.Signal.Data.updateConversation(conversation.attributes);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSendOptions(options = {}): Promise<SendOptionsType> {
|
|
||||||
const sendMetadata = await this.getSendMetadata(options);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sendMetadata,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSendMetadata(
|
|
||||||
options: { syncMessage?: boolean; disableMeCheck?: boolean } = {}
|
|
||||||
): Promise<SendMetadataType | undefined> {
|
|
||||||
const { syncMessage, disableMeCheck } = options;
|
|
||||||
|
|
||||||
// START: this code has an Expiration date of ~2018/11/21
|
|
||||||
// We don't want to enable unidentified delivery for send unless it is
|
|
||||||
// also enabled for our own account.
|
|
||||||
const myId = window.ConversationController.getOurConversationId();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const me = window.ConversationController.get(myId)!;
|
|
||||||
if (!disableMeCheck && me.get('sealedSender') === SEALED_SENDER.DISABLED) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// END
|
|
||||||
|
|
||||||
if (!this.isPrivate()) {
|
|
||||||
assert(
|
|
||||||
this.contactCollection,
|
|
||||||
'getSendMetadata: expected contactCollection to be defined'
|
|
||||||
);
|
|
||||||
const result: SendMetadataType = {};
|
|
||||||
await Promise.all(
|
|
||||||
this.contactCollection.map(async conversation => {
|
|
||||||
const sendMetadata =
|
|
||||||
(await conversation.getSendMetadata(options)) || {};
|
|
||||||
Object.assign(result, sendMetadata);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessKey = this.get('accessKey');
|
|
||||||
const sealedSender = this.get('sealedSender');
|
|
||||||
|
|
||||||
// We never send sync messages as sealed sender
|
|
||||||
if (syncMessage && this.isMe()) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const e164 = this.get('e164');
|
|
||||||
const uuid = this.get('uuid');
|
|
||||||
|
|
||||||
const senderCertificate = await this.getSenderCertificateForDirectConversation();
|
|
||||||
|
|
||||||
// If we've never fetched user's profile, we default to what we have
|
|
||||||
if (sealedSender === SEALED_SENDER.UNKNOWN) {
|
|
||||||
const info = {
|
|
||||||
accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)),
|
|
||||||
senderCertificate,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...(e164 ? { [e164]: info } : {}),
|
|
||||||
...(uuid ? { [uuid]: info } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sealedSender === SEALED_SENDER.DISABLED) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = {
|
|
||||||
accessKey:
|
|
||||||
accessKey && sealedSender === SEALED_SENDER.ENABLED
|
|
||||||
? accessKey
|
|
||||||
: arrayBufferToBase64(getRandomBytes(16)),
|
|
||||||
senderCertificate,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(e164 ? { [e164]: info } : {}),
|
|
||||||
...(uuid ? { [uuid]: info } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSenderCertificateForDirectConversation(): Promise<
|
|
||||||
undefined | SerializedCertificateType
|
|
||||||
> {
|
|
||||||
if (!this.isPrivate()) {
|
|
||||||
throw new Error(
|
|
||||||
'getSenderCertificateForDirectConversation should only be called for direct conversations'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const phoneNumberSharingMode = parsePhoneNumberSharingMode(
|
|
||||||
window.storage.get('phoneNumberSharingMode')
|
|
||||||
);
|
|
||||||
|
|
||||||
let certificateMode: SenderCertificateMode;
|
|
||||||
switch (phoneNumberSharingMode) {
|
|
||||||
case PhoneNumberSharingMode.Everybody:
|
|
||||||
certificateMode = SenderCertificateMode.WithE164;
|
|
||||||
break;
|
|
||||||
case PhoneNumberSharingMode.ContactsOnly: {
|
|
||||||
const isInSystemContacts = Boolean(this.get('name'));
|
|
||||||
certificateMode = isInSystemContacts
|
|
||||||
? SenderCertificateMode.WithE164
|
|
||||||
: SenderCertificateMode.WithoutE164;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case PhoneNumberSharingMode.Nobody:
|
|
||||||
certificateMode = SenderCertificateMode.WithoutE164;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw missingCaseError(phoneNumberSharingMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return senderCertificateService.get(certificateMode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is this someone who is a contact, or are we sharing our profile with them?
|
// Is this someone who is a contact, or are we sharing our profile with them?
|
||||||
|
@ -4234,100 +3964,18 @@ export class ConversationModel extends window.Backbone
|
||||||
}
|
}
|
||||||
|
|
||||||
async markRead(
|
async markRead(
|
||||||
newestUnreadDate: number,
|
newestUnreadId: number,
|
||||||
providedOptions: { readAt?: number; sendReadReceipts: boolean }
|
options: { readAt?: number; sendReadReceipts: boolean } = {
|
||||||
|
sendReadReceipts: true,
|
||||||
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const options = providedOptions || {};
|
const unreadCount = await markConversationRead(
|
||||||
window._.defaults(options, { sendReadReceipts: true });
|
this.attributes,
|
||||||
|
newestUnreadId,
|
||||||
const conversationId = this.id;
|
options
|
||||||
window.Whisper.Notifications.removeBy({ conversationId });
|
|
||||||
|
|
||||||
let unreadMessages:
|
|
||||||
| MessageModelCollectionType
|
|
||||||
| Array<MessageModel> = await this.getUnread();
|
|
||||||
const oldUnread = unreadMessages.filter(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
message => message.get('received_at')! <= newestUnreadDate
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let read = await Promise.all(
|
|
||||||
window._.map(oldUnread, async providedM => {
|
|
||||||
const m = window.MessageController.register(providedM.id, providedM);
|
|
||||||
|
|
||||||
// Note that this will update the message in the database
|
|
||||||
await m.markRead(options.readAt);
|
|
||||||
|
|
||||||
return {
|
|
||||||
senderE164: m.get('source'),
|
|
||||||
senderUuid: m.get('sourceUuid'),
|
|
||||||
senderId: window.ConversationController.ensureContactIds({
|
|
||||||
e164: m.get('source'),
|
|
||||||
uuid: m.get('sourceUuid'),
|
|
||||||
}),
|
|
||||||
timestamp: m.get('sent_at'),
|
|
||||||
hasErrors: m.hasErrors(),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Some messages we're marking read are local notifications with no sender
|
|
||||||
read = window._.filter(read, m => Boolean(m.senderId));
|
|
||||||
unreadMessages = unreadMessages.filter(m => Boolean(m.isIncoming()));
|
|
||||||
|
|
||||||
const unreadCount = unreadMessages.length - read.length;
|
|
||||||
this.set({ unreadCount });
|
this.set({ unreadCount });
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
|
|
||||||
// If a message has errors, we don't want to send anything out about it.
|
|
||||||
// read syncs - let's wait for a client that really understands the message
|
|
||||||
// to mark it read. we'll mark our local error read locally, though.
|
|
||||||
// read receipts - here we can run into infinite loops, where each time the
|
|
||||||
// conversation is viewed, another error message shows up for the contact
|
|
||||||
read = read.filter(item => !item.hasErrors);
|
|
||||||
|
|
||||||
if (read.length && options.sendReadReceipts) {
|
|
||||||
window.log.info(`Sending ${read.length} read syncs`);
|
|
||||||
// Because syncReadMessages sends to our other devices, and sendReadReceipts goes
|
|
||||||
// to a contact, we need accessKeys for both.
|
|
||||||
const {
|
|
||||||
sendOptions,
|
|
||||||
} = await window.ConversationController.prepareForSend(
|
|
||||||
window.ConversationController.getOurConversationId(),
|
|
||||||
{ syncMessage: true }
|
|
||||||
);
|
|
||||||
await this.wrapSend(
|
|
||||||
window.textsecure.messaging.syncReadMessages(read, sendOptions)
|
|
||||||
);
|
|
||||||
await this.sendReadReceiptsFor(read);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendReadReceiptsFor(items: Array<unknown>): Promise<void> {
|
|
||||||
// Only send read receipts for accepted conversations
|
|
||||||
if (window.storage.get('read-receipt-setting') && this.getAccepted()) {
|
|
||||||
window.log.info(`Sending ${items.length} read receipts`);
|
|
||||||
const convoSendOptions = await this.getSendOptions();
|
|
||||||
const receiptsBySender = window._.groupBy(items, 'senderId');
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
window._.map(receiptsBySender, async (receipts, senderId) => {
|
|
||||||
const timestamps = window._.map(receipts, 'timestamp');
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const c = window.ConversationController.get(senderId)!;
|
|
||||||
await this.wrapSend(
|
|
||||||
window.textsecure.messaging.sendReadReceipts(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
c.get('e164')!,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
c.get('uuid')!,
|
|
||||||
timestamps,
|
|
||||||
convoSendOptions
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is an expensive operation we use to populate the message request hero row. It
|
// This is an expensive operation we use to populate the message request hero row. It
|
||||||
|
@ -4443,8 +4091,7 @@ export class ConversationModel extends window.Backbone
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendMetadata =
|
const { sendMetadata = {} } = await c.getSendOptions();
|
||||||
(await c.getSendMetadata({ disableMeCheck: true })) || {};
|
|
||||||
const getInfo =
|
const getInfo =
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
sendMetadata[c.get('uuid')!] || sendMetadata[c.get('e164')!] || {};
|
sendMetadata[c.get('uuid')!] || sendMetadata[c.get('e164')!] || {};
|
||||||
|
|
|
@ -28,6 +28,7 @@ import { missingCaseError } from '../util/missingCaseError';
|
||||||
import { ColorType } from '../types/Colors';
|
import { ColorType } from '../types/Colors';
|
||||||
import { CallMode } from '../types/Calling';
|
import { CallMode } from '../types/Calling';
|
||||||
import { BodyRangesType } from '../types/Util';
|
import { BodyRangesType } from '../types/Util';
|
||||||
|
import { ReactionType } from '../types/Reactions';
|
||||||
import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change';
|
import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change';
|
||||||
import {
|
import {
|
||||||
PropsData as TimerNotificationProps,
|
PropsData as TimerNotificationProps,
|
||||||
|
@ -50,6 +51,7 @@ import { AttachmentType, isImage, isVideo } from '../types/Attachment';
|
||||||
import { MIMEType } from '../types/MIME';
|
import { MIMEType } from '../types/MIME';
|
||||||
import { LinkPreviewType } from '../types/message/LinkPreviews';
|
import { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
import { ourProfileKeyService } from '../services/ourProfileKey';
|
import { ourProfileKeyService } from '../services/ourProfileKey';
|
||||||
|
import { markRead, setToExpire } from '../services/MessageUpdater';
|
||||||
|
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
@ -1755,7 +1757,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.get('unread')) {
|
if (this.get('unread')) {
|
||||||
await this.markRead();
|
this.set(markRead(this.attributes));
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.eraseContents();
|
await this.eraseContents();
|
||||||
|
@ -2030,33 +2032,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async markRead(
|
markRead(readAt?: number, options = {}): void {
|
||||||
readAt?: number,
|
this.set(markRead(this.attributes, readAt, options));
|
||||||
options: { skipSave?: boolean } = {}
|
|
||||||
): Promise<void> {
|
|
||||||
const { skipSave } = options;
|
|
||||||
|
|
||||||
this.unset('unread');
|
|
||||||
|
|
||||||
if (this.get('expireTimer') && !this.get('expirationStartTimestamp')) {
|
|
||||||
const expirationStartTimestamp = Math.min(
|
|
||||||
Date.now(),
|
|
||||||
readAt || Date.now()
|
|
||||||
);
|
|
||||||
this.set({ expirationStartTimestamp });
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Whisper.Notifications.removeBy({ messageId: this.id });
|
|
||||||
|
|
||||||
if (!skipSave) {
|
|
||||||
window.Signal.Util.queueUpdateMessage(this.attributes);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isExpiring(): number | null {
|
isExpiring(): number | null {
|
||||||
return this.get('expireTimer') && this.get('expirationStartTimestamp');
|
return this.get('expireTimer') && this.get('expirationStartTimestamp');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setToExpire(force = false, options = {}): void {
|
||||||
|
this.set(setToExpire(this.attributes, { ...options, force }));
|
||||||
|
}
|
||||||
|
|
||||||
isExpired(): boolean {
|
isExpired(): boolean {
|
||||||
return this.msTilExpire() <= 0;
|
return this.msTilExpire() <= 0;
|
||||||
}
|
}
|
||||||
|
@ -2076,33 +2063,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return msFromNow;
|
return msFromNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setToExpire(
|
|
||||||
force = false,
|
|
||||||
options: { skipSave?: boolean } = {}
|
|
||||||
): Promise<void> {
|
|
||||||
const { skipSave } = options || {};
|
|
||||||
|
|
||||||
if (this.isExpiring() && (force || !this.get('expires_at'))) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const start = this.get('expirationStartTimestamp')!;
|
|
||||||
const delta = this.get('expireTimer') * 1000;
|
|
||||||
const expiresAt = start + delta;
|
|
||||||
|
|
||||||
this.set({ expires_at: expiresAt });
|
|
||||||
|
|
||||||
window.log.info('Set message expiration', {
|
|
||||||
start,
|
|
||||||
expiresAt,
|
|
||||||
sentAt: this.get('sent_at'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const id = this.get('id');
|
|
||||||
if (id && !skipSave) {
|
|
||||||
window.Signal.Util.queueUpdateMessage(this.attributes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getIncomingContact(): ConversationModel | undefined | null {
|
getIncomingContact(): ConversationModel | undefined | null {
|
||||||
if (!this.isIncoming()) {
|
if (!this.isIncoming()) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -4110,7 +4070,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
this.get('conversationId')
|
this.get('conversationId')
|
||||||
);
|
);
|
||||||
|
|
||||||
let staleReactionFromId: string | undefined;
|
let reactionToRemove: Partial<ReactionType> | undefined;
|
||||||
|
|
||||||
if (reaction.get('remove')) {
|
if (reaction.get('remove')) {
|
||||||
window.log.info('Removing reaction for message', messageId);
|
window.log.info('Removing reaction for message', messageId);
|
||||||
|
@ -4121,7 +4081,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
);
|
);
|
||||||
this.set({ reactions: newReactions });
|
this.set({ reactions: newReactions });
|
||||||
|
|
||||||
staleReactionFromId = reaction.get('fromId');
|
reactionToRemove = {
|
||||||
|
emoji: reaction.get('emoji'),
|
||||||
|
targetAuthorUuid: reaction.get('targetAuthorUuid'),
|
||||||
|
targetTimestamp: reaction.get('targetTimestamp'),
|
||||||
|
};
|
||||||
|
|
||||||
|
await window.Signal.Data.removeReactionFromConversation({
|
||||||
|
emoji: reaction.get('emoji'),
|
||||||
|
fromId: reaction.get('fromId'),
|
||||||
|
targetAuthorUuid: reaction.get('targetAuthorUuid'),
|
||||||
|
targetTimestamp: reaction.get('targetTimestamp'),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
window.log.info('Adding reaction for message', messageId);
|
window.log.info('Adding reaction for message', messageId);
|
||||||
const newReactions = reactions.filter(
|
const newReactions = reactions.filter(
|
||||||
|
@ -4134,17 +4105,30 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
re => re.fromId === reaction.get('fromId')
|
re => re.fromId === reaction.get('fromId')
|
||||||
);
|
);
|
||||||
if (oldReaction) {
|
if (oldReaction) {
|
||||||
staleReactionFromId = oldReaction.fromId;
|
reactionToRemove = {
|
||||||
|
emoji: oldReaction.emoji,
|
||||||
|
targetAuthorUuid: oldReaction.targetAuthorUuid,
|
||||||
|
targetTimestamp: oldReaction.targetTimestamp,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await window.Signal.Data.addReaction({
|
||||||
|
conversationId: this.get('conversationId'),
|
||||||
|
emoji: reaction.get('emoji'),
|
||||||
|
fromId: reaction.get('fromId'),
|
||||||
|
messageReceivedAt: this.get('received_at'),
|
||||||
|
targetAuthorUuid: reaction.get('targetAuthorUuid'),
|
||||||
|
targetTimestamp: reaction.get('targetTimestamp'),
|
||||||
|
});
|
||||||
|
|
||||||
// Only notify for reactions to our own messages
|
// Only notify for reactions to our own messages
|
||||||
if (conversation && this.isOutgoing() && !reaction.get('fromSync')) {
|
if (conversation && this.isOutgoing() && !reaction.get('fromSync')) {
|
||||||
conversation.notify(this, reaction);
|
conversation.notify(this, reaction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (staleReactionFromId) {
|
if (reactionToRemove) {
|
||||||
this.clearNotifications(reaction.get('fromId'));
|
this.clearNotifications(reactionToRemove);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newCount = (this.get('reactions') || []).length;
|
const newCount = (this.get('reactions') || []).length;
|
||||||
|
@ -4184,10 +4168,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
this.getConversation()!.updateLastMessage();
|
this.getConversation()!.updateLastMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearNotifications(reactionFromId?: string): void {
|
clearNotifications(reaction: Partial<ReactionType> = {}): void {
|
||||||
window.Whisper.Notifications.removeBy({
|
window.Whisper.Notifications.removeBy({
|
||||||
|
...reaction,
|
||||||
messageId: this.id,
|
messageId: this.id,
|
||||||
reactionFromId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
87
ts/services/MessageUpdater.ts
Normal file
87
ts/services/MessageUpdater.ts
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { MessageAttributesType } from '../model-types.d';
|
||||||
|
|
||||||
|
export function markRead(
|
||||||
|
messageAttrs: MessageAttributesType,
|
||||||
|
readAt?: number,
|
||||||
|
{ skipSave = false } = {}
|
||||||
|
): MessageAttributesType {
|
||||||
|
const nextMessageAttributes = {
|
||||||
|
...messageAttrs,
|
||||||
|
unread: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { id: messageId, expireTimer, expirationStartTimestamp } = messageAttrs;
|
||||||
|
|
||||||
|
if (expireTimer && !expirationStartTimestamp) {
|
||||||
|
nextMessageAttributes.expirationStartTimestamp = Math.min(
|
||||||
|
Date.now(),
|
||||||
|
readAt || Date.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Whisper.Notifications.removeBy({ messageId });
|
||||||
|
|
||||||
|
if (!skipSave) {
|
||||||
|
window.Signal.Util.queueUpdateMessage(nextMessageAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextMessageAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpiresAt(
|
||||||
|
messageAttrs: Pick<
|
||||||
|
MessageAttributesType,
|
||||||
|
'expireTimer' | 'expirationStartTimestamp'
|
||||||
|
>
|
||||||
|
): number | undefined {
|
||||||
|
const expireTimerMs = messageAttrs.expireTimer * 1000;
|
||||||
|
return messageAttrs.expirationStartTimestamp
|
||||||
|
? messageAttrs.expirationStartTimestamp + expireTimerMs
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToExpire(
|
||||||
|
messageAttrs: MessageAttributesType,
|
||||||
|
{ force = false, skipSave = false } = {}
|
||||||
|
): MessageAttributesType {
|
||||||
|
if (!isExpiring(messageAttrs) || (!force && messageAttrs.expires_at)) {
|
||||||
|
return messageAttrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = getExpiresAt(messageAttrs);
|
||||||
|
|
||||||
|
if (!expiresAt) {
|
||||||
|
return messageAttrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMessageAttributes = {
|
||||||
|
...messageAttrs,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.log.info('Set message expiration', {
|
||||||
|
start: messageAttrs.expirationStartTimestamp,
|
||||||
|
expiresAt,
|
||||||
|
sentAt: messageAttrs.sent_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (messageAttrs.id && !skipSave) {
|
||||||
|
window.Signal.Util.queueUpdateMessage(nextMessageAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextMessageAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpiring(
|
||||||
|
messageAttrs: Pick<
|
||||||
|
MessageAttributesType,
|
||||||
|
'expireTimer' | 'expirationStartTimestamp'
|
||||||
|
>
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
messageAttrs.expireTimer && messageAttrs.expirationStartTimestamp
|
||||||
|
);
|
||||||
|
}
|
|
@ -28,6 +28,7 @@ import { CURRENT_SCHEMA_VERSION } from '../../js/modules/types/message';
|
||||||
import { createBatcher } from '../util/batcher';
|
import { createBatcher } from '../util/batcher';
|
||||||
import { assert } from '../util/assert';
|
import { assert } from '../util/assert';
|
||||||
import { cleanDataForIpc } from './cleanDataForIpc';
|
import { cleanDataForIpc } from './cleanDataForIpc';
|
||||||
|
import { ReactionType } from '../types/Reactions';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ConversationModelCollectionType,
|
ConversationModelCollectionType,
|
||||||
|
@ -166,7 +167,11 @@ const dataInterface: ClientInterface = {
|
||||||
saveMessages,
|
saveMessages,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
removeMessages,
|
removeMessages,
|
||||||
getUnreadByConversation,
|
getUnreadByConversationAndMarkRead,
|
||||||
|
getUnreadReactionsAndMarkRead,
|
||||||
|
markReactionAsRead,
|
||||||
|
removeReactionFromConversation,
|
||||||
|
addReaction,
|
||||||
|
|
||||||
getMessageBySender,
|
getMessageBySender,
|
||||||
getMessageById,
|
getMessageById,
|
||||||
|
@ -1041,15 +1046,43 @@ async function getMessageBySender(
|
||||||
return new Message(messages[0]);
|
return new Message(messages[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUnreadByConversation(
|
async function getUnreadByConversationAndMarkRead(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
{
|
newestUnreadId: number,
|
||||||
MessageCollection,
|
readAt?: number
|
||||||
}: { MessageCollection: typeof MessageModelCollectionType }
|
|
||||||
) {
|
) {
|
||||||
const messages = await channels.getUnreadByConversation(conversationId);
|
return channels.getUnreadByConversationAndMarkRead(
|
||||||
|
conversationId,
|
||||||
|
newestUnreadId,
|
||||||
|
readAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new MessageCollection(messages);
|
async function getUnreadReactionsAndMarkRead(
|
||||||
|
conversationId: string,
|
||||||
|
newestUnreadId: number
|
||||||
|
) {
|
||||||
|
return channels.getUnreadReactionsAndMarkRead(conversationId, newestUnreadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markReactionAsRead(
|
||||||
|
targetAuthorUuid: string,
|
||||||
|
targetTimestamp: number
|
||||||
|
) {
|
||||||
|
return channels.markReactionAsRead(targetAuthorUuid, targetTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeReactionFromConversation(reaction: {
|
||||||
|
emoji: string;
|
||||||
|
fromId: string;
|
||||||
|
targetAuthorUuid: string;
|
||||||
|
targetTimestamp: number;
|
||||||
|
}) {
|
||||||
|
return channels.removeReactionFromConversation(reaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addReaction(reactionObj: ReactionType) {
|
||||||
|
return channels.addReaction(reactionObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessageJSON(messages: Array<MessageTypeUnhydrated>) {
|
function handleMessageJSON(messages: Array<MessageTypeUnhydrated>) {
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
/* eslint-disable @typescript-eslint/ban-types */
|
/* eslint-disable @typescript-eslint/ban-types */
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ConversationAttributesType,
|
ConversationAttributesType,
|
||||||
ConversationModelCollectionType,
|
ConversationModelCollectionType,
|
||||||
|
@ -14,6 +13,7 @@ import {
|
||||||
import { MessageModel } from '../models/messages';
|
import { MessageModel } from '../models/messages';
|
||||||
import { ConversationModel } from '../models/conversations';
|
import { ConversationModel } from '../models/conversations';
|
||||||
import { StoredJob } from '../jobs/types';
|
import { StoredJob } from '../jobs/types';
|
||||||
|
import { ReactionType } from '../types/Reactions';
|
||||||
|
|
||||||
export type AttachmentDownloadJobType = {
|
export type AttachmentDownloadJobType = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -343,9 +343,32 @@ export type ServerInterface = DataInterface & {
|
||||||
getNextTapToViewMessageToAgeOut: () => Promise<MessageType | undefined>;
|
getNextTapToViewMessageToAgeOut: () => Promise<MessageType | undefined>;
|
||||||
getOutgoingWithoutExpiresAt: () => Promise<Array<MessageType>>;
|
getOutgoingWithoutExpiresAt: () => Promise<Array<MessageType>>;
|
||||||
getTapToViewMessagesNeedingErase: () => Promise<Array<MessageType>>;
|
getTapToViewMessagesNeedingErase: () => Promise<Array<MessageType>>;
|
||||||
getUnreadByConversation: (
|
getUnreadByConversationAndMarkRead: (
|
||||||
conversationId: string
|
conversationId: string,
|
||||||
) => Promise<Array<MessageType>>;
|
newestUnreadId: number,
|
||||||
|
readAt?: number
|
||||||
|
) => Promise<
|
||||||
|
Array<
|
||||||
|
Pick<MessageType, 'id' | 'source' | 'sourceUuid' | 'sent_at' | 'type'>
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
getUnreadReactionsAndMarkRead: (
|
||||||
|
conversationId: string,
|
||||||
|
newestUnreadId: number
|
||||||
|
) => Promise<
|
||||||
|
Array<Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp'>>
|
||||||
|
>;
|
||||||
|
markReactionAsRead: (
|
||||||
|
targetAuthorUuid: string,
|
||||||
|
targetTimestamp: number
|
||||||
|
) => Promise<ReactionType | undefined>;
|
||||||
|
removeReactionFromConversation: (reaction: {
|
||||||
|
emoji: string;
|
||||||
|
fromId: string;
|
||||||
|
targetAuthorUuid: string;
|
||||||
|
targetTimestamp: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
addReaction: (reactionObj: ReactionType) => Promise<void>;
|
||||||
removeConversation: (id: Array<string> | string) => Promise<void>;
|
removeConversation: (id: Array<string> | string) => Promise<void>;
|
||||||
removeMessage: (id: string) => Promise<void>;
|
removeMessage: (id: string) => Promise<void>;
|
||||||
removeMessages: (ids: Array<string>) => Promise<void>;
|
removeMessages: (ids: Array<string>) => Promise<void>;
|
||||||
|
@ -463,10 +486,32 @@ export type ClientInterface = DataInterface & {
|
||||||
getTapToViewMessagesNeedingErase: (options: {
|
getTapToViewMessagesNeedingErase: (options: {
|
||||||
MessageCollection: typeof MessageModelCollectionType;
|
MessageCollection: typeof MessageModelCollectionType;
|
||||||
}) => Promise<MessageModelCollectionType>;
|
}) => Promise<MessageModelCollectionType>;
|
||||||
getUnreadByConversation: (
|
getUnreadByConversationAndMarkRead: (
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
options: { MessageCollection: typeof MessageModelCollectionType }
|
newestUnreadId: number,
|
||||||
) => Promise<MessageModelCollectionType>;
|
readAt?: number
|
||||||
|
) => Promise<
|
||||||
|
Array<
|
||||||
|
Pick<MessageType, 'id' | 'source' | 'sourceUuid' | 'sent_at' | 'type'>
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
getUnreadReactionsAndMarkRead: (
|
||||||
|
conversationId: string,
|
||||||
|
newestUnreadId: number
|
||||||
|
) => Promise<
|
||||||
|
Array<Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp'>>
|
||||||
|
>;
|
||||||
|
markReactionAsRead: (
|
||||||
|
targetAuthorUuid: string,
|
||||||
|
targetTimestamp: number
|
||||||
|
) => Promise<ReactionType | undefined>;
|
||||||
|
removeReactionFromConversation: (reaction: {
|
||||||
|
emoji: string;
|
||||||
|
fromId: string;
|
||||||
|
targetAuthorUuid: string;
|
||||||
|
targetTimestamp: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
addReaction: (reactionObj: ReactionType) => Promise<void>;
|
||||||
removeConversation: (
|
removeConversation: (
|
||||||
id: string,
|
id: string,
|
||||||
options: { Conversation: typeof ConversationModel }
|
options: { Conversation: typeof ConversationModel }
|
||||||
|
|
356
ts/sql/Server.ts
356
ts/sql/Server.ts
|
@ -28,13 +28,14 @@ import {
|
||||||
omit,
|
omit,
|
||||||
} from 'lodash';
|
} from 'lodash';
|
||||||
|
|
||||||
import { assert } from '../util/assert';
|
|
||||||
import { isNormalNumber } from '../util/isNormalNumber';
|
|
||||||
import { combineNames } from '../util/combineNames';
|
|
||||||
import { isNotNil } from '../util/isNotNil';
|
|
||||||
|
|
||||||
import { GroupV2MemberType } from '../model-types.d';
|
import { GroupV2MemberType } from '../model-types.d';
|
||||||
|
import { ReactionType } from '../types/Reactions';
|
||||||
import { StoredJob } from '../jobs/types';
|
import { StoredJob } from '../jobs/types';
|
||||||
|
import { assert } from '../util/assert';
|
||||||
|
import { combineNames } from '../util/combineNames';
|
||||||
|
import { getExpiresAt } from '../services/MessageUpdater';
|
||||||
|
import { isNormalNumber } from '../util/isNormalNumber';
|
||||||
|
import { isNotNil } from '../util/isNotNil';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AttachmentDownloadJobType,
|
AttachmentDownloadJobType,
|
||||||
|
@ -156,7 +157,11 @@ const dataInterface: ServerInterface = {
|
||||||
saveMessages,
|
saveMessages,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
removeMessages,
|
removeMessages,
|
||||||
getUnreadByConversation,
|
getUnreadByConversationAndMarkRead,
|
||||||
|
getUnreadReactionsAndMarkRead,
|
||||||
|
markReactionAsRead,
|
||||||
|
addReaction,
|
||||||
|
removeReactionFromConversation,
|
||||||
getMessageBySender,
|
getMessageBySender,
|
||||||
getMessageById,
|
getMessageById,
|
||||||
_getAllMessages,
|
_getAllMessages,
|
||||||
|
@ -1714,6 +1719,39 @@ function updateToSchemaVersion28(currentVersion: number, db: Database) {
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateToSchemaVersion29(currentVersion: number, db: Database) {
|
||||||
|
if (currentVersion >= 29) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE reactions(
|
||||||
|
conversationId STRING,
|
||||||
|
emoji STRING,
|
||||||
|
fromId STRING,
|
||||||
|
messageReceivedAt INTEGER,
|
||||||
|
targetAuthorUuid STRING,
|
||||||
|
targetTimestamp INTEGER,
|
||||||
|
unread INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX reactions_unread ON reactions (
|
||||||
|
unread,
|
||||||
|
conversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX reaction_identifier ON reactions (
|
||||||
|
emoji,
|
||||||
|
targetAuthorUuid,
|
||||||
|
targetTimestamp
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.pragma('user_version = 29');
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
const SCHEMA_VERSIONS = [
|
const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion1,
|
updateToSchemaVersion1,
|
||||||
updateToSchemaVersion2,
|
updateToSchemaVersion2,
|
||||||
|
@ -1743,6 +1781,7 @@ const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion26,
|
updateToSchemaVersion26,
|
||||||
updateToSchemaVersion27,
|
updateToSchemaVersion27,
|
||||||
updateToSchemaVersion28,
|
updateToSchemaVersion28,
|
||||||
|
updateToSchemaVersion29,
|
||||||
];
|
];
|
||||||
|
|
||||||
function updateSchema(db: Database): void {
|
function updateSchema(db: Database): void {
|
||||||
|
@ -2961,25 +3000,298 @@ async function getMessageBySender({
|
||||||
return rows.map(row => jsonToObject(row.json));
|
return rows.map(row => jsonToObject(row.json));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUnreadByConversation(
|
function getExpireData(
|
||||||
conversationId: string
|
messageExpireTimer: number,
|
||||||
): Promise<Array<MessageType>> {
|
readAt?: number
|
||||||
|
): {
|
||||||
|
expirationStartTimestamp: number;
|
||||||
|
expiresAt: number;
|
||||||
|
} {
|
||||||
|
const expirationStartTimestamp = Math.min(Date.now(), readAt || Date.now());
|
||||||
|
const expiresAt = getExpiresAt({
|
||||||
|
expireTimer: messageExpireTimer,
|
||||||
|
expirationStartTimestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// We are guaranteeing an expirationStartTimestamp above so this should
|
||||||
|
// definitely return a number.
|
||||||
|
if (!expiresAt || typeof expiresAt !== 'number') {
|
||||||
|
assert(false, 'Expected expiresAt to be a number');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expirationStartTimestamp,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExpirationTimers(
|
||||||
|
messageExpireTimer: number,
|
||||||
|
messagesWithExpireTimer: Set<string>,
|
||||||
|
readAt?: number
|
||||||
|
) {
|
||||||
|
const { expirationStartTimestamp, expiresAt } = getExpireData(
|
||||||
|
messageExpireTimer,
|
||||||
|
readAt
|
||||||
|
);
|
||||||
|
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
const rows: JSONRows = db
|
const stmt = db.prepare<Query>(
|
||||||
.prepare<Query>(
|
`
|
||||||
`
|
UPDATE messages
|
||||||
SELECT json FROM messages WHERE
|
SET
|
||||||
unread = $unread AND
|
unread = 0,
|
||||||
conversationId = $conversationId
|
expires_at = $expiresAt,
|
||||||
ORDER BY received_at DESC, sent_at DESC;
|
expirationStartTimestamp = $expirationStartTimestamp,
|
||||||
`
|
json = json_patch(json, $jsonPatch)
|
||||||
)
|
WHERE
|
||||||
.all({
|
id = $id
|
||||||
unread: 1,
|
`
|
||||||
conversationId,
|
);
|
||||||
|
messagesWithExpireTimer.forEach(id => {
|
||||||
|
stmt.run({
|
||||||
|
id,
|
||||||
|
expirationStartTimestamp,
|
||||||
|
expiresAt,
|
||||||
|
jsonPatch: JSON.stringify({
|
||||||
|
expirationStartTimestamp,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
unread: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUnreadByConversationAndMarkRead(
|
||||||
|
conversationId: string,
|
||||||
|
newestUnreadId: number,
|
||||||
|
readAt?: number
|
||||||
|
): Promise<
|
||||||
|
Array<Pick<MessageType, 'id' | 'source' | 'sourceUuid' | 'sent_at' | 'type'>>
|
||||||
|
> {
|
||||||
|
const db = getInstance();
|
||||||
|
return db.transaction(() => {
|
||||||
|
const rows = db
|
||||||
|
.prepare<Query>(
|
||||||
|
`
|
||||||
|
SELECT id, expireTimer, expirationStartTimestamp, json
|
||||||
|
FROM messages WHERE
|
||||||
|
unread = $unread AND
|
||||||
|
conversationId = $conversationId AND
|
||||||
|
received_at <= $newestUnreadId
|
||||||
|
ORDER BY received_at DESC, sent_at DESC;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all({
|
||||||
|
unread: 1,
|
||||||
|
conversationId,
|
||||||
|
newestUnreadId,
|
||||||
|
});
|
||||||
|
|
||||||
|
let messageExpireTimer: number | undefined;
|
||||||
|
const messagesWithExpireTimer: Set<string> = new Set();
|
||||||
|
const messagesToMarkRead: Array<string> = [];
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.expireTimer && !row.expirationStartTimestamp) {
|
||||||
|
messageExpireTimer = row.expireTimer;
|
||||||
|
messagesWithExpireTimer.add(row.id);
|
||||||
|
}
|
||||||
|
messagesToMarkRead.push(row.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
return rows.map(row => jsonToObject(row.json));
|
if (messagesToMarkRead.length) {
|
||||||
|
const stmt = db.prepare<Query>(
|
||||||
|
`
|
||||||
|
UPDATE messages
|
||||||
|
SET
|
||||||
|
unread = 0,
|
||||||
|
json = json_patch(json, $jsonPatch)
|
||||||
|
WHERE
|
||||||
|
id = $id;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
messagesToMarkRead.forEach(id =>
|
||||||
|
stmt.run({
|
||||||
|
id,
|
||||||
|
jsonPatch: JSON.stringify({ unread: 0 }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageExpireTimer && messagesWithExpireTimer.size) {
|
||||||
|
// We use the messageExpireTimer set above from whichever row we have
|
||||||
|
// in the database. Since this is the same conversation the expireTimer
|
||||||
|
// should be the same for all messages within it.
|
||||||
|
updateExpirationTimers(
|
||||||
|
messageExpireTimer,
|
||||||
|
messagesWithExpireTimer,
|
||||||
|
readAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map(row => {
|
||||||
|
const json = jsonToObject(row.json);
|
||||||
|
const expireAttrs = {};
|
||||||
|
if (messageExpireTimer && messagesWithExpireTimer.has(row.id)) {
|
||||||
|
const { expirationStartTimestamp, expiresAt } = getExpireData(
|
||||||
|
messageExpireTimer,
|
||||||
|
readAt
|
||||||
|
);
|
||||||
|
Object.assign(expireAttrs, {
|
||||||
|
expirationStartTimestamp,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
unread: false,
|
||||||
|
...pick(json, ['id', 'sent_at', 'source', 'sourceUuid', 'type']),
|
||||||
|
...expireAttrs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUnreadReactionsAndMarkRead(
|
||||||
|
conversationId: string,
|
||||||
|
newestUnreadId: number
|
||||||
|
): Promise<Array<Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp'>>> {
|
||||||
|
const db = getInstance();
|
||||||
|
return db.transaction(() => {
|
||||||
|
const unreadMessages = db
|
||||||
|
.prepare<Query>(
|
||||||
|
`
|
||||||
|
SELECT targetAuthorUuid, targetTimestamp
|
||||||
|
FROM reactions WHERE
|
||||||
|
unread = 1 AND
|
||||||
|
conversationId = $conversationId AND
|
||||||
|
messageReceivedAt <= $newestUnreadId;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all({
|
||||||
|
conversationId,
|
||||||
|
newestUnreadId,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
UPDATE reactions SET
|
||||||
|
unread = 0 WHERE
|
||||||
|
$conversationId = conversationId AND
|
||||||
|
$messageReceivedAt <= messageReceivedAt;
|
||||||
|
`);
|
||||||
|
|
||||||
|
return unreadMessages;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markReactionAsRead(
|
||||||
|
targetAuthorUuid: string,
|
||||||
|
targetTimestamp: number
|
||||||
|
): Promise<ReactionType | undefined> {
|
||||||
|
const db = getInstance();
|
||||||
|
return db.transaction(() => {
|
||||||
|
const readReaction = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM reactions
|
||||||
|
WHERE
|
||||||
|
targetAuthorUuid = $targetAuthorUuid AND
|
||||||
|
targetTimestamp = $targetTimestamp AND
|
||||||
|
unread = 1
|
||||||
|
ORDER BY rowId DESC
|
||||||
|
LIMIT 1;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.get({
|
||||||
|
targetAuthorUuid,
|
||||||
|
targetTimestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE reactions SET
|
||||||
|
unread = 0 WHERE
|
||||||
|
$targetAuthorUuid = targetAuthorUuid AND
|
||||||
|
$targetTimestamp = targetTimestamp;
|
||||||
|
`
|
||||||
|
).run({
|
||||||
|
targetAuthorUuid,
|
||||||
|
targetTimestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return readReaction;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addReaction({
|
||||||
|
conversationId,
|
||||||
|
emoji,
|
||||||
|
fromId,
|
||||||
|
messageReceivedAt,
|
||||||
|
targetAuthorUuid,
|
||||||
|
targetTimestamp,
|
||||||
|
}: ReactionType): Promise<void> {
|
||||||
|
const db = getInstance();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO reactions (
|
||||||
|
conversationId,
|
||||||
|
emoji,
|
||||||
|
fromId,
|
||||||
|
messageReceivedAt,
|
||||||
|
targetAuthorUuid,
|
||||||
|
targetTimestamp,
|
||||||
|
unread
|
||||||
|
) VALUES (
|
||||||
|
$conversationId,
|
||||||
|
$emoji,
|
||||||
|
$fromId,
|
||||||
|
$messageReceivedAt,
|
||||||
|
$targetAuthorUuid,
|
||||||
|
$targetTimestamp,
|
||||||
|
$unread
|
||||||
|
);`
|
||||||
|
)
|
||||||
|
.run({
|
||||||
|
conversationId,
|
||||||
|
emoji,
|
||||||
|
fromId,
|
||||||
|
messageReceivedAt,
|
||||||
|
targetAuthorUuid,
|
||||||
|
targetTimestamp,
|
||||||
|
unread: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeReactionFromConversation({
|
||||||
|
emoji,
|
||||||
|
fromId,
|
||||||
|
targetAuthorUuid,
|
||||||
|
targetTimestamp,
|
||||||
|
}: {
|
||||||
|
emoji: string;
|
||||||
|
fromId: string;
|
||||||
|
targetAuthorUuid: string;
|
||||||
|
targetTimestamp: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const db = getInstance();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM reactions WHERE
|
||||||
|
emoji = $emoji AND
|
||||||
|
fromId = $fromId AND
|
||||||
|
targetAuthorUuid = $targetAuthorUuid AND
|
||||||
|
targetTimestamp = $targetTimestamp;`
|
||||||
|
)
|
||||||
|
.run({
|
||||||
|
emoji,
|
||||||
|
fromId,
|
||||||
|
targetAuthorUuid,
|
||||||
|
targetTimestamp,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOlderMessagesByConversation(
|
async function getOlderMessagesByConversation(
|
||||||
|
|
11
ts/types/Reactions.ts
Normal file
11
ts/types/Reactions.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export type ReactionType = Readonly<{
|
||||||
|
conversationId: string;
|
||||||
|
emoji: string;
|
||||||
|
fromId: string;
|
||||||
|
messageReceivedAt: number;
|
||||||
|
targetAuthorUuid: string;
|
||||||
|
targetTimestamp: number;
|
||||||
|
}>;
|
57
ts/util/getConversationMembers.ts
Normal file
57
ts/util/getConversationMembers.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { compact } from 'lodash';
|
||||||
|
import { ConversationAttributesType } from '../model-types.d';
|
||||||
|
import { isDirectConversation } from './whatTypeOfConversation';
|
||||||
|
|
||||||
|
export function getConversationMembers(
|
||||||
|
conversationAttrs: ConversationAttributesType,
|
||||||
|
options: { includePendingMembers?: boolean } = {}
|
||||||
|
): Array<ConversationAttributesType> {
|
||||||
|
if (isDirectConversation(conversationAttrs)) {
|
||||||
|
return [conversationAttrs];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversationAttrs.membersV2) {
|
||||||
|
const { includePendingMembers } = options;
|
||||||
|
const members: Array<{ conversationId: string }> = includePendingMembers
|
||||||
|
? [
|
||||||
|
...(conversationAttrs.membersV2 || []),
|
||||||
|
...(conversationAttrs.pendingMembersV2 || []),
|
||||||
|
]
|
||||||
|
: conversationAttrs.membersV2 || [];
|
||||||
|
|
||||||
|
return compact(
|
||||||
|
members.map(member => {
|
||||||
|
const conversation = window.ConversationController.get(
|
||||||
|
member.conversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
// In groups we won't sent to contacts we believe are unregistered
|
||||||
|
if (conversation && conversation.isUnregistered()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversation?.attributes;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversationAttrs.members) {
|
||||||
|
return compact(
|
||||||
|
conversationAttrs.members.map(id => {
|
||||||
|
const conversation = window.ConversationController.get(id);
|
||||||
|
|
||||||
|
// In groups we won't send to contacts we believe are unregistered
|
||||||
|
if (conversation && conversation.isUnregistered()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversation?.attributes;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
135
ts/util/getSendOptions.ts
Normal file
135
ts/util/getSendOptions.ts
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { ConversationAttributesType } from '../model-types.d';
|
||||||
|
import { SendMetadataType, SendOptionsType } from '../textsecure/SendMessage';
|
||||||
|
import { arrayBufferToBase64, getRandomBytes } from '../Crypto';
|
||||||
|
import { getConversationMembers } from './getConversationMembers';
|
||||||
|
import { isDirectConversation, isMe } from './whatTypeOfConversation';
|
||||||
|
import { missingCaseError } from './missingCaseError';
|
||||||
|
import { senderCertificateService } from '../services/senderCertificate';
|
||||||
|
import {
|
||||||
|
PhoneNumberSharingMode,
|
||||||
|
parsePhoneNumberSharingMode,
|
||||||
|
} from './phoneNumberSharingMode';
|
||||||
|
import {
|
||||||
|
SenderCertificateMode,
|
||||||
|
SerializedCertificateType,
|
||||||
|
} from '../textsecure/OutgoingMessage';
|
||||||
|
|
||||||
|
const SEALED_SENDER = {
|
||||||
|
UNKNOWN: 0,
|
||||||
|
ENABLED: 1,
|
||||||
|
DISABLED: 2,
|
||||||
|
UNRESTRICTED: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSendOptions(
|
||||||
|
conversationAttrs: ConversationAttributesType,
|
||||||
|
options: { syncMessage?: boolean } = {}
|
||||||
|
): Promise<SendOptionsType> {
|
||||||
|
const { syncMessage } = options;
|
||||||
|
|
||||||
|
if (!isDirectConversation(conversationAttrs)) {
|
||||||
|
const contactCollection = getConversationMembers(conversationAttrs);
|
||||||
|
const sendMetadata: SendMetadataType = {};
|
||||||
|
await Promise.all(
|
||||||
|
contactCollection.map(async contactAttrs => {
|
||||||
|
const conversation = window.ConversationController.get(contactAttrs.id);
|
||||||
|
if (!conversation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
sendMetadata: conversationSendMetadata,
|
||||||
|
} = await conversation.getSendOptions(options);
|
||||||
|
Object.assign(sendMetadata, conversationSendMetadata || {});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { sendMetadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessKey, sealedSender } = conversationAttrs;
|
||||||
|
|
||||||
|
// We never send sync messages as sealed sender
|
||||||
|
if (syncMessage && isMe(conversationAttrs)) {
|
||||||
|
return {
|
||||||
|
sendMetadata: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { e164, uuid } = conversationAttrs;
|
||||||
|
|
||||||
|
const senderCertificate = await getSenderCertificateForDirectConversation(
|
||||||
|
conversationAttrs
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we've never fetched user's profile, we default to what we have
|
||||||
|
if (sealedSender === SEALED_SENDER.UNKNOWN) {
|
||||||
|
const identifierData = {
|
||||||
|
accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)),
|
||||||
|
senderCertificate,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
sendMetadata: {
|
||||||
|
...(e164 ? { [e164]: identifierData } : {}),
|
||||||
|
...(uuid ? { [uuid]: identifierData } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sealedSender === SEALED_SENDER.DISABLED) {
|
||||||
|
return {
|
||||||
|
sendMetadata: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierData = {
|
||||||
|
accessKey:
|
||||||
|
accessKey && sealedSender === SEALED_SENDER.ENABLED
|
||||||
|
? accessKey
|
||||||
|
: arrayBufferToBase64(getRandomBytes(16)),
|
||||||
|
senderCertificate,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendMetadata: {
|
||||||
|
...(e164 ? { [e164]: identifierData } : {}),
|
||||||
|
...(uuid ? { [uuid]: identifierData } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSenderCertificateForDirectConversation(
|
||||||
|
conversationAttrs: ConversationAttributesType
|
||||||
|
): Promise<undefined | SerializedCertificateType> {
|
||||||
|
if (!isDirectConversation(conversationAttrs)) {
|
||||||
|
throw new Error(
|
||||||
|
'getSenderCertificateForDirectConversation should only be called for direct conversations'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneNumberSharingMode = parsePhoneNumberSharingMode(
|
||||||
|
window.storage.get('phoneNumberSharingMode')
|
||||||
|
);
|
||||||
|
|
||||||
|
let certificateMode: SenderCertificateMode;
|
||||||
|
switch (phoneNumberSharingMode) {
|
||||||
|
case PhoneNumberSharingMode.Everybody:
|
||||||
|
certificateMode = SenderCertificateMode.WithE164;
|
||||||
|
break;
|
||||||
|
case PhoneNumberSharingMode.ContactsOnly: {
|
||||||
|
const isInSystemContacts = Boolean(conversationAttrs.name);
|
||||||
|
certificateMode = isInSystemContacts
|
||||||
|
? SenderCertificateMode.WithE164
|
||||||
|
: SenderCertificateMode.WithoutE164;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PhoneNumberSharingMode.Nobody:
|
||||||
|
certificateMode = SenderCertificateMode.WithoutE164;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw missingCaseError(phoneNumberSharingMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return senderCertificateService.get(certificateMode);
|
||||||
|
}
|
86
ts/util/handleMessageSend.ts
Normal file
86
ts/util/handleMessageSend.ts
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { CallbackResultType } from '../textsecure/SendMessage';
|
||||||
|
|
||||||
|
const SEALED_SENDER = {
|
||||||
|
UNKNOWN: 0,
|
||||||
|
ENABLED: 1,
|
||||||
|
DISABLED: 2,
|
||||||
|
UNRESTRICTED: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handleMessageSend(
|
||||||
|
promise: Promise<CallbackResultType | void | null>
|
||||||
|
): Promise<CallbackResultType | void | null> {
|
||||||
|
try {
|
||||||
|
const result = await promise;
|
||||||
|
if (result) {
|
||||||
|
await handleMessageSendResult(
|
||||||
|
result.failoverIdentifiers,
|
||||||
|
result.unidentifiedDeliveries
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
if (err) {
|
||||||
|
await handleMessageSendResult(
|
||||||
|
err.failoverIdentifiers,
|
||||||
|
err.unidentifiedDeliveries
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessageSendResult(
|
||||||
|
failoverIdentifiers: Array<string> | undefined,
|
||||||
|
unidentifiedDeliveries: Array<string> | undefined
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all(
|
||||||
|
(failoverIdentifiers || []).map(async identifier => {
|
||||||
|
const conversation = window.ConversationController.get(identifier);
|
||||||
|
|
||||||
|
if (
|
||||||
|
conversation &&
|
||||||
|
conversation.get('sealedSender') !== SEALED_SENDER.DISABLED
|
||||||
|
) {
|
||||||
|
window.log.info(
|
||||||
|
`Setting sealedSender to DISABLED for conversation ${conversation.idForLogging()}`
|
||||||
|
);
|
||||||
|
conversation.set({
|
||||||
|
sealedSender: SEALED_SENDER.DISABLED,
|
||||||
|
});
|
||||||
|
window.Signal.Data.updateConversation(conversation.attributes);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
(unidentifiedDeliveries || []).map(async identifier => {
|
||||||
|
const conversation = window.ConversationController.get(identifier);
|
||||||
|
|
||||||
|
if (
|
||||||
|
conversation &&
|
||||||
|
conversation.get('sealedSender') === SEALED_SENDER.UNKNOWN
|
||||||
|
) {
|
||||||
|
if (conversation.get('accessKey')) {
|
||||||
|
window.log.info(
|
||||||
|
`Setting sealedSender to ENABLED for conversation ${conversation.idForLogging()}`
|
||||||
|
);
|
||||||
|
conversation.set({
|
||||||
|
sealedSender: SEALED_SENDER.ENABLED,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.log.info(
|
||||||
|
`Setting sealedSender to UNRESTRICTED for conversation ${conversation.idForLogging()}`
|
||||||
|
);
|
||||||
|
conversation.set({
|
||||||
|
sealedSender: SEALED_SENDER.UNRESTRICTED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.Signal.Data.updateConversation(conversation.attributes);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
84
ts/util/isConversationAccepted.ts
Normal file
84
ts/util/isConversationAccepted.ts
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { ConversationAttributesType } from '../model-types.d';
|
||||||
|
import { isDirectConversation, isMe } from './whatTypeOfConversation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if this conversation should be considered "accepted" in terms
|
||||||
|
* of message requests
|
||||||
|
*/
|
||||||
|
export function isConversationAccepted(
|
||||||
|
conversationAttrs: ConversationAttributesType
|
||||||
|
): boolean {
|
||||||
|
const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled(
|
||||||
|
'desktop.messageRequests'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!messageRequestsEnabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMe(conversationAttrs)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageRequestEnum =
|
||||||
|
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
|
||||||
|
|
||||||
|
const { messageRequestResponseType } = conversationAttrs;
|
||||||
|
if (messageRequestResponseType === messageRequestEnum.ACCEPT) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sentMessageCount } = conversationAttrs;
|
||||||
|
|
||||||
|
const hasSentMessages = sentMessageCount > 0;
|
||||||
|
const hasMessagesBeforeMessageRequests =
|
||||||
|
(conversationAttrs.messageCountBeforeMessageRequests || 0) > 0;
|
||||||
|
const hasNoMessages = (conversationAttrs.messageCount || 0) === 0;
|
||||||
|
|
||||||
|
const isEmptyPrivateConvo =
|
||||||
|
hasNoMessages && isDirectConversation(conversationAttrs);
|
||||||
|
const isEmptyWhitelistedGroup =
|
||||||
|
hasNoMessages &&
|
||||||
|
!isDirectConversation(conversationAttrs) &&
|
||||||
|
conversationAttrs.profileSharing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
isFromOrAddedByTrustedContact(conversationAttrs) ||
|
||||||
|
hasSentMessages ||
|
||||||
|
hasMessagesBeforeMessageRequests ||
|
||||||
|
// an empty group is the scenario where we need to rely on
|
||||||
|
// whether the profile has already been shared or not
|
||||||
|
isEmptyPrivateConvo ||
|
||||||
|
isEmptyWhitelistedGroup
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this someone who is a contact, or are we sharing our profile with them?
|
||||||
|
// Or is the person who added us to this group a contact or are we sharing profile
|
||||||
|
// with them?
|
||||||
|
function isFromOrAddedByTrustedContact(
|
||||||
|
conversationAttrs: ConversationAttributesType
|
||||||
|
): boolean {
|
||||||
|
if (isDirectConversation(conversationAttrs)) {
|
||||||
|
return Boolean(conversationAttrs.name || conversationAttrs.profileSharing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { addedBy } = conversationAttrs;
|
||||||
|
if (!addedBy) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = window.ConversationController.get(addedBy);
|
||||||
|
if (!conversation) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
isMe(conversation.attributes) ||
|
||||||
|
conversation.get('name') ||
|
||||||
|
conversation.get('profileSharing')
|
||||||
|
);
|
||||||
|
}
|
111
ts/util/markConversationRead.ts
Normal file
111
ts/util/markConversationRead.ts
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { ConversationAttributesType } from '../model-types.d';
|
||||||
|
import { handleMessageSend } from './handleMessageSend';
|
||||||
|
import { sendReadReceiptsFor } from './sendReadReceiptsFor';
|
||||||
|
|
||||||
|
export async function markConversationRead(
|
||||||
|
conversationAttrs: ConversationAttributesType,
|
||||||
|
newestUnreadId: number,
|
||||||
|
options: { readAt?: number; sendReadReceipts: boolean } = {
|
||||||
|
sendReadReceipts: true,
|
||||||
|
}
|
||||||
|
): Promise<number> {
|
||||||
|
const { id: conversationId } = conversationAttrs;
|
||||||
|
window.Whisper.Notifications.removeBy({ conversationId });
|
||||||
|
|
||||||
|
const [unreadMessages, unreadReactions] = await Promise.all([
|
||||||
|
window.Signal.Data.getUnreadByConversationAndMarkRead(
|
||||||
|
conversationId,
|
||||||
|
newestUnreadId,
|
||||||
|
options.readAt
|
||||||
|
),
|
||||||
|
window.Signal.Data.getUnreadReactionsAndMarkRead(
|
||||||
|
conversationId,
|
||||||
|
newestUnreadId
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const unreadReactionSyncData = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
senderUuid?: string;
|
||||||
|
senderE164?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
unreadReactions.forEach(reaction => {
|
||||||
|
const targetKey = `${reaction.targetAuthorUuid}/${reaction.targetTimestamp}`;
|
||||||
|
if (unreadReactionSyncData.has(targetKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unreadReactionSyncData.set(targetKey, {
|
||||||
|
senderE164: undefined,
|
||||||
|
senderUuid: reaction.targetAuthorUuid,
|
||||||
|
timestamp: reaction.targetTimestamp,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const allReadMessagesSync = unreadMessages.map(messageSyncData => {
|
||||||
|
const message = window.MessageController.getById(messageSyncData.id);
|
||||||
|
// we update the in-memory MessageModel with the fresh database call data
|
||||||
|
if (message) {
|
||||||
|
message.set(messageSyncData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
senderE164: messageSyncData.source,
|
||||||
|
senderUuid: messageSyncData.sourceUuid,
|
||||||
|
senderId: window.ConversationController.ensureContactIds({
|
||||||
|
e164: messageSyncData.source,
|
||||||
|
uuid: messageSyncData.sourceUuid,
|
||||||
|
}),
|
||||||
|
timestamp: messageSyncData.sent_at,
|
||||||
|
hasErrors: message ? message.hasErrors() : false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Some messages we're marking read are local notifications with no sender
|
||||||
|
const messagesWithSenderId = allReadMessagesSync.filter(syncMessage =>
|
||||||
|
Boolean(syncMessage.senderId)
|
||||||
|
);
|
||||||
|
const incomingUnreadMessages = unreadMessages.filter(
|
||||||
|
message => message.type === 'incoming'
|
||||||
|
);
|
||||||
|
const unreadCount =
|
||||||
|
incomingUnreadMessages.length - messagesWithSenderId.length;
|
||||||
|
|
||||||
|
// If a message has errors, we don't want to send anything out about it.
|
||||||
|
// read syncs - let's wait for a client that really understands the message
|
||||||
|
// to mark it read. we'll mark our local error read locally, though.
|
||||||
|
// read receipts - here we can run into infinite loops, where each time the
|
||||||
|
// conversation is viewed, another error message shows up for the contact
|
||||||
|
const unreadMessagesSyncData = messagesWithSenderId.filter(
|
||||||
|
item => !item.hasErrors
|
||||||
|
);
|
||||||
|
|
||||||
|
const readSyncs = [
|
||||||
|
...unreadMessagesSyncData,
|
||||||
|
...Array.from(unreadReactionSyncData.values()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (readSyncs.length && options.sendReadReceipts) {
|
||||||
|
window.log.info(`Sending ${readSyncs.length} read syncs`);
|
||||||
|
// Because syncReadMessages sends to our other devices, and sendReadReceipts goes
|
||||||
|
// to a contact, we need accessKeys for both.
|
||||||
|
const {
|
||||||
|
sendOptions,
|
||||||
|
} = await window.ConversationController.prepareForSend(
|
||||||
|
window.ConversationController.getOurConversationId(),
|
||||||
|
{ syncMessage: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleMessageSend(
|
||||||
|
window.textsecure.messaging.syncReadMessages(readSyncs, sendOptions)
|
||||||
|
);
|
||||||
|
await sendReadReceiptsFor(conversationAttrs, unreadMessagesSyncData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unreadCount;
|
||||||
|
}
|
43
ts/util/sendReadReceiptsFor.ts
Normal file
43
ts/util/sendReadReceiptsFor.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { groupBy, map } from 'lodash';
|
||||||
|
import { ConversationAttributesType } from '../model-types.d';
|
||||||
|
import { getSendOptions } from './getSendOptions';
|
||||||
|
import { handleMessageSend } from './handleMessageSend';
|
||||||
|
import { isConversationAccepted } from './isConversationAccepted';
|
||||||
|
|
||||||
|
export async function sendReadReceiptsFor(
|
||||||
|
conversationAttrs: ConversationAttributesType,
|
||||||
|
items: Array<unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
// Only send read receipts for accepted conversations
|
||||||
|
if (
|
||||||
|
window.storage.get('read-receipt-setting') &&
|
||||||
|
isConversationAccepted(conversationAttrs)
|
||||||
|
) {
|
||||||
|
window.log.info(`Sending ${items.length} read receipts`);
|
||||||
|
const convoSendOptions = await getSendOptions(conversationAttrs);
|
||||||
|
const receiptsBySender = groupBy(items, 'senderId');
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
map(receiptsBySender, async (receipts, senderId) => {
|
||||||
|
const timestamps = map(receipts, 'timestamp');
|
||||||
|
const conversation = window.ConversationController.get(senderId);
|
||||||
|
|
||||||
|
if (conversation) {
|
||||||
|
await handleMessageSend(
|
||||||
|
window.textsecure.messaging.sendReadReceipts(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
conversation.get('e164')!,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
conversation.get('uuid')!,
|
||||||
|
timestamps,
|
||||||
|
convoSendOptions
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
17
ts/util/whatTypeOfConversation.ts
Normal file
17
ts/util/whatTypeOfConversation.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { ConversationAttributesType } from '../model-types.d';
|
||||||
|
|
||||||
|
export function isDirectConversation(
|
||||||
|
conversationAttrs: ConversationAttributesType
|
||||||
|
): boolean {
|
||||||
|
return conversationAttrs.type === 'private';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMe(conversationAttrs: ConversationAttributesType): boolean {
|
||||||
|
const { e164, uuid } = conversationAttrs;
|
||||||
|
const ourNumber = window.textsecure.storage.user.getNumber();
|
||||||
|
const ourUuid = window.textsecure.storage.user.getUuid();
|
||||||
|
return Boolean((e164 && e164 === ourNumber) || (uuid && uuid === ourUuid));
|
||||||
|
}
|
2
ts/window.d.ts
vendored
2
ts/window.d.ts
vendored
|
@ -587,9 +587,9 @@ export type DCodeIOType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type MessageControllerType = {
|
type MessageControllerType = {
|
||||||
getById: (id: string) => MessageModel | undefined;
|
|
||||||
findBySender: (sender: string) => MessageModel | null;
|
findBySender: (sender: string) => MessageModel | null;
|
||||||
findBySentAt: (sentAt: number) => MessageModel | null;
|
findBySentAt: (sentAt: number) => MessageModel | null;
|
||||||
|
getById: (id: string) => MessageModel | undefined;
|
||||||
register: (id: string, model: MessageModel) => MessageModel;
|
register: (id: string, model: MessageModel) => MessageModel;
|
||||||
unregister: (id: string) => void;
|
unregister: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue