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) {
|
||||
// TODO DESKTOP-1509: use setToExpire once this is TS
|
||||
await message.setToExpire(false, { skipSave: true });
|
||||
}
|
||||
|
||||
|
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
function getById(id) {
|
||||
const existing = messageLookup[id];
|
||||
return existing && existing.message ? existing.message : null;
|
||||
return existing && existing.message ? existing.message : undefined;
|
||||
}
|
||||
|
||||
function findBySentAt(sentAt) {
|
||||
|
|
|
@ -60,8 +60,14 @@
|
|||
// Remove the last notification if both conditions hold:
|
||||
//
|
||||
// 1. Either `conversationId` or `messageId` matches (if present)
|
||||
// 2. `reactionFromId` matches (if present)
|
||||
removeBy({ conversationId, messageId, reactionFromId }) {
|
||||
// 2. `emoji`, `targetAuthorUuid`, `targetTimestamp` matches (if present)
|
||||
removeBy({
|
||||
conversationId,
|
||||
messageId,
|
||||
emoji,
|
||||
targetAuthorUuid,
|
||||
targetTimestamp,
|
||||
}) {
|
||||
if (!this.notificationData) {
|
||||
return;
|
||||
}
|
||||
|
@ -81,10 +87,15 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const { reaction } = this.notificationData;
|
||||
if (
|
||||
reactionFromId &&
|
||||
this.notificationData.reaction &&
|
||||
this.notificationData.reaction.fromId !== reactionFromId
|
||||
reaction &&
|
||||
emoji &&
|
||||
targetAuthorUuid &&
|
||||
targetTimestamp &&
|
||||
(reaction.emoji !== emoji ||
|
||||
reaction.targetAuthorUuid !== targetAuthorUuid ||
|
||||
reaction.targetTimestamp !== targetTimestamp)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -98,6 +98,7 @@
|
|||
});
|
||||
|
||||
if (message.isExpiring() && !expirationStartTimestamp) {
|
||||
// TODO DESKTOP-1509: use setToExpire once this is TS
|
||||
await message.setToExpire(false, { skipSave: true });
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,30 @@
|
|||
|
||||
// eslint-disable-next-line func-names
|
||||
(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 || {};
|
||||
Whisper.ReadSyncs = new (Backbone.Collection.extend({
|
||||
forMessage(message) {
|
||||
|
@ -47,19 +71,14 @@
|
|||
|
||||
return item.isIncoming() && senderId === receipt.get('senderId');
|
||||
});
|
||||
if (found) {
|
||||
Whisper.Notifications.removeBy({ messageId: found.id });
|
||||
} else {
|
||||
window.log.info(
|
||||
'No message for read sync',
|
||||
receipt.get('senderId'),
|
||||
receipt.get('sender'),
|
||||
receipt.get('senderUuid'),
|
||||
receipt.get('timestamp')
|
||||
);
|
||||
|
||||
if (!found) {
|
||||
await maybeItIsAReactionReadSync(receipt);
|
||||
return;
|
||||
}
|
||||
|
||||
Whisper.Notifications.removeBy({ messageId: found.id });
|
||||
|
||||
const message = MessageController.register(found.id, found);
|
||||
const readAt = receipt.get('read_at');
|
||||
|
||||
|
@ -67,7 +86,8 @@
|
|||
// timer to the time specified by the read sync if it's earlier than
|
||||
// the previous read time.
|
||||
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 = () => {
|
||||
// onReadMessage may result in messages older than this one being
|
||||
|
@ -100,6 +120,7 @@
|
|||
message.set({ expirationStartTimestamp });
|
||||
|
||||
const force = true;
|
||||
// TODO DESKTOP-1509: use setToExpire once this is TS
|
||||
await message.setToExpire(force, { skipSave: true });
|
||||
|
||||
const conversation = message.getConversation();
|
||||
|
|
|
@ -723,7 +723,7 @@ export class ConversationController {
|
|||
|
||||
async prepareForSend(
|
||||
id: string | undefined,
|
||||
options?: { syncMessage?: boolean; disableMeCheck?: boolean }
|
||||
options?: { syncMessage?: boolean }
|
||||
): Promise<{
|
||||
wrap: (
|
||||
promise: Promise<CallbackResultType | void | null>
|
||||
|
|
|
@ -20,6 +20,7 @@ import { initializeAllJobQueues } from './jobs/initializeAllJobQueues';
|
|||
import { removeStorageKeyJobQueue } from './jobs/removeStorageKeyJobQueue';
|
||||
import { ourProfileKeyService } from './services/ourProfileKey';
|
||||
import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey';
|
||||
import { setToExpire } from './services/MessageUpdater';
|
||||
|
||||
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}`
|
||||
);
|
||||
message.set(
|
||||
'expirationStartTimestamp',
|
||||
expirationStartTimestamp || sentAt
|
||||
setToExpire({
|
||||
...message.attributes,
|
||||
expirationStartTimestamp: expirationStartTimestamp || sentAt,
|
||||
})
|
||||
);
|
||||
await message.setToExpire();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
6
ts/model-types.d.ts
vendored
6
ts/model-types.d.ts
vendored
|
@ -109,8 +109,6 @@ export type MessageAttributesType = {
|
|||
quote?: QuotedMessageType;
|
||||
reactions?: Array<{
|
||||
emoji: string;
|
||||
timestamp: number;
|
||||
fromId: string;
|
||||
from: {
|
||||
id: string;
|
||||
color?: string;
|
||||
|
@ -120,6 +118,10 @@ export type MessageAttributesType = {
|
|||
isMe?: boolean;
|
||||
phoneNumber?: string;
|
||||
};
|
||||
fromId: string;
|
||||
targetAuthorUuid: string;
|
||||
targetTimestamp: number;
|
||||
timestamp: number;
|
||||
}>;
|
||||
read_by: Array<string | null>;
|
||||
requiredProtocolVersion: number;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
/* eslint-disable class-methods-use-this */
|
||||
/* eslint-disable camelcase */
|
||||
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
|
||||
import { compact } from 'lodash';
|
||||
import {
|
||||
MessageModelCollectionType,
|
||||
WhatIsThis,
|
||||
|
@ -15,7 +16,6 @@ import { CallMode, CallHistoryDetailsType } from '../types/Calling';
|
|||
import {
|
||||
CallbackResultType,
|
||||
GroupV2InfoType,
|
||||
SendMetadataType,
|
||||
SendOptionsType,
|
||||
} from '../textsecure/SendMessage';
|
||||
import {
|
||||
|
@ -26,7 +26,6 @@ import { ColorType } from '../types/Colors';
|
|||
import { MessageModel } from './messages';
|
||||
import { isMuted } from '../util/isMuted';
|
||||
import { isConversationUnregistered } from '../util/isConversationUnregistered';
|
||||
import { assert } from '../util/assert';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
||||
import { MIMEType, IMAGE_WEBP } from '../types/MIME';
|
||||
|
@ -35,7 +34,6 @@ import {
|
|||
base64ToArrayBuffer,
|
||||
deriveAccessKey,
|
||||
fromEncodedBinaryToArrayBuffer,
|
||||
getRandomBytes,
|
||||
stringFromBytes,
|
||||
trimForDisplay,
|
||||
verifyAccessKey,
|
||||
|
@ -45,16 +43,13 @@ import { BodyRangesType } from '../types/Util';
|
|||
import { getTextWithMentions } from '../util';
|
||||
import { migrateColor } from '../util/migrateColor';
|
||||
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 { 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 */
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
@ -1211,6 +1206,7 @@ export class ConversationModel extends window.Backbone
|
|||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const model = this.messageCollection!.add(message, { merge: true });
|
||||
// TODO use MessageUpdater.setToExpire
|
||||
model.setToExpire();
|
||||
|
||||
if (!existing) {
|
||||
|
@ -1535,7 +1531,7 @@ export class ConversationModel extends window.Backbone
|
|||
|
||||
if (isLocalAction) {
|
||||
// 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
|
||||
|
@ -2304,43 +2300,7 @@ export class ConversationModel extends window.Backbone
|
|||
* of message requests
|
||||
*/
|
||||
getAccepted(): boolean {
|
||||
const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled(
|
||||
'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
|
||||
);
|
||||
return isConversationAccepted(this.attributes);
|
||||
}
|
||||
|
||||
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 {
|
||||
const required = ['type'];
|
||||
const missing = window._.filter(required, attr => !attributes[attr]);
|
||||
|
@ -2785,50 +2739,11 @@ export class ConversationModel extends window.Backbone
|
|||
getMembers(
|
||||
options: { includePendingMembers?: boolean } = {}
|
||||
): Array<ConversationModel> {
|
||||
if (this.isPrivate()) {
|
||||
return [this];
|
||||
}
|
||||
|
||||
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 [];
|
||||
return compact(
|
||||
getConversationMembers(this.attributes, options).map(conversationAttrs =>
|
||||
window.ConversationController.get(conversationAttrs.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
getMemberIds(): Array<string> {
|
||||
|
@ -3477,198 +3392,13 @@ export class ConversationModel extends window.Backbone
|
|||
async wrapSend(
|
||||
promise: Promise<CallbackResultType | void | null>
|
||||
): Promise<CallbackResultType | void | null> {
|
||||
return promise.then(
|
||||
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;
|
||||
}
|
||||
);
|
||||
return handleMessageSend(promise);
|
||||
}
|
||||
|
||||
async 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);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
async getSendOptions(
|
||||
options: { syncMessage?: boolean } = {}
|
||||
): Promise<SendOptionsType> {
|
||||
return getSendOptions(this.attributes, options);
|
||||
}
|
||||
|
||||
// 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(
|
||||
newestUnreadDate: number,
|
||||
providedOptions: { readAt?: number; sendReadReceipts: boolean }
|
||||
newestUnreadId: number,
|
||||
options: { readAt?: number; sendReadReceipts: boolean } = {
|
||||
sendReadReceipts: true,
|
||||
}
|
||||
): Promise<void> {
|
||||
const options = providedOptions || {};
|
||||
window._.defaults(options, { sendReadReceipts: true });
|
||||
|
||||
const conversationId = this.id;
|
||||
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
|
||||
const unreadCount = await markConversationRead(
|
||||
this.attributes,
|
||||
newestUnreadId,
|
||||
options
|
||||
);
|
||||
|
||||
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 });
|
||||
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
|
||||
|
@ -4443,8 +4091,7 @@ export class ConversationModel extends window.Backbone
|
|||
));
|
||||
}
|
||||
|
||||
const sendMetadata =
|
||||
(await c.getSendMetadata({ disableMeCheck: true })) || {};
|
||||
const { sendMetadata = {} } = await c.getSendOptions();
|
||||
const getInfo =
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sendMetadata[c.get('uuid')!] || sendMetadata[c.get('e164')!] || {};
|
||||
|
|
|
@ -28,6 +28,7 @@ import { missingCaseError } from '../util/missingCaseError';
|
|||
import { ColorType } from '../types/Colors';
|
||||
import { CallMode } from '../types/Calling';
|
||||
import { BodyRangesType } from '../types/Util';
|
||||
import { ReactionType } from '../types/Reactions';
|
||||
import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change';
|
||||
import {
|
||||
PropsData as TimerNotificationProps,
|
||||
|
@ -50,6 +51,7 @@ import { AttachmentType, isImage, isVideo } from '../types/Attachment';
|
|||
import { MIMEType } from '../types/MIME';
|
||||
import { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
import { ourProfileKeyService } from '../services/ourProfileKey';
|
||||
import { markRead, setToExpire } from '../services/MessageUpdater';
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
/* eslint-disable more/no-then */
|
||||
|
@ -1755,7 +1757,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
}
|
||||
|
||||
if (this.get('unread')) {
|
||||
await this.markRead();
|
||||
this.set(markRead(this.attributes));
|
||||
}
|
||||
|
||||
await this.eraseContents();
|
||||
|
@ -2030,33 +2032,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
}
|
||||
}
|
||||
|
||||
async markRead(
|
||||
readAt?: number,
|
||||
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);
|
||||
}
|
||||
markRead(readAt?: number, options = {}): void {
|
||||
this.set(markRead(this.attributes, readAt, options));
|
||||
}
|
||||
|
||||
isExpiring(): number | null {
|
||||
return this.get('expireTimer') && this.get('expirationStartTimestamp');
|
||||
}
|
||||
|
||||
setToExpire(force = false, options = {}): void {
|
||||
this.set(setToExpire(this.attributes, { ...options, force }));
|
||||
}
|
||||
|
||||
isExpired(): boolean {
|
||||
return this.msTilExpire() <= 0;
|
||||
}
|
||||
|
@ -2076,33 +2063,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
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 {
|
||||
if (!this.isIncoming()) {
|
||||
return null;
|
||||
|
@ -4110,7 +4070,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
this.get('conversationId')
|
||||
);
|
||||
|
||||
let staleReactionFromId: string | undefined;
|
||||
let reactionToRemove: Partial<ReactionType> | undefined;
|
||||
|
||||
if (reaction.get('remove')) {
|
||||
window.log.info('Removing reaction for message', messageId);
|
||||
|
@ -4121,7 +4081,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
);
|
||||
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 {
|
||||
window.log.info('Adding reaction for message', messageId);
|
||||
const newReactions = reactions.filter(
|
||||
|
@ -4134,17 +4105,30 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
re => re.fromId === reaction.get('fromId')
|
||||
);
|
||||
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
|
||||
if (conversation && this.isOutgoing() && !reaction.get('fromSync')) {
|
||||
conversation.notify(this, reaction);
|
||||
}
|
||||
}
|
||||
|
||||
if (staleReactionFromId) {
|
||||
this.clearNotifications(reaction.get('fromId'));
|
||||
if (reactionToRemove) {
|
||||
this.clearNotifications(reactionToRemove);
|
||||
}
|
||||
|
||||
const newCount = (this.get('reactions') || []).length;
|
||||
|
@ -4184,10 +4168,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
this.getConversation()!.updateLastMessage();
|
||||
}
|
||||
|
||||
clearNotifications(reactionFromId?: string): void {
|
||||
clearNotifications(reaction: Partial<ReactionType> = {}): void {
|
||||
window.Whisper.Notifications.removeBy({
|
||||
...reaction,
|
||||
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 { assert } from '../util/assert';
|
||||
import { cleanDataForIpc } from './cleanDataForIpc';
|
||||
import { ReactionType } from '../types/Reactions';
|
||||
|
||||
import {
|
||||
ConversationModelCollectionType,
|
||||
|
@ -166,7 +167,11 @@ const dataInterface: ClientInterface = {
|
|||
saveMessages,
|
||||
removeMessage,
|
||||
removeMessages,
|
||||
getUnreadByConversation,
|
||||
getUnreadByConversationAndMarkRead,
|
||||
getUnreadReactionsAndMarkRead,
|
||||
markReactionAsRead,
|
||||
removeReactionFromConversation,
|
||||
addReaction,
|
||||
|
||||
getMessageBySender,
|
||||
getMessageById,
|
||||
|
@ -1041,15 +1046,43 @@ async function getMessageBySender(
|
|||
return new Message(messages[0]);
|
||||
}
|
||||
|
||||
async function getUnreadByConversation(
|
||||
async function getUnreadByConversationAndMarkRead(
|
||||
conversationId: string,
|
||||
{
|
||||
MessageCollection,
|
||||
}: { MessageCollection: typeof MessageModelCollectionType }
|
||||
newestUnreadId: number,
|
||||
readAt?: number
|
||||
) {
|
||||
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>) {
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
/* eslint-disable camelcase */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import {
|
||||
ConversationAttributesType,
|
||||
ConversationModelCollectionType,
|
||||
|
@ -14,6 +13,7 @@ import {
|
|||
import { MessageModel } from '../models/messages';
|
||||
import { ConversationModel } from '../models/conversations';
|
||||
import { StoredJob } from '../jobs/types';
|
||||
import { ReactionType } from '../types/Reactions';
|
||||
|
||||
export type AttachmentDownloadJobType = {
|
||||
id: string;
|
||||
|
@ -343,9 +343,32 @@ export type ServerInterface = DataInterface & {
|
|||
getNextTapToViewMessageToAgeOut: () => Promise<MessageType | undefined>;
|
||||
getOutgoingWithoutExpiresAt: () => Promise<Array<MessageType>>;
|
||||
getTapToViewMessagesNeedingErase: () => Promise<Array<MessageType>>;
|
||||
getUnreadByConversation: (
|
||||
conversationId: string
|
||||
) => Promise<Array<MessageType>>;
|
||||
getUnreadByConversationAndMarkRead: (
|
||||
conversationId: string,
|
||||
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>;
|
||||
removeMessage: (id: string) => Promise<void>;
|
||||
removeMessages: (ids: Array<string>) => Promise<void>;
|
||||
|
@ -463,10 +486,32 @@ export type ClientInterface = DataInterface & {
|
|||
getTapToViewMessagesNeedingErase: (options: {
|
||||
MessageCollection: typeof MessageModelCollectionType;
|
||||
}) => Promise<MessageModelCollectionType>;
|
||||
getUnreadByConversation: (
|
||||
getUnreadByConversationAndMarkRead: (
|
||||
conversationId: string,
|
||||
options: { MessageCollection: typeof MessageModelCollectionType }
|
||||
) => Promise<MessageModelCollectionType>;
|
||||
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: string,
|
||||
options: { Conversation: typeof ConversationModel }
|
||||
|
|
356
ts/sql/Server.ts
356
ts/sql/Server.ts
|
@ -28,13 +28,14 @@ import {
|
|||
omit,
|
||||
} 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 { ReactionType } from '../types/Reactions';
|
||||
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 {
|
||||
AttachmentDownloadJobType,
|
||||
|
@ -156,7 +157,11 @@ const dataInterface: ServerInterface = {
|
|||
saveMessages,
|
||||
removeMessage,
|
||||
removeMessages,
|
||||
getUnreadByConversation,
|
||||
getUnreadByConversationAndMarkRead,
|
||||
getUnreadReactionsAndMarkRead,
|
||||
markReactionAsRead,
|
||||
addReaction,
|
||||
removeReactionFromConversation,
|
||||
getMessageBySender,
|
||||
getMessageById,
|
||||
_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 = [
|
||||
updateToSchemaVersion1,
|
||||
updateToSchemaVersion2,
|
||||
|
@ -1743,6 +1781,7 @@ const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion26,
|
||||
updateToSchemaVersion27,
|
||||
updateToSchemaVersion28,
|
||||
updateToSchemaVersion29,
|
||||
];
|
||||
|
||||
function updateSchema(db: Database): void {
|
||||
|
@ -2961,25 +3000,298 @@ async function getMessageBySender({
|
|||
return rows.map(row => jsonToObject(row.json));
|
||||
}
|
||||
|
||||
async function getUnreadByConversation(
|
||||
conversationId: string
|
||||
): Promise<Array<MessageType>> {
|
||||
function getExpireData(
|
||||
messageExpireTimer: number,
|
||||
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 rows: JSONRows = db
|
||||
.prepare<Query>(
|
||||
`
|
||||
SELECT json FROM messages WHERE
|
||||
unread = $unread AND
|
||||
conversationId = $conversationId
|
||||
ORDER BY received_at DESC, sent_at DESC;
|
||||
`
|
||||
)
|
||||
.all({
|
||||
unread: 1,
|
||||
conversationId,
|
||||
const stmt = db.prepare<Query>(
|
||||
`
|
||||
UPDATE messages
|
||||
SET
|
||||
unread = 0,
|
||||
expires_at = $expiresAt,
|
||||
expirationStartTimestamp = $expirationStartTimestamp,
|
||||
json = json_patch(json, $jsonPatch)
|
||||
WHERE
|
||||
id = $id
|
||||
`
|
||||
);
|
||||
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(
|
||||
|
|
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 = {
|
||||
getById: (id: string) => MessageModel | undefined;
|
||||
findBySender: (sender: string) => MessageModel | null;
|
||||
findBySentAt: (sentAt: number) => MessageModel | null;
|
||||
getById: (id: string) => MessageModel | undefined;
|
||||
register: (id: string, model: MessageModel) => MessageModel;
|
||||
unregister: (id: string) => void;
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue