Remove backbone as a dependency

Co-authored-by: Yash <yash@signal.org>
Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com>
Co-authored-by: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com>
Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
Scott Nonnenberg 2025-08-12 06:55:29 +10:00
commit 237e239e05
69 changed files with 963 additions and 2110 deletions

View file

@ -104,7 +104,7 @@ const rules = {
// Prefer functional components with default params // Prefer functional components with default params
'react/require-default-props': 'off', 'react/require-default-props': 'off',
// Empty fragments are used in adapters between backbone and react views. // Empty fragments are used in adapters between models and react views.
'react/jsx-no-useless-fragment': [ 'react/jsx-no-useless-fragment': [
'error', 'error',
{ {

View file

@ -2959,31 +2959,6 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE SOFTWARE
## backbone
Copyright (c) 2010-2024 Jeremy Ashkenas, DocumentCloud
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
## blob-util ## blob-util
Apache License Apache License

View file

@ -3,7 +3,6 @@
import { run } from 'endanger'; import { run } from 'endanger';
import migrateBackboneToRedux from './rules/migrateBackboneToRedux';
import packageJsonVersionsShouldBePinned from './rules/packageJsonVersionsShouldBePinned'; import packageJsonVersionsShouldBePinned from './rules/packageJsonVersionsShouldBePinned';
import pnpmLockDepsShouldHaveIntegrity from './rules/pnpmLockDepsShouldHaveIntegrity'; import pnpmLockDepsShouldHaveIntegrity from './rules/pnpmLockDepsShouldHaveIntegrity';
@ -19,7 +18,6 @@ function isGitDeletedError(error: unknown) {
async function main() { async function main() {
try { try {
await run( await run(
migrateBackboneToRedux(),
packageJsonVersionsShouldBePinned(), packageJsonVersionsShouldBePinned(),
pnpmLockDepsShouldHaveIntegrity() pnpmLockDepsShouldHaveIntegrity()
); );

View file

@ -1,54 +0,0 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { Line, Rule } from 'endanger';
export default function migrateBackboneToRedux() {
return new Rule({
match: {
files: ['**/*.{js,jsx,ts,tsx}'],
},
messages: {
foundNewBackboneFile: `
**Prefer Redux**
Don't create new Backbone files, use Redux
`,
foundBackboneFileWithManyChanges: `
**Prefer Redux**
Migrate Backbone files to Redux when making major changes
`,
},
async run({ files, context }) {
for (let file of files.modifiedOrCreated) {
let lines = await file.lines();
let matchedLine: Line | null = null;
for (let line of lines) {
// Check for the most stable part of the backbone `import`
if (
(await line.contains("from 'backbone'")) ||
(await line.contains('window.Backbone'))
) {
matchedLine = line;
break;
}
}
if (!matchedLine) {
continue;
}
if (file.created) {
context.warn('foundNewBackboneFile', { file, line: matchedLine });
} else if (file.modifiedOnly) {
if (await file.diff().changedBy({ added: 0.1 })) {
context.warn('foundBackboneFileWithManyChanges', {
file,
line: matchedLine,
});
}
}
}
},
});
}

View file

@ -20,7 +20,7 @@ function has<T extends object, const K extends T[any]>(
return Object.hasOwn(value, key); return Object.hasOwn(value, key);
} }
export default function migrateBackboneToRedux() { export default function pnpmLockDepsShouldHaveIntegrity() {
return new Rule({ return new Rule({
match: { match: {
files: ['pnpm-lock.yaml'], files: ['pnpm-lock.yaml'],

View file

@ -139,7 +139,6 @@
"@tanstack/react-virtual": "3.11.2", "@tanstack/react-virtual": "3.11.2",
"@types/dom-mediacapture-transform": "0.1.11", "@types/dom-mediacapture-transform": "0.1.11",
"@types/fabric": "4.5.3", "@types/fabric": "4.5.3",
"backbone": "1.6.0",
"blob-util": "2.0.2", "blob-util": "2.0.2",
"blueimp-load-image": "5.16.0", "blueimp-load-image": "5.16.0",
"blurhash": "2.0.5", "blurhash": "2.0.5",
@ -256,7 +255,6 @@
"@storybook/types": "8.1.11", "@storybook/types": "8.1.11",
"@tailwindcss/cli": "4.1.7", "@tailwindcss/cli": "4.1.7",
"@tailwindcss/postcss": "4.1.7", "@tailwindcss/postcss": "4.1.7",
"@types/backbone": "1.4.22",
"@types/blueimp-load-image": "5.16.6", "@types/blueimp-load-image": "5.16.6",
"@types/chai": "4.3.16", "@types/chai": "4.3.16",
"@types/chai-as-promised": "7.1.4", "@types/chai-as-promised": "7.1.4",
@ -378,7 +376,6 @@
"react-contextmenu>react-dom": "18.3.1" "react-contextmenu>react-dom": "18.3.1"
}, },
"patchedDependencies": { "patchedDependencies": {
"@types/backbone@1.4.22": "patches/@types+backbone+1.4.22.patch",
"casual@1.6.2": "patches/casual+1.6.2.patch", "casual@1.6.2": "patches/casual+1.6.2.patch",
"protobufjs@7.3.2": "patches/protobufjs+7.3.2.patch", "protobufjs@7.3.2": "patches/protobufjs+7.3.2.patch",
"@types/express@4.17.21": "patches/@types+express+4.17.21.patch", "@types/express@4.17.21": "patches/@types+express+4.17.21.patch",
@ -393,7 +390,6 @@
"growing-file@0.1.3": "patches/growing-file+0.1.3.patch", "growing-file@0.1.3": "patches/growing-file+0.1.3.patch",
"websocket@1.0.34": "patches/websocket+1.0.34.patch", "websocket@1.0.34": "patches/websocket+1.0.34.patch",
"@types/websocket@1.0.0": "patches/@types+websocket+1.0.0.patch", "@types/websocket@1.0.0": "patches/@types+websocket+1.0.0.patch",
"backbone@1.6.0": "patches/backbone+1.6.0.patch",
"node-fetch@2.6.7": "patches/node-fetch+2.6.7.patch", "node-fetch@2.6.7": "patches/node-fetch+2.6.7.patch",
"zod@3.23.8": "patches/zod+3.23.8.patch", "zod@3.23.8": "patches/zod+3.23.8.patch",
"app-builder-lib": "patches/app-builder-lib.patch", "app-builder-lib": "patches/app-builder-lib.patch",

View file

@ -1,49 +0,0 @@
diff --git a/index.d.ts b/index.d.ts
index 15d9d4b..a431841 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -66,7 +66,7 @@ declare namespace Backbone {
collection?: Collection<TModel> | undefined;
}
- type CombinedModelConstructorOptions<E, M extends Model<any, any, E> = Model> = ModelConstructorOptions<M> & E;
+ type CombinedModelConstructorOptions<E, M extends Model<any, any, E> = Model<any, any, E>> = ModelConstructorOptions<M> & E;
interface ModelSetOptions extends Silenceable, Validable {}
@@ -204,7 +204,7 @@ declare namespace Backbone {
*/
static extend(properties: any, classProperties?: any): any;
- attributes: Partial<T>;
+ attributes: T;
changed: Partial<T>;
cidPrefix: string;
cid: string;
@@ -220,7 +220,7 @@ declare namespace Backbone {
* That works only if you set it in the constructor or the initialize method.
*/
defaults(): Partial<T>;
- id: string | number;
+ id: string;
idAttribute: string;
validationError: any;
@@ -251,7 +251,7 @@ declare namespace Backbone {
* return super.get("name");
* }
*/
- get<A extends _StringKey<T>>(attributeName: A): T[A] | undefined;
+ get<A extends _StringKey<T>>(attributeName: A): T[A];
/**
* For strongly-typed assignment of attributes, use the `set` method only privately in public setter properties.
@@ -285,7 +285,7 @@ declare namespace Backbone {
previousAttributes(): Partial<T>;
save(attributes?: Partial<T> | null, options?: ModelSaveOptions): JQueryXHR;
unset(attribute: _StringKey<T>, options?: Silenceable): this;
- validate(attributes: Partial<T>, options?: any): any;
+ validate(attributes: T, options?: any): any;
private _validate(attributes: Partial<T>, options: any): boolean;
// mixins from underscore

File diff suppressed because one or more lines are too long

32
pnpm-lock.yaml generated
View file

@ -15,9 +15,6 @@ overrides:
react-contextmenu>react-dom: 18.3.1 react-contextmenu>react-dom: 18.3.1
patchedDependencies: patchedDependencies:
'@types/backbone@1.4.22':
hash: 9dace206a9f53e0e3b0203051b26aec1e92ad49744b156ad8076946356c6c8e7
path: patches/@types+backbone+1.4.22.patch
'@types/express@4.17.21': '@types/express@4.17.21':
hash: 85d9b3f3cac67003e41b22245281f53b51d7d1badd0bcc222d547ab802599bae hash: 85d9b3f3cac67003e41b22245281f53b51d7d1badd0bcc222d547ab802599bae
path: patches/@types+express+4.17.21.patch path: patches/@types+express+4.17.21.patch
@ -36,9 +33,6 @@ patchedDependencies:
app-builder-lib: app-builder-lib:
hash: b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420 hash: b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420
path: patches/app-builder-lib.patch path: patches/app-builder-lib.patch
backbone@1.6.0:
hash: 342b4b6012f8aecfa041554256444cb25af75bc933cf2ab1e91c4f66a8e47a31
path: patches/backbone+1.6.0.patch
casual@1.6.2: casual@1.6.2:
hash: b88b5052437cbdc1882137778b76ca5037f71b2a030ae9ef39dc97f51670d599 hash: b88b5052437cbdc1882137778b76ca5037f71b2a030ae9ef39dc97f51670d599
path: patches/casual+1.6.2.patch path: patches/casual+1.6.2.patch
@ -158,9 +152,6 @@ importers:
'@types/fabric': '@types/fabric':
specifier: 4.5.3 specifier: 4.5.3
version: 4.5.3(patch_hash=e5f339ecf72fbab1c91505e7713e127a7184bfe8164aa3a9afe9bf45a0ad6b89) version: 4.5.3(patch_hash=e5f339ecf72fbab1c91505e7713e127a7184bfe8164aa3a9afe9bf45a0ad6b89)
backbone:
specifier: 1.6.0
version: 1.6.0(patch_hash=342b4b6012f8aecfa041554256444cb25af75bc933cf2ab1e91c4f66a8e47a31)
blob-util: blob-util:
specifier: 2.0.2 specifier: 2.0.2
version: 2.0.2 version: 2.0.2
@ -504,9 +495,6 @@ importers:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: 4.1.7 specifier: 4.1.7
version: 4.1.7 version: 4.1.7
'@types/backbone':
specifier: 1.4.22
version: 1.4.22(patch_hash=9dace206a9f53e0e3b0203051b26aec1e92ad49744b156ad8076946356c6c8e7)
'@types/blueimp-load-image': '@types/blueimp-load-image':
specifier: 5.16.6 specifier: 5.16.6
version: 5.16.6 version: 5.16.6
@ -3780,9 +3768,6 @@ packages:
'@types/babel__traverse@7.20.6': '@types/babel__traverse@7.20.6':
resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==}
'@types/backbone@1.4.22':
resolution: {integrity: sha512-i79hj6XPfsJ37yBHUb9560luep8SPoAbGcpA9TeW1R6Jufk4hHZn5q0l2xuTVtugBcoLlxGQ5qOjaNLBPmqaAg==}
'@types/blueimp-load-image@5.16.6': '@types/blueimp-load-image@5.16.6':
resolution: {integrity: sha512-e7s6CdDCUoBQdCe62Q6OS+DF68M8+ABxCEMh2Isjt4Fl3xuddljCHMN8mak48AMSVGGwUUtNRaZbkzgL5PEWew==} resolution: {integrity: sha512-e7s6CdDCUoBQdCe62Q6OS+DF68M8+ABxCEMh2Isjt4Fl3xuddljCHMN8mak48AMSVGGwUUtNRaZbkzgL5PEWew==}
@ -4046,9 +4031,6 @@ packages:
'@types/stack-utils@2.0.3': '@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
'@types/underscore@1.13.0':
resolution: {integrity: sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==}
'@types/unist@2.0.11': '@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@ -4625,9 +4607,6 @@ packages:
peerDependencies: peerDependencies:
'@babel/core': ^7.0.0 '@babel/core': ^7.0.0
backbone@1.6.0:
resolution: {integrity: sha512-13PUjmsgw/49EowNcQvfG4gmczz1ximTMhUktj0Jfrjth0MVaTxehpU+qYYX4MxnuIuhmvBLC6/ayxuAGnOhbA==}
bail@1.0.5: bail@1.0.5:
resolution: {integrity: sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==} resolution: {integrity: sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==}
@ -14587,11 +14566,6 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.26.8 '@babel/types': 7.26.8
'@types/backbone@1.4.22(patch_hash=9dace206a9f53e0e3b0203051b26aec1e92ad49744b156ad8076946356c6c8e7)':
dependencies:
'@types/jquery': 3.5.32
'@types/underscore': 1.13.0
'@types/blueimp-load-image@5.16.6': {} '@types/blueimp-load-image@5.16.6': {}
'@types/body-parser@1.19.5': '@types/body-parser@1.19.5':
@ -14890,8 +14864,6 @@ snapshots:
'@types/stack-utils@2.0.3': {} '@types/stack-utils@2.0.3': {}
'@types/underscore@1.13.0': {}
'@types/unist@2.0.11': {} '@types/unist@2.0.11': {}
'@types/use-sync-external-store@0.0.6': {} '@types/use-sync-external-store@0.0.6': {}
@ -15611,10 +15583,6 @@ snapshots:
babel-plugin-jest-hoist: 29.6.3 babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.1.0(@babel/core@7.26.0) babel-preset-current-node-syntax: 1.1.0(@babel/core@7.26.0)
backbone@1.6.0(patch_hash=342b4b6012f8aecfa041554256444cb25af75bc933cf2ab1e91c4f66a8e47a31):
dependencies:
underscore: 1.13.7
bail@1.0.5: {} bail@1.0.5: {}
balanced-match@1.0.2: {} balanced-match@1.0.2: {}

View file

@ -96,7 +96,7 @@ module.exports = {
// Prefer functional components with default params // Prefer functional components with default params
'react/require-default-props': 'off', 'react/require-default-props': 'off',
// Empty fragments are used in adapters between backbone and react views. // Empty fragments are used in adapters between models and react views.
'react/jsx-no-useless-fragment': [ 'react/jsx-no-useless-fragment': [
'error', 'error',
{ {

View file

@ -220,7 +220,7 @@ export function getCI({
} }
function unlink() { function unlink() {
window.Whisper.events.trigger('unlinkAndDisconnect'); window.Whisper.events.emit('unlinkAndDisconnect');
} }
function print(...args: ReadonlyArray<unknown>) { function print(...args: ReadonlyArray<unknown>) {

View file

@ -96,7 +96,7 @@ export async function populateConversationWithMessages({
postSaveUpdates, postSaveUpdates,
}); });
conversation.set('active_at', Date.now()); conversation.set({ active_at: Date.now() });
await DataWriter.updateConversation(conversation.attributes); await DataWriter.updateConversation(conversation.attributes);
log.info(`${logId}: populating conversation complete`); log.info(`${logId}: populating conversation complete`);
} }

View file

@ -4,15 +4,6 @@
import { debounce, pick, uniq, without } from 'lodash'; import { debounce, pick, uniq, without } from 'lodash';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { v4 as generateUuid } from 'uuid'; import { v4 as generateUuid } from 'uuid';
import { batch as batchDispatch } from 'react-redux';
import type {
ConversationModelCollectionType,
ConversationAttributesType,
ConversationAttributesTypeType,
ConversationRenderInfoType,
} from './model-types.d';
import type { ConversationModel } from './models/conversations';
import { DataReader, DataWriter } from './sql/Client'; import { DataReader, DataWriter } from './sql/Client';
import { createLogger } from './logging/log'; import { createLogger } from './logging/log';
@ -21,8 +12,12 @@ import { getAuthorId } from './messages/helpers';
import { maybeDeriveGroupV2Id } from './groups'; import { maybeDeriveGroupV2Id } from './groups';
import { assertDev, strictAssert } from './util/assert'; import { assertDev, strictAssert } from './util/assert';
import { drop } from './util/drop'; import { drop } from './util/drop';
import { isGroup, isGroupV1, isGroupV2 } from './util/whatTypeOfConversation'; import {
import type { ServiceIdString, AciString, PniString } from './types/ServiceId'; isDirectConversation,
isGroup,
isGroupV1,
isGroupV2,
} from './util/whatTypeOfConversation';
import { import {
isServiceIdString, isServiceIdString,
normalizePni, normalizePni,
@ -42,6 +37,18 @@ import { isTestOrMockEnvironment } from './environment';
import { isConversationAccepted } from './util/isConversationAccepted'; import { isConversationAccepted } from './util/isConversationAccepted';
import { areWePending } from './util/groupMembershipUtils'; import { areWePending } from './util/groupMembershipUtils';
import { conversationJobQueue } from './jobs/conversationJobQueue'; import { conversationJobQueue } from './jobs/conversationJobQueue';
import { createBatcher } from './util/batcher';
import { validateConversation } from './util/validateConversation';
import { ConversationModel } from './models/conversations';
import { INITIAL_EXPIRE_TIMER_VERSION } from './util/expirationTimer';
import { missingCaseError } from './util/missingCaseError';
import type {
ConversationAttributesType,
ConversationAttributesTypeType,
ConversationRenderInfoType,
} from './model-types.d';
import type { ServiceIdString, AciString, PniString } from './types/ServiceId';
const log = createLogger('ConversationController'); const log = createLogger('ConversationController');
@ -129,11 +136,7 @@ async function safeCombineConversations(
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
const { const { getAllConversations, getMessagesBySentAt } = DataReader;
getAllConversations,
getAllGroupsInvolvingServiceId,
getMessagesBySentAt,
} = DataReader;
const { const {
migrateConversationMessages, migrateConversationMessages,
@ -143,57 +146,197 @@ const {
updateConversations, updateConversations,
} = DataWriter; } = DataWriter;
// We have to run this in background.js, after all backbone models and collections on
// Whisper.* have been created. Once those are in typescript we can use more reasonable
// require statements for referencing these things, giving us more flexibility here.
export function start(): void {
const conversations = new window.Whisper.ConversationCollection();
window.ConversationController = new ConversationController(conversations);
window.getConversations = () => conversations;
}
export class ConversationController { export class ConversationController {
#_initialFetchComplete = false; #_initialFetchComplete = false;
#isReadOnly = false; #isReadOnly = false;
private _initialPromise: undefined | Promise<void>; #_initialPromise: undefined | Promise<void>;
#_conversations: Array<ConversationModel> = [];
#_conversationOpenStart = new Map<string, number>(); #_conversationOpenStart = new Map<string, number>();
#_hasQueueEmptied = false; #_hasQueueEmptied = false;
#_combineConversationsQueue = new PQueue({ concurrency: 1 }); #_combineConversationsQueue = new PQueue({ concurrency: 1 });
#_signalConversationId: undefined | string; #_signalConversationId: undefined | string;
constructor(private _conversations: ConversationModelCollectionType) { #delayBeforeUpdatingRedux: (() => number) | undefined;
const debouncedUpdateUnreadCount = debounce( #isAppStillLoading: (() => boolean) | undefined;
this.updateUnreadCount.bind(this),
SECOND,
{
leading: true,
maxWait: SECOND,
trailing: true,
}
);
// lookups
#_byE164: Record<string, ConversationModel> = Object.create(null);
#_byServiceId: Record<string, ConversationModel> = Object.create(null);
#_byPni: Record<string, ConversationModel> = Object.create(null);
#_byGroupId: Record<string, ConversationModel> = Object.create(null);
#_byId: Record<string, ConversationModel> = Object.create(null);
#debouncedUpdateUnreadCount = debounce(
this.updateUnreadCount.bind(this),
SECOND,
{
leading: true,
maxWait: SECOND,
trailing: true,
}
);
#convoUpdateBatcher = createBatcher<
| { type: 'change' | 'add'; conversation: ConversationModel }
| { type: 'remove'; id: string }
>({
name: 'changedConvoBatcher',
processBatch: batch => {
let changedOrAddedBatch = new Array<ConversationModel>();
const {
conversationsUpdated,
conversationRemoved,
onConversationClosed,
} = window.reduxActions.conversations;
function flushChangedOrAddedBatch() {
if (!changedOrAddedBatch.length) {
return;
}
conversationsUpdated(
changedOrAddedBatch.map(conversation => conversation.format())
);
changedOrAddedBatch = [];
}
for (const item of batch) {
if (item.type === 'add' || item.type === 'change') {
changedOrAddedBatch.push(item.conversation);
} else {
strictAssert(item.type === 'remove', 'must be remove');
flushChangedOrAddedBatch();
onConversationClosed(item.id, 'removed');
conversationRemoved(item.id);
}
}
flushChangedOrAddedBatch();
},
wait: () => {
return this.#delayBeforeUpdatingRedux?.() ?? 1;
},
maxSize: Infinity,
});
constructor() {
// A few things can cause us to update the app-level unread count // A few things can cause us to update the app-level unread count
window.Whisper.events.on('updateUnreadCount', debouncedUpdateUnreadCount); window.Whisper.events.on(
this._conversations.on( 'updateUnreadCount',
'add remove change:active_at change:unreadCount change:markedUnread change:isArchived change:muteExpiresAt', this.#debouncedUpdateUnreadCount
debouncedUpdateUnreadCount
); );
}
// If the conversation is muted we set a timeout so when the mute expires registerDelayBeforeUpdatingRedux(
// we can reset the mute state on the model. If the mute has already expired delayBeforeUpdatingRedux: () => number
// then we reset the state right away. ): void {
this._conversations.on('add', (model: ConversationModel): void => { this.#delayBeforeUpdatingRedux = delayBeforeUpdatingRedux;
// Don't modify conversations in backup integration testing }
if (isTestOrMockEnvironment()) { registerIsAppStillLoading(isAppStillLoading: () => boolean): void {
return; this.#isAppStillLoading = isAppStillLoading;
}
conversationUpdated(
conversation: ConversationModel,
previousAttributes: ConversationAttributesType
): void {
// eslint-disable-next-line no-param-reassign
conversation.cachedProps = undefined;
const hasAttributeChanged = (name: keyof ConversationAttributesType) => {
return (
name in conversation.attributes &&
conversation.attributes[name] !== previousAttributes[name]
);
};
this.#convoUpdateBatcher.add({ type: 'change', conversation });
if (isDirectConversation(conversation.attributes)) {
const updateLastMessage =
hasAttributeChanged('name') ||
hasAttributeChanged('profileName') ||
hasAttributeChanged('profileFamilyName') ||
hasAttributeChanged('e164');
const memberVerifiedChange = hasAttributeChanged('verified');
if (updateLastMessage || memberVerifiedChange) {
this.#updateAllGroupsWithMember(conversation, {
updateLastMessage,
memberVerifiedChange,
});
}
}
}
#updateAllGroupsWithMember(
member: ConversationModel,
{
updateLastMessage,
memberVerifiedChange,
}: { updateLastMessage: boolean; memberVerifiedChange: boolean }
): void {
const memberServiceId = member.getServiceId();
if (!memberServiceId) {
return;
}
if (!updateLastMessage && !memberVerifiedChange) {
log.error(
`updateAllGroupsWithMember: Called for ${member.idForLogging()} but neither option set`
);
}
const groups = this.getAllGroupsInvolvingServiceId(memberServiceId);
groups.forEach(conversation => {
if (updateLastMessage) {
conversation.debouncedUpdateLastMessage();
}
if (memberVerifiedChange) {
conversation.onMemberVerifiedChange();
} }
model.startMuteTimer();
}); });
} }
#addConversation(conversation: ConversationModel): void {
this.#_conversations.push(conversation);
this.#addToLookup(conversation);
this.#debouncedUpdateUnreadCount();
// Don't modify conversations in backup integration testing
if (!isTestOrMockEnvironment()) {
// If the conversation is muted we set a timeout so when the mute expires
// we can reset the mute state on the model. If the mute has already expired
// then we reset the state right away.
conversation.startMuteTimer();
}
if (this.#isAppStillLoading?.()) {
// The redux update will happen inside the batcher
this.#convoUpdateBatcher.add({ type: 'add', conversation });
} else {
const { conversationsUpdated } = window.reduxActions.conversations;
// During normal app usage, we require conversations to be added synchronously
conversationsUpdated([conversation.format()]);
}
}
#removeConversation(conversation: ConversationModel): void {
this.#_conversations = without(this.#_conversations, conversation);
this.#removeFromLookup(conversation);
this.#debouncedUpdateUnreadCount();
const { id } = conversation || {};
// The redux update call will happen inside the batcher
this.#convoUpdateBatcher.add({ type: 'remove', id });
}
updateUnreadCount(): void { updateUnreadCount(): void {
if (!this.#_hasQueueEmptied) { if (!this.#_hasQueueEmptied) {
return; return;
@ -203,7 +346,7 @@ export class ConversationController {
window.storage.get('badge-count-muted-conversations') || false; window.storage.get('badge-count-muted-conversations') || false;
const unreadStats = countAllConversationsUnreadStats( const unreadStats = countAllConversationsUnreadStats(
this._conversations.map( this.#_conversations.map(
(conversation): ConversationPropsForUnreadStats => { (conversation): ConversationPropsForUnreadStats => {
// Need to pull this out manually into the Redux shape // Need to pull this out manually into the Redux shape
// because `conversation.format()` can return cached props by the // because `conversation.format()` can return cached props by the
@ -251,24 +394,39 @@ export class ConversationController {
'ConversationController.get() needs complete initial fetch' 'ConversationController.get() needs complete initial fetch'
); );
} }
if (!id) {
return undefined;
}
// This function takes null just fine. Backbone typings are too restrictive. return (
return this._conversations.get(id as string); this.#_byE164[id] ||
this.#_byE164[`+${id}`] ||
this.#_byServiceId[id] ||
this.#_byPni[id] ||
this.#_byGroupId[id] ||
this.#_byId[id]
);
} }
getAll(): Array<ConversationModel> { getAll(): Array<ConversationModel> {
return this._conversations.models; return this.#_conversations;
} }
dangerouslyCreateAndAdd( dangerouslyCreateAndAdd(
attributes: Partial<ConversationAttributesType> attributes: ConversationAttributesType
): ConversationModel { ): ConversationModel {
return this._conversations.add(attributes); const model = new ConversationModel(attributes);
this.#addConversation(model);
return model;
} }
dangerouslyRemoveById(id: string): void { dangerouslyRemoveById(id: string): void {
this._conversations.remove(id); const model = this.get(id);
this._conversations.resetLookups(); if (!model) {
return;
}
this.#removeConversation(model);
} }
getOrCreate( getOrCreate(
@ -292,7 +450,7 @@ export class ConversationController {
); );
} }
let conversation = this._conversations.get(identifier); let conversation = this.get(identifier);
if (conversation) { if (conversation) {
return conversation; return conversation;
} }
@ -304,44 +462,64 @@ export class ConversationController {
const id = generateUuid(); const id = generateUuid();
if (type === 'group') { if (type === 'group') {
conversation = this._conversations.add({ conversation = new ConversationModel({
id, id,
serviceId: undefined, serviceId: undefined,
e164: undefined, e164: undefined,
groupId: identifier, groupId: identifier,
type, type,
version: 2, version: 2,
expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION,
unreadCount: 0,
verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT,
messageCount: 0,
sentMessageCount: 0,
...additionalInitialProps, ...additionalInitialProps,
}); });
this.#addConversation(conversation);
} else if (isServiceIdString(identifier)) { } else if (isServiceIdString(identifier)) {
conversation = this._conversations.add({ conversation = new ConversationModel({
id, id,
serviceId: identifier, serviceId: identifier,
e164: undefined, e164: undefined,
groupId: undefined, groupId: undefined,
type, type,
version: 2, version: 2,
expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION,
unreadCount: 0,
verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT,
messageCount: 0,
sentMessageCount: 0,
...additionalInitialProps, ...additionalInitialProps,
}); });
this.#addConversation(conversation);
} else { } else {
conversation = this._conversations.add({ conversation = new ConversationModel({
id, id,
serviceId: undefined, serviceId: undefined,
e164: identifier, e164: identifier,
groupId: undefined, groupId: undefined,
type, type,
version: 2, version: 2,
expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION,
unreadCount: 0,
verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT,
messageCount: 0,
sentMessageCount: 0,
...additionalInitialProps, ...additionalInitialProps,
}); });
this.#addConversation(conversation);
} }
const create = async () => { const create = async () => {
if (!conversation.isValid()) { const validationErrorString = validateConversation(
const validationError = conversation.validationError || {}; conversation.attributes
);
if (validationErrorString) {
log.error( log.error(
'Contact is not valid. Not saving, but adding to collection:', 'Contact is not valid. Not saving, but adding to collection:',
conversation.idForLogging(), conversation.idForLogging(),
Errors.toLogFormat(validationError) validationErrorString
); );
return conversation; return conversation;
@ -755,7 +933,7 @@ export class ConversationController {
(targetOldServiceIds.pni !== pni || (targetOldServiceIds.pni !== pni ||
(aci && targetOldServiceIds.aci !== aci)) (aci && targetOldServiceIds.aci !== aci))
) { ) {
targetConversation.unset('needsTitleTransition'); targetConversation.set({ needsTitleTransition: undefined });
mergePromises.push( mergePromises.push(
targetConversation.addPhoneNumberDiscoveryIfNeeded( targetConversation.addPhoneNumberDiscoveryIfNeeded(
targetOldServiceIds.pni targetOldServiceIds.pni
@ -873,12 +1051,10 @@ export class ConversationController {
// We also want to find duplicate GV1 IDs. You might expect to see a "byGroupV1Id" map // We also want to find duplicate GV1 IDs. You might expect to see a "byGroupV1Id" map
// here. Instead, we check for duplicates on the derived GV2 ID. // here. Instead, we check for duplicates on the derived GV2 ID.
const { models } = this._conversations;
// We iterate from the oldest conversations to the newest. This allows us, in a // We iterate from the oldest conversations to the newest. This allows us, in a
// conflict case, to keep the one with activity the most recently. // conflict case, to keep the one with activity the most recently.
for (let i = models.length - 1; i >= 0; i -= 1) { for (let i = this.#_conversations.length - 1; i >= 0; i -= 1) {
const conversation = models[i]; const conversation = this.#_conversations[i];
assertDev( assertDev(
conversation, conversation,
'Expected conversation to be found in array during iteration' 'Expected conversation to be found in array during iteration'
@ -1090,15 +1266,14 @@ export class ConversationController {
} else { } else {
activeAt = obsoleteActiveAt || currentActiveAt; activeAt = obsoleteActiveAt || currentActiveAt;
} }
current.set('active_at', activeAt); current.set({ active_at: activeAt });
current.set( current.set({
'expireTimerVersion', expireTimerVersion: Math.max(
Math.max(
obsolete.get('expireTimerVersion') ?? 1, obsolete.get('expireTimerVersion') ?? 1,
current.get('expireTimerVersion') ?? 1 current.get('expireTimerVersion') ?? 1
) ),
); });
const obsoleteExpireTimer = obsolete.get('expireTimer'); const obsoleteExpireTimer = obsolete.get('expireTimer');
const currentExpireTimer = current.get('expireTimer'); const currentExpireTimer = current.get('expireTimer');
@ -1106,7 +1281,7 @@ export class ConversationController {
!currentExpireTimer || !currentExpireTimer ||
(obsoleteExpireTimer && obsoleteExpireTimer < currentExpireTimer) (obsoleteExpireTimer && obsoleteExpireTimer < currentExpireTimer)
) { ) {
current.set('expireTimer', obsoleteExpireTimer); current.set({ expireTimer: obsoleteExpireTimer });
} }
const currentHadMessages = (current.get('messageCount') ?? 0) > 0; const currentHadMessages = (current.get('messageCount') ?? 0) > 0;
@ -1136,11 +1311,11 @@ export class ConversationController {
>; >;
keys.forEach(key => { keys.forEach(key => {
if (current.get(key) === undefined) { if (current.get(key) === undefined) {
current.set(key, dataToCopy[key]); current.set({ [key]: dataToCopy[key] });
// To ensure that any files on disk don't get deleted out from under us // To ensure that any files on disk don't get deleted out from under us
if (key === 'draftAttachments') { if (key === 'draftAttachments') {
obsolete.set(key, undefined); obsolete.set({ [key]: undefined });
} }
} }
}); });
@ -1244,8 +1419,7 @@ export class ConversationController {
log.warn( log.warn(
`${logId}: Eliminate old conversation from ConversationController lookups` `${logId}: Eliminate old conversation from ConversationController lookups`
); );
this._conversations.remove(obsolete); this.#removeConversation(obsolete);
this._conversations.resetLookups();
current.captureChange('combineConversations'); current.captureChange('combineConversations');
drop(current.updateLastMessage()); drop(current.updateLastMessage());
@ -1305,22 +1479,25 @@ export class ConversationController {
return null; return null;
} }
async getAllGroupsInvolvingServiceId( getAllGroupsInvolvingServiceId(
serviceId: ServiceIdString serviceId: ServiceIdString
): Promise<Array<ConversationModel>> { ): Array<ConversationModel> {
const groups = await getAllGroupsInvolvingServiceId(serviceId); return this.#_conversations
return groups.map(group => { .map(conversation => {
const existing = this.get(group.id); if (!isGroup(conversation.attributes)) {
if (existing) { return;
return existing; }
} if (!conversation.hasMember(serviceId)) {
return;
}
return this._conversations.add(group); return conversation;
}); })
.filter(isNotNil);
} }
getByDerivedGroupV2Id(groupId: string): ConversationModel | undefined { getByDerivedGroupV2Id(groupId: string): ConversationModel | undefined {
return this._conversations.find( return this.#_conversations.find(
item => item.get('derivedGroupV2Id') === groupId item => item.get('derivedGroupV2Id') === groupId
); );
} }
@ -1336,14 +1513,18 @@ export class ConversationController {
} }
reset(): void { reset(): void {
delete this._initialPromise; const { removeAllConversations } = window.reduxActions.conversations;
this.#_initialPromise = undefined;
this.#_initialFetchComplete = false; this.#_initialFetchComplete = false;
this._conversations.reset([]); this.#_conversations = [];
removeAllConversations();
this.#resetLookups();
} }
load(): Promise<void> { load(): Promise<void> {
this._initialPromise ||= this.#doLoad(); this.#_initialPromise ||= this.#doLoad();
return this._initialPromise; return this.#_initialPromise;
} }
// A number of things outside conversation.attributes affect conversation re-rendering. // A number of things outside conversation.attributes affect conversation re-rendering.
@ -1354,7 +1535,7 @@ export class ConversationController {
let count = 0; let count = 0;
const conversations = identifiers const conversations = identifiers
? identifiers.map(identifier => this.get(identifier)).filter(isNotNil) ? identifiers.map(identifier => this.get(identifier)).filter(isNotNil)
: this._conversations.models.slice(); : this.#_conversations.slice();
log.info( log.info(
`forceRerender: Starting to loop through ${conversations.length} conversations` `forceRerender: Starting to loop through ${conversations.length} conversations`
); );
@ -1366,7 +1547,7 @@ export class ConversationController {
conversation.oldCachedProps = conversation.cachedProps; conversation.oldCachedProps = conversation.cachedProps;
conversation.cachedProps = null; conversation.cachedProps = null;
conversation.trigger('props-change', conversation, false); this.conversationUpdated(conversation, conversation.attributes);
count += 1; count += 1;
} }
@ -1426,8 +1607,10 @@ export class ConversationController {
); );
} }
conversation.set('avatar', undefined); conversation.set({
conversation.set('profileAvatar', undefined); avatar: undefined,
profileAvatar: undefined,
});
drop(updateConversation(conversation.attributes)); drop(updateConversation(conversation.attributes));
numberOfConversationsMigrated += 1; numberOfConversationsMigrated += 1;
} }
@ -1449,7 +1632,7 @@ export class ConversationController {
} }
log.warn(`Repairing ${convo.idForLogging()}'s isPinned`); log.warn(`Repairing ${convo.idForLogging()}'s isPinned`);
convo.set('isPinned', true); convo.set({ isPinned: true });
drop(updateConversation(convo.attributes)); drop(updateConversation(convo.attributes));
} }
@ -1469,7 +1652,7 @@ export class ConversationController {
await updateConversations( await updateConversations(
sharedWith.map(c => { sharedWith.map(c => {
c.unset('shareMyPhoneNumber'); c.set({ shareMyPhoneNumber: undefined });
return c.attributes; return c.attributes;
}) })
); );
@ -1496,15 +1679,14 @@ export class ConversationController {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await removeConversation(convo.id); await removeConversation(convo.id);
this._conversations.remove(convo); this.#removeConversation(convo);
this._conversations.resetLookups();
} }
} }
async #doLoad(): Promise<void> { async #doLoad(): Promise<void> {
log.info('starting initial fetch'); log.info('starting initial fetch');
if (this._conversations.length) { if (this.#_conversations.length) {
throw new Error('ConversationController: Already loaded!'); throw new Error('ConversationController: Already loaded!');
} }
@ -1540,14 +1722,16 @@ export class ConversationController {
this.#_initialFetchComplete = true; this.#_initialFetchComplete = true;
// Hydrate the final set of conversations // Hydrate the final set of conversations
batchDispatch(() => {
this._conversations.add( collection
collection.filter(conversation => !conversation.isTemporary) .filter(conversation => !conversation.isTemporary)
.forEach(conversation =>
this.#_conversations.push(new ConversationModel(conversation))
); );
}); this.#generateLookups();
await Promise.all( await Promise.all(
this._conversations.map(async conversation => { this.#_conversations.map(async conversation => {
try { try {
// Hydrate contactCollection, now that initial fetch is complete // Hydrate contactCollection, now that initial fetch is complete
conversation.fetchContacts(); conversation.fetchContacts();
@ -1587,13 +1771,14 @@ export class ConversationController {
); );
log.info( log.info(
'done with initial fetch, ' + 'done with initial fetch, ' +
`got ${this._conversations.length} conversations` `got ${this.#_conversations.length} conversations`
); );
} catch (error) { } catch (error) {
log.error('initial fetch failed', Errors.toLogFormat(error)); log.error('initial fetch failed', Errors.toLogFormat(error));
throw error; throw error;
} }
} }
async archiveSessionsForConversation( async archiveSessionsForConversation(
conversationId: string | undefined conversationId: string | undefined
): Promise<void> { ): Promise<void> {
@ -1635,4 +1820,203 @@ export class ConversationController {
log.info(`${logId}: Complete!`); log.info(`${logId}: Complete!`);
} }
idUpdated(
model: ConversationModel,
idProp: 'e164' | 'serviceId' | 'pni' | 'groupId',
oldValue: string | undefined
): void {
const logId = `idUpdated/${model.idForLogging()}/${idProp}`;
if (oldValue) {
if (idProp === 'e164') {
delete this.#_byE164[oldValue];
} else if (idProp === 'serviceId') {
delete this.#_byServiceId[oldValue];
} else if (idProp === 'pni') {
delete this.#_byPni[oldValue];
} else if (idProp === 'groupId') {
delete this.#_byGroupId[oldValue];
} else {
throw missingCaseError(idProp);
}
}
if (idProp === 'e164') {
const e164 = model.get('e164');
if (e164) {
const existing = this.#_byE164[e164];
if (existing) {
log.warn(`${logId}: Existing match found on lookup`);
}
this.#_byE164[e164] = model;
}
} else if (idProp === 'serviceId') {
const serviceId = model.getServiceId();
if (serviceId) {
const existing = this.#_byServiceId[serviceId];
if (existing) {
log.warn(`${logId}: Existing match found on lookup`);
}
this.#_byServiceId[serviceId] = model;
}
} else if (idProp === 'pni') {
const pni = model.get('pni');
if (pni) {
const existing = this.#_byPni[pni];
if (existing) {
log.warn(`${logId}: Existing match found on lookup`);
}
this.#_byPni[pni] = model;
}
} else if (idProp === 'groupId') {
const groupId = model.get('groupId');
if (groupId) {
const existing = this.#_byGroupId[groupId];
if (existing) {
log.warn(`${logId}: Existing match found on lookup`);
}
this.#_byGroupId[groupId] = model;
}
} else {
throw missingCaseError(idProp);
}
}
#resetLookups(): void {
this.#eraseLookups();
this.#generateLookups();
}
#addToLookup(conversation: ConversationModel): void {
const logId = `addToLookup/${conversation.idForLogging()}`;
const id = conversation.get('id');
if (id) {
const existing = this.#_byId[id];
if (existing) {
log.warn(`${logId}: Conflict found by id`);
}
if (!existing || (existing && !existing.getServiceId())) {
this.#_byId[id] = conversation;
}
}
const e164 = conversation.get('e164');
if (e164) {
const existing = this.#_byE164[e164];
if (existing) {
log.warn(`${logId}: Conflict found by e164`);
}
if (!existing || (existing && !existing.getServiceId())) {
this.#_byE164[e164] = conversation;
}
}
const serviceId = conversation.getServiceId();
if (serviceId) {
const existing = this.#_byServiceId[serviceId];
if (existing) {
log.warn(`${logId}: Conflict found by serviceId`);
}
if (!existing || (existing && !existing.get('e164'))) {
this.#_byServiceId[serviceId] = conversation;
}
}
const pni = conversation.getPni();
if (pni) {
const existing = this.#_byPni[pni];
if (existing) {
log.warn(`${logId}: Conflict found by pni`);
}
if (!existing || (existing && !existing.getServiceId())) {
this.#_byPni[pni] = conversation;
}
}
const groupId = conversation.get('groupId');
if (groupId) {
const existing = this.#_byGroupId[groupId];
if (existing) {
log.warn(`${logId}: Conflict found by groupId`);
}
this.#_byGroupId[groupId] = conversation;
}
}
#removeFromLookup(conversation: ConversationModel): void {
const logId = `removeFromLookup/${conversation.idForLogging()}`;
const id = conversation.get('id');
if (id) {
const existing = this.#_byId[id];
if (existing && existing !== conversation) {
log.warn(`${logId}: By id; model in lookup didn't match conversation`);
} else {
delete this.#_byId[id];
}
}
const e164 = conversation.get('e164');
if (e164) {
const existing = this.#_byE164[e164];
if (existing && existing !== conversation) {
log.warn(
`${logId}: By e164; model in lookup didn't match conversation`
);
} else {
delete this.#_byE164[e164];
}
}
const serviceId = conversation.getServiceId();
if (serviceId) {
const existing = this.#_byServiceId[serviceId];
if (existing && existing !== conversation) {
log.warn(
`${logId}: By serviceId; model in lookup didn't match conversation`
);
} else {
delete this.#_byServiceId[serviceId];
}
}
const pni = conversation.getPni();
if (pni) {
const existing = this.#_byPni[pni];
if (existing && existing !== conversation) {
log.warn(`${logId}: By pni; model in lookup didn't match conversation`);
} else {
delete this.#_byPni[pni];
}
}
const groupId = conversation.get('groupId');
if (groupId) {
const existing = this.#_byGroupId[groupId];
if (existing && existing !== conversation) {
log.warn(
`${logId}: By groupId; model in lookup didn't match conversation`
);
} else {
delete this.#_byGroupId[groupId];
}
}
}
#generateLookups(): void {
this.#_conversations.forEach(conversation =>
this.#addToLookup(conversation)
);
}
#eraseLookups(): void {
this.#_byE164 = Object.create(null);
this.#_byServiceId = Object.create(null);
this.#_byPni = Object.create(null);
this.#_byGroupId = Object.create(null);
this.#_byId = Object.create(null);
}
} }

View file

@ -2621,11 +2621,13 @@ export class SignalProtocolStore extends EventEmitter {
async removeAllConfiguration(): Promise<void> { async removeAllConfiguration(): Promise<void> {
// Conversations. These properties are not present in redux. // Conversations. These properties are not present in redux.
window.getConversations().forEach(conversation => { window.ConversationController.getAll().forEach(conversation => {
conversation.unset('storageID'); conversation.set({
conversation.unset('needsStorageServiceSync'); storageID: undefined,
conversation.unset('storageUnknownFields'); needsStorageServiceSync: undefined,
conversation.unset('senderKeyInfo'); storageUnknownFields: undefined,
senderKeyInfo: undefined,
});
}); });
await DataWriter.removeAllConfiguration(); await DataWriter.removeAllConfiguration();

View file

@ -1,159 +0,0 @@
// Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type * as Backbone from 'backbone';
import { createLogger } from '../logging/log';
const log = createLogger('reliable_trigger');
type InternalBackboneEvent = {
callback: (...args: Array<unknown>) => unknown;
ctx: unknown;
};
/* eslint-disable */
// This file was taken from Backbone and then modified. It does not conform to this
// project's standards.
// Note: this is all the code required to customize Backbone's trigger() method to make
// it resilient to exceptions thrown by event handlers. Indentation and code styles
// were kept inline with the Backbone implementation for easier diffs.
// The changes are:
// 1. added 'name' parameter to triggerEvents to give it access to the
// current event name
// 2. added try/catch handlers to triggerEvents with error logging inside
// every while loop
// And of course, we update the prototypes of Backbone.Model/Backbone.View as well as
// Backbone.Events itself
// Regular expression used to split event strings.
const eventSplitter = /\s+/;
// Implement fancy features of the Events API such as multiple event
// names `"change blur"` and jQuery-style event maps `{change: action}`
// in terms of the existing API.
const eventsApi = function (
obj: Backbone.Events,
name: string | Record<string, unknown>,
rest: ReadonlyArray<unknown>
) {
if (!name) return true;
// Handle event maps.
if (typeof name === 'object') {
for (const key in name) {
obj.trigger(key, name[key], ...rest);
}
return false;
}
// Handle space separated event names.
if (eventSplitter.test(name)) {
const names = name.split(eventSplitter);
for (let i = 0, l = names.length; i < l; i++) {
obj.trigger(names[i], ...rest);
}
return false;
}
return true;
};
// A difficult-to-believe, but optimized internal dispatch function for
// triggering events. Tries to keep the usual cases speedy (most internal
// Backbone events have 3 arguments).
const triggerEvents = function (
events: ReadonlyArray<InternalBackboneEvent>,
name: string,
args: Array<unknown>
) {
let ev,
i = -1,
l = events.length,
a1 = args[0],
a2 = args[1],
a3 = args[2];
const logError = function (error: unknown) {
log.error(
'Model caught error triggering',
name,
'event:',
error && error instanceof Error && error.stack ? error.stack : error
);
};
switch (args.length) {
case 0:
while (++i < l) {
try {
(ev = events[i]).callback.call(ev.ctx);
} catch (error) {
logError(error);
}
}
return;
case 1:
while (++i < l) {
try {
(ev = events[i]).callback.call(ev.ctx, a1);
} catch (error) {
logError(error);
}
}
return;
case 2:
while (++i < l) {
try {
(ev = events[i]).callback.call(ev.ctx, a1, a2);
} catch (error) {
logError(error);
}
}
return;
case 3:
while (++i < l) {
try {
(ev = events[i]).callback.call(ev.ctx, a1, a2, a3);
} catch (error) {
logError(error);
}
}
return;
default:
while (++i < l) {
try {
(ev = events[i]).callback.apply(ev.ctx, args);
} catch (error) {
logError(error);
}
}
}
};
// Trigger one or many events, firing all bound callbacks. Callbacks are
// passed the same arguments as `trigger` is, apart from the event name
// (unless you're listening on `"all"`, which will cause your callback to
// receive the true name of the event as the first argument).
function trigger<
T extends Backbone.Events & {
_events: undefined | Record<string, ReadonlyArray<InternalBackboneEvent>>;
},
>(this: T, name: string, ...args: Array<unknown>): T {
if (!this._events) return this;
if (!eventsApi(this, name, args)) return this;
const events = this._events[name];
const allEvents = this._events.all;
if (events) triggerEvents(events, name, args);
if (allEvents) triggerEvents(allEvents, name, [...arguments]);
return this;
}
[
window.Backbone.Model.prototype,
window.Backbone.Collection.prototype,
window.Backbone.Events,
].forEach(proto => {
Object.assign(proto, { trigger });
});

View file

@ -1,12 +1,11 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isNumber, groupBy, throttle } from 'lodash'; import { isNumber, throttle } from 'lodash';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import pMap from 'p-map'; import pMap from 'p-map';
import { v7 as generateUuid } from 'uuid'; import { v7 as generateUuid } from 'uuid';
import { batch as batchDispatch } from 'react-redux';
import * as Registration from './util/registration'; import * as Registration from './util/registration';
import MessageReceiver from './textsecure/MessageReceiver'; import MessageReceiver from './textsecure/MessageReceiver';
@ -25,8 +24,6 @@ import * as Bytes from './Bytes';
import * as Timers from './Timers'; import * as Timers from './Timers';
import * as indexedDb from './indexeddb'; import * as indexedDb from './indexeddb';
import type { MenuOptionsType } from './types/menu'; import type { MenuOptionsType } from './types/menu';
import type { Receipt } from './types/Receipt';
import { ReceiptType } from './types/Receipt';
import { SocketStatus } from './types/SocketStatus'; import { SocketStatus } from './types/SocketStatus';
import { DEFAULT_CONVERSATION_COLOR } from './types/Colors'; import { DEFAULT_CONVERSATION_COLOR } from './types/Colors';
import { ThemeType } from './types/Util'; import { ThemeType } from './types/Util';
@ -153,10 +150,7 @@ import { deleteAllLogs } from './util/deleteAllLogs';
import { startInteractionMode } from './services/InteractionMode'; import { startInteractionMode } from './services/InteractionMode';
import { ReactionSource } from './reactions/ReactionSource'; import { ReactionSource } from './reactions/ReactionSource';
import { singleProtoJobQueue } from './jobs/singleProtoJobQueue'; import { singleProtoJobQueue } from './jobs/singleProtoJobQueue';
import { import { conversationJobQueue } from './jobs/conversationJobQueue';
conversationJobQueue,
conversationQueueJobEnum,
} from './jobs/conversationJobQueue';
import { SeenStatus } from './MessageSeenStatus'; import { SeenStatus } from './MessageSeenStatus';
import MessageSender from './textsecure/SendMessage'; import MessageSender from './textsecure/SendMessage';
import type AccountManager from './textsecure/AccountManager'; import type AccountManager from './textsecure/AccountManager';
@ -304,29 +298,7 @@ export async function startApp(): Promise<void> {
const onRetryRequestQueue = new PQueue({ concurrency: 1 }); const onRetryRequestQueue = new PQueue({ concurrency: 1 });
onRetryRequestQueue.pause(); onRetryRequestQueue.pause();
window.Whisper.deliveryReceiptQueue = new PQueue({
concurrency: 1,
timeout: durations.MINUTE * 30,
});
window.Whisper.deliveryReceiptQueue.pause(); window.Whisper.deliveryReceiptQueue.pause();
window.Whisper.deliveryReceiptBatcher = createBatcher<Receipt>({
name: 'Whisper.deliveryReceiptBatcher',
wait: 500,
maxSize: 100,
processBatch: async deliveryReceipts => {
const groups = groupBy(deliveryReceipts, 'conversationId');
await Promise.all(
Object.keys(groups).map(async conversationId => {
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.Receipts,
conversationId,
receiptsType: ReceiptType.Delivery,
receipts: groups[conversationId],
});
})
);
},
});
if (window.platform === 'darwin') { if (window.platform === 'darwin') {
window.addEventListener('dblclick', (event: Event) => { window.addEventListener('dblclick', (event: Event) => {
@ -441,7 +413,7 @@ export async function startApp(): Promise<void> {
}); });
accountManager.addEventListener('endRegistration', () => { accountManager.addEventListener('endRegistration', () => {
window.Whisper.events.trigger('userChanged', false); window.Whisper.events.emit('userChanged', false);
drop(window.storage.put('postRegistrationSyncsStatus', 'incomplete')); drop(window.storage.put('postRegistrationSyncsStatus', 'incomplete'));
registrationCompleted?.resolve(); registrationCompleted?.resolve();
@ -596,6 +568,23 @@ export async function startApp(): Promise<void> {
storage: window.storage, storage: window.storage,
serverTrustRoot: window.getServerTrustRoot(), serverTrustRoot: window.getServerTrustRoot(),
}); });
window.ConversationController.registerDelayBeforeUpdatingRedux(() => {
if (backupsService.isImportRunning()) {
return 500;
}
if (messageReceiver && !messageReceiver.hasEmptied()) {
return 250;
}
return 1;
});
window.ConversationController.registerIsAppStillLoading(() => {
return (
backupsService.isImportRunning() ||
!window.reduxStore?.getState().app.hasInitialLoadCompleted
);
});
function queuedEventListener<E extends Event>( function queuedEventListener<E extends Event>(
handler: (event: E) => Promise<void> | void handler: (event: E) => Promise<void> | void
@ -1215,114 +1204,6 @@ export async function startApp(): Promise<void> {
function setupAppState() { function setupAppState() {
initializeRedux(getParametersForRedux()); initializeRedux(getParametersForRedux());
// Here we set up a full redux store with initial state for our LeftPane Root
const convoCollection = window.getConversations();
const {
conversationsUpdated,
conversationRemoved,
removeAllConversations,
onConversationClosed,
} = window.reduxActions.conversations;
// Conversation add/update/remove actions are batched in this batcher to ensure
// that we retain correct orderings
const convoUpdateBatcher = createBatcher<
| { type: 'change' | 'add'; conversation: ConversationModel }
| { type: 'remove'; id: string }
>({
name: 'changedConvoBatcher',
processBatch(batch) {
let changedOrAddedBatch = new Array<ConversationModel>();
function flushChangedOrAddedBatch() {
if (!changedOrAddedBatch.length) {
return;
}
conversationsUpdated(
changedOrAddedBatch.map(conversation => conversation.format())
);
changedOrAddedBatch = [];
}
batchDispatch(() => {
for (const item of batch) {
if (item.type === 'add' || item.type === 'change') {
changedOrAddedBatch.push(item.conversation);
} else {
strictAssert(item.type === 'remove', 'must be remove');
flushChangedOrAddedBatch();
onConversationClosed(item.id, 'removed');
conversationRemoved(item.id);
}
}
flushChangedOrAddedBatch();
});
},
wait: () => {
if (backupsService.isImportRunning()) {
return 500;
}
if (messageReceiver && !messageReceiver.hasEmptied()) {
return 250;
}
// This delay ensures that the .format() call isn't synchronous as a
// Backbone property is changed. Important because our _byUuid/_byE164
// lookups aren't up-to-date as the change happens; just a little bit
// after.
return 1;
},
maxSize: Infinity,
});
convoCollection.on('add', (conversation: ConversationModel | undefined) => {
if (!conversation) {
return;
}
if (
backupsService.isImportRunning() ||
!window.reduxStore.getState().app.hasInitialLoadCompleted
) {
convoUpdateBatcher.add({ type: 'add', conversation });
} else {
// During normal app usage, we require conversations to be added synchronously
conversationsUpdated([conversation.format()]);
}
});
convoCollection.on('remove', conversation => {
const { id } = conversation || {};
convoUpdateBatcher.add({ type: 'remove', id });
});
convoCollection.on(
'props-change',
(conversation: ConversationModel | undefined, isBatched?: boolean) => {
if (!conversation) {
return;
}
// `isBatched` is true when the `.set()` call on the conversation model already
// runs from within `react-redux`'s batch. Instead of batching the redux update
// for later, update immediately. To ensure correct update ordering, only do this
// optimization if there are no other pending conversation updates
if (isBatched && !convoUpdateBatcher.anyPending()) {
conversationsUpdated([conversation.format()]);
return;
}
convoUpdateBatcher.add({ type: 'change', conversation });
}
);
// Called by SignalProtocolStore#removeAllData()
convoCollection.on('reset', removeAllConversations);
window.Whisper.events.on('userChanged', (reconnect = false) => { window.Whisper.events.on('userChanged', (reconnect = false) => {
const newDeviceId = window.textsecure.storage.user.getDeviceId(); const newDeviceId = window.textsecure.storage.user.getDeviceId();
const newNumber = window.textsecure.storage.user.getNumber(); const newNumber = window.textsecure.storage.user.getNumber();
@ -1332,7 +1213,7 @@ export async function startApp(): Promise<void> {
window.ConversationController.getOurConversation(); window.ConversationController.getOurConversation();
if (ourConversation?.get('e164') !== newNumber) { if (ourConversation?.get('e164') !== newNumber) {
ourConversation?.set('e164', newNumber); ourConversation?.set({ e164: newNumber });
} }
window.reduxActions.user.userChanged({ window.reduxActions.user.userChanged({
@ -1566,7 +1447,7 @@ export async function startApp(): Promise<void> {
window.IPC.setMenuBarVisibility(!hideMenuBar); window.IPC.setMenuBarVisibility(!hideMenuBar);
startTimeTravelDetector(() => { startTimeTravelDetector(() => {
window.Whisper.events.trigger('timetravel'); window.Whisper.events.emit('timetravel');
}); });
updateExpiringMessagesService(); updateExpiringMessagesService();
@ -3145,7 +3026,7 @@ export async function startApp(): Promise<void> {
} }
async function unlinkAndDisconnect(): Promise<void> { async function unlinkAndDisconnect(): Promise<void> {
window.Whisper.events.trigger('unauthorized'); window.Whisper.events.emit('unauthorized');
log.warn( log.warn(
'unlinkAndDisconnect: Client is no longer authorized; ' + 'unlinkAndDisconnect: Client is no longer authorized; ' +
@ -3192,7 +3073,7 @@ export async function startApp(): Promise<void> {
const ourConversation = const ourConversation =
window.ConversationController.getOurConversation(); window.ConversationController.getOurConversation();
if (ourConversation) { if (ourConversation) {
ourConversation.unset('username'); ourConversation.set({ username: undefined });
await DataWriter.updateConversation(ourConversation.attributes); await DataWriter.updateConversation(ourConversation.attributes);
} }

View file

@ -554,6 +554,9 @@ export function CallsList({
}; };
let timer = setTimeout(() => { let timer = setTimeout(() => {
if (controller.signal.aborted) {
return;
}
setSearchState(prevSearchState => { setSearchState(prevSearchState => {
if (prevSearchState.state === 'init') { if (prevSearchState.state === 'init') {
return defaultPendingState; return defaultPendingState;
@ -561,6 +564,10 @@ export function CallsList({
return prevSearchState; return prevSearchState;
}); });
timer = setTimeout(() => { timer = setTimeout(() => {
if (controller.signal.aborted) {
return;
}
// Show loading indicator after a delay // Show loading indicator after a delay
setSearchState(defaultPendingState); setSearchState(defaultPendingState);
}, 300); }, 300);

View file

@ -140,7 +140,7 @@ type PropsHousekeepingType = {
}; };
export type PropsActionsType = { export type PropsActionsType = {
// From Backbone // From Model
acknowledgeGroupMemberNameCollisions: ( acknowledgeGroupMemberNameCollisions: (
conversationId: string, conversationId: string,
groupNameCollisions: ReadonlyDeep<GroupNameCollisionsWithIdsByTitle> groupNameCollisions: ReadonlyDeep<GroupNameCollisionsWithIdsByTitle>

View file

@ -3304,7 +3304,11 @@ async function updateGroup(
}); });
if (idChanged) { if (idChanged) {
conversation.trigger('idUpdated', conversation, 'groupId', previousId); window.ConversationController.idUpdated(
conversation,
'groupId',
previousId
);
} }
// Save these most recent updates to conversation // Save these most recent updates to conversation

View file

@ -104,7 +104,7 @@ export async function onDelete(item: DeleteForMeAttributesType): Promise<void> {
let result: boolean; let result: boolean;
if (item.deleteAttachmentData) { if (item.deleteAttachmentData) {
// This will find the message, then work with a backbone model to mirror what // This will find the message, then work with a model to mirror what
// modifyTargetMessage does. // modifyTargetMessage does.
result = await deleteAttachmentFromMessage( result = await deleteAttachmentFromMessage(
conversation.id, conversation.id,

View file

@ -297,7 +297,7 @@ const deleteSentProtoBatcher = createWaitBatcher({
// `deleteSentProtoRecipient` has already updated the database so there // `deleteSentProtoRecipient` has already updated the database so there
// is no need in calling `updateConversation` // is no need in calling `updateConversation`
convo.unset('shareMyPhoneNumber'); convo.set({ shareMyPhoneNumber: undefined });
} }
}, },
}); });

View file

@ -57,7 +57,7 @@ export async function saveAndNotify(
conversation.incrementSentMessageCount(); conversation.incrementSentMessageCount();
} }
window.Whisper.events.trigger('incrementProgress'); window.Whisper.events.emit('incrementProgress');
confirm(); confirm();
if (!isStory(message.attributes)) { if (!isStory(message.attributes)) {

8
ts/model-types.d.ts vendored
View file

@ -1,14 +1,12 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as Backbone from 'backbone';
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
import type { GroupV2ChangeType } from './groups'; import type { GroupV2ChangeType } from './groups';
import type { DraftBodyRanges, RawBodyRange } from './types/BodyRange'; import type { DraftBodyRanges, RawBodyRange } from './types/BodyRange';
import type { CustomColorType, ConversationColorType } from './types/Colors'; import type { CustomColorType, ConversationColorType } from './types/Colors';
import type { SendMessageChallengeData } from './textsecure/Errors'; import type { SendMessageChallengeData } from './textsecure/Errors';
import type { ConversationModel } from './models/conversations';
import type { ProfileNameChangeType } from './util/getStringForProfileChange'; import type { ProfileNameChangeType } from './util/getStringForProfileChange';
import type { CapabilitiesType } from './textsecure/WebAPI'; import type { CapabilitiesType } from './textsecure/WebAPI';
import type { ReadStatus } from './messages/MessageReadStatus'; import type { ReadStatus } from './messages/MessageReadStatus';
@ -486,7 +484,7 @@ export type ConversationAttributesType = {
groupInviteLinkPassword?: string; groupInviteLinkPassword?: string;
previousGroupV1Id?: string; previousGroupV1Id?: string;
previousGroupV1Members?: Array<string>; previousGroupV1Members?: Array<string>;
acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle; acknowledgedGroupNameCollisions?: ReadonlyDeep<GroupNameCollisionsWithIdsByTitle>;
// Used only when user is waiting for approval to join via link // Used only when user is waiting for approval to join via link
isTemporary?: boolean; isTemporary?: boolean;
@ -561,7 +559,3 @@ export type ShallowChallengeError = CustomError & {
readonly retryAfter: number; readonly retryAfter: number;
readonly data: SendMessageChallengeData; readonly data: SendMessageChallengeData;
}; };
export declare class ConversationModelCollectionType extends Backbone.Collection<ConversationModel> {
resetLookups(): void;
}

View file

@ -1,8 +1,7 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { compact, has, isNumber, throttle, debounce } from 'lodash'; import { compact, isNumber, throttle, debounce } from 'lodash';
import { batch as batchDispatch } from 'react-redux';
import { v4 as generateGuid } from 'uuid'; import { v4 as generateGuid } from 'uuid';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
@ -193,22 +192,10 @@ import { getTypingIndicatorSetting } from '../types/Util';
import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer'; import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer';
import { maybeNotify } from '../messages/maybeNotify'; import { maybeNotify } from '../messages/maybeNotify';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import * as Message from '../types/Message2';
const log = createLogger('conversations'); const log = createLogger('conversations');
window.Whisper = window.Whisper || {};
const { Message } = window.Signal.Types;
const {
copyIntoTempDirectory,
deleteAttachmentData,
doesAttachmentExist,
getAbsoluteAttachmentPath,
getAbsoluteTempPath,
readStickerData,
upgradeMessageSchema,
writeNewAttachmentData,
} = window.Signal.Migrations;
const { const {
getConversationRangeCenteredOnMessage, getConversationRangeCenteredOnMessage,
getOlderMessagesByConversation, getOlderMessagesByConversation,
@ -228,15 +215,6 @@ const SEND_REPORTING_THRESHOLD_MS = 25;
const MESSAGE_LOAD_CHUNK_SIZE = 30; const MESSAGE_LOAD_CHUNK_SIZE = 30;
const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([
'lastProfile',
'profileLastFetchedAt',
'needsStorageServiceSync',
'storageID',
'storageVersion',
'storageUnknownFields',
]);
const MAX_EXPIRE_TIMER_VERSION = 0xffffffff; const MAX_EXPIRE_TIMER_VERSION = 0xffffffff;
type CachedIdenticon = { type CachedIdenticon = {
@ -245,11 +223,13 @@ type CachedIdenticon = {
readonly path?: string; readonly path?: string;
readonly url: string; readonly url: string;
}; };
type StringKey<T> = keyof T & string;
export class ConversationModel extends window.Backbone export class ConversationModel {
.Model<ConversationAttributesType> {
static COLORS: string; static COLORS: string;
#_attributes: ConversationAttributesType;
cachedProps?: ConversationType | null; cachedProps?: ConversationType | null;
oldCachedProps?: ConversationType | null; oldCachedProps?: ConversationType | null;
@ -263,7 +243,7 @@ export class ConversationModel extends window.Backbone
} }
>; >;
contactCollection?: Backbone.Collection<ConversationModel>; contactCollection?: Array<ConversationModel>;
debouncedUpdateLastMessage: (() => void) & { flush(): void }; debouncedUpdateLastMessage: (() => void) & { flush(): void };
@ -305,19 +285,70 @@ export class ConversationModel extends window.Backbone
#lastIsTyping?: boolean; #lastIsTyping?: boolean;
#muteTimer?: NodeJS.Timeout; #muteTimer?: NodeJS.Timeout;
#isInReduxBatch = false;
#privVerifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus; #privVerifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus;
#isShuttingDown = false; #isShuttingDown = false;
#savePromises = new Set<Promise<void>>(); #savePromises = new Set<Promise<void>>();
override defaults(): Partial<ConversationAttributesType> { public get id(): string {
return { return this.#_attributes.id;
unreadCount: 0, }
verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT,
messageCount: 0, public get<keyName extends StringKey<ConversationAttributesType>>(
sentMessageCount: 0, key: keyName
expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION, ): ConversationAttributesType[keyName] {
return this.attributes[key];
}
public set(
attributes: Partial<ConversationAttributesType>,
{ noTrigger }: { noTrigger?: boolean } = {}
): void {
const previousAttributes = this.#_attributes;
this.#_attributes = {
...previousAttributes,
...attributes,
}; };
if (noTrigger) {
return;
}
const hasAttributeChanged = (name: keyof ConversationAttributesType) => {
return (
name in attributes && attributes[name] !== previousAttributes[name]
);
};
if (hasAttributeChanged('profileKey')) {
this.onChangeProfileKey();
}
const clearUsernameTriggers: Array<keyof ConversationAttributesType> = [
'name',
'profileName',
'profileFamilyName',
'e164',
'systemGivenName',
'systemFamilyName',
'systemNickname',
];
if (clearUsernameTriggers.some(attrName => hasAttributeChanged(attrName))) {
drop(this.maybeClearUsername());
}
if (hasAttributeChanged('members') || hasAttributeChanged('membersV2')) {
this.fetchContacts();
}
if (hasAttributeChanged('active_at')) {
drop(this.#onActiveAtChange());
}
window.ConversationController.conversationUpdated(this, previousAttributes);
}
public get attributes(): Readonly<ConversationAttributesType> {
return this.#_attributes;
} }
idForLogging(): string { idForLogging(): string {
@ -328,20 +359,8 @@ export class ConversationModel extends window.Backbone
return getSendTarget(this.attributes); return getSendTarget(this.attributes);
} }
getContactCollection(): Backbone.Collection<ConversationModel> {
const collection = new window.Backbone.Collection<ConversationModel>();
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
collection.comparator = (
left: ConversationModel,
right: ConversationModel
) => {
return collator.compare(left.getTitle(), right.getTitle());
};
return collection;
}
constructor(attributes: ConversationAttributesType) { constructor(attributes: ConversationAttributesType) {
super(attributes); this.#_attributes = attributes;
// Note that we intentionally don't use `initialize()` method because it // Note that we intentionally don't use `initialize()` method because it
// isn't compatible with esnext output of esbuild. // isn't compatible with esnext output of esbuild.
@ -354,7 +373,7 @@ export class ConversationModel extends window.Backbone
'ConversationModel.initialize: normalizing serviceId from ' + 'ConversationModel.initialize: normalizing serviceId from ' +
`${serviceId} to ${normalizedServiceId}` `${serviceId} to ${normalizedServiceId}`
); );
this.set('serviceId', normalizedServiceId); this.set({ serviceId: normalizedServiceId });
} }
if (isValidE164(attributes.id, false)) { if (isValidE164(attributes.id, false)) {
@ -374,71 +393,35 @@ export class ConversationModel extends window.Backbone
200 200
); );
this.contactCollection = this.getContactCollection(); this.contactCollection = [];
this.contactCollection.on(
'change:name change:profileName change:profileFamilyName change:e164',
this.debouncedUpdateLastMessage,
this
);
if (!isDirectConversation(this.attributes)) {
this.contactCollection.on(
'change:verified',
this.onMemberVerifiedChange.bind(this)
);
}
this.on('change:profileKey', this.onChangeProfileKey);
this.on(
'change:name change:profileName change:profileFamilyName change:e164 ' +
'change:systemGivenName change:systemFamilyName change:systemNickname',
() => this.maybeClearUsername()
);
const sealedSender = this.get('sealedSender'); const sealedSender = this.get('sealedSender');
if (sealedSender === undefined) { if (sealedSender === undefined) {
this.set({ sealedSender: SEALED_SENDER.UNKNOWN }); this.set({ sealedSender: SEALED_SENDER.UNKNOWN });
} }
// @ts-expect-error -- Removing legacy prop
this.unset('unidentifiedDelivery');
// @ts-expect-error -- Removing legacy prop
this.unset('unidentifiedDeliveryUnrestricted');
// @ts-expect-error -- Removing legacy prop
this.unset('hasFetchedProfile');
// @ts-expect-error -- Removing legacy prop
this.unset('tokens');
this.on('change:members change:membersV2', this.fetchContacts); if (
this.on('change:active_at', this.#onActiveAtChange); // @ts-expect-error -- Removing legacy prop
this.get('unidentifiedDelivery') ||
// @ts-expect-error -- Removing legacy prop
this.get('unidentifiedDeliveryUnrestricted') ||
// @ts-expect-error -- Removing legacy prop
this.get('hasFetchedProfile') ||
// @ts-expect-error -- Removing legacy prop
this.get('tokens')
) {
this.set({
// @ts-expect-error -- Removing legacy prop
unidentifiedDelivery: undefined,
unidentifiedDeliveryUnrestricted: undefined,
hasFetchedProfile: undefined,
tokens: undefined,
});
}
this.typingRefreshTimer = null; this.typingRefreshTimer = null;
this.typingPauseTimer = null; this.typingPauseTimer = null;
// We clear our cached props whenever we change so that the next call to format() will
// result in refresh via a getProps() call. See format() below.
this.on(
'change',
(_model: ConversationModel, options: { force?: boolean } = {}) => {
const changedKeys = Object.keys(this.changed || {});
const isPropsCacheStillValid =
!options.force &&
Boolean(
changedKeys.length &&
changedKeys.every(key =>
ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE.has(key)
)
);
if (isPropsCacheStillValid) {
return;
}
if (this.cachedProps) {
this.oldCachedProps = this.cachedProps;
}
this.cachedProps = null;
this.trigger('props-change', this, this.#isInReduxBatch);
}
);
// Set `isFetchingUUID` eagerly to avoid UI flicker when opening the // Set `isFetchingUUID` eagerly to avoid UI flicker when opening the
// conversation for the first time. // conversation for the first time.
this.isFetchingUUID = this.isSMSOnly(); this.isFetchingUUID = this.isSMSOnly();
@ -468,7 +451,7 @@ export class ConversationModel extends window.Backbone
const migratedColor = this.getColor(); const migratedColor = this.getColor();
if (this.get('color') !== migratedColor) { if (this.get('color') !== migratedColor) {
this.set('color', migratedColor); this.set({ color: migratedColor });
// Not saving the conversation here we're hoping it'll be saved elsewhere // Not saving the conversation here we're hoping it'll be saved elsewhere
// this may cause some color thrashing if Signal is restarted without // this may cause some color thrashing if Signal is restarted without
// the convo saving. If that is indeed the case and it's too disruptive // the convo saving. If that is indeed the case and it's too disruptive
@ -942,8 +925,7 @@ export class ConversationModel extends window.Backbone
} }
if (blocked && !wasBlocked) { if (blocked && !wasBlocked) {
// We need to force a props refresh - blocked state is not in backbone attributes window.ConversationController.conversationUpdated(this, this.attributes);
this.trigger('change', this, { force: true });
if (!viaStorageServiceSync) { if (!viaStorageServiceSync) {
this.captureChange('block'); this.captureChange('block');
@ -975,7 +957,7 @@ export class ConversationModel extends window.Backbone
if (unblocked && wasBlocked) { if (unblocked && wasBlocked) {
// We need to force a props refresh - blocked state is not in backbone attributes // We need to force a props refresh - blocked state is not in backbone attributes
this.trigger('change', this, { force: true }); window.ConversationController.conversationUpdated(this, this.attributes);
if (!viaStorageServiceSync) { if (!viaStorageServiceSync) {
this.captureChange('unblock'); this.captureChange('unblock');
@ -1213,7 +1195,7 @@ export class ConversationModel extends window.Backbone
); );
this.isFetchingUUID = true; this.isFetchingUUID = true;
this.trigger('change', this, { force: true }); window.ConversationController.conversationUpdated(this, this.attributes);
try { try {
// Attempt to fetch UUID // Attempt to fetch UUID
@ -1225,7 +1207,7 @@ export class ConversationModel extends window.Backbone
} finally { } finally {
// No redux update here // No redux update here
this.isFetchingUUID = false; this.isFetchingUUID = false;
this.trigger('change', this, { force: true }); window.ConversationController.conversationUpdated(this, this.attributes);
log.info( log.info(
`Done fetching uuid for a sms-only conversation ${this.idForLogging()}` `Done fetching uuid for a sms-only conversation ${this.idForLogging()}`
@ -1240,14 +1222,6 @@ export class ConversationModel extends window.Backbone
this.setRegistered(); this.setRegistered();
} }
override isValid(): boolean {
return (
isDirectConversation(this.attributes) ||
isGroupV1(this.attributes) ||
isGroupV2(this.attributes)
);
}
async maybeMigrateV1Group(): Promise<void> { async maybeMigrateV1Group(): Promise<void> {
if (!isGroupV1(this.attributes)) { if (!isGroupV1(this.attributes)) {
return; return;
@ -2078,7 +2052,7 @@ export class ConversationModel extends window.Backbone
return; return;
} }
this.set('e164', e164 || undefined); this.set({ e164: e164 || undefined });
// This user changed their phone number // This user changed their phone number
if (oldValue && e164 && this.get('sharingPhoneNumber')) { if (oldValue && e164 && this.get('sharingPhoneNumber')) {
@ -2086,7 +2060,7 @@ export class ConversationModel extends window.Backbone
} }
drop(DataWriter.updateConversation(this.attributes)); drop(DataWriter.updateConversation(this.attributes));
this.trigger('idUpdated', this, 'e164', oldValue); window.ConversationController.idUpdated(this, 'e164', oldValue);
this.captureChange('updateE164'); this.captureChange('updateE164');
} }
@ -2096,14 +2070,13 @@ export class ConversationModel extends window.Backbone
return; return;
} }
this.set( this.set({
'serviceId', serviceId: serviceId
serviceId
? normalizeServiceId(serviceId, 'Conversation.updateServiceId') ? normalizeServiceId(serviceId, 'Conversation.updateServiceId')
: undefined : undefined,
); });
drop(DataWriter.updateConversation(this.attributes)); drop(DataWriter.updateConversation(this.attributes));
this.trigger('idUpdated', this, 'serviceId', oldValue); window.ConversationController.idUpdated(this, 'serviceId', oldValue);
// We should delete the old sessions and identity information in all situations except // We should delete the old sessions and identity information in all situations except
// for the case where we need to do old and new PNI comparisons. We'll wait // for the case where we need to do old and new PNI comparisons. We'll wait
@ -2144,17 +2117,16 @@ export class ConversationModel extends window.Backbone
return; return;
} }
this.set( this.set({
'pni', pni: pni ? normalizePni(pni, 'Conversation.updatePni') : undefined,
pni ? normalizePni(pni, 'Conversation.updatePni') : undefined });
);
const newPniSignatureVerified = pni ? pniSignatureVerified : false; const newPniSignatureVerified = pni ? pniSignatureVerified : false;
if (this.get('pniSignatureVerified') !== newPniSignatureVerified) { if (this.get('pniSignatureVerified') !== newPniSignatureVerified) {
log.warn( log.warn(
`updatePni/${this.idForLogging()}: setting ` + `updatePni/${this.idForLogging()}: setting ` +
`pniSignatureVerified to ${newPniSignatureVerified}` `pniSignatureVerified to ${newPniSignatureVerified}`
); );
this.set('pniSignatureVerified', newPniSignatureVerified); this.set({ pniSignatureVerified: newPniSignatureVerified });
this.captureChange('pniSignatureVerified'); this.captureChange('pniSignatureVerified');
} }
@ -2211,16 +2183,16 @@ export class ConversationModel extends window.Backbone
} }
drop(DataWriter.updateConversation(this.attributes)); drop(DataWriter.updateConversation(this.attributes));
this.trigger('idUpdated', this, 'pni', oldValue); window.ConversationController.idUpdated(this, 'pni', oldValue);
this.captureChange('updatePni'); this.captureChange('updatePni');
} }
updateGroupId(groupId?: string): void { updateGroupId(groupId?: string): void {
const oldValue = this.get('groupId'); const oldValue = this.get('groupId');
if (groupId && groupId !== oldValue) { if (groupId && groupId !== oldValue) {
this.set('groupId', groupId); this.set({ groupId });
drop(DataWriter.updateConversation(this.attributes)); drop(DataWriter.updateConversation(this.attributes));
this.trigger('idUpdated', this, 'groupId', oldValue); window.ConversationController.idUpdated(this, 'groupId', oldValue);
} }
} }
@ -2232,7 +2204,7 @@ export class ConversationModel extends window.Backbone
return; return;
} }
this.set('reportingToken', newValue); this.set({ reportingToken: newValue });
await DataWriter.updateConversation(this.attributes); await DataWriter.updateConversation(this.attributes);
} }
@ -3021,7 +2993,7 @@ export class ConversationModel extends window.Backbone
return false; return false;
} }
if (contacts.length === 1 && isMe(contacts.first()?.attributes)) { if (contacts.length === 1 && isMe(contacts[0]?.attributes)) {
return false; return false;
} }
@ -3158,9 +3130,7 @@ export class ConversationModel extends window.Backbone
onMemberVerifiedChange(): void { onMemberVerifiedChange(): void {
// If the verified state of a member changes, our aggregate state changes. // If the verified state of a member changes, our aggregate state changes.
// We trigger both events to replicate the behavior of window.Backbone.Model.set() window.ConversationController.conversationUpdated(this, this.attributes);
this.trigger('change:verified', this);
this.trigger('change', this, { force: true });
} }
async toggleVerified(): Promise<unknown> { async toggleVerified(): Promise<unknown> {
@ -3527,7 +3497,7 @@ export class ConversationModel extends window.Backbone
const notificationId = await this.addNotification( const notificationId = await this.addNotification(
'universal-timer-notification' 'universal-timer-notification'
); );
this.set('pendingUniversalTimer', notificationId); this.set({ pendingUniversalTimer: notificationId });
} }
async maybeApplyUniversalTimer(): Promise<void> { async maybeApplyUniversalTimer(): Promise<void> {
@ -3560,7 +3530,7 @@ export class ConversationModel extends window.Backbone
return false; return false;
} }
this.set('pendingUniversalTimer', undefined); this.set({ pendingUniversalTimer: undefined });
log.info( log.info(
`maybeRemoveUniversalTimer(${this.idForLogging()}): removed notification` `maybeRemoveUniversalTimer(${this.idForLogging()}): removed notification`
); );
@ -3593,7 +3563,7 @@ export class ConversationModel extends window.Backbone
const notificationId = await this.addNotification( const notificationId = await this.addNotification(
'contact-removed-notification' 'contact-removed-notification'
); );
this.set('pendingRemovedContactNotification', notificationId); this.set({ pendingRemovedContactNotification: notificationId });
await DataWriter.updateConversation(this.attributes); await DataWriter.updateConversation(this.attributes);
} }
@ -3603,7 +3573,7 @@ export class ConversationModel extends window.Backbone
return false; return false;
} }
this.set('pendingRemovedContactNotification', undefined); this.set({ pendingRemovedContactNotification: undefined });
log.info( log.info(
`maybeClearContactRemoved(${this.idForLogging()}): removed notification` `maybeClearContactRemoved(${this.idForLogging()}): removed notification`
); );
@ -3679,10 +3649,6 @@ export class ConversationModel extends window.Backbone
); );
} }
override validate(attributes = this.attributes): string | null {
return validateConversation(attributes);
}
async queueJob<T>( async queueJob<T>(
name: string, name: string,
callback: (abortSignal: AbortSignal) => Promise<T> callback: (abortSignal: AbortSignal) => Promise<T>
@ -3838,6 +3804,8 @@ export class ConversationModel extends window.Backbone
} }
async sendStickerMessage(packId: string, stickerId: number): Promise<void> { async sendStickerMessage(packId: string, stickerId: number): Promise<void> {
const { readStickerData } = window.Signal.Migrations;
const packData = Stickers.getStickerPack(packId); const packData = Stickers.getStickerPack(packId);
const stickerData = Stickers.getSticker(packId, stickerId); const stickerData = Stickers.getSticker(packId, stickerId);
if (!stickerData || !packData) { if (!stickerData || !packData) {
@ -3927,18 +3895,6 @@ export class ConversationModel extends window.Backbone
} }
} }
batchReduxChanges(callback: () => void): void {
strictAssert(!this.#isInReduxBatch, 'Nested redux batching is not allowed');
this.#isInReduxBatch = true;
batchDispatch(() => {
try {
callback();
} finally {
this.#isInReduxBatch = false;
}
});
}
beforeMessageSend({ beforeMessageSend({
message, message,
dontAddMessage, dontAddMessage,
@ -3952,57 +3908,53 @@ export class ConversationModel extends window.Backbone
now: number; now: number;
extraReduxActions?: () => void; extraReduxActions?: () => void;
}): void { }): void {
this.batchReduxChanges(() => { const { clearUnreadMetrics } = window.reduxActions.conversations;
const { clearUnreadMetrics } = window.reduxActions.conversations; clearUnreadMetrics(this.id);
clearUnreadMetrics(this.id);
const enabledProfileSharing = Boolean(!this.get('profileSharing')); const enabledProfileSharing = Boolean(!this.get('profileSharing'));
const unarchivedConversation = Boolean(this.get('isArchived')); const unarchivedConversation = Boolean(this.get('isArchived'));
log.info( log.info(
`beforeMessageSend(${this.idForLogging()}): ` + `beforeMessageSend(${this.idForLogging()}): ` +
`clearDraft(${!dontClearDraft}) addMessage(${!dontAddMessage})` `clearDraft(${!dontClearDraft}) addMessage(${!dontAddMessage})`
); );
if (!dontAddMessage) { if (!dontAddMessage) {
this.#doAddSingleMessage(message, { isJustSent: true }); this.#doAddSingleMessage(message, { isJustSent: true });
} }
const draftProperties = dontClearDraft const draftProperties = dontClearDraft
? {} ? {}
: { : {
draft: '', draft: '',
draftEditMessage: undefined, draftEditMessage: undefined,
draftBodyRanges: [], draftBodyRanges: [],
draftTimestamp: null, draftTimestamp: null,
quotedMessageId: undefined, quotedMessageId: undefined,
}; };
const lastMessageProperties = this.getLastMessageData(message, message); const lastMessageProperties = this.getLastMessageData(message, message);
const isEditMessage = Boolean(message.editHistory); const isEditMessage = Boolean(message.editHistory);
this.set({ this.set({
...draftProperties, ...draftProperties,
...lastMessageProperties, ...lastMessageProperties,
...(enabledProfileSharing ? { profileSharing: true } : {}), ...(enabledProfileSharing ? { profileSharing: true } : {}),
...(dontAddMessage ...(dontAddMessage ? {} : this.incrementSentMessageCount({ dry: true })),
? {} // If it's an edit message we don't want to optimistically set the
: this.incrementSentMessageCount({ dry: true })), // active_at & timestamp to now. We want it to stay the same.
// If it's an edit message we don't want to optimistically set the active_at: isEditMessage ? this.get('active_at') : now,
// active_at & timestamp to now. We want it to stay the same. timestamp: isEditMessage ? this.get('timestamp') : now,
active_at: isEditMessage ? this.get('active_at') : now, ...(unarchivedConversation ? { isArchived: false } : {}),
timestamp: isEditMessage ? this.get('timestamp') : now,
...(unarchivedConversation ? { isArchived: false } : {}),
});
if (enabledProfileSharing) {
this.captureChange('beforeMessageSend/mandatoryProfileSharing');
}
if (unarchivedConversation) {
this.captureChange('beforeMessageSend/unarchive');
}
extraReduxActions?.();
}); });
if (enabledProfileSharing) {
this.captureChange('beforeMessageSend/mandatoryProfileSharing');
}
if (unarchivedConversation) {
this.captureChange('beforeMessageSend/unarchive');
}
extraReduxActions?.();
} }
async enqueueMessageForSend( async enqueueMessageForSend(
@ -4037,6 +3989,9 @@ export class ConversationModel extends window.Backbone
extraReduxActions?: () => void; extraReduxActions?: () => void;
} = {} } = {}
): Promise<MessageAttributesType | undefined> { ): Promise<MessageAttributesType | undefined> {
const { deleteAttachmentData, upgradeMessageSchema } =
window.Signal.Migrations;
if (this.isGroupV1AndDisabled()) { if (this.isGroupV1AndDisabled()) {
return; return;
} }
@ -4266,7 +4221,7 @@ export class ConversationModel extends window.Backbone
log.info(`maybeClearUsername(${this.idForLogging()}): clearing username`); log.info(`maybeClearUsername(${this.idForLogging()}): clearing username`);
this.unset('username'); this.set({ username: undefined });
if (this.get('needsTitleTransition') && getProfileName(this.attributes)) { if (this.get('needsTitleTransition') && getProfileName(this.attributes)) {
log.info( log.info(
@ -4274,7 +4229,7 @@ export class ConversationModel extends window.Backbone
); );
const { type, e164, username } = this.attributes; const { type, e164, username } = this.attributes;
this.unset('needsTitleTransition'); this.set({ needsTitleTransition: undefined });
await this.addNotification('title-transition-notification', { await this.addNotification('title-transition-notification', {
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
@ -4310,7 +4265,7 @@ export class ConversationModel extends window.Backbone
log.info(`updateUsername(${this.idForLogging()}): updating username`); log.info(`updateUsername(${this.idForLogging()}): updating username`);
this.set('username', username); this.set({ username });
this.captureChange('updateUsername'); this.captureChange('updateUsername');
if (shouldSave) { if (shouldSave) {
@ -4469,7 +4424,7 @@ export class ConversationModel extends window.Backbone
async #onActiveAtChange(): Promise<void> { async #onActiveAtChange(): Promise<void> {
if (this.get('active_at') && this.get('messagesDeleted')) { if (this.get('active_at') && this.get('messagesDeleted')) {
this.set('messagesDeleted', false); this.set({ messagesDeleted: false });
await DataWriter.updateConversation(this.attributes); await DataWriter.updateConversation(this.attributes);
} }
} }
@ -4713,7 +4668,7 @@ export class ConversationModel extends window.Backbone
'updateExpirationTimer: Resetting expireTimerVersion since this is initialSync' 'updateExpirationTimer: Resetting expireTimerVersion since this is initialSync'
); );
// This is reset after unlink, but we do it here as well to recover from errors // This is reset after unlink, but we do it here as well to recover from errors
this.set('expireTimerVersion', INITIAL_EXPIRE_TIMER_VERSION); this.set({ expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION });
} }
let expireTimer: DurationInSeconds | undefined = providedExpireTimer; let expireTimer: DurationInSeconds | undefined = providedExpireTimer;
@ -5006,6 +4961,12 @@ export class ConversationModel extends window.Backbone
decryptionKey?: Uint8Array | null | undefined; decryptionKey?: Uint8Array | null | undefined;
forceFetch?: boolean; forceFetch?: boolean;
}): Promise<void> { }): Promise<void> {
const {
deleteAttachmentData,
doesAttachmentExist,
writeNewAttachmentData,
} = window.Signal.Migrations;
const { avatarUrl, decryptionKey, forceFetch } = options; const { avatarUrl, decryptionKey, forceFetch } = options;
if (isMe(this.attributes)) { if (isMe(this.attributes)) {
if (avatarUrl) { if (avatarUrl) {
@ -5106,7 +5067,7 @@ export class ConversationModel extends window.Backbone
const { type, e164, username } = this.attributes; const { type, e164, username } = this.attributes;
this.unset('needsTitleTransition'); this.set({ needsTitleTransition: undefined });
await this.addNotification('title-transition-notification', { await this.addNotification('title-transition-notification', {
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
@ -5122,7 +5083,7 @@ export class ConversationModel extends window.Backbone
} }
// Don't trigger immediate profile fetches when syncing to remote storage // Don't trigger immediate profile fetches when syncing to remote storage
this.set({ profileKey }, { silent: viaStorageServiceSync }); this.set({ profileKey }, { noTrigger: viaStorageServiceSync });
// If our profile key was cleared above, we don't tell our linked devices about it. // If our profile key was cleared above, we don't tell our linked devices about it.
// We want linked devices to tell us what it should be, instead of telling them to // We want linked devices to tell us what it should be, instead of telling them to
@ -5244,10 +5205,7 @@ export class ConversationModel extends window.Backbone
} }
fetchContacts(): void { fetchContacts(): void {
const members = this.getMembers(); this.contactCollection = this.getMembers();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.contactCollection!.reset(members);
} }
async destroyMessages({ async destroyMessages({
@ -5423,7 +5381,7 @@ export class ConversationModel extends window.Backbone
} }
const newVersion = expireTimerVersion + 1; const newVersion = expireTimerVersion + 1;
this.set('expireTimerVersion', newVersion); this.set({ expireTimerVersion: newVersion });
await DataWriter.updateConversation(this.attributes); await DataWriter.updateConversation(this.attributes);
} }
@ -5511,6 +5469,8 @@ export class ConversationModel extends window.Backbone
url: string; url: string;
absolutePath?: string; absolutePath?: string;
}> { }> {
const { getAbsoluteTempPath } = window.Signal.Migrations;
const saveToDisk = shouldSaveNotificationAvatarToDisk(); const saveToDisk = shouldSaveNotificationAvatarToDisk();
const avatarUrl = getLocalAvatarUrl(this.attributes); const avatarUrl = getLocalAvatarUrl(this.attributes);
if (avatarUrl) { if (avatarUrl) {
@ -5532,6 +5492,13 @@ export class ConversationModel extends window.Backbone
} }
async #getTemporaryAvatarPath(): Promise<string | undefined> { async #getTemporaryAvatarPath(): Promise<string | undefined> {
const {
copyIntoTempDirectory,
deleteAttachmentData,
getAbsoluteAttachmentPath,
getAbsoluteTempPath,
} = window.Signal.Migrations;
const avatar = getAvatar(this.attributes); const avatar = getAvatar(this.attributes);
if (avatar?.path == null) { if (avatar?.path == null) {
return undefined; return undefined;
@ -5672,13 +5639,19 @@ export class ConversationModel extends window.Backbone
); );
// User was not previously typing before. State change! // User was not previously typing before. State change!
if (!record) { if (!record) {
this.trigger('change', this, { force: true }); window.ConversationController.conversationUpdated(
this,
this.attributes
);
} }
} else { } else {
delete this.contactTypingTimers[typingToken]; delete this.contactTypingTimers[typingToken];
if (record) { if (record) {
// User was previously typing, and is no longer. State change! // User was previously typing, and is no longer. State change!
this.trigger('change', this, { force: true }); window.ConversationController.conversationUpdated(
this,
this.attributes
);
} }
} }
} }
@ -5692,7 +5665,7 @@ export class ConversationModel extends window.Backbone
delete this.contactTypingTimers[typingToken]; delete this.contactTypingTimers[typingToken];
// User was previously typing, but timed out or we received message. State change! // User was previously typing, but timed out or we received message. State change!
this.trigger('change', this, { force: true }); window.ConversationController.conversationUpdated(this, this.attributes);
} }
} }
@ -5701,11 +5674,11 @@ export class ConversationModel extends window.Backbone
return; return;
} }
const validationError = this.validate(); const validationErrorString = validateConversation(this.attributes);
if (validationError) { if (validationErrorString) {
log.error( log.error(
`not pinning ${this.idForLogging()} because of ` + `not pinning ${this.idForLogging()} because of ` +
`validation error ${validationError}` `validation error ${validationErrorString}`
); );
return; return;
} }
@ -5719,7 +5692,7 @@ export class ConversationModel extends window.Backbone
this.writePinnedConversations([...pinnedConversationIds]); this.writePinnedConversations([...pinnedConversationIds]);
this.set('isPinned', true); this.set({ isPinned: true });
if (this.get('isArchived')) { if (this.get('isArchived')) {
this.set({ isArchived: false }); this.set({ isArchived: false });
@ -5742,7 +5715,7 @@ export class ConversationModel extends window.Backbone
this.writePinnedConversations([...pinnedConversationIds]); this.writePinnedConversations([...pinnedConversationIds]);
this.set('isPinned', false); this.set({ isPinned: false });
drop(DataWriter.updateConversation(this.attributes)); drop(DataWriter.updateConversation(this.attributes));
} }
@ -5771,7 +5744,7 @@ export class ConversationModel extends window.Backbone
acknowledgeGroupMemberNameCollisions( acknowledgeGroupMemberNameCollisions(
groupNameCollisions: ReadonlyDeep<GroupNameCollisionsWithIdsByTitle> groupNameCollisions: ReadonlyDeep<GroupNameCollisionsWithIdsByTitle>
): void { ): void {
this.set('acknowledgedGroupNameCollisions', groupNameCollisions); this.set({ acknowledgedGroupNameCollisions: groupNameCollisions });
drop(DataWriter.updateConversation(this.attributes)); drop(DataWriter.updateConversation(this.attributes));
} }
@ -5851,176 +5824,3 @@ export class ConversationModel extends window.Backbone
log.info(`conversation ${this.idForLogging()} jobQueue shutdown complete`); log.info(`conversation ${this.idForLogging()} jobQueue shutdown complete`);
} }
} }
window.Whisper.Conversation = ConversationModel;
window.Whisper.ConversationCollection = window.Backbone.Collection.extend({
model: window.Whisper.Conversation,
/**
* window.Backbone defines a `_byId` field. Here we set up additional `_byE164`,
* `_byServiceId`, and `_byGroupId` fields so we can track conversations by more
* than just their id.
*/
initialize() {
this.eraseLookups();
this.on(
'idUpdated',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(model: ConversationModel, idProp: string, oldValue: any) => {
if (oldValue) {
if (idProp === 'e164') {
delete this._byE164[oldValue];
}
if (idProp === 'serviceId') {
delete this._byServiceId[oldValue];
}
if (idProp === 'pni') {
delete this._byPni[oldValue];
}
if (idProp === 'groupId') {
delete this._byGroupId[oldValue];
}
}
const e164 = model.get('e164');
if (e164) {
this._byE164[e164] = model;
}
const serviceId = model.getServiceId();
if (serviceId) {
this._byServiceId[serviceId] = model;
}
const pni = model.getPni();
if (pni) {
this._byPni[pni] = model;
}
const groupId = model.get('groupId');
if (groupId) {
this._byGroupId[groupId] = model;
}
}
);
},
reset(models?: Array<ConversationModel>, options?: Backbone.Silenceable) {
window.Backbone.Collection.prototype.reset.call(this, models, options);
this.resetLookups();
},
resetLookups() {
this.eraseLookups();
this.generateLookups(this.models);
},
generateLookups(models: ReadonlyArray<ConversationModel>) {
models.forEach(model => {
const e164 = model.get('e164');
if (e164) {
const existing = this._byE164[e164];
// Prefer the contact with both e164 and serviceId
if (!existing || (existing && !existing.getServiceId())) {
this._byE164[e164] = model;
}
}
const serviceId = model.getServiceId();
if (serviceId) {
const existing = this._byServiceId[serviceId];
// Prefer the contact with both e164 and seviceId
if (!existing || (existing && !existing.get('e164'))) {
this._byServiceId[serviceId] = model;
}
}
const pni = model.getPni();
if (pni) {
const existing = this._byPni[pni];
// Prefer the contact with both serviceId and pni
if (!existing || (existing && !existing.getServiceId())) {
this._byPni[pni] = model;
}
}
const groupId = model.get('groupId');
if (groupId) {
this._byGroupId[groupId] = model;
}
});
},
eraseLookups() {
this._byE164 = Object.create(null);
this._byServiceId = Object.create(null);
this._byPni = Object.create(null);
this._byGroupId = Object.create(null);
},
add(
data:
| ConversationModel
| ConversationAttributesType
| Array<ConversationModel>
| Array<ConversationAttributesType>
) {
let hydratedData: Array<ConversationModel> | ConversationModel;
// First, we need to ensure that the data we're working with is Conversation models
if (Array.isArray(data)) {
hydratedData = [];
for (let i = 0, max = data.length; i < max; i += 1) {
const item = data[i];
// We create a new model if it's not already a model
if (has(item, 'get')) {
hydratedData.push(item as ConversationModel);
} else {
hydratedData.push(
new window.Whisper.Conversation(item as ConversationAttributesType)
);
}
}
} else if (has(data, 'get')) {
hydratedData = data as ConversationModel;
} else {
hydratedData = new window.Whisper.Conversation(
data as ConversationAttributesType
);
}
// Next, we update our lookups first to prevent infinite loops on the 'add' event
this.generateLookups(
Array.isArray(hydratedData) ? hydratedData : [hydratedData]
);
// Lastly, we fire off the add events related to this change
// Go home Backbone, you're drunk.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
window.Backbone.Collection.prototype.add.call(this, hydratedData as any);
return hydratedData;
},
/**
* window.Backbone collections have a `_byId` field that `get` defers to. Here, we
* override `get` to first access our custom `_byE164`, `_byServiceId`, and
* `_byGroupId` functions, followed by falling back to the original
* window.Backbone implementation.
*/
get(id: string) {
return (
this._byE164[id] ||
this._byE164[`+${id}`] ||
this._byServiceId[id] ||
this._byPni[id] ||
this._byGroupId[id] ||
window.Backbone.Collection.prototype.get.call(this, id)
);
},
comparator(m: ConversationModel) {
return -(m.get('active_at') || 0);
},
});

View file

@ -73,7 +73,7 @@ export async function enqueueReactionForSend({
) { ) {
log.info('Enabling profile sharing for reaction send'); log.info('Enabling profile sharing for reaction send');
if (!messageConversation.get('profileSharing')) { if (!messageConversation.get('profileSharing')) {
messageConversation.set('profileSharing', true); messageConversation.set({ profileSharing: true });
await DataWriter.updateConversation(messageConversation.attributes); await DataWriter.updateConversation(messageConversation.attributes);
} }
await messageConversation.restoreContact(); await messageConversation.restoreContact();

View file

@ -703,8 +703,9 @@ export class BackupImportStream extends Writable {
svrPin, svrPin,
}: Backups.IAccountData): Promise<void> { }: Backups.IAccountData): Promise<void> {
strictAssert(this.#ourConversation === undefined, 'Duplicate AccountData'); strictAssert(this.#ourConversation === undefined, 'Duplicate AccountData');
const me = const me = {
window.ConversationController.getOurConversationOrThrow().attributes; ...window.ConversationController.getOurConversationOrThrow().attributes,
};
this.#ourConversation = me; this.#ourConversation = me;
const { storage } = window; const { storage } = window;

View file

@ -97,7 +97,7 @@ async function updateConversationFromContactSync(
); );
} }
window.Whisper.events.trigger('incrementProgress'); window.Whisper.events.emit('incrementProgress');
} }
const queue = new PQueue({ concurrency: 1 }); const queue = new PQueue({ concurrency: 1 });
@ -182,11 +182,11 @@ async function doContactSync({
type: 'private', type: 'private',
}; };
const validationError = validateConversation(partialConversation); const validationErrorString = validateConversation(partialConversation);
if (validationError) { if (validationErrorString) {
log.error( log.error(
`${logId}: Invalid contact received`, `${logId}: Invalid contact received`,
Errors.toLogFormat(validationError) Errors.toLogFormat(validationErrorString)
); );
continue; continue;
} }
@ -261,7 +261,7 @@ async function doContactSync({
await Promise.all(promises); await Promise.all(promises);
await window.storage.put('synced_at', Date.now()); await window.storage.put('synced_at', Date.now());
window.Whisper.events.trigger('contactSync:complete'); window.Whisper.events.emit('contactSync:complete');
if (isInitialSync) { if (isInitialSync) {
isInitialSync = false; isInitialSync = false;
} }

View file

@ -553,7 +553,7 @@ async function doGetProfile(
// Record that the accessKey we have in the conversation is invalid // Record that the accessKey we have in the conversation is invalid
const sealedSender = c.get('sealedSender'); const sealedSender = c.get('sealedSender');
if (sealedSender !== SEALED_SENDER.DISABLED) { if (sealedSender !== SEALED_SENDER.DISABLED) {
c.set('sealedSender', SEALED_SENDER.DISABLED); c.set({ sealedSender: SEALED_SENDER.DISABLED });
} }
// Retry fetch using last known profileKey or fetch unversioned profile. // Retry fetch using last known profileKey or fetch unversioned profile.
@ -580,7 +580,7 @@ async function doGetProfile(
if (error.code === 404) { if (error.code === 404) {
log.info(`${logId}: Profile not found`); log.info(`${logId}: Profile not found`);
c.set('profileLastFetchedAt', Date.now()); c.set({ profileLastFetchedAt: Date.now() });
if (!isVersioned || ignoreProfileKey) { if (!isVersioned || ignoreProfileKey) {
log.info(`${logId}: Marking conversation unregistered`); log.info(`${logId}: Marking conversation unregistered`);
@ -655,20 +655,20 @@ async function doGetProfile(
if (isFieldDefined(profile.about)) { if (isFieldDefined(profile.about)) {
if (updatedDecryptionKey != null) { if (updatedDecryptionKey != null) {
const decrypted = decryptField(profile.about, updatedDecryptionKey); const decrypted = decryptField(profile.about, updatedDecryptionKey);
c.set('about', formatTextField(decrypted)); c.set({ about: formatTextField(decrypted) });
} }
} else { } else {
c.unset('about'); c.set({ about: undefined });
} }
// Step #: Save profile `aboutEmoji` to conversation // Step #: Save profile `aboutEmoji` to conversation
if (isFieldDefined(profile.aboutEmoji)) { if (isFieldDefined(profile.aboutEmoji)) {
if (updatedDecryptionKey != null) { if (updatedDecryptionKey != null) {
const decrypted = decryptField(profile.aboutEmoji, updatedDecryptionKey); const decrypted = decryptField(profile.aboutEmoji, updatedDecryptionKey);
c.set('aboutEmoji', formatTextField(decrypted)); c.set({ aboutEmoji: formatTextField(decrypted) });
} }
} else { } else {
c.unset('aboutEmoji'); c.set({ aboutEmoji: undefined });
} }
// Step #: Save profile `phoneNumberSharing` to conversation // Step #: Save profile `phoneNumberSharing` to conversation
@ -681,10 +681,10 @@ async function doGetProfile(
// It should be one byte, but be conservative about it and // It should be one byte, but be conservative about it and
// set `sharingPhoneNumber` to `false` in all cases except [0x01]. // set `sharingPhoneNumber` to `false` in all cases except [0x01].
const sharingPhoneNumber = decrypted.length === 1 && decrypted[0] === 1; const sharingPhoneNumber = decrypted.length === 1 && decrypted[0] === 1;
c.set('sharingPhoneNumber', sharingPhoneNumber); c.set({ sharingPhoneNumber });
} }
} else { } else {
c.unset('sharingPhoneNumber'); c.set({ sharingPhoneNumber: undefined });
} }
// Step #: Save our own `paymentAddress` to Storage // Step #: Save our own `paymentAddress` to Storage
@ -697,7 +697,7 @@ async function doGetProfile(
if (profile.capabilities != null) { if (profile.capabilities != null) {
c.set({ capabilities: profile.capabilities }); c.set({ capabilities: profile.capabilities });
} else { } else {
c.unset('capabilities'); c.set({ capabilities: undefined });
} }
// Step #: Save our own `observedCapabilities` to Storage and trigger sync if changed // Step #: Save our own `observedCapabilities` to Storage and trigger sync if changed
@ -752,7 +752,7 @@ async function doGetProfile(
})), })),
}); });
} else { } else {
c.unset('badges'); c.set({ badges: undefined });
} }
// Step #: Save updated (or clear if missing) profile `credential` to conversation // Step #: Save updated (or clear if missing) profile `credential` to conversation
@ -771,7 +771,7 @@ async function doGetProfile(
log.warn( log.warn(
`${logId}: Included credential request, but got no credential. Clearing profileKeyCredential.` `${logId}: Included credential request, but got no credential. Clearing profileKeyCredential.`
); );
c.unset('profileKeyCredential'); c.set({ profileKeyCredential: undefined });
} }
} }
@ -822,7 +822,7 @@ async function doGetProfile(
} }
} }
c.set('profileLastFetchedAt', Date.now()); c.set({ profileLastFetchedAt: Date.now() });
// After we successfully decrypted - update lastProfile property // After we successfully decrypted - update lastProfile property
if ( if (

View file

@ -85,6 +85,7 @@ import { fromPniUuidBytesOrUntaggedString } from '../util/ServiceId';
import { isDone as isRegistrationDone } from '../util/registration'; import { isDone as isRegistrationDone } from '../util/registration';
import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue'; import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue';
import { isMockEnvironment } from '../environment'; import { isMockEnvironment } from '../environment';
import { validateConversation } from '../util/validateConversation';
const log = createLogger('storage'); const log = createLogger('storage');
@ -241,9 +242,9 @@ async function generateManifest(
}; };
} }
const conversations = window.getConversations(); const conversations = window.ConversationController.getAll();
for (let i = 0; i < conversations.length; i += 1) { for (let i = 0; i < conversations.length; i += 1) {
const conversation = conversations.models[i]; const conversation = conversations[i];
let identifierType; let identifierType;
let storageRecord; let storageRecord;
@ -267,10 +268,12 @@ async function generateManifest(
let shouldDrop = false; let shouldDrop = false;
let dropReason: string | undefined; let dropReason: string | undefined;
const validationError = conversation.validate(); const validationErrorString = validateConversation(
if (validationError) { conversation.attributes
);
if (validationErrorString) {
shouldDrop = true; shouldDrop = true;
dropReason = `local validation error=${validationError}`; dropReason = `local validation error=${validationErrorString}`;
} else if (conversation.isUnregisteredAndStale()) { } else if (conversation.isUnregisteredAndStale()) {
shouldDrop = true; shouldDrop = true;
dropReason = 'unregistered and stale'; dropReason = 'unregistered and stale';
@ -294,7 +297,7 @@ async function generateManifest(
`dropping contact=${recordID} ` + `dropping contact=${recordID} ` +
`due to ${dropReason}` `due to ${dropReason}`
); );
conversation.unset('storageID'); conversation.set({ storageID: undefined });
deleteKeys.add(droppedID); deleteKeys.add(droppedID);
continue; continue;
} }
@ -1267,7 +1270,7 @@ async function processManifest(
const localVersions = new Map<string, number | undefined>(); const localVersions = new Map<string, number | undefined>();
let localRecordCount = 0; let localRecordCount = 0;
const conversations = window.getConversations(); const conversations = window.ConversationController.getAll();
conversations.forEach((conversation: ConversationModel) => { conversations.forEach((conversation: ConversationModel) => {
const storageID = conversation.get('storageID'); const storageID = conversation.get('storageID');
if (storageID) { if (storageID) {
@ -1387,44 +1390,45 @@ async function processManifest(
// new storageID for that record, and upload. // new storageID for that record, and upload.
// This might happen if a device pushes a manifest which doesn't contain // This might happen if a device pushes a manifest which doesn't contain
// the keys that we have in our local database. // the keys that we have in our local database.
window.getConversations().forEach((conversation: ConversationModel) => { window.ConversationController.getAll().forEach(
const storageID = conversation.get('storageID'); (conversation: ConversationModel) => {
if (storageID && !remoteKeys.has(storageID)) { const storageID = conversation.get('storageID');
const storageVersion = conversation.get('storageVersion'); if (storageID && !remoteKeys.has(storageID)) {
const missingKey = redactStorageID( const storageVersion = conversation.get('storageVersion');
storageID, const missingKey = redactStorageID(
storageVersion, storageID,
conversation storageVersion,
); conversation
// Remote might have dropped this conversation already, but our value of
// `firstUnregisteredAt` is too high for us to drop it. Don't reupload it!
if (
isDirectConversation(conversation.attributes) &&
conversation.isUnregistered()
) {
log.info(
`process(${version}): localKey=${missingKey} is ` +
'unregistered and not in remote manifest'
); );
conversation.setUnregistered({
timestamp: Date.now() - getMessageQueueTime(),
fromStorageService: true,
// Saving below // Remote might have dropped this conversation already, but our value of
shouldSave: false, // `firstUnregisteredAt` is too high for us to drop it. Don't reupload it!
}); if (
} else { isDirectConversation(conversation.attributes) &&
log.info( conversation.isUnregistered()
`process(${version}): localKey=${missingKey} ` + ) {
'was not in remote manifest' log.info(
); `process(${version}): localKey=${missingKey} is ` +
'unregistered and not in remote manifest'
);
conversation.setUnregistered({
timestamp: Date.now() - getMessageQueueTime(),
fromStorageService: true,
// Saving below
shouldSave: false,
});
} else {
log.info(
`process(${version}): localKey=${missingKey} ` +
'was not in remote manifest'
);
}
conversation.set({ storageID: undefined, storageVersion: undefined });
drop(updateConversation(conversation.attributes));
} }
conversation.unset('storageID');
conversation.unset('storageVersion');
drop(updateConversation(conversation.attributes));
} }
}); );
// Refetch various records post-merge // Refetch various records post-merge
{ {
@ -2192,10 +2196,12 @@ export async function eraseAllStorageServiceState({
window.reduxActions.user.eraseStorageServiceState(); window.reduxActions.user.eraseStorageServiceState();
// Conversations. These properties are not present in redux. // Conversations. These properties are not present in redux.
window.getConversations().forEach(conversation => { window.ConversationController.getAll().forEach(conversation => {
conversation.unset('storageID'); conversation.set({
conversation.unset('needsStorageServiceSync'); storageID: undefined,
conversation.unset('storageUnknownFields'); needsStorageServiceSync: undefined,
storageUnknownFields: undefined,
});
}); });
// Then make sure outstanding conversation saves are flushed // Then make sure outstanding conversation saves are flushed
@ -2290,7 +2296,7 @@ export const runStorageServiceSyncJob = debounce(
await sync({ reason }); await sync({ reason });
// Notify listeners about sync completion // Notify listeners about sync completion
window.Whisper.events.trigger('storageService:syncComplete'); window.Whisper.events.emit('storageService:syncComplete');
}, },
`sync v${window.storage.get('manifestVersion')}` `sync v${window.storage.get('manifestVersion')}`
) )

View file

@ -212,7 +212,7 @@ function addUnknownFields(
// If the record doesn't have unknown fields attached but we have them // If the record doesn't have unknown fields attached but we have them
// saved locally then we need to clear it out // saved locally then we need to clear it out
details.push('clearing unknown fields'); details.push('clearing unknown fields');
conversation.unset('storageUnknownFields'); conversation.set({ storageUnknownFields: undefined });
} }
} }
@ -1487,9 +1487,10 @@ export async function mergeAccountRecord(
} }
if (pinnedConversations) { if (pinnedConversations) {
const modelPinnedConversations = window const modelPinnedConversations =
.getConversations() window.ConversationController.getAll().filter(convo =>
.filter(convo => Boolean(convo.get('isPinned'))); Boolean(convo.get('isPinned'))
);
const modelPinnedConversationIds = modelPinnedConversations.map(convo => const modelPinnedConversationIds = modelPinnedConversations.map(convo =>
convo.get('id') convo.get('id')

View file

@ -210,7 +210,7 @@ async function updateUsernameAndSyncProfile(
): Promise<void> { ): Promise<void> {
const me = window.ConversationController.getOurConversationOrThrow(); const me = window.ConversationController.getOurConversationOrThrow();
// Update backbone, update DB, then tell linked devices about profile update // Update model, update DB, then tell linked devices about profile update
await me.updateUsername(username); await me.updateUsername(username);
try { try {

View file

@ -135,7 +135,7 @@ export async function writeProfile(
maybeProfileAvatarUpdate = { profileAvatar: undefined }; maybeProfileAvatarUpdate = { profileAvatar: undefined };
} }
// Update backbone, update DB, run storage service upload // Update model, update DB, run storage service upload
model.set({ model.set({
about: aboutText, about: aboutText,
aboutEmoji, aboutEmoji,

View file

@ -2,14 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
export async function toggleVerification(id: string): Promise<void> { export async function toggleVerification(id: string): Promise<void> {
const contact = window.getConversations().get(id); const contact = window.ConversationController.get(id);
if (contact) { if (contact) {
await contact.toggleVerified(); await contact.toggleVerified();
} }
} }
export async function reloadProfiles(id: string): Promise<void> { export async function reloadProfiles(id: string): Promise<void> {
const contact = window.getConversations().get(id); const contact = window.ConversationController.get(id);
if (contact) { if (contact) {
await contact.getProfiles(); await contact.getProfiles();
} }

View file

@ -8,7 +8,7 @@ import { explodePromise } from '../util/explodePromise';
// Matching Whisper.events.trigger API // Matching Whisper.events.trigger API
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function trigger(name: string, ...rest: Array<any>): void { export function trigger(name: string, ...rest: Array<any>): void {
window.Whisper.events.trigger(name, ...rest); window.Whisper.events.emit(name, ...rest);
} }
export const waitForEvent = ( export const waitForEvent = (

View file

@ -7,7 +7,6 @@ import type { ReadonlyDeep } from 'type-fest';
import * as Crypto from './Crypto'; import * as Crypto from './Crypto';
import * as Curve from './Curve'; import * as Curve from './Curve';
import { start as conversationControllerStart } from './ConversationController';
import * as Groups from './groups'; import * as Groups from './groups';
import OS from './util/os/osMain'; import OS from './util/os/osMain';
import { isProduction } from './util/version'; import { isProduction } from './util/version';
@ -486,8 +485,6 @@ export const setup = (options: {
Components, Components,
Crypto, Crypto,
Curve, Curve,
// Note: used in test/index.html, and not type-checked!
conversationControllerStart,
Groups, Groups,
Migrations, Migrations,
OS, OS,

View file

@ -699,10 +699,6 @@ type ReadableInterface = {
getAllConversations: () => Array<ConversationType>; getAllConversations: () => Array<ConversationType>;
getAllConversationIds: () => Array<string>; getAllConversationIds: () => Array<string>;
getAllGroupsInvolvingServiceId: (
serviceId: ServiceIdString
) => Array<ConversationType>;
getGroupSendCombinedEndorsementExpiration: (groupId: string) => number | null; getGroupSendCombinedEndorsementExpiration: (groupId: string) => number | null;
getGroupSendEndorsementsData: ( getGroupSendEndorsementsData: (
groupId: string groupId: string

View file

@ -372,7 +372,6 @@ export const DataReader: ServerReadableInterface = {
getAllConversations, getAllConversations,
getAllConversationIds, getAllConversationIds,
getAllGroupsInvolvingServiceId,
getGroupSendCombinedEndorsementExpiration, getGroupSendCombinedEndorsementExpiration,
getGroupSendEndorsementsData, getGroupSendEndorsementsData,
@ -1945,27 +1944,6 @@ function getAllConversationIds(db: ReadableDB): Array<string> {
return rows.map(row => row.id); return rows.map(row => row.id);
} }
function getAllGroupsInvolvingServiceId(
db: ReadableDB,
serviceId: ServiceIdString
): Array<ConversationType> {
const rows: ConversationRows = db
.prepare(
`
SELECT json, profileLastFetchedAt, expireTimerVersion
FROM conversations WHERE
type = 'group' AND
members LIKE $serviceId
ORDER BY id ASC;
`
)
.all({
serviceId: `%${serviceId}%`,
});
return rows.map(row => rowToConversation(row));
}
function searchMessages( function searchMessages(
db: ReadableDB, db: ReadableDB,
{ {

View file

@ -900,8 +900,10 @@ function addPendingAttachment(
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (conversation) { if (conversation) {
conversation.attributes.draftAttachments = nextAttachments; conversation.set({
conversation.attributes.draftChanged = true; draftAttachments: nextAttachments,
draftChanged: true,
});
drop(DataWriter.updateConversation(conversation.attributes)); drop(DataWriter.updateConversation(conversation.attributes));
} }
}; };
@ -1202,8 +1204,10 @@ function removeAttachment(
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (conversation) { if (conversation) {
conversation.attributes.draftAttachments = nextAttachments; conversation.set({
conversation.attributes.draftChanged = true; draftAttachments: nextAttachments,
draftChanged: true,
});
await DataWriter.updateConversation(conversation.attributes); await DataWriter.updateConversation(conversation.attributes);
} }

View file

@ -1541,12 +1541,10 @@ async function getAvatarsAndUpdateConversation(
const nextAvatars = getNextAvatarsData(avatars, nextAvatarId); const nextAvatars = getNextAvatarsData(avatars, nextAvatarId);
// We don't save buffers to the db, but we definitely want it in-memory so // We don't save buffers to the db, but we definitely want it in-memory so
// we don't have to re-generate them. // we don't have to re-generate them.
//
// Mutating here because we don't want to trigger a model change conversation.set({
// because we're updating redux here manually ourselves. Au revoir Backbone! avatars: nextAvatars.map(avatarData => omit(avatarData, ['buffer'])),
conversation.attributes.avatars = nextAvatars.map(avatarData => });
omit(avatarData, ['buffer'])
);
await DataWriter.updateConversation(conversation.attributes); await DataWriter.updateConversation(conversation.attributes);
return nextAvatars; return nextAvatars;
@ -1922,15 +1920,12 @@ function discardEditMessage(
conversationId: string conversationId: string
): ThunkAction<void, RootStateType, unknown, never> { ): ThunkAction<void, RootStateType, unknown, never> {
return () => { return () => {
window.ConversationController.get(conversationId)?.set( window.ConversationController.get(conversationId)?.set({
{ draftEditMessage: undefined,
draftEditMessage: undefined, draftBodyRanges: undefined,
draftBodyRanges: undefined, draft: undefined,
draft: undefined, quotedMessageId: undefined,
quotedMessageId: undefined, });
},
{ unset: true }
);
}; };
} }
@ -2036,7 +2031,7 @@ function generateNewGroupLink(
/** /**
* Not an actual redux action creator, so it doesn't produce an action (or dispatch * Not an actual redux action creator, so it doesn't produce an action (or dispatch
* itself) because updates are managed through the backbone model, which will trigger * itself) because updates are managed through the model, which will trigger
* necessary updates and refresh conversation_view. * necessary updates and refresh conversation_view.
* *
* In practice, it's similar to an already-connected thunk action. Later on we will * In practice, it's similar to an already-connected thunk action. Later on we will
@ -2229,9 +2224,8 @@ function myProfileChanged(
avatarUpdateOptions avatarUpdateOptions
); );
// writeProfile above updates the backbone model which in turn updates // writeProfile above updates the model which in turn updates
// redux through it's on:change event listener. Once we lose Backbone // redux through it's on:change event listener.
// we'll need to manually sync these new changes.
// We just want to clear whatever error was there before: // We just want to clear whatever error was there before:
dispatch({ dispatch({
@ -2267,7 +2261,7 @@ function removeCustomColorOnConversations(
): ThunkAction<void, RootStateType, unknown, CustomColorRemovedActionType> { ): ThunkAction<void, RootStateType, unknown, CustomColorRemovedActionType> {
return async dispatch => { return async dispatch => {
const conversationsToUpdate: Array<ConversationAttributesType> = []; const conversationsToUpdate: Array<ConversationAttributesType> = [];
window.getConversations().forEach(conversation => { window.ConversationController.getAll().forEach(conversation => {
if (conversation.get('customColorId') === colorId) { if (conversation.get('customColorId') === colorId) {
conversation.set({ conversation.set({
conversationColor: undefined, conversationColor: undefined,
@ -2301,7 +2295,7 @@ function resetAllChatColors(): ThunkAction<
// Calling this with no args unsets all the colors in the db // Calling this with no args unsets all the colors in the db
await DataWriter.updateAllConversationColors(); await DataWriter.updateAllConversationColors();
window.getConversations().forEach(conversation => { window.ConversationController.getAll().forEach(conversation => {
conversation.set({ conversation.set({
conversationColor: undefined, conversationColor: undefined,
customColor: undefined, customColor: undefined,

View file

@ -182,7 +182,7 @@ function stickerPackAdded(
): StickerPackAddedAction { ): StickerPackAddedAction {
const { status, attemptedStatus } = payload; const { status, attemptedStatus } = payload;
// We do this to trigger a toast, which is still done via Backbone // We do this to trigger a toast, which is still done via Whisper.events
if ( if (
status === 'error' && status === 'error' &&
attemptedStatus === 'installed' && attemptedStatus === 'installed' &&
@ -336,7 +336,7 @@ function stickerPackUpdated(
): StickerPackUpdatedAction { ): StickerPackUpdatedAction {
const { status, attemptedStatus } = patch; const { status, attemptedStatus } = patch;
// We do this to trigger a toast, which is still done via Backbone // We do this to trigger a toast, which is still done via Whisper.events
if ( if (
status === 'error' && status === 'error' &&
attemptedStatus === 'installed' && attemptedStatus === 'installed' &&

View file

@ -113,7 +113,7 @@ export function getInitialState(
} }
export function generateConversationsState(): ConversationsStateType { export function generateConversationsState(): ConversationsStateType {
const convoCollection = window.getConversations(); const convoCollection = window.ConversationController.getAll();
const formattedConversations = convoCollection.map(conversation => const formattedConversations = convoCollection.map(conversation =>
conversation.format() conversation.format()
); );

View file

@ -44,7 +44,7 @@ export function initializeRedux(data: ReduxInitData): void {
window.reduxStore = store; window.reduxStore = store;
// Binding these actions to our redux store and exposing them allows us to update // Binding these actions to our redux store and exposing them allows us to update
// redux when things change in the backbone world. // redux when things change in the rest of the app.
window.reduxActions = { window.reduxActions = {
accounts: bindActionCreators(actionCreators.accounts, store.dispatch), accounts: bindActionCreators(actionCreators.accounts, store.dispatch),
app: bindActionCreators(actionCreators.app, store.dispatch), app: bindActionCreators(actionCreators.app, store.dispatch),

View file

@ -834,7 +834,7 @@ export const getComposeSelectedContacts = createSelector(
// What needs to happen to pull that selector logic here? // What needs to happen to pull that selector logic here?
// 1) contactTypingTimers - that UI-only state needs to be moved to redux // 1) contactTypingTimers - that UI-only state needs to be moved to redux
// 2) all of the message selectors need to be reselect-based; today those // 2) all of the message selectors need to be reselect-based; today those
// Backbone-based prop-generation functions expect to get Conversation information // model-based prop-generation functions expect to get Conversation information
// directly via ConversationController // directly via ConversationController
export function _conversationSelector( export function _conversationSelector(
conversation?: ConversationType conversation?: ConversationType

View file

@ -100,8 +100,7 @@ async function uploadProfile({
lastName: string; lastName: string;
}): Promise<void> { }): Promise<void> {
const us = window.ConversationController.getOurConversationOrThrow(); const us = window.ConversationController.getOurConversationOrThrow();
us.set('profileName', firstName); us.set({ profileName: firstName, profileFamilyName: lastName });
us.set('profileFamilyName', lastName);
us.captureChange('standaloneProfile'); us.captureChange('standaloneProfile');
await DataWriter.updateConversation(us.attributes); await DataWriter.updateConversation(us.attributes);

View file

@ -577,7 +577,7 @@ export function SmartPreferences(): JSX.Element | null {
createItemsAccess('call-ringtone-notification', true); createItemsAccess('call-ringtone-notification', true);
const [hasCountMutedConversations, onCountMutedConversationsChange] = const [hasCountMutedConversations, onCountMutedConversationsChange] =
createItemsAccess('badge-count-muted-conversations', false, () => { createItemsAccess('badge-count-muted-conversations', false, () => {
window.Whisper.events.trigger('updateUnreadCount'); window.Whisper.events.emit('updateUnreadCount');
}); });
const [hasHideMenuBar, onHideMenuBarChange] = createItemsAccess( const [hasHideMenuBar, onHideMenuBarChange] = createItemsAccess(
'hide-menu-bar', 'hide-menu-bar',

View file

@ -1,138 +0,0 @@
// Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { Model } from 'backbone';
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('reliable trigger', () => {
describe('trigger', () => {
let model: Model;
beforeEach(() => {
model = new Model();
});
it('returns successfully if this._events is falsey', () => {
(model as any)._events = null;
model.trigger('click');
});
it('handles space-separated list of events to trigger', () => {
let a = false;
let b = false;
model.on('a', () => {
a = true;
});
model.on('b', () => {
b = true;
});
model.trigger('a b');
assert.strictEqual(a, true);
assert.strictEqual(b, true);
});
it('calls all clients registered for "all" event', () => {
let count = 0;
model.on('all', () => {
count += 1;
});
model.trigger('left');
model.trigger('right');
assert.strictEqual(count, 2);
});
it('calls all clients registered for target event', () => {
let a = false;
let b = false;
model.on('event', () => {
a = true;
});
model.on('event', () => {
b = true;
});
model.trigger('event');
assert.strictEqual(a, true);
assert.strictEqual(b, true);
});
it('successfully returns and calls all clients even if first failed', () => {
let a = false;
let b = false;
model.on('event', () => {
a = true;
throw new Error('a is set, but exception is thrown');
});
model.on('event', () => {
b = true;
});
model.trigger('event');
assert.strictEqual(a, true);
assert.strictEqual(b, true);
});
it('calls clients with no args', () => {
let called = false;
model.on('event', () => {
called = true;
});
model.trigger('event');
assert.strictEqual(called, true);
});
it('calls clients with 1 arg', () => {
let args: Array<unknown> = [];
model.on('event', (...eventArgs) => {
args = eventArgs;
});
model.trigger('event', 1);
assert.strictEqual(args[0], 1);
});
it('calls clients with 2 args', () => {
let args: Array<unknown> = [];
model.on('event', (...eventArgs) => {
args = eventArgs;
});
model.trigger('event', 1, 2);
assert.strictEqual(args[0], 1);
assert.strictEqual(args[1], 2);
});
it('calls clients with 3 args', () => {
let args: Array<unknown> = [];
model.on('event', (...eventArgs) => {
args = eventArgs;
});
model.trigger('event', 1, 2, 3);
assert.strictEqual(args[0], 1);
assert.strictEqual(args[1], 2);
assert.strictEqual(args[2], 3);
});
it('calls clients with 4+ args', () => {
let args: Array<unknown> = [];
model.on('event', (...eventArgs) => {
args = eventArgs;
});
model.trigger('event', 1, 2, 3, 4);
assert.strictEqual(args[0], 1);
assert.strictEqual(args[1], 2);
assert.strictEqual(args[2], 3);
assert.strictEqual(args[3], 4);
});
});
});

View file

@ -10,6 +10,7 @@ import { IMAGE_PNG } from '../../types/MIME';
import { generateAci, generatePni } from '../../types/ServiceId'; import { generateAci, generatePni } from '../../types/ServiceId';
import { MessageModel } from '../../models/messages'; import { MessageModel } from '../../models/messages';
import { DurationInSeconds } from '../../util/durations'; import { DurationInSeconds } from '../../util/durations';
import { ConversationModel } from '../../models/conversations';
describe('Conversations', () => { describe('Conversations', () => {
async function resetConversationController(): Promise<void> { async function resetConversationController(): Promise<void> {
@ -32,7 +33,7 @@ describe('Conversations', () => {
it('updates lastMessage even in race conditions with db', async () => { it('updates lastMessage even in race conditions with db', async () => {
// Creating a fake conversation // Creating a fake conversation
const conversation = new window.Whisper.Conversation({ const conversation = new ConversationModel({
avatars: [], avatars: [],
id: generateUuid(), id: generateUuid(),
e164: '+15551234567', e164: '+15551234567',
@ -111,7 +112,7 @@ describe('Conversations', () => {
it('only produces attachments on a quote with an image', async () => { it('only produces attachments on a quote with an image', async () => {
// Creating a fake conversation // Creating a fake conversation
const conversation = new window.Whisper.Conversation({ const conversation = new ConversationModel({
avatars: [], avatars: [],
id: generateUuid(), id: generateUuid(),
e164: '+15551234567', e164: '+15551234567',

View file

@ -86,8 +86,8 @@ describe('MessageCache', () => {
}); });
}); });
describe('register: syncing with backbone', () => { describe('register: syncing with models', () => {
it('backbone to redux', () => { it('model to redux', () => {
const message1 = new MessageModel({ const message1 = new MessageModel({
conversationId: 'xyz', conversationId: 'xyz',
id: uuid(), id: uuid(),
@ -126,7 +126,7 @@ describe('MessageCache', () => {
); );
}); });
it('redux to backbone (working with models)', () => { it('redux to model (working with models)', () => {
const message = new MessageModel({ const message = new MessageModel({
conversationId: 'xyz', conversationId: 'xyz',
id: uuid(), id: uuid(),

View file

@ -130,7 +130,7 @@ describe('both/state/ducks/conversations', () => {
sinonSandbox = sinon.createSandbox(); sinonSandbox = sinon.createSandbox();
sinonSandbox.stub(window.Whisper.events, 'trigger'); sinonSandbox.stub(window.Whisper.events, 'emit');
createGroupStub = sinon.stub(); createGroupStub = sinon.stub();
}); });

View file

@ -68,7 +68,7 @@ describe('updateConversationsWithUuidLookup', () => {
return { conversation: convoUuid, mergePromises: [] }; return { conversation: convoUuid, mergePromises: [] };
} }
convoE164.unset('e164'); convoE164.set({ e164: undefined });
convoUuid.updateE164(e164); convoUuid.updateE164(e164);
return { conversation: convoUuid, mergePromises: [] }; return { conversation: convoUuid, mergePromises: [] };
} }

View file

@ -140,6 +140,9 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
const CallsTabDetailsTitle = CallsTabDetails.locator( const CallsTabDetailsTitle = CallsTabDetails.locator(
'.ConversationDetailsHeader__title' '.ConversationDetailsHeader__title'
); );
const AnyCallListAvatar = CallsTabSidebar.locator(
'.CallsList__ItemAvatar'
).first();
debug('waiting for unread badge to hit correct value', unreadCount); debug('waiting for unread badge to hit correct value', unreadCount);
await CallsNavTabUnread.getByText(`${unreadCount} unread`).waitFor(); await CallsNavTabUnread.getByText(`${unreadCount} unread`).waitFor();
@ -147,6 +150,9 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
debug('opening calls tab'); debug('opening calls tab');
await CallsNavTab.click(); await CallsNavTab.click();
await CreateCallLink.waitFor();
await AnyCallListAvatar.waitFor();
async function measure(runId: number): Promise<number> { async function measure(runId: number): Promise<number> {
// setup // setup
const searchContact = contacts[runId % contacts.length]; const searchContact = contacts[runId % contacts.length];
@ -182,6 +188,7 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
await NewCallDetailsTitle.waitFor(); await NewCallDetailsTitle.waitFor();
await SearchBar.clear(); await SearchBar.clear();
await CreateCallLink.waitFor(); await CreateCallLink.waitFor();
await AnyCallListAvatar.waitFor();
// measure // measure
const end = Date.now(); const end = Date.now();

View file

@ -365,7 +365,7 @@ export class SocketManager extends EventListener {
error instanceof LibSignalErrorBase && error instanceof LibSignalErrorBase &&
error.code === ErrorCode.AppExpired error.code === ErrorCode.AppExpired
) { ) {
window.Whisper.events.trigger('httpResponse499'); window.Whisper.events.emit('httpResponse499');
return; return;
} else if ( } else if (
error instanceof LibSignalErrorBase && error instanceof LibSignalErrorBase &&

View file

@ -64,7 +64,7 @@ export class UpdateKeysListener {
(error.code === 422 || error.code === 403) (error.code === 422 || error.code === 403)
) { ) {
log.error(`run: Got a ${error.code} uploading PNI keys; unlinking`); log.error(`run: Got a ${error.code} uploading PNI keys; unlinking`);
window.Whisper.events.trigger('unlinkAndDisconnect'); window.Whisper.events.emit('unlinkAndDisconnect');
} else { } else {
const errorString = const errorString =
error instanceof HTTPError error instanceof HTTPError

View file

@ -5,7 +5,7 @@ import type { HTTPError } from './Errors';
export async function handleStatusCode(status: number): Promise<void> { export async function handleStatusCode(status: number): Promise<void> {
if (status === 499) { if (status === 499) {
window.Whisper.events.trigger('httpResponse499'); window.Whisper.events.emit('httpResponse499');
} }
} }

View file

@ -470,7 +470,7 @@ async function _promiseAjax<Type extends ResponseType, OutputShape>(
if (!unauthenticated && response.status === 401) { if (!unauthenticated && response.status === 401) {
log.warn('Got 401 from Signal Server. We might be unlinked.'); log.warn('Got 401 from Signal Server. We might be unlinked.');
window.Whisper.events.trigger('mightBeUnlinked'); window.Whisper.events.emit('mightBeUnlinked');
} }
} }
@ -2048,23 +2048,23 @@ export function initialize({
}); });
socketManager.on('statusChange', () => { socketManager.on('statusChange', () => {
window.Whisper.events.trigger('socketStatusChange'); window.Whisper.events.emit('socketStatusChange');
}); });
socketManager.on('online', () => { socketManager.on('online', () => {
window.Whisper.events.trigger('online'); window.Whisper.events.emit('online');
}); });
socketManager.on('offline', () => { socketManager.on('offline', () => {
window.Whisper.events.trigger('offline'); window.Whisper.events.emit('offline');
}); });
socketManager.on('authError', () => { socketManager.on('authError', () => {
window.Whisper.events.trigger('unlinkAndDisconnect'); window.Whisper.events.emit('unlinkAndDisconnect');
}); });
socketManager.on('firstEnvelope', incoming => { socketManager.on('firstEnvelope', incoming => {
window.Whisper.events.trigger('firstEnvelope', incoming); window.Whisper.events.emit('firstEnvelope', incoming);
}); });
socketManager.on('serverAlerts', alerts => { socketManager.on('serverAlerts', alerts => {

View file

@ -58,7 +58,7 @@ export class User {
]); ]);
// Notify redux about phone number change // Notify redux about phone number change
window.Whisper.events.trigger('userChanged', true); window.Whisper.events.emit('userChanged', true);
} }
public getNumber(): string | undefined { public getNumber(): string | undefined {

View file

@ -1262,10 +1262,12 @@ async function saveCallHistory({
); );
}); });
conversation.set( conversation.set({
'active_at', active_at: Math.max(
Math.max(conversation.get('active_at') ?? 0, callHistory.timestamp) conversation.get('active_at') ?? 0,
); callHistory.timestamp
),
});
if (canConversationBeUnarchived(conversation.attributes)) { if (canConversationBeUnarchived(conversation.attributes)) {
conversation.setArchived(false); conversation.setArchived(false);

View file

@ -15,20 +15,20 @@ export async function checkOurPniIdentityKey(): Promise<void> {
const { pni: remotePni } = await server.whoami(); const { pni: remotePni } = await server.whoami();
if (remotePni !== ourPni) { if (remotePni !== ourPni) {
log.warn(`remote pni mismatch, ${remotePni} != ${ourPni}`); log.warn(`remote pni mismatch, ${remotePni} != ${ourPni}`);
window.Whisper.events.trigger('unlinkAndDisconnect'); window.Whisper.events.emit('unlinkAndDisconnect');
return; return;
} }
const localKeyPair = await window.storage.protocol.getIdentityKeyPair(ourPni); const localKeyPair = await window.storage.protocol.getIdentityKeyPair(ourPni);
if (!localKeyPair) { if (!localKeyPair) {
log.warn(`no local key pair for ${ourPni}, unlinking`); log.warn(`no local key pair for ${ourPni}, unlinking`);
window.Whisper.events.trigger('unlinkAndDisconnect'); window.Whisper.events.emit('unlinkAndDisconnect');
return; return;
} }
const { identityKey: remoteKey } = await server.getKeysForServiceId(ourPni); const { identityKey: remoteKey } = await server.getKeysForServiceId(ourPni);
if (!constantTimeEqual(localKeyPair.publicKey.serialize(), remoteKey)) { if (!constantTimeEqual(localKeyPair.publicKey.serialize(), remoteKey)) {
log.warn(`local/remote key mismatch for ${ourPni}, unlinking`); log.warn(`local/remote key mismatch for ${ourPni}, unlinking`);
window.Whisper.events.trigger('unlinkAndDisconnect'); window.Whisper.events.emit('unlinkAndDisconnect');
} }
} }

View file

@ -124,7 +124,7 @@ export async function cleanupMessages(
); );
} }
/** Removes a message from redux caches & backbone, but does NOT delete files on disk, /** Removes a message from redux caches & MessageCache, but does NOT delete files on disk,
* story replies, edit histories, attachments, etc. Should ONLY be called in conjunction * story replies, edit histories, attachments, etc. Should ONLY be called in conjunction
* with deleteMessageData. */ * with deleteMessageData. */
export function cleanupMessageFromMemory(message: MessageAttributesType): void { export function cleanupMessageFromMemory(message: MessageAttributesType): void {

View file

@ -26,7 +26,7 @@ export function isSignalConnection(
} }
export function getSignalConnections(): Array<ConversationModel> { export function getSignalConnections(): Array<ConversationModel> {
return window return window.ConversationController.getAll().filter(conversation =>
.getConversations() isSignalConnection(conversation.attributes)
.filter(conversation => isSignalConnection(conversation.attributes)); );
} }

View file

@ -116,7 +116,7 @@ function processError(error: unknown): void {
log.warn( log.warn(
`Got 401/403 for ${conversation.idForLogging()}, setting sealedSender = DISABLED` `Got 401/403 for ${conversation.idForLogging()}, setting sealedSender = DISABLED`
); );
conversation.set('sealedSender', SEALED_SENDER.DISABLED); conversation.set({ sealedSender: SEALED_SENDER.DISABLED });
drop(updateConversation(conversation.attributes)); drop(updateConversation(conversation.attributes));
} }
} }

View file

@ -84,7 +84,7 @@ async function fetchAndUpdateDeviceName() {
} }
await window.storage.user.setDeviceName(newName); await window.storage.user.setDeviceName(newName);
window.Whisper.events.trigger('deviceNameChanged'); window.Whisper.events.emit('deviceNameChanged');
log.info( log.info(
'fetchAndUpdateDeviceName: successfully updated new device name locally' 'fetchAndUpdateDeviceName: successfully updated new device name locally'
); );

View file

@ -214,7 +214,7 @@ export async function onStoryRecipientUpdate(
}); });
if (handledMessages.length) { if (handledMessages.length) {
window.Whisper.events.trigger('incrementProgress'); window.Whisper.events.emit('incrementProgress');
confirm(); confirm();
} }
}) })

View file

@ -241,7 +241,7 @@ export async function sendStoryMessage(
group => group.getStorySendMode() !== StorySendMode.Always group => group.getStorySendMode() !== StorySendMode.Always
); );
for (const group of groupsToUpdate) { for (const group of groupsToUpdate) {
group.set('storySendMode', StorySendMode.Always); group.set({ storySendMode: StorySendMode.Always });
} }
void DataWriter.updateConversations( void DataWriter.updateConversations(
groupsToUpdate.map(group => group.attributes) groupsToUpdate.map(group => group.attributes)

View file

@ -2,7 +2,11 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ValidateConversationType } from '../model-types.d'; import type { ValidateConversationType } from '../model-types.d';
import { isDirectConversation } from './whatTypeOfConversation'; import {
isDirectConversation,
isGroupV1,
isGroupV2,
} from './whatTypeOfConversation';
import { isServiceIdString } from '../types/ServiceId'; import { isServiceIdString } from '../types/ServiceId';
export function validateConversation( export function validateConversation(
@ -22,6 +26,14 @@ export function validateConversation(
return error; return error;
} }
if (
!isDirectConversation(attributes) &&
!isGroupV1(attributes) &&
!isGroupV2(attributes)
) {
return 'Conversation is not direct, groupv1 or groupv2';
}
return null; return null;
} }

14
ts/window.d.ts vendored
View file

@ -3,15 +3,14 @@
// Captures the globals put in place by preload.js, background.js and others // Captures the globals put in place by preload.js, background.js and others
import type EventEmitter from 'node:events';
import type { Store } from 'redux'; import type { Store } from 'redux';
import type * as Backbone from 'backbone';
import type { SystemPreferences } from 'electron'; import type { SystemPreferences } from 'electron';
import type PQueue from 'p-queue/dist'; import type PQueue from 'p-queue/dist';
import type { assert } from 'chai'; import type { assert } from 'chai';
import type { PhoneNumber, PhoneNumberFormat } from 'google-libphonenumber'; import type { PhoneNumber, PhoneNumberFormat } from 'google-libphonenumber';
import type { MochaOptions } from 'mocha'; import type { MochaOptions } from 'mocha';
import type { ConversationModelCollectionType } from './model-types.d';
import type { textsecure } from './textsecure'; import type { textsecure } from './textsecure';
import type { Storage } from './textsecure/Storage'; import type { Storage } from './textsecure/Storage';
import type { import type {
@ -34,7 +33,6 @@ import type { Receipt } from './types/Receipt';
import type { ConversationController } from './ConversationController'; import type { ConversationController } from './ConversationController';
import type { ReduxActions } from './state/types'; import type { ReduxActions } from './state/types';
import type { createApp } from './state/roots/createApp'; import type { createApp } from './state/roots/createApp';
import type { ConversationModel } from './models/conversations';
import type { BatcherType } from './util/batcher'; import type { BatcherType } from './util/batcher';
import type { ConfirmationDialog } from './components/ConfirmationDialog'; import type { ConfirmationDialog } from './components/ConfirmationDialog';
import type { SignalProtocolStore } from './SignalProtocolStore'; import type { SignalProtocolStore } from './SignalProtocolStore';
@ -183,7 +181,6 @@ export type SignalCoreType = {
createApp: typeof createApp; createApp: typeof createApp;
}; };
}; };
conversationControllerStart: () => void;
challengeHandler?: ChallengeHandler; challengeHandler?: ChallengeHandler;
// Only for debugging in Dev Tools // Only for debugging in Dev Tools
@ -206,7 +203,6 @@ declare global {
enterMouseMode: () => void; enterMouseMode: () => void;
getAccountManager: () => AccountManager; getAccountManager: () => AccountManager;
getAppInstance: () => string | undefined; getAppInstance: () => string | undefined;
getConversations: () => ConversationModelCollectionType;
getBuildCreation: () => number; getBuildCreation: () => number;
getBuildExpiration: () => number; getBuildExpiration: () => number;
getHostName: () => string; getHostName: () => string;
@ -247,9 +243,6 @@ declare global {
// The types below have been somewhat organized. See DESKTOP-4801 // The types below have been somewhat organized. See DESKTOP-4801
// ======================================================================== // ========================================================================
// Backbone
Backbone: typeof Backbone;
ConversationController: ConversationController; ConversationController: ConversationController;
Events: IPCEventsType; Events: IPCEventsType;
FontFace: typeof FontFace; FontFace: typeof FontFace;
@ -331,10 +324,7 @@ declare global {
} }
export type WhisperType = { export type WhisperType = {
Conversation: typeof ConversationModel;
ConversationCollection: typeof ConversationModelCollectionType;
deliveryReceiptQueue: PQueue; deliveryReceiptQueue: PQueue;
deliveryReceiptBatcher: BatcherType<Receipt>; deliveryReceiptBatcher: BatcherType<Receipt>;
events: Backbone.Events; events: EventEmitter;
}; };

View file

@ -1,9 +1,11 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import EventEmitter from 'node:events';
import { ipcRenderer as ipc } from 'electron'; import { ipcRenderer as ipc } from 'electron';
import * as semver from 'semver'; import * as semver from 'semver';
import { mapValues } from 'lodash'; import { groupBy, mapValues } from 'lodash';
import PQueue from 'p-queue';
import type { IPCType } from '../../window.d'; import type { IPCType } from '../../window.d';
import { parseIntWithFallback } from '../../util/parseIntWithFallback'; import { parseIntWithFallback } from '../../util/parseIntWithFallback';
@ -24,6 +26,15 @@ import { AggregatedStats } from '../../textsecure/WebsocketResources';
import { UNAUTHENTICATED_CHANNEL_NAME } from '../../textsecure/SocketManager'; import { UNAUTHENTICATED_CHANNEL_NAME } from '../../textsecure/SocketManager';
import { isProduction } from '../../util/version'; import { isProduction } from '../../util/version';
import { ToastType } from '../../types/Toast'; import { ToastType } from '../../types/Toast';
import { ConversationController } from '../../ConversationController';
import { createBatcher } from '../../util/batcher';
import { ReceiptType } from '../../types/Receipt';
import type { Receipt } from '../../types/Receipt';
import { MINUTE } from '../../util/durations';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../../jobs/conversationJobQueue';
const log = createLogger('phase1-ipc'); const log = createLogger('phase1-ipc');
@ -47,6 +58,32 @@ window.Flags = Flags;
window.RETRY_DELAY = false; window.RETRY_DELAY = false;
window.Whisper = {
events: new EventEmitter(),
deliveryReceiptQueue: new PQueue({
concurrency: 1,
timeout: MINUTE * 30,
}),
deliveryReceiptBatcher: createBatcher<Receipt>({
name: 'Whisper.deliveryReceiptBatcher',
wait: 500,
maxSize: 100,
processBatch: async deliveryReceipts => {
const groups = groupBy(deliveryReceipts, 'conversationId');
await Promise.all(
Object.keys(groups).map(async conversationId => {
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.Receipts,
conversationId,
receiptsType: ReceiptType.Delivery,
receipts: groups[conversationId],
});
})
);
},
}),
};
window.ConversationController = new ConversationController();
window.platform = process.platform; window.platform = process.platform;
window.getTitle = () => title; window.getTitle = () => title;
window.getAppInstance = () => config.appInstance; window.getAppInstance = () => config.appInstance;
@ -272,35 +309,35 @@ ipc.on('additional-log-data-request', async event => {
}); });
ipc.on('open-settings-tab', () => { ipc.on('open-settings-tab', () => {
window.Whisper.events.trigger('openSettingsTab'); window.Whisper.events.emit('openSettingsTab');
}); });
ipc.on('set-up-as-new-device', () => { ipc.on('set-up-as-new-device', () => {
window.Whisper.events.trigger('setupAsNewDevice'); window.Whisper.events.emit('setupAsNewDevice');
}); });
ipc.on('set-up-as-standalone', () => { ipc.on('set-up-as-standalone', () => {
window.Whisper.events.trigger('setupAsStandalone'); window.Whisper.events.emit('setupAsStandalone');
}); });
ipc.on('stage-local-backup-for-import', () => { ipc.on('stage-local-backup-for-import', () => {
window.Whisper.events.trigger('stageLocalBackupForImport'); window.Whisper.events.emit('stageLocalBackupForImport');
}); });
ipc.on('challenge:response', (_event, response) => { ipc.on('challenge:response', (_event, response) => {
window.Whisper.events.trigger('challengeResponse', response); window.Whisper.events.emit('challengeResponse', response);
}); });
ipc.on('power-channel:suspend', () => { ipc.on('power-channel:suspend', () => {
window.Whisper.events.trigger('powerMonitorSuspend'); window.Whisper.events.emit('powerMonitorSuspend');
}); });
ipc.on('power-channel:resume', () => { ipc.on('power-channel:resume', () => {
window.Whisper.events.trigger('powerMonitorResume'); window.Whisper.events.emit('powerMonitorResume');
}); });
ipc.on('power-channel:lock-screen', () => { ipc.on('power-channel:lock-screen', () => {
window.Whisper.events.trigger('powerMonitorLockScreen'); window.Whisper.events.emit('powerMonitorLockScreen');
}); });
ipc.on( ipc.on(
@ -328,7 +365,7 @@ ipc.on('window:set-menu-options', (_event, options) => {
if (!window.Whisper.events) { if (!window.Whisper.events) {
return; return;
} }
window.Whisper.events.trigger('setMenuOptions', options); window.Whisper.events.emit('setMenuOptions', options);
}); });
window.sendChallengeRequest = request => ipc.send('challenge:request', request); window.sendChallengeRequest = request => ipc.send('challenge:request', request);

View file

@ -1,7 +1,6 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import Backbone from 'backbone';
import { PhoneNumberUtil, PhoneNumberFormat } from 'google-libphonenumber'; import { PhoneNumberUtil, PhoneNumberFormat } from 'google-libphonenumber';
import * as moment from 'moment'; import * as moment from 'moment';
// @ts-expect-error -- no types // @ts-expect-error -- no types
@ -21,7 +20,6 @@ const log = createLogger('phase2-dependencies');
initializeLogging(); initializeLogging();
window.nodeSetImmediate = setImmediate; window.nodeSetImmediate = setImmediate;
window.Backbone = Backbone;
window.textsecure = textsecure; window.textsecure = textsecure;
const { config } = window.SignalContext; const { config } = window.SignalContext;

View file

@ -1,7 +1,7 @@
// Copyright 2017 Signal Messenger, LLC // Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { clone, has } from 'lodash'; import { has } from 'lodash';
import { contextBridge } from 'electron'; import { contextBridge } from 'electron';
import { createLogger } from '../../logging/log'; import { createLogger } from '../../logging/log';
@ -17,7 +17,6 @@ import '../preload';
import './phase2-dependencies'; import './phase2-dependencies';
import './phase3-post-signal'; import './phase3-post-signal';
import './phase4-test'; import './phase4-test';
import '../../backbone/reliable_trigger';
import type { import type {
CdsLookupOptionsType, CdsLookupOptionsType,
@ -25,7 +24,6 @@ import type {
} from '../../textsecure/WebAPI'; } from '../../textsecure/WebAPI';
import type { FeatureFlagType } from '../../window.d'; import type { FeatureFlagType } from '../../window.d';
import type { StorageAccessType } from '../../types/Storage.d'; import type { StorageAccessType } from '../../types/Storage.d';
import { start as startConversationController } from '../../ConversationController';
import { initMessageCleanup } from '../../services/messageStateCleanup'; import { initMessageCleanup } from '../../services/messageStateCleanup';
import { Environment, getEnvironment } from '../../environment'; import { Environment, getEnvironment } from '../../environment';
import { isProduction } from '../../util/version'; import { isProduction } from '../../util/version';
@ -52,9 +50,7 @@ if (window.SignalContext.config.proxyUrl) {
log.info('Using provided proxy url'); log.info('Using provided proxy url');
} }
window.Whisper.events = clone(window.Backbone.Events);
initMessageCleanup(); initMessageCleanup();
startConversationController();
if ( if (
!isProduction(window.SignalContext.getVersion()) || !isProduction(window.SignalContext.getVersion()) ||