From 516c65092be1aa9b88145b157cb8f7acd6acc1b1 Mon Sep 17 00:00:00 2001 From: Brian Harder <84676669+brianHarder@users.noreply.github.com> Date: Tue, 10 Jun 2025 16:31:17 -0700 Subject: [PATCH 01/54] PR 1 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index fa1b59b6fc8..db8cd00c948 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ +# Merged PRs + +Reaction Overlay: https://github.com/signalapp/Signal-Desktop/pull/7348 + # Signal Desktop Signal Desktop links with Signal on [Android](https://github.com/signalapp/Signal-Android) or [iOS](https://github.com/signalapp/Signal-iOS) and lets you message from your Windows, macOS, and Linux computers. From 5e47c5c47307a00320fd55cf926ede98a786af28 Mon Sep 17 00:00:00 2001 From: Brian Harder <84676669+brianHarder@users.noreply.github.com> Date: Tue, 10 Jun 2025 16:33:28 -0700 Subject: [PATCH 02/54] Update README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index db8cd00c948..920dc2ff356 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,13 @@ Reaction Overlay: https://github.com/signalapp/Signal-Desktop/pull/7348 + +--- + +## 🔻 Below this line is the original Signal README 🔻 + +--- + # Signal Desktop Signal Desktop links with Signal on [Android](https://github.com/signalapp/Signal-Android) or [iOS](https://github.com/signalapp/Signal-iOS) and lets you message from your Windows, macOS, and Linux computers. From cb5728842d56f0ed0bf9a19fd7254d2b7986f297 Mon Sep 17 00:00:00 2001 From: Brian Harder <84676669+brianHarder@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:59:45 -0700 Subject: [PATCH 03/54] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 920dc2ff356..5b4495a118f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ # Merged PRs Reaction Overlay: https://github.com/signalapp/Signal-Desktop/pull/7348 +Edit Group Modal Alignment: https://github.com/signalapp/Signal-Desktop/pull/7347 --- From d781d405f3518ad2f41fd0ac77924e9e2cc15d90 Mon Sep 17 00:00:00 2001 From: Brian Harder <84676669+brianHarder@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:00:02 -0700 Subject: [PATCH 04/54] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b4495a118f..cbeee08c770 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Merged PRs -Reaction Overlay: https://github.com/signalapp/Signal-Desktop/pull/7348 +Reaction Overlay: https://github.com/signalapp/Signal-Desktop/pull/7348
Edit Group Modal Alignment: https://github.com/signalapp/Signal-Desktop/pull/7347 From 3352a3df6d4795e6fcc2e4d41fa68a57f4ebcf94 Mon Sep 17 00:00:00 2001 From: Brian Harder <84676669+brianHarder@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:32:17 -0700 Subject: [PATCH 05/54] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index cbeee08c770..360a6c3014d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ Reaction Overlay: https://github.com/signalapp/Signal-Desktop/pull/7348
Edit Group Modal Alignment: https://github.com/signalapp/Signal-Desktop/pull/7347 +# PRs in Review + +Drag-and-Drop from Web Browsers: https://github.com/signalapp/Signal-Desktop/pull/7425
+ --- From 2dfcf8c82e0a92abf883206d6c19adb193b42e45 Mon Sep 17 00:00:00 2001 From: Brian Harder <84676669+brianHarder@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:34:01 -0700 Subject: [PATCH 06/54] Update README.md --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 360a6c3014d..3734bb67d97 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,12 @@ -# Merged PRs +# Pull Requests +Drag-and-Drop from Web Browsers: https://github.com/signalapp/Signal-Desktop/pull/7425
Reaction Overlay: https://github.com/signalapp/Signal-Desktop/pull/7348
Edit Group Modal Alignment: https://github.com/signalapp/Signal-Desktop/pull/7347 -# PRs in Review - -Drag-and-Drop from Web Browsers: https://github.com/signalapp/Signal-Desktop/pull/7425
- - --- ## 🔻 Below this line is the original Signal README 🔻 From 1cd1527729ad182bb2a4e053e8915248144eb1de Mon Sep 17 00:00:00 2001 From: yash-signal Date: Thu, 7 Aug 2025 11:59:40 -0500 Subject: [PATCH 07/54] Fix self badge retrieval --- ts/services/profiles.ts | 58 ++++++++++++++++------- ts/state/selectors/badges.ts | 15 ++++-- ts/test-mock/network/serverAlerts_test.ts | 20 ++++++++ 3 files changed, 72 insertions(+), 21 deletions(-) diff --git a/ts/services/profiles.ts b/ts/services/profiles.ts index 68279093e68..3a105837a2f 100644 --- a/ts/services/profiles.ts +++ b/ts/services/profiles.ts @@ -311,23 +311,15 @@ async function buildProfileFetchOptions({ const accessKey = conversation.get('accessKey'); const serviceId = conversation.getCheckedServiceId('getProfile'); - if ( - profileKey && - profileKeyVersion && - accessKey && - !options.ignoreProfileKey - ) { + function getProfileCredentialsToUseIfExpired(profileKeyArg: string): { + credentialRequestContext: ProfileKeyCredentialRequestContext | null; + credentialRequestHex: string | null; + } { if (!conversation.hasProfileKeyCredentialExpired()) { log.info(`${logId}: using unexpired profile key credential`); return { - profileKey, - profileCredentialRequestContext: null, - request: { - accessKey, - groupSendToken: null, - profileKeyVersion, - profileKeyCredentialRequest: null, - }, + credentialRequestContext: null, + credentialRequestHex: null, }; } @@ -335,17 +327,32 @@ async function buildProfileFetchOptions({ const result = generateProfileKeyCredentialRequest( clientZkProfileCipher, serviceId, - profileKey + profileKeyArg ); + return { + credentialRequestContext: result.context, + credentialRequestHex: result.requestHex, + }; + } + + if ( + profileKey && + profileKeyVersion && + accessKey && + !options.ignoreProfileKey && + !isMe(conversation.attributes) + ) { + const { credentialRequestContext, credentialRequestHex } = + getProfileCredentialsToUseIfExpired(profileKey); return { profileKey, - profileCredentialRequestContext: result.context, + profileCredentialRequestContext: credentialRequestContext, request: { accessKey, groupSendToken: null, profileKeyVersion, - profileKeyCredentialRequest: result.requestHex, + profileKeyCredentialRequest: credentialRequestHex, }, }; } @@ -373,6 +380,23 @@ async function buildProfileFetchOptions({ }; } + // For self we also use the versioned profile on the authenticated socket, + // with profile key credentials if needed. + if (profileKey && profileKeyVersion && isMe(conversation.attributes)) { + const { credentialRequestContext, credentialRequestHex } = + getProfileCredentialsToUseIfExpired(profileKey); + return { + profileKey, + profileCredentialRequestContext: credentialRequestContext, + request: { + accessKey: null, + groupSendToken: null, + profileKeyVersion, + profileKeyCredentialRequest: credentialRequestHex, + }, + }; + } + // Fallback to group send tokens for unversioned profiles if (groupId != null && !options.ignoreGroupSendToken) { log.info(`${logId}: fetching group endorsements`); diff --git a/ts/state/selectors/badges.ts b/ts/state/selectors/badges.ts index 5b91f732c64..270e27bf824 100644 --- a/ts/state/selectors/badges.ts +++ b/ts/state/selectors/badges.ts @@ -8,6 +8,7 @@ import type { StateType } from '../reducer'; import type { BadgesStateType } from '../ducks/badges'; import type { BadgeType } from '../../badges/types'; import { getOwn } from '../../util/getOwn'; +import type { ConversationType } from '../ducks/conversations'; const log = createLogger('badges'); @@ -54,19 +55,25 @@ export const getBadgesSelector = createSelector( ); export type PreferredBadgeSelectorType = ( - conversationBadges: ReadonlyArray> + conversationBadges: ConversationType['badges'] ) => undefined | BadgeType; export const getPreferredBadgeSelector = createSelector( getBadgesById, (badgesById): PreferredBadgeSelectorType => conversationBadges => { - const firstId: undefined | string = conversationBadges[0]?.id; - if (!firstId) { + // Find the first visible badge. For other people's badges, isVisible will be + // unset and the badge is guaranteed to be visible. + // For the local user's badges, isVisible will be set and we need to check it. + const firstVisibleBadge = conversationBadges.find(conversationBadge => + 'isVisible' in conversationBadge ? conversationBadge.isVisible : true + ); + + if (!firstVisibleBadge) { return undefined; } - const badge = getOwn(badgesById, firstId); + const badge = getOwn(badgesById, firstVisibleBadge.id); if (!badge) { log.error( 'getPreferredBadgeSelector: conversation badge was not found' diff --git a/ts/test-mock/network/serverAlerts_test.ts b/ts/test-mock/network/serverAlerts_test.ts index 64b5353ad98..6fa4afb6f66 100644 --- a/ts/test-mock/network/serverAlerts_test.ts +++ b/ts/test-mock/network/serverAlerts_test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import createDebug from 'debug'; import type { Page } from 'playwright'; +import { StorageState, type PrimaryDevice } from '@signalapp/mock-server'; import type { App } from '../playwright'; import { Bootstrap } from '../bootstrap'; @@ -18,10 +19,25 @@ describe('serverAlerts', function (this: Mocha.Suite) { this.timeout(MINUTE); let bootstrap: Bootstrap; let app: App; + let pinned: PrimaryDevice; beforeEach(async () => { bootstrap = new Bootstrap(); await bootstrap.init(); + + // Set up a pinned contact to trigger profile fetch to test unauth socket + let state = StorageState.getEmpty(); + const { phone, contacts } = bootstrap; + [pinned] = contacts; + + state = state.addContact(pinned, { + identityKey: pinned.publicKey.serialize(), + profileKey: pinned.profileKey.serialize(), + whitelisted: true, + }); + + state = state.pin(pinned); + await phone.setStorageState(state); }); afterEach(async function (this: Mocha.Context) { @@ -81,6 +97,10 @@ describe('serverAlerts', function (this: Mocha.Suite) { ? await bootstrap.link() : await setupAppToUseLibsignalWebsockets(bootstrap); const window = await app.getWindow(); + + // Trigger a profile fetch for a contact to ensure unauth websocket is used + await window.getByTestId(pinned.device.aci).click(); + await testCase.test(window); if (transport === 'libsignal') { From fda89eaf088d1180e362f1566fa21d2317b84d9c Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:24:25 -0700 Subject: [PATCH 08/54] Update pino to 9.8.0 --- ACKNOWLEDGMENTS.md | 2 +- package.json | 2 +- pnpm-lock.yaml | 18 +++++++++--------- ts/logging/log.ts | 7 +------ 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index f6cf5e7d032..d02a8a0c8e4 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -4731,7 +4731,7 @@ Signal Desktop makes use of the following open source projects. The MIT License (MIT) - Copyright (c) 2016-2024 Matteo Collina, David Mark Clements and the Pino contributors listed at https://github.com/pinojs/pino#the-team and in the README file. + Copyright (c) 2016-2025 Matteo Collina, David Mark Clements and the Pino contributors listed at and in the README file. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/package.json b/package.json index b0f375edb73..4941dc18671 100644 --- a/package.json +++ b/package.json @@ -189,7 +189,7 @@ "p-timeout": "4.1.0", "parsecurrency": "1.1.1", "pify": "3.0.0", - "pino": "9.5.0", + "pino": "9.8.0", "protobufjs": "7.3.2", "proxy-agent": "6.4.0", "qrcode-generator": "1.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 587da19f6c3..b0e3f76ceea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,8 +309,8 @@ importers: specifier: 3.0.0 version: 3.0.0 pino: - specifier: 9.5.0 - version: 9.5.0 + specifier: 9.8.0 + version: 9.8.0 protobufjs: specifier: 7.3.2 version: 7.3.2(patch_hash=0ae0fcb7c2b673e67231536164cc4841642d16c8a26578de4d43637e2a6f1774) @@ -8552,8 +8552,8 @@ packages: pino-std-serializers@7.0.0: resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} - pino@9.5.0: - resolution: {integrity: sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==} + pino@9.8.0: + resolution: {integrity: sha512-L5+rV1wL7vGAcxXP7sPpN5lrJ07Piruka6ArXr7EWBXxdVWjJshGVX8suFsiusJVcGKDGUFfbgbnKdg+VAC+0g==} hasBin: true pinpoint@1.1.0: @@ -8808,8 +8808,8 @@ packages: resolution: {integrity: sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==} engines: {node: '>=8'} - process-warning@4.0.1: - resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} @@ -20265,14 +20265,14 @@ snapshots: pino-std-serializers@7.0.0: {} - pino@9.5.0: + pino@9.8.0: dependencies: atomic-sleep: 1.0.0 fast-redact: 3.5.0 on-exit-leak-free: 2.1.2 pino-abstract-transport: 2.0.0 pino-std-serializers: 7.0.0 - process-warning: 4.0.1 + process-warning: 5.0.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.5.0 @@ -20464,7 +20464,7 @@ snapshots: dependencies: fromentries: 1.3.2 - process-warning@4.0.1: {} + process-warning@5.0.0: {} process@0.11.10: {} diff --git a/ts/logging/log.ts b/ts/logging/log.ts index d194543ff79..fa390c55d78 100644 --- a/ts/logging/log.ts +++ b/ts/logging/log.ts @@ -126,12 +126,7 @@ function debugLog( const consoleMethod = getLogLevelString(level); - const { msgPrefixSym } = pino.symbols as unknown as { - readonly msgPrefixSym: unique symbol; - }; - const msgPrefix = (logger as unknown as Record)[ - msgPrefixSym - ]; + const { msgPrefix } = logger; const pattern = getPattern(); From 369a2d433b29198b5a4239a7890a2fc4d3e4a60a Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:27:59 -0700 Subject: [PATCH 09/54] Update electron to 37.2.6 --- .nvmrc | 2 +- package.json | 4 ++-- pnpm-lock.yaml | 21 ++++++++++++++------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.nvmrc b/.nvmrc index b8ffd70759f..7377d130eda 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.15.0 +22.17.1 diff --git a/package.json b/package.json index 4941dc18671..3813df4754e 100644 --- a/package.json +++ b/package.json @@ -309,7 +309,7 @@ "csv-parse": "5.5.6", "danger": "12.3.3", "debug": "4.3.7", - "electron": "36.3.2", + "electron": "37.2.6", "electron-builder": "26.0.14", "electron-mocha": "13.0.1", "endanger": "7.0.4", @@ -424,7 +424,7 @@ ] }, "engines": { - "node": "22.15.0" + "node": "22.17.1" }, "build": { "appId": "org.whispersystems.signal-desktop", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0e3f76ceea..c7b4948e8d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -664,8 +664,8 @@ importers: specifier: 4.3.7 version: 4.3.7(supports-color@8.1.1) electron: - specifier: 36.3.2 - version: 36.3.2 + specifier: 37.2.6 + version: 37.2.6 electron-builder: specifier: 26.0.14 version: 26.0.14(electron-builder-squirrel-windows@26.0.14) @@ -3957,6 +3957,9 @@ packages: '@types/node@22.15.24': resolution: {integrity: sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==} + '@types/node@22.17.0': + resolution: {integrity: sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==} + '@types/normalize-path@3.0.2': resolution: {integrity: sha512-DO++toKYPaFn0Z8hQ7Tx+3iT9t77IJo/nDiqTXilgEP+kPNIYdpS9kh3fXuc53ugqwp9pxC1PVjCpV1tQDyqMA==} @@ -5648,8 +5651,8 @@ packages: engines: {node: '>= 12.20.55'} hasBin: true - electron@36.3.2: - resolution: {integrity: sha512-v0/j7n22CL3OYv9BIhq6JJz2+e1HmY9H4bjTk8/WzVT9JwVX/T/21YNdR7xuQ6XDSEo9gP5JnqmjOamE+CUY8Q==} + electron@37.2.6: + resolution: {integrity: sha512-Ns6xyxE+hIK5UlujtRlw7w4e2Ju/ImCWXf1Q/PoOhc0N3/6SN6YW7+ujCarsHbxWnolbW+1RlkHtdklUJpjbPA==} engines: {node: '>= 12.20.55'} hasBin: true @@ -14787,6 +14790,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.17.0': + dependencies: + undici-types: 6.21.0 + '@types/normalize-path@3.0.2': {} '@types/parse-json@4.0.2': {} @@ -14920,7 +14927,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.17.0 optional: true '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.56.0)(typescript@5.6.3))(eslint@8.56.0)(typescript@5.6.3)': @@ -16754,10 +16761,10 @@ snapshots: transitivePeerDependencies: - supports-color - electron@36.3.2: + electron@37.2.6: dependencies: '@electron/get': 2.0.3 - '@types/node': 22.15.24 + '@types/node': 22.17.0 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color From 62f6462fc05d96741983f28e40941cd5444d04e8 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:33:21 -0700 Subject: [PATCH 10/54] Faster CI runs --- .github/workflows/benchmark.yml | 183 ++++++------------ .github/workflows/ci.yml | 19 +- package.json | 4 +- pnpm-lock.yaml | 26 +-- preload.wrapper.ts | 5 + ts/ConversationController.ts | 30 ++- ts/background.ts | 1 + ts/components/ConversationList.tsx | 25 ++- .../conversation/MessageTextRenderer.tsx | 8 +- ts/environment.ts | 2 +- ts/jobs/helpers/commonShouldJobContinue.ts | 4 +- ts/scripts/generate-preload-cache.ts | 1 + ts/scripts/mocha-separator.ts | 27 +++ ts/services/backups/index.ts | 1 + ts/services/storage.ts | 13 +- ts/state/ducks/conversations.ts | 9 +- ts/state/smart/ChatsTab.tsx | 14 -- ts/test-mock/bootstrap.ts | 27 ++- ts/test-mock/messaging/readSync_test.ts | 4 +- ts/test-mock/messaging/relink_test.ts | 2 + ts/test-mock/messaging/unprocessed_test.ts | 4 +- ts/test-mock/playwright.ts | 1 + ts/test-mock/storage/drop_test.ts | 10 +- ts/test-mock/storage/pin_unpin_test.ts | 7 +- ts/test-mock/storage/sticker_test.ts | 15 +- ts/textsecure/AccountManager.ts | 5 +- 26 files changed, 241 insertions(+), 206 deletions(-) create mode 100644 ts/scripts/mocha-separator.ts diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 526672546b6..0d09c2bb027 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -14,6 +14,52 @@ on: jobs: linux: + strategy: + matrix: + metric: + - startup + - send + - groupSend + - largeGroupSendWithBlocks + - largeGroupSend + - convoOpen + - callHistorySearch + - backup + include: + - metric: startup + script: ts/test-mock/benchmarks/startup_bench.js + runCount: 10 + - metric: send + script: ts/test-mock/benchmarks/send_bench.js + runCount: 100 + - metric: groupSend + script: ts/test-mock/benchmarks/group_send_bench.js + runCount: 100 + conversationSize: 500 + - metric: largeGroupSendWithBlocks + script: ts/test-mock/benchmarks/group_send_bench.js + runCount: 50 + conversationSize: 500 + groupSize: 500 + contactCount: 500 + blockedCount: 10 + discardCount: 2 + - metric: largeGroupSend + script: ts/test-mock/benchmarks/group_send_bench.js + runCount: 20 + conversationSize: 50 + groupSize: 500 + contactCount: 500 + discardCount: 2 + - metric: convoOpen + script: ts/test-mock/benchmarks/group_send_bench.js + runCount: 100 + - metric: callHistorySearch + script: ts/test-mock/benchmarks/call_history_search_bench.js + runCount: 100 + - metric: backup + script: ts/test-mock/benchmarks/backup_bench.js + runs-on: ubuntu-22.04-8-cores if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' && (!github.event.schedule || github.ref == 'refs/heads/main') }} timeout-minutes: 30 @@ -72,132 +118,22 @@ jobs: run: | echo "MAX_CYCLES=2" >> "$GITHUB_ENV" - - name: Run startup benchmarks + - name: Run ${{ matrix.metric }} run: | set -o pipefail - xvfb-run --auto-servernum node ts/test-mock/benchmarks/startup_bench.js | - tee benchmark-startup.log + xvfb-run --auto-servernum node ${{ matrix.script }} | tee benchmark.log timeout-minutes: 10 env: NODE_ENV: production - RUN_COUNT: 10 ELECTRON_ENABLE_STACK_DUMPING: on DEBUG: 'mock:benchmarks' - ARTIFACTS_DIR: artifacts/startup - - - name: Run send benchmarks - run: | - set -o pipefail - rm -rf /tmp/mock - xvfb-run --auto-servernum node ts/test-mock/benchmarks/send_bench.js | - tee benchmark-send.log - timeout-minutes: 10 - env: - NODE_ENV: production - RUN_COUNT: 100 - ELECTRON_ENABLE_STACK_DUMPING: on - # DEBUG: 'mock:benchmarks' - ARTIFACTS_DIR: artifacts/send - - - name: Run group send benchmarks - run: | - set -o pipefail - rm -rf /tmp/mock - xvfb-run --auto-servernum node \ - ts/test-mock/benchmarks/group_send_bench.js | \ - tee benchmark-group-send.log - timeout-minutes: 10 - env: - NODE_ENV: production - RUN_COUNT: 100 - CONVERSATION_SIZE: 500 - ELECTRON_ENABLE_STACK_DUMPING: on - # DEBUG: 'mock:benchmarks' - ARTIFACTS_DIR: artifacts/group-send - - - name: Run large group send benchmarks with blocks - run: | - set -o pipefail - rm -rf /tmp/mock - xvfb-run --auto-servernum node \ - ts/test-mock/benchmarks/group_send_bench.js | \ - tee benchmark-large-group-send-with-blocks.log - timeout-minutes: 10 - env: - NODE_ENV: production - GROUP_SIZE: 500 - CONTACT_COUNT: 500 - BLOCKED_COUNT: 10 - DISCARD_COUNT: 2 - RUN_COUNT: 50 - CONVERSATION_SIZE: 500 - ELECTRON_ENABLE_STACK_DUMPING: on - # DEBUG: 'mock:benchmarks' - ARTIFACTS_DIR: artifacts/group-send - - - name: Run large group send benchmarks with delivery receipts - run: | - set -o pipefail - rm -rf /tmp/mock - xvfb-run --auto-servernum node \ - ts/test-mock/benchmarks/group_send_bench.js | \ - tee benchmark-large-group-send.log - timeout-minutes: 10 - env: - NODE_ENV: production - GROUP_SIZE: 500 - CONTACT_COUNT: 500 - GROUP_DELIVERY_RECEIPTS: 500 - DISCARD_COUNT: 2 - RUN_COUNT: 20 - CONVERSATION_SIZE: 50 - ELECTRON_ENABLE_STACK_DUMPING: on - # DEBUG: 'mock:benchmarks' - ARTIFACTS_DIR: artifacts/large-group-send - - - name: Run conversation open benchmarks - run: | - set -o pipefail - rm -rf /tmp/mock - xvfb-run --auto-servernum node \ - ts/test-mock/benchmarks/convo_open_bench.js | \ - tee benchmark-convo-open.log - timeout-minutes: 10 - env: - NODE_ENV: production - RUN_COUNT: 100 - ELECTRON_ENABLE_STACK_DUMPING: on - # DEBUG: 'mock:benchmarks' - ARTIFACTS_DIR: artifacts/convo-open - - - name: Run call history search benchmarks - run: | - set -o pipefail - rm -rf /tmp/mock - xvfb-run --auto-servernum node \ - ts/test-mock/benchmarks/call_history_search_bench.js | \ - tee benchmark-call-history-search.log - timeout-minutes: 10 - env: - NODE_ENV: production - RUN_COUNT: 100 - ELECTRON_ENABLE_STACK_DUMPING: on - # DEBUG: 'mock:benchmarks' - ARTIFACTS_DIR: artifacts/call-history-search - - - name: Run backup benchmarks - run: | - set -o pipefail - rm -rf /tmp/mock - xvfb-run --auto-servernum node \ - ts/test-mock/benchmarks/backup_bench.js | \ - tee benchmark-backup.log - timeout-minutes: 10 - env: - NODE_ENV: production - ELECTRON_ENABLE_STACK_DUMPING: on - # DEBUG: 'mock:benchmarks' - ARTIFACTS_DIR: artifacts/backup-bench + ARTIFACTS_DIR: artifacts/${{ matrix.metric }} + GROUP_SIZE: ${{ matrix.groupSize }} + CONTACT_COUNT: ${{ matrix.contactCount }} + BLOCKED_COUNT: ${{ matrix.blockedCount }} + DISCARD_COUNT: ${{ matrix.discardCount }} + RUN_COUNT: ${{ matrix.runCount }} + CONVERSATION_SIZE: ${{ matrix.conversationSize }} - name: Upload benchmark logs on failure if: failure() @@ -222,13 +158,6 @@ jobs: - name: Publish to DataDog working-directory: benchmark-results run: | - node ./bin/publish.js ../benchmark-startup.log desktop.ci.performance.startup - node ./bin/publish.js ../benchmark-send.log desktop.ci.performance.send - node ./bin/publish.js ../benchmark-group-send.log desktop.ci.performance.groupSend - node ./bin/publish.js ../benchmark-large-group-send-with-blocks.log desktop.ci.performance.largeGroupSendWithBlocks - node ./bin/publish.js ../benchmark-large-group-send.log desktop.ci.performance.largeGroupSend - node ./bin/publish.js ../benchmark-convo-open.log desktop.ci.performance.convoOpen - node ./bin/publish.js ../benchmark-call-history-search.log desktop.ci.performance.callHistorySearch - node ./bin/publish.js ../benchmark-backup.log desktop.ci.performance.backup + node ./bin/publish.js ../benchmark.log destop.ci.performance.${{ matrix.metric }} env: DD_API_KEY: ${{ secrets.DATADOG_API_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12fd4eff547..a8d2b594ea7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -370,6 +370,11 @@ jobs: mock-tests: needs: lint + + strategy: + matrix: + workerIndex: [0, 1, 2, 3] + runs-on: ubuntu-22.04-8-cores if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' }} timeout-minutes: 30 @@ -429,12 +434,24 @@ jobs: run: | set -o pipefail xvfb-run --auto-servernum pnpm run test-mock + timeout-minutes: 15 + env: + NODE_ENV: production + DEBUG: mock:test:* + ARTIFACTS_DIR: artifacts/mock + WORKER_INDEX: ${{ matrix.workerIndex }} + WORKER_COUNT: 4 + + - name: Run docker mock server tests + if: ${{ matrix.workerIndex == 0 }} + run: | + set -o pipefail xvfb-run --auto-servernum pnpm run test-mock-docker timeout-minutes: 15 env: NODE_ENV: production DEBUG: mock:test:* - ARTIFACTS_DIR: artifacts/startup + ARTIFACTS_DIR: artifacts/mock-docker - name: Upload mock server test logs on failure if: failure() diff --git a/package.json b/package.json index 3813df4754e..dece504362e 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "test-electron": "node ts/scripts/test-electron.js", "test-release": "node ts/scripts/test-release.js", "test-node": "cross-env LANG=en-us electron-mocha --timeout 10000 --main test/fix-linux-gtk.js --file test/setup-test-node.js --recursive ts/test-node", - "test-mock": "mocha --require ts/test-mock/setup-ci.js ts/test-mock/**/*_test.js", + "test-mock": "node ts/scripts/mocha-separator.js --require ts/test-mock/setup-ci.js -- ts/test-mock/**/*_test.js", "test-mock-docker": "mocha --require ts/test-mock/setup-ci.js ts/test-mock/**/*_test.docker.js", "test-eslint": "mocha .eslint/rules/**/*.test.js --ignore-leaks", "test-lint-intl": "ts-node ./build/intl-linter/linter.ts --test", @@ -336,7 +336,7 @@ "npm-run-all": "4.1.5", "p-limit": "3.1.0", "pixelmatch": "5.3.0", - "playwright": "1.45.0", + "playwright": "1.54.2", "pngjs": "7.0.0", "postcss": "8.5.3", "postcss-loader": "8.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7b4948e8d3..0dbacbea662 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -745,8 +745,8 @@ importers: specifier: 5.3.0 version: 5.3.0 playwright: - specifier: 1.45.0 - version: 1.45.0 + specifier: 1.54.2 + version: 1.54.2 pngjs: specifier: 7.0.0 version: 7.0.0 @@ -8578,18 +8578,18 @@ packages: resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} engines: {node: '>=14.16'} - playwright-core@1.45.0: - resolution: {integrity: sha512-lZmHlFQ0VYSpAs43dRq1/nJ9G/6SiTI7VPqidld9TDefL9tX87bTKExWZZUF5PeRyqtXqd8fQi2qmfIedkwsNQ==} + playwright-core@1.50.1: + resolution: {integrity: sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==} engines: {node: '>=18'} hasBin: true - playwright-core@1.50.1: - resolution: {integrity: sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==} + playwright-core@1.54.2: + resolution: {integrity: sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==} engines: {node: '>=18'} hasBin: true - playwright@1.45.0: - resolution: {integrity: sha512-4z3ac3plDfYzGB6r0Q3LF8POPR20Z8D0aXcxbJvmfMgSSq1hkcgvFRXJk9rUq5H/MJ0Ktal869hhOdI/zUTeLA==} + playwright@1.54.2: + resolution: {integrity: sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==} engines: {node: '>=18'} hasBin: true @@ -14312,7 +14312,7 @@ snapshots: jest-serializer-html: 7.1.0 jest-watch-typeahead: 2.2.2(jest@29.7.0(@types/node@20.17.6)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@20.17.6)(typescript@5.6.3))) nyc: 15.1.0 - playwright: 1.45.0 + playwright: 1.54.2 storybook: 8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - '@swc/helpers' @@ -20302,13 +20302,13 @@ snapshots: dependencies: find-up: 6.3.0 - playwright-core@1.45.0: {} - playwright-core@1.50.1: {} - playwright@1.45.0: + playwright-core@1.54.2: {} + + playwright@1.54.2: dependencies: - playwright-core: 1.45.0 + playwright-core: 1.54.2 optionalDependencies: fsevents: 2.3.2 diff --git a/preload.wrapper.ts b/preload.wrapper.ts index ebefa6cac3c..00528618237 100644 --- a/preload.wrapper.ts +++ b/preload.wrapper.ts @@ -61,6 +61,11 @@ const fn = script.runInThisContext({ // See `ts/scripts/generate-preload-cache.ts` if (process.env.GENERATE_PRELOAD_CACHE) { + // Use hottest cache possible in CI + if (process.env.CI) { + fn(require, __dirname); + window.startApp(); + } writeFileSync(cachePath, script.createCachedData()); ipcRenderer.send('shutdown'); } else { diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index e5f6cf855e7..3df134a8796 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -351,6 +351,16 @@ export class ConversationController { if (isGroupV1(conversation.attributes)) { maybeDeriveGroupV2Id(conversation); } + + // If conversation does not have pre-existing storageID and is not our + // own (that we create on link), it might need to be uploaded to storage + // service. + if (conversation.attributes.storageID == null) { + StorageService.storageServiceUploadJob({ + reason: 'new conversation', + }); + } + await saveConversation(conversation.attributes); } catch (error) { log.error( @@ -1221,11 +1231,15 @@ export class ConversationController { log.warn(`${logId}: Update messages table`); await migrateConversationMessages(obsoleteId, currentId); - log.warn(`${logId}: Emit refreshConversation event to close old/open new`); - window.Whisper.events.trigger('refreshConversation', { - newId: currentId, - oldId: obsoleteId, - }); + if ( + window.reduxStore.getState().conversations.selectedConversationId === + obsoleteId + ) { + log.warn(`${logId}: opening new conversation`); + window.reduxActions.conversations.showConversation({ + conversationId: currentId, + }); + } log.warn( `${logId}: Eliminate old conversation from ConversationController lookups` @@ -1236,8 +1250,10 @@ export class ConversationController { current.captureChange('combineConversations'); drop(current.updateLastMessage()); - const state = window.reduxStore.getState(); - if (state.conversations.selectedConversationId === current.id) { + if ( + window.reduxStore.getState().conversations.selectedConversationId === + current.id + ) { // TODO: DESKTOP-4807 drop(current.loadNewestMessages(undefined, undefined)); } diff --git a/ts/background.ts b/ts/background.ts index a5f98e8a51b..f198cb09672 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1457,6 +1457,7 @@ export async function startApp(): Promise { await StorageService.runStorageServiceSyncJob({ reason: andSync, }); + StorageService.runStorageServiceSyncJob.flush(); } } diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index 01d0b1bb8a6..3caa6e14537 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -318,14 +318,15 @@ export function ConversationList({ ); const renderRow: ListRowRenderer = useCallback( - ({ key, index, style }) => { + ({ key: providedKey, index, style }) => { const row = getRow(index); if (!row) { assertDev(false, `Expected a row at index ${index}`); - return
; + return
; } let result: ReactNode; + let key: string; switch (row.type) { case RowType.ArchiveButton: result = ( @@ -344,9 +345,11 @@ export function ConversationList({ ); + key = 'archive'; break; case RowType.Blank: result = undefined; + key = `blank:${providedKey}`; break; case RowType.Contact: { const { isClickable = true, hasContextMenu = false } = row; @@ -368,6 +371,7 @@ export function ConversationList({ onRemove={isClickable ? removeConversation : undefined} /> ); + key = `contact:${row.contact.id}`; break; } case RowType.ContactCheckbox: @@ -382,6 +386,7 @@ export function ConversationList({ theme={theme} /> ); + key = `contact-checkbox:${row.contact.id}`; break; case RowType.ClearFilterButton: result = ( @@ -400,6 +405,7 @@ export function ConversationList({
); + key = 'clear-filter'; break; case RowType.PhoneNumberCheckbox: result = ( @@ -419,6 +425,7 @@ export function ConversationList({ theme={theme} /> ); + key = `phone-number-checkbox:${row.phoneNumber.e164}`; break; case RowType.UsernameCheckbox: result = ( @@ -438,6 +445,7 @@ export function ConversationList({ theme={theme} /> ); + key = `username-checkbox:${row.username}`; break; case RowType.GenericCheckbox: result = ( @@ -453,6 +461,7 @@ export function ConversationList({ clickable /> ); + key = `generic-checkbox:${providedKey}`; break; case RowType.Conversation: { const itemProps = pick(row.conversation, [ @@ -486,6 +495,7 @@ export function ConversationList({ 'serviceId', ]); const { badges, title, unreadCount, lastMessage } = itemProps; + key = `conversation:${itemProps.id}`; result = ( ); + key = 'create-new-group'; break; case RowType.FindByUsername: result = ( @@ -523,6 +534,7 @@ export function ConversationList({ onClick={showFindByUsername} /> ); + key = 'find-by-username'; break; case RowType.FindByPhoneNumber: result = ( @@ -532,6 +544,7 @@ export function ConversationList({ onClick={showFindByPhoneNumber} /> ); + key = 'find-by-phonenumber'; break; case RowType.Header: { const headerText = row.getHeaderText(i18n); @@ -543,16 +556,20 @@ export function ConversationList({ {headerText}
); + key = `header:${providedKey}`; break; } case RowType.MessageSearchResult: result = <>{renderMessageSearchResult?.(row.messageId)}; + key = `message-search-result:${row.messageId}`; break; case RowType.SearchResultsLoadingFakeHeader: result = ; + key = `loading-header:${providedKey}`; break; case RowType.SearchResultsLoadingFakeRow: result = ; + key = `loading-row:${providedKey}`; break; case RowType.SelectSingleGroup: result = ( @@ -562,6 +579,7 @@ export function ConversationList({ onSelectGroup={onSelectConversation} /> ); + key = 'select-single-group'; break; case RowType.StartNewConversation: result = ( @@ -577,6 +595,7 @@ export function ConversationList({ showConversation={showConversation} /> ); + key = 'start-new-conversation'; break; case RowType.UsernameSearchResult: result = ( @@ -592,6 +611,7 @@ export function ConversationList({ showConversation={showConversation} /> ); + key = `username-search-result:${row.username}`; break; case RowType.EmptyResults: result = ( @@ -599,6 +619,7 @@ export function ConversationList({ {row.message} ); + key = 'empty-results'; break; default: throw missingCaseError(row); diff --git a/ts/components/conversation/MessageTextRenderer.tsx b/ts/components/conversation/MessageTextRenderer.tsx index 0a4ba853df3..4356c0cc398 100644 --- a/ts/components/conversation/MessageTextRenderer.tsx +++ b/ts/components/conversation/MessageTextRenderer.tsx @@ -272,7 +272,13 @@ function renderNode({ !isLinkSneaky(node.url) ) { return ( - + {content} ); diff --git a/ts/environment.ts b/ts/environment.ts index 79131e0e78b..ac7216031dd 100644 --- a/ts/environment.ts +++ b/ts/environment.ts @@ -48,7 +48,7 @@ export const parseEnvironment = makeEnumParser( export const isTestEnvironment = (env: Environment): boolean => env === Environment.Test; -const isMockEnvironment = (): boolean => { +export const isMockEnvironment = (): boolean => { if (isMockTestEnvironment == null) { log.error('Mock test environment not set'); } diff --git a/ts/jobs/helpers/commonShouldJobContinue.ts b/ts/jobs/helpers/commonShouldJobContinue.ts index ae6a4caa951..5552d0c0d6a 100644 --- a/ts/jobs/helpers/commonShouldJobContinue.ts +++ b/ts/jobs/helpers/commonShouldJobContinue.ts @@ -24,7 +24,9 @@ export async function commonShouldJobContinue({ } try { - await waitForOnline({ timeout: timeRemaining }); + if (isDeviceLinked()) { + await waitForOnline({ timeout: timeRemaining }); + } } catch (err: unknown) { log.info("didn't come online in time, giving up"); return false; diff --git a/ts/scripts/generate-preload-cache.ts b/ts/scripts/generate-preload-cache.ts index 365cde35687..f3c8fede731 100644 --- a/ts/scripts/generate-preload-cache.ts +++ b/ts/scripts/generate-preload-cache.ts @@ -39,6 +39,7 @@ async function main(): Promise { WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY, XAUTHORITY: process.env.XAUTHORITY, + CI: process.env.CI ? 'on' : undefined, GENERATE_PRELOAD_CACHE: 'on', SIGNAL_CI_CONFIG: JSON.stringify({ storagePath, diff --git a/ts/scripts/mocha-separator.ts b/ts/scripts/mocha-separator.ts new file mode 100644 index 00000000000..ed65258aa86 --- /dev/null +++ b/ts/scripts/mocha-separator.ts @@ -0,0 +1,27 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; + +const MOCHA = join(__dirname, '..', '..', 'node_modules', '.bin', 'mocha'); +const WORKER_COUNT = parseInt(process.env.WORKER_COUNT || '1', 10); +const WORKER_INDEX = parseInt(process.env.WORKER_INDEX || '0', 10); + +const separator = process.argv.indexOf('--'); +if (separator === -1) { + throw new Error('Expected `--` separator between options and files'); +} + +const flags = process.argv.slice(2, separator); +const files = process.argv.slice(separator + 1); + +const filteredFiles = files.filter((_file, index) => { + return index % WORKER_COUNT === WORKER_INDEX; +}); + +console.log(`Running on ${filteredFiles.length}/${files.length} of files`); + +spawnSync(MOCHA, [...flags, ...filteredFiles], { + stdio: 'inherit', +}); diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index a16e1aa8b82..48819709c13 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -1034,6 +1034,7 @@ export class BackupsService { window.Whisper.events.once('storageService:syncComplete', resolve); runStorageServiceSyncJob({ reason }); + runStorageServiceSyncJob.flush(); await storageService; } diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 3dff3a85587..f0db9865496 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -82,7 +82,9 @@ import { getRoomIdFromRootKeyString, } from '../util/callLinksRingrtc'; import { fromPniUuidBytesOrUntaggedString } from '../util/ServiceId'; +import { isDone as isRegistrationDone } from '../util/registration'; import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue'; +import { isMockEnvironment } from '../environment'; const log = createLogger('storage'); @@ -2083,7 +2085,6 @@ async function upload({ if (!window.storage.get('storageKey')) { // requesting new keys runs the sync job which will detect the conflict // and re-run the upload job once we're merged and up-to-date. - log.info(`${logId}: no storageKey, requesting new keys`); backOff.reset(); if (window.ConversationController.areWePrimaryDevice()) { @@ -2091,6 +2092,12 @@ async function upload({ return; } + if (!isRegistrationDone()) { + log.warn(`${logId}: no storageKey, unlinked`); + return; + } + + log.info(`${logId}: no storageKey, requesting new keys`); await singleProtoJobQueue.add(MessageSender.getRequestKeySyncMessage()); return; @@ -2267,7 +2274,7 @@ export const storageServiceUploadJob = debounce( `upload v${window.storage.get('manifestVersion')}` ); }, - 500 + isMockEnvironment() ? 0 : 500 ); export const runStorageServiceSyncJob = debounce( @@ -2289,7 +2296,7 @@ export const runStorageServiceSyncJob = debounce( ) ); }, - 500 + isMockEnvironment() ? 0 : 500 ); export const addPendingDelete = (item: ExtendedStorageID): void => { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 9e0f67af474..167b93a612d 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -4879,14 +4879,17 @@ function onConversationClosed( ): ThunkAction { return async dispatch => { const conversation = window.ConversationController.get(conversationId); + // Conversation was removed due to the merge if (!conversation) { - throw new Error('onConversationClosed: Conversation not found'); + log.warn( + `onConversationClosed: Conversation ${conversationId} not found` + ); } - const logId = `onConversationClosed/${conversation.idForLogging()}`; + const logId = `onConversationClosed/${conversation?.idForLogging() ?? conversationId}`; log.info(`${logId}: unloading due to ${reason}`); - if (conversation.get('draftChanged')) { + if (conversation?.get('draftChanged')) { if (conversation.hasDraft()) { log.info(`${logId}: new draft info needs update`); const now = Date.now(); diff --git a/ts/state/smart/ChatsTab.tsx b/ts/state/smart/ChatsTab.tsx index c190b0a1066..ed583e43f53 100644 --- a/ts/state/smart/ChatsTab.tsx +++ b/ts/state/smart/ChatsTab.tsx @@ -102,18 +102,6 @@ export const SmartChatsTab = memo(function SmartChatsTab() { }, [prevConversationId, selectedConversationId]); useEffect(() => { - function refreshConversation({ - newId, - oldId, - }: { - newId: string; - oldId: string; - }) { - if (prevConversationId === oldId) { - showConversation({ conversationId: newId }); - } - } - // Close current opened conversation to reload the group information once // linked. function unload() { @@ -128,12 +116,10 @@ export const SmartChatsTab = memo(function SmartChatsTab() { } window.Whisper.events.on('pack-install-failed', packInstallFailed); - window.Whisper.events.on('refreshConversation', refreshConversation); window.Whisper.events.on('setupAsNewDevice', unload); return () => { window.Whisper.events.off('pack-install-failed', packInstallFailed); - window.Whisper.events.off('refreshConversation', refreshConversation); window.Whisper.events.off('setupAsNewDevice', unload); }; }, [onConversationClosed, prevConversationId, showConversation, showToast]); diff --git a/ts/test-mock/bootstrap.ts b/ts/test-mock/bootstrap.ts index 41361b14cdf..1e5182d37ff 100644 --- a/ts/test-mock/bootstrap.ts +++ b/ts/test-mock/bootstrap.ts @@ -400,17 +400,22 @@ export class Bootstrap { await app.stageLocalBackupForImport(localBackup); } - debug('looking for QR code or relink button'); - const qrCode = window.locator( - '.module-InstallScreenQrCodeNotScannedStep__qr-code__code' + let gotProvisionURL = false; + + drop( + (async () => { + try { + const relinkButton = window.locator('.LeftPaneDialog__icon--relink'); + await relinkButton.waitFor(); + if (gotProvisionURL) { + return; + } + await relinkButton.click(); + } catch { + // Ignore, provision will fail if QR code was never generated + } + })() ); - const relinkButton = window.locator('.LeftPaneDialog__icon--relink'); - await qrCode.or(relinkButton).waitFor(); - if (await relinkButton.isVisible()) { - debug('unlinked, clicking left pane button'); - await relinkButton.click(); - await qrCode.waitFor(); - } debug('waiting for provision'); const provision = await this.server.waitForProvision(); @@ -418,6 +423,8 @@ export class Bootstrap { debug('waiting for provision URL'); const provisionURL = await app.waitForProvisionURL(); + gotProvisionURL = true; + debug('completing provision'); this.#privDesktop = await provision.complete({ provisionURL, diff --git a/ts/test-mock/messaging/readSync_test.ts b/ts/test-mock/messaging/readSync_test.ts index 1056243fd2c..20ed985abfc 100644 --- a/ts/test-mock/messaging/readSync_test.ts +++ b/ts/test-mock/messaging/readSync_test.ts @@ -57,7 +57,9 @@ describe('readSync', function (this: Mocha.Suite) { const leftPane = page.locator('#LeftPane'); await leftPane - .locator('.module-conversation-list__item--contact-or-conversation') + .locator( + '.module-conversation-list__item--contact-or-conversation >> "<(˶ᵔᵕᵔ˶)>"' + ) .first() .waitFor(); diff --git a/ts/test-mock/messaging/relink_test.ts b/ts/test-mock/messaging/relink_test.ts index 272b0ebc159..211b78ec76d 100644 --- a/ts/test-mock/messaging/relink_test.ts +++ b/ts/test-mock/messaging/relink_test.ts @@ -87,11 +87,13 @@ describe('messaging/relink', function (this: Mocha.Suite) { ) .waitFor(); + debug('unlinkng'); await app.unlink(); await app.waitForUnlink(); await phone.unlink(desktop); await server.removeDevice(desktop.number, desktop.deviceId); + debug('closing'); await app.close(); debug('change pinned contact, identity key'); diff --git a/ts/test-mock/messaging/unprocessed_test.ts b/ts/test-mock/messaging/unprocessed_test.ts index 7edb02fb29a..7a6efda4c50 100644 --- a/ts/test-mock/messaging/unprocessed_test.ts +++ b/ts/test-mock/messaging/unprocessed_test.ts @@ -88,9 +88,7 @@ describe('unprocessed', function (this: Mocha.Suite) { const page = await app.getWindow(); debug('opening conversation'); - await page - .locator(`[data-testid="${alice.device.aci}"] >> "${alice.profileName}"`) - .click(); + await page.getByTestId(alice.device.aci).click(); await page.locator('.module-message__text >> "hello: 4"').waitFor(); await page.locator('.module-message__text >> "hello: 5"').waitFor(); diff --git a/ts/test-mock/playwright.ts b/ts/test-mock/playwright.ts index 4b4cacdd521..d81ad47bb75 100644 --- a/ts/test-mock/playwright.ts +++ b/ts/test-mock/playwright.ts @@ -83,6 +83,7 @@ export class App extends EventEmitter { snapshots: true, }); } + await page?.emulateMedia({ reducedMotion: 'reduce' }); await page?.waitForLoadState('load'); })(), 20 * SECOND diff --git a/ts/test-mock/storage/drop_test.ts b/ts/test-mock/storage/drop_test.ts index c5e44e6a822..64162e2347b 100644 --- a/ts/test-mock/storage/drop_test.ts +++ b/ts/test-mock/storage/drop_test.ts @@ -110,10 +110,12 @@ describe('storage service', function (this: Mocha.Suite) { } const updatedState = await phone.setStorageState( - state.addRecord({ - type: IdentifierType.ACCOUNT, - record: oldAccount.record, - }) + state + .addRecord({ + type: IdentifierType.ACCOUNT, + record: oldAccount.record, + }) + .updateAccount({}) ); debug('sending fetch storage'); diff --git a/ts/test-mock/storage/pin_unpin_test.ts b/ts/test-mock/storage/pin_unpin_test.ts index 6fd44abdb21..0c222feed8e 100644 --- a/ts/test-mock/storage/pin_unpin_test.ts +++ b/ts/test-mock/storage/pin_unpin_test.ts @@ -48,20 +48,21 @@ describe('storage service', function (this: Mocha.Suite) { debug('Unpinning group via storage service'); { const state = await phone.expectStorageState('initial state'); + const newState = state.unpinGroup(group); - await phone.setStorageState(state.unpinGroup(group)); + await phone.setStorageState(newState); await phone.sendFetchStorage({ timestamp: bootstrap.getTimestamp(), }); - await leftPane.locator(`[data-testid="${group.id}"]`).waitFor(); + await app.waitForManifestVersion(newState.version); } debug('Pinning group in the app'); { const state = await phone.expectStorageState('consistency check'); - const convo = leftPane.locator(`[data-testid="${group.id}"]`); + const convo = leftPane.getByTestId(group.id); await convo.click(); const moreButton = conversationStack.locator( diff --git a/ts/test-mock/storage/sticker_test.ts b/ts/test-mock/storage/sticker_test.ts index f104d0b7e06..d793df50fb7 100644 --- a/ts/test-mock/storage/sticker_test.ts +++ b/ts/test-mock/storage/sticker_test.ts @@ -70,11 +70,10 @@ describe('storage service', function (this: Mocha.Suite) { await conversationView .locator(`a:has-text("${STICKER_PACKS[0].id.toString('hex')}")`) - .click({ noWaitAfter: true }); + .click(); await window - .locator( - '.module-sticker-manager__preview-modal__footer--install button >> "Install"' - ) + .getByTestId('StickerPreviewModal') + .getByRole('button', { name: 'Install' }) .click(); debug('waiting for sync message'); @@ -114,12 +113,10 @@ describe('storage service', function (this: Mocha.Suite) { await conversationView .locator(`a:has-text("${STICKER_PACKS[0].id.toString('hex')}")`) - .click({ noWaitAfter: true }); + .click(); await window - .locator( - '.module-sticker-manager__preview-modal__footer--install button ' + - '>> "Uninstall"' - ) + .getByTestId('StickerPreviewModal') + .getByRole('button', { name: 'Uninstall' }) .click(); // Confirm diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index e3d676785fb..e99df27ca8d 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -28,6 +28,7 @@ import type { import createTaskWithTimeout from './TaskWithTimeout'; import * as Bytes from '../Bytes'; import * as Errors from '../types/errors'; +import { isMockEnvironment } from '../environment'; import { senderCertificateService } from '../services/senderCertificate'; import { backupsService } from '../services/backups'; import { @@ -96,7 +97,9 @@ const LAST_RESORT_KEY_UPDATE_TIME_KEY: StorageKeyByServiceIdKind = { }; const PRE_KEY_ARCHIVE_AGE = 90 * DAY; -const PRE_KEY_GEN_BATCH_SIZE = 100; +// Use 20 keys for mock tests which is above the minimum, but takes much less +// time to generate and store in the database (especially for PQ keys) +const PRE_KEY_GEN_BATCH_SIZE = isMockEnvironment() ? 20 : 100; const PRE_KEY_MAX_COUNT = 200; const PRE_KEY_ID_KEY: StorageKeyByServiceIdKind = { [ServiceIdKind.ACI]: 'maxPreKeyId', From 7e0639194f4885b4923dafe2ccdb694a7766190e Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:01:50 -0700 Subject: [PATCH 11/54] Fix calls tab mark read error when adhoc call is the latest --- ts/util/callDisposition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts index df1f77c125d..d846190fef1 100644 --- a/ts/util/callDisposition.ts +++ b/ts/util/callDisposition.ts @@ -1457,7 +1457,7 @@ export async function markAllCallHistoryReadAndSync( : Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ, timestamp: Long.fromNumber(latestCall.timestamp), peerId: getBytesForPeerId(latestCall), - callId: Long.fromString(latestCall.callId), + callId: getCallIdForProto(latestCall), }); const syncMessage = MessageSender.createSyncMessage(); From 7658722d767b69075398b0287ac57174164b404f Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:40:14 -0700 Subject: [PATCH 12/54] Update @signalapp/sqlcipher to 2.2.2 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index dece504362e..d8881cfd190 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "@signalapp/minimask": "1.0.1", "@signalapp/quill-cjs": "2.1.2", "@signalapp/ringrtc": "2.56.0", - "@signalapp/sqlcipher": "2.1.0", + "@signalapp/sqlcipher": "2.2.2", "@signalapp/windows-ucv": "1.0.1", "@tanstack/react-virtual": "3.11.2", "@types/dom-mediacapture-transform": "0.1.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dbacbea662..b5d488b442c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,8 +144,8 @@ importers: specifier: 2.56.0 version: 2.56.0 '@signalapp/sqlcipher': - specifier: 2.1.0 - version: 2.1.0 + specifier: 2.2.2 + version: 2.2.2 '@signalapp/windows-ucv': specifier: 1.0.1 version: 1.0.1 @@ -3321,8 +3321,8 @@ packages: '@signalapp/ringrtc@2.56.0': resolution: {integrity: sha512-6zjREIkJZXsLVo4sfQa7qqXtSIhOS41EWxardn8uNb7+zsjmPzAoPEAYZLzuzIBnRfIp2pc6pk/7gwtlqgbJhg==} - '@signalapp/sqlcipher@2.1.0': - resolution: {integrity: sha512-SRHiTOBcC25KXM7aZMyDiY2MP5uwsZM1NOIlUZlH0gtR4kTPiLEJqyhomA8II6lhl9BTGc9R2M/GLelQrQhvhw==} + '@signalapp/sqlcipher@2.2.2': + resolution: {integrity: sha512-AboQsxVG/YjhflJnzBliNLo9bJXB4whh3jE34IDD7r1dbw7X71UU2NKmRQiFCnr+2MY68pjFZgau3lsPqL1efg==} '@signalapp/windows-ucv@1.0.1': resolution: {integrity: sha512-tArRaDzAFXQ6BcYseUtd9bp52/sb5C/zbCIoNkDH+FUoxnZRvX25Fv2HHbH7Xe2+bcdb4+DQmyoUeHMhTUxAmA==} @@ -14005,7 +14005,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@signalapp/sqlcipher@2.1.0': + '@signalapp/sqlcipher@2.2.2': dependencies: node-addon-api: 8.3.0 node-gyp-build: 4.8.4 From 8819e4df92cf359ddf1d2013d41f74b2b9f56c8d Mon Sep 17 00:00:00 2001 From: emir-signal Date: Thu, 7 Aug 2025 15:12:56 -0400 Subject: [PATCH 13/54] Add support for call link epochs Co-authored-by: trevor-signal --- app/main.ts | 1 + protos/Backups.proto | 1 + protos/SignalService.proto | 1 + protos/SignalStorage.proto | 1 + ts/components/CallLinkDetails.tsx | 1 + ts/components/CallLinkEditModal.tsx | 6 +- .../CallingAdhocCallInfo.stories.tsx | 1 + ts/jobs/callLinkRefreshJobQueue.ts | 15 +++- ts/services/LinkPreview.ts | 14 ++-- ts/services/backups/export.ts | 7 +- ts/services/backups/import.ts | 7 +- ts/services/calling.ts | 41 +++++++--- ts/services/storageRecordOps.ts | 16 ++++ ts/sql/Interface.ts | 5 ++ ts/sql/Server.ts | 34 ++++---- ts/sql/migrations/1430-call-links-epoch-id.ts | 16 ++++ ts/sql/migrations/index.ts | 2 + ts/sql/server/callLinks.ts | 78 +++++++++++++++---- ts/state/ducks/calling.ts | 65 ++++++++++++---- ts/state/ducks/globalModals.ts | 1 + ts/state/smart/CallLinkDetails.tsx | 2 +- ts/state/smart/CallLinkEditModal.tsx | 3 +- ts/test-electron/backup/calling_test.ts | 2 + ts/test-electron/state/ducks/calling_test.ts | 29 +++++-- ts/test-helpers/fakeCallLink.ts | 33 ++++++++ ts/test-node/util/callLinks_test.ts | 9 ++- ts/test-node/util/signalRoutes_test.ts | 15 +++- ts/textsecure/MessageReceiver.ts | 4 + ts/textsecure/messageReceiverEvents.ts | 1 + ts/types/CallLink.ts | 7 ++ ts/util/callLinks.ts | 18 +++++ ts/util/callLinksRingrtc.ts | 19 +++++ ts/util/onCallLinkUpdateSync.ts | 6 +- ts/util/sendCallLinkUpdateSync.ts | 4 +- ts/util/signalRoutes.ts | 11 ++- ts/windows/main/phase1-ipc.ts | 3 +- 36 files changed, 393 insertions(+), 86 deletions(-) create mode 100644 ts/sql/migrations/1430-call-links-epoch-id.ts diff --git a/app/main.ts b/app/main.ts index 07ed69d7c7d..7f2dedc4bee 100644 --- a/app/main.ts +++ b/app/main.ts @@ -2906,6 +2906,7 @@ function handleSignalRoute(route: ParsedSignalRoute) { } else if (route.key === 'linkCall') { mainWindow.webContents.send('start-call-link', { key: route.args.key, + epoch: route.args.epoch, }); } else if (route.key === 'showWindow') { mainWindow.webContents.send('show-window'); diff --git a/protos/Backups.proto b/protos/Backups.proto index 000902d9f6f..b2398c2d2e4 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -344,6 +344,7 @@ message CallLink { string name = 3; Restrictions restrictions = 4; uint64 expirationMs = 5; + optional bytes epoch = 6; } message AdHocCall { diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 15b09dd97c5..332549fef83 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -700,6 +700,7 @@ message SyncMessage { optional bytes rootKey = 1; optional bytes adminPasskey = 2; optional Type type = 3; // defaults to UPDATE + optional bytes epoch = 4; } message CallLogEvent { diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 1eafd913c42..f9b25686f34 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -338,6 +338,7 @@ message CallLinkRecord { bytes adminPasskey = 2; // Non-empty when the current user is an admin uint64 deletedAtTimestampMs = 3; // When present and non-zero, `adminPasskey` // should be cleared + optional bytes epoch = 4; } message Recipient { diff --git a/ts/components/CallLinkDetails.tsx b/ts/components/CallLinkDetails.tsx index 0bef6975cef..98c3e57795b 100644 --- a/ts/components/CallLinkDetails.tsx +++ b/ts/components/CallLinkDetails.tsx @@ -67,6 +67,7 @@ export function CallLinkDetails({ const webUrl = linkCallRoute.toWebUrl({ key: callLink.rootKey, + epoch: callLink.epoch, }); const joinButton = ( ); - key = 'archive'; break; case RowType.Blank: result = undefined; - key = `blank:${providedKey}`; break; case RowType.Contact: { const { isClickable = true, hasContextMenu = false } = row; @@ -371,7 +368,6 @@ export function ConversationList({ onRemove={isClickable ? removeConversation : undefined} /> ); - key = `contact:${row.contact.id}`; break; } case RowType.ContactCheckbox: @@ -386,7 +382,6 @@ export function ConversationList({ theme={theme} /> ); - key = `contact-checkbox:${row.contact.id}`; break; case RowType.ClearFilterButton: result = ( @@ -405,7 +400,6 @@ export function ConversationList({ ); - key = 'clear-filter'; break; case RowType.PhoneNumberCheckbox: result = ( @@ -425,7 +419,6 @@ export function ConversationList({ theme={theme} /> ); - key = `phone-number-checkbox:${row.phoneNumber.e164}`; break; case RowType.UsernameCheckbox: result = ( @@ -445,7 +438,6 @@ export function ConversationList({ theme={theme} /> ); - key = `username-checkbox:${row.username}`; break; case RowType.GenericCheckbox: result = ( @@ -461,7 +453,6 @@ export function ConversationList({ clickable /> ); - key = `generic-checkbox:${providedKey}`; break; case RowType.Conversation: { const itemProps = pick(row.conversation, [ @@ -495,7 +486,6 @@ export function ConversationList({ 'serviceId', ]); const { badges, title, unreadCount, lastMessage } = itemProps; - key = `conversation:${itemProps.id}`; result = ( ); - key = 'create-new-group'; break; case RowType.FindByUsername: result = ( @@ -534,7 +523,6 @@ export function ConversationList({ onClick={showFindByUsername} /> ); - key = 'find-by-username'; break; case RowType.FindByPhoneNumber: result = ( @@ -544,7 +532,6 @@ export function ConversationList({ onClick={showFindByPhoneNumber} /> ); - key = 'find-by-phonenumber'; break; case RowType.Header: { const headerText = row.getHeaderText(i18n); @@ -556,20 +543,16 @@ export function ConversationList({ {headerText} ); - key = `header:${providedKey}`; break; } case RowType.MessageSearchResult: result = <>{renderMessageSearchResult?.(row.messageId)}; - key = `message-search-result:${row.messageId}`; break; case RowType.SearchResultsLoadingFakeHeader: result = ; - key = `loading-header:${providedKey}`; break; case RowType.SearchResultsLoadingFakeRow: result = ; - key = `loading-row:${providedKey}`; break; case RowType.SelectSingleGroup: result = ( @@ -579,7 +562,6 @@ export function ConversationList({ onSelectGroup={onSelectConversation} /> ); - key = 'select-single-group'; break; case RowType.StartNewConversation: result = ( @@ -595,7 +577,6 @@ export function ConversationList({ showConversation={showConversation} /> ); - key = 'start-new-conversation'; break; case RowType.UsernameSearchResult: result = ( @@ -611,7 +592,6 @@ export function ConversationList({ showConversation={showConversation} /> ); - key = `username-search-result:${row.username}`; break; case RowType.EmptyResults: result = ( @@ -619,7 +599,6 @@ export function ConversationList({ {row.message} ); - key = 'empty-results'; break; default: throw missingCaseError(row); From d0e8a281488348d39b0bb29fe15b25b7678a0fd9 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:15:37 -0700 Subject: [PATCH 23/54] Key ConversationList by mode in left pane --- ts/components/ConversationList.tsx | 25 +++++++++++++++++++++++-- ts/components/LeftPane.tsx | 1 + 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index 01d0b1bb8a6..3caa6e14537 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -318,14 +318,15 @@ export function ConversationList({ ); const renderRow: ListRowRenderer = useCallback( - ({ key, index, style }) => { + ({ key: providedKey, index, style }) => { const row = getRow(index); if (!row) { assertDev(false, `Expected a row at index ${index}`); - return
; + return
; } let result: ReactNode; + let key: string; switch (row.type) { case RowType.ArchiveButton: result = ( @@ -344,9 +345,11 @@ export function ConversationList({ ); + key = 'archive'; break; case RowType.Blank: result = undefined; + key = `blank:${providedKey}`; break; case RowType.Contact: { const { isClickable = true, hasContextMenu = false } = row; @@ -368,6 +371,7 @@ export function ConversationList({ onRemove={isClickable ? removeConversation : undefined} /> ); + key = `contact:${row.contact.id}`; break; } case RowType.ContactCheckbox: @@ -382,6 +386,7 @@ export function ConversationList({ theme={theme} /> ); + key = `contact-checkbox:${row.contact.id}`; break; case RowType.ClearFilterButton: result = ( @@ -400,6 +405,7 @@ export function ConversationList({
); + key = 'clear-filter'; break; case RowType.PhoneNumberCheckbox: result = ( @@ -419,6 +425,7 @@ export function ConversationList({ theme={theme} /> ); + key = `phone-number-checkbox:${row.phoneNumber.e164}`; break; case RowType.UsernameCheckbox: result = ( @@ -438,6 +445,7 @@ export function ConversationList({ theme={theme} /> ); + key = `username-checkbox:${row.username}`; break; case RowType.GenericCheckbox: result = ( @@ -453,6 +461,7 @@ export function ConversationList({ clickable /> ); + key = `generic-checkbox:${providedKey}`; break; case RowType.Conversation: { const itemProps = pick(row.conversation, [ @@ -486,6 +495,7 @@ export function ConversationList({ 'serviceId', ]); const { badges, title, unreadCount, lastMessage } = itemProps; + key = `conversation:${itemProps.id}`; result = ( ); + key = 'create-new-group'; break; case RowType.FindByUsername: result = ( @@ -523,6 +534,7 @@ export function ConversationList({ onClick={showFindByUsername} /> ); + key = 'find-by-username'; break; case RowType.FindByPhoneNumber: result = ( @@ -532,6 +544,7 @@ export function ConversationList({ onClick={showFindByPhoneNumber} /> ); + key = 'find-by-phonenumber'; break; case RowType.Header: { const headerText = row.getHeaderText(i18n); @@ -543,16 +556,20 @@ export function ConversationList({ {headerText}
); + key = `header:${providedKey}`; break; } case RowType.MessageSearchResult: result = <>{renderMessageSearchResult?.(row.messageId)}; + key = `message-search-result:${row.messageId}`; break; case RowType.SearchResultsLoadingFakeHeader: result = ; + key = `loading-header:${providedKey}`; break; case RowType.SearchResultsLoadingFakeRow: result = ; + key = `loading-row:${providedKey}`; break; case RowType.SelectSingleGroup: result = ( @@ -562,6 +579,7 @@ export function ConversationList({ onSelectGroup={onSelectConversation} /> ); + key = 'select-single-group'; break; case RowType.StartNewConversation: result = ( @@ -577,6 +595,7 @@ export function ConversationList({ showConversation={showConversation} /> ); + key = 'start-new-conversation'; break; case RowType.UsernameSearchResult: result = ( @@ -592,6 +611,7 @@ export function ConversationList({ showConversation={showConversation} /> ); + key = `username-search-result:${row.username}`; break; case RowType.EmptyResults: result = ( @@ -599,6 +619,7 @@ export function ConversationList({ {row.message} ); + key = 'empty-results'; break; default: throw missingCaseError(row); diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 881109b9d4d..07e73ec9b6f 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -842,6 +842,7 @@ export function LeftPane({ tabIndex={-1} > Date: Mon, 11 Aug 2025 10:22:54 -0700 Subject: [PATCH 24/54] Fix image thumbnail cover size --- stylesheets/_modules.scss | 4 ---- ts/components/ImageOrBlurhash.tsx | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index b3c07acb9fe..b6056a57dcf 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3340,10 +3340,6 @@ button.module-image__border-overlay:focus { &.module-image:hover::before { opacity: 1; } - - &.module-image .module-image__image { - aspect-ratio: 1 / 1; - } } // Module: Staged Generic Attachment diff --git a/ts/components/ImageOrBlurhash.tsx b/ts/components/ImageOrBlurhash.tsx index 8dc5a454324..9081e123180 100644 --- a/ts/components/ImageOrBlurhash.tsx +++ b/ts/components/ImageOrBlurhash.tsx @@ -59,6 +59,9 @@ export function ImageOrBlurhash({ ? `${intrinsicWidth} / ${intrinsicHeight}` : undefined, + width: '100%', + height: '100%', + // Preserve aspect ratio backgroundSize: 'cover', backgroundPosition: 'center', From 3573884afbcb8e5dda8e8fd97a2ee272ad56e38d Mon Sep 17 00:00:00 2001 From: Alex Bakon Date: Mon, 11 Aug 2025 14:24:54 -0400 Subject: [PATCH 25/54] Upgrade libsignal to v0.78.2 --- ACKNOWLEDGMENTS.md | 2 +- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- ts/textsecure/preconnect.ts | 1 + 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index d02a8a0c8e4..c1c70fbf34d 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -5943,7 +5943,7 @@ Signal Desktop makes use of the following open source projects. libsignal makes use of the following open source projects. -## spqr 0.1.0, partial-default-derive 0.1.0, partial-default 0.1.0 +## spqr 1.2.0, partial-default-derive 0.1.0, partial-default 0.1.0 ``` GNU AFFERO GENERAL PUBLIC LICENSE diff --git a/package.json b/package.json index d8881cfd190..70f965a462c 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "@react-aria/utils": "3.25.3", "@react-spring/web": "9.7.5", "@react-types/shared": "3.27.0", - "@signalapp/libsignal-client": "0.76.7", + "@signalapp/libsignal-client": "0.78.2", "@signalapp/minimask": "1.0.1", "@signalapp/quill-cjs": "2.1.2", "@signalapp/ringrtc": "2.56.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5d488b442c..0289851047b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,8 +132,8 @@ importers: specifier: 3.27.0 version: 3.27.0(react@18.3.1) '@signalapp/libsignal-client': - specifier: 0.76.7 - version: 0.76.7 + specifier: 0.78.2 + version: 0.78.2 '@signalapp/minimask': specifier: 1.0.1 version: 1.0.1 @@ -3302,8 +3302,8 @@ packages: '@signalapp/libsignal-client@0.60.2': resolution: {integrity: sha512-tU4kNP/yCwkFntb2ahXOSQJtzdy+YifAB2yv5hw0qyKSidRHLn6bYiz4Zo2tjxLDRoBLAUxCRsQramStiqNZdA==} - '@signalapp/libsignal-client@0.76.7': - resolution: {integrity: sha512-iGWTlFkko7IKlm96Iy91Wz5sIN089nj02ifOk6BWtLzeVi0kFaNj+jK26Sl1JRXy/VfXevcYtiOivOg43BPqpg==} + '@signalapp/libsignal-client@0.78.2': + resolution: {integrity: sha512-WpoygjlvB7jqwqwXKzRtYIbXNLuCHaeLBp0pZ3yqSTRNuRCo94Oqf/GRh9uV/Ljs2gSLqIzGcdyQ+Ikn61LZnA==} '@signalapp/minimask@1.0.1': resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==} @@ -13958,7 +13958,7 @@ snapshots: type-fest: 4.26.1 uuid: 8.3.2 - '@signalapp/libsignal-client@0.76.7': + '@signalapp/libsignal-client@0.78.2': dependencies: node-gyp-build: 4.8.4 type-fest: 4.26.1 diff --git a/ts/textsecure/preconnect.ts b/ts/textsecure/preconnect.ts index fef0ff2ec25..c54a29287bd 100644 --- a/ts/textsecure/preconnect.ts +++ b/ts/textsecure/preconnect.ts @@ -40,6 +40,7 @@ function resolveLibsignalNet( TESTING_localServer_chatPort: parseInt(getMockServerPort(url), 10), TESTING_localServer_cdsiPort: DISCARD_PORT, TESTING_localServer_svr2Port: DISCARD_PORT, + TESTING_localServer_svrBPort: DISCARD_PORT, TESTING_localServer_rootCertificateDer: pemToDer(certificateAuthority), }); } From c2be1f1da6f3d0dd340fe71009def70148e03955 Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:44:10 -0700 Subject: [PATCH 26/54] Use ErrorBoundary for donations --- CONTRIBUTING.md | 2 +- _locales/en/messages.json | 18 ++++- .../components/DonationProgressModal.scss | 4 + ts/components/DebugLogErrorModal.stories.tsx | 39 ++++++++++ ts/components/DebugLogErrorModal.tsx | 57 +++++++++++++++ ts/components/DonationProgressModal.tsx | 1 + ts/components/DonationsErrorBoundary.tsx | 73 +++++++++++++++++++ ts/components/GlobalModalContainer.tsx | 15 ++++ ts/state/ducks/globalModals.ts | 53 ++++++++++++++ ts/state/smart/GlobalModalContainer.tsx | 17 +++++ ts/state/smart/Preferences.tsx | 13 ++-- 11 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 ts/components/DebugLogErrorModal.stories.tsx create mode 100644 ts/components/DebugLogErrorModal.tsx create mode 100644 ts/components/DonationsErrorBoundary.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a0d0eb5bc9..e2798bff7b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ instance while you make changes - they'll run until you stop them: ``` pnpm run dev:transpile # recompiles when you change .ts files -pnpm run dev:sass # recompiles when you change .scss files +pnpm run dev:styles # recompiles when you change .scss files ``` #### Known issues diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 51684ebf2db..ee361d63fff 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4684,6 +4684,18 @@ "messageformat": "Please try again or contact support.", "description": "Description text in popup dialog when user-initiated task has gone wrong" }, + "icu:DebugLogErrorModal__UnexpectedError": { + "messageformat": "An unexpected error occurred", + "description": "Title of the dialog shown when an unexpected user interface or coding bug occurs. The dialog's description text requests the user to submit debug logs." + }, + "icu:DebugLogErrorModal__SubmitDebugLog": { + "messageformat": "Submit debug log", + "description": "Primary button text in the dialog shown when an unexpected user interface or coding bug occurs. Clicking the button will open the Debug Log submission dialog." + }, + "icu:DebugLogErrorModal__SubmitDebugLog__Cancel": { + "messageformat": "No thanks", + "description": "Secondary button text in the dialog shown when an unexpected user interface or coding bug occurs. Clicking the button will dismiss the error dialog." + }, "icu:Confirmation--confirm": { "messageformat": "Okay", "description": "Button to dismiss popup dialog when user-initiated task has gone wrong" @@ -9024,12 +9036,16 @@ }, "icu:Donations__GenericError": { "messageformat": "An error occurred with your donation", - "description": "Title of the dialog shown when some unknown error has happened during a user's attempted donation" + "description": "Title of the dialog shown when some unknown error has happened during a user's attempted donation. This will show when we detect an error but haven't designed a string for the error type." }, "icu:Donations__GenericError__Description": { "messageformat": "Your donation might not have been processed. Click on “Donate to Signal” and then “Donation Receipts” to check your receipts and confirm.", "description": "An explanation for the 'error occurred' dialog" }, + "icu:DonationsErrorBoundary__DonationUnexpectedError": { + "messageformat": "Try again or submit a debug log to Support for help completing your donation. Debug logs helps us diagnose and fix the issue, and do not contain identifying information.", + "description": "Description of the dialog shown when an unexpected user interface or coding bug occurs while using a donations-related part of the app." + }, "icu:Donations__Processing": { "messageformat": "Processing donation...", "description": "Explainer text for donation progress dialog" diff --git a/stylesheets/components/DonationProgressModal.scss b/stylesheets/components/DonationProgressModal.scss index f3bfc6f1f03..3209c181e46 100644 --- a/stylesheets/components/DonationProgressModal.scss +++ b/stylesheets/components/DonationProgressModal.scss @@ -21,6 +21,10 @@ @include mixins.font-body-2; } +.DonationProgressModal .SpinnerV2 { + margin-inline: auto; +} + .DonationProgressModal .SpinnerV2__Path { color: variables.$color-ultramarine; } diff --git a/ts/components/DebugLogErrorModal.stories.tsx b/ts/components/DebugLogErrorModal.stories.tsx new file mode 100644 index 00000000000..4db48fe1164 --- /dev/null +++ b/ts/components/DebugLogErrorModal.stories.tsx @@ -0,0 +1,39 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { action } from '@storybook/addon-actions'; + +import type { Meta } from '@storybook/react'; +import type { PropsType } from './DebugLogErrorModal'; +import { DebugLogErrorModal } from './DebugLogErrorModal'; + +const { i18n } = window.SignalContext; + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + description: overrideProps.description ?? '', + i18n, + onClose: action('onClick'), + onSubmitDebugLog: action('onSubmitDebugLog'), +}); + +export default { + title: 'Components/DebugLogErrorModal', + argTypes: {}, + args: {}, +} satisfies Meta; + +export function Default(): JSX.Element { + return ; +} + +export function Donations(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/DebugLogErrorModal.tsx b/ts/components/DebugLogErrorModal.tsx new file mode 100644 index 00000000000..47ab5719b63 --- /dev/null +++ b/ts/components/DebugLogErrorModal.tsx @@ -0,0 +1,57 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import type { LocalizerType } from '../types/Util'; +import { Modal } from './Modal'; +import { Button, ButtonVariant } from './Button'; + +export type PropsType = { + description?: string; + i18n: LocalizerType; + onClose: () => void; + onSubmitDebugLog: () => void; +}; + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} + +export function DebugLogErrorModal(props: PropsType): JSX.Element { + const { description, i18n, onClose, onSubmitDebugLog } = props; + + const footer = ( + <> + + + + ); + + return ( + +
+ {description || i18n('icu:ErrorModal--description')} +
+
+ ); +} diff --git a/ts/components/DonationProgressModal.tsx b/ts/components/DonationProgressModal.tsx index 9e99e2a8030..087a40fa16f 100644 --- a/ts/components/DonationProgressModal.tsx +++ b/ts/components/DonationProgressModal.tsx @@ -34,6 +34,7 @@ export function DonationProgressModal(props: PropsType): JSX.Element { i18n={i18n} moduleClassName="DonationProgressModal" modalName="DonationProgressModal" + noEscapeClose noMouseClose onClose={() => undefined} > diff --git a/ts/components/DonationsErrorBoundary.tsx b/ts/components/DonationsErrorBoundary.tsx new file mode 100644 index 00000000000..99b69594a6e --- /dev/null +++ b/ts/components/DonationsErrorBoundary.tsx @@ -0,0 +1,73 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { ReactNode, ErrorInfo } from 'react'; +import React, { Component, useCallback } from 'react'; +import { createLogger } from '../logging/log'; +import * as Errors from '../types/errors'; + +const log = createLogger('DonationsErrorBoundary'); + +type ErrorBoundaryProps = Readonly<{ + children: ReactNode; + onError: (error: unknown, info: ErrorInfo) => void; + fallback: (error: unknown) => ReactNode; +}>; + +type ErrorBoundaryState = { + caught?: { error: unknown }; +}; + +class ErrorBoundary extends Component { + // eslint-disable-next-line react/state-in-constructor + override state: ErrorBoundaryState = {}; + + static getDerivedStateFromError(error: unknown) { + return { caught: { error } }; + } + + override componentDidCatch(error: unknown, info: ErrorInfo) { + this.props.onError(error, info); + } + + override render() { + if (this.state.caught != null) { + return this.props.fallback(this.state.caught.error); + } + + return this.props.children; + } +} + +export type DonationsErrorBoundaryProps = Readonly<{ + children: ReactNode; +}>; + +export function DonationsErrorBoundary( + props: DonationsErrorBoundaryProps +): JSX.Element { + const fallback = useCallback(() => { + return
; + }, []); + + const handleError = useCallback((error: unknown, info: ErrorInfo) => { + log.error( + 'DonationsErrorBoundary: Caught error', + Errors.toLogFormat(error), + info.componentStack + ); + + if (window.reduxActions) { + window.reduxActions.globalModals.showDebugLogErrorModal({ + description: window.i18n( + 'icu:DonationsErrorBoundary__DonationUnexpectedError' + ), + }); + } + }, []); + + return ( + + {props.children} + + ); +} diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index 3ad1fcce799..4ce0880ef9c 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -80,6 +80,13 @@ export type PropsType = { description?: string; title?: string | null; }) => JSX.Element; + // DebugLogErrorModal + debugLogErrorModalProps: + | { + description?: string; + } + | undefined; + renderDebugLogErrorModal: (opts: { description?: string }) => JSX.Element; // DeleteMessageModal deleteMessagesProps: DeleteMessagesPropsType | undefined; renderDeleteMessagesModal: () => JSX.Element; @@ -186,6 +193,9 @@ export function GlobalModalContainer({ // ErrorModal errorModalProps, renderErrorModal, + // DebugLogErrorModal + debugLogErrorModalProps, + renderDebugLogErrorModal, // DeleteMessageModal deleteMessagesProps, renderDeleteMessagesModal, @@ -263,6 +273,11 @@ export function GlobalModalContainer({ return renderErrorModal(errorModalProps); } + // Errors where we want them to submit a debug log + if (debugLogErrorModalProps) { + return renderDebugLogErrorModal(debugLogErrorModalProps); + } + // Safety Number if (hasSafetyNumberChangeModal || safetyNumberChangedBlockingData) { return renderSendAnywayDialog(); diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index c518a9f0a01..4c1aa5d242e 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -116,6 +116,9 @@ export type GlobalModalsStateType = ReadonlyDeep<{ criticalIdlePrimaryDeviceModal: boolean; deleteMessagesProps?: DeleteMessagesPropsType; draftGifMessageSendModalProps: SmartDraftGifMessageSendModalProps | null; + debugLogErrorModalProps?: { + description?: string; + }; editHistoryMessages?: EditHistoryMessagesType; editNicknameAndNoteModalProps: EditNicknameAndNoteModalPropsType | null; errorModalProps?: { @@ -200,6 +203,8 @@ const SHOW_STICKER_PACK_PREVIEW = 'globalModals/SHOW_STICKER_PACK_PREVIEW'; const CLOSE_STICKER_PACK_PREVIEW = 'globalModals/CLOSE_STICKER_PACK_PREVIEW'; const CLOSE_ERROR_MODAL = 'globalModals/CLOSE_ERROR_MODAL'; export const SHOW_ERROR_MODAL = 'globalModals/SHOW_ERROR_MODAL'; +const CLOSE_DEBUG_LOG_ERROR_MODAL = 'globalModals/CLOSE_DEBUG_LOG_ERROR_MODAL'; +const SHOW_DEBUG_LOG_ERROR_MODAL = 'globalModals/SHOW_DEBUG_LOG_ERROR_MODAL'; const TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL = 'globalModals/TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL'; const TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION = @@ -419,6 +424,17 @@ export type ShowErrorModalActionType = ReadonlyDeep<{ }; }>; +type CloseDebugLogErrorModalActionType = ReadonlyDeep<{ + type: typeof CLOSE_DEBUG_LOG_ERROR_MODAL; +}>; + +type ShowDebugLogErrorModalActionType = ReadonlyDeep<{ + type: typeof SHOW_DEBUG_LOG_ERROR_MODAL; + payload: { + description?: string; + }; +}>; + type CloseMediaPermissionsModalActionType = ReadonlyDeep<{ type: typeof CLOSE_MEDIA_PERMISSIONS_MODAL; }>; @@ -482,6 +498,7 @@ type CloseEditHistoryModalActionType = ReadonlyDeep<{ export type GlobalModalsActionType = ReadonlyDeep< | CloseEditHistoryModalActionType + | CloseDebugLogErrorModalActionType | CloseErrorModalActionType | CloseMediaPermissionsModalActionType | CloseGV2MigrationDialogActionType @@ -504,6 +521,7 @@ export type GlobalModalsActionType = ReadonlyDeep< | ShowBackfillFailureModalActionType | ShowCriticalIdlePrimaryDeviceModalActionType | ShowContactModalActionType + | ShowDebugLogErrorModalActionType | ShowEditHistoryModalActionType | ShowErrorModalActionType | ShowLowDiskSpaceBackupImportModalActionType @@ -538,6 +556,7 @@ export type GlobalModalsActionType = ReadonlyDeep< // Action Creators export const actions = { + closeDebugLogErrorModal, closeEditHistoryModal, closeErrorModal, closeGV2MigrationDialog, @@ -560,6 +579,7 @@ export const actions = { showBlockingSafetyNumberChangeDialog, showContactModal, showCriticalIdlePrimaryDeviceModal, + showDebugLogErrorModal, showEditHistoryModal, showErrorModal, showGV2MigrationDialog, @@ -1095,6 +1115,25 @@ function showErrorModal({ }; } +function closeDebugLogErrorModal(): CloseDebugLogErrorModalActionType { + return { + type: CLOSE_DEBUG_LOG_ERROR_MODAL, + }; +} + +function showDebugLogErrorModal({ + description, +}: { + description?: string; +}): ShowDebugLogErrorModalActionType { + return { + type: SHOW_DEBUG_LOG_ERROR_MODAL, + payload: { + description, + }, + }; +} + function closeMediaPermissionsModal(): CloseMediaPermissionsModalActionType { return { type: CLOSE_MEDIA_PERMISSIONS_MODAL, @@ -1591,6 +1630,20 @@ export function reducer( }; } + if (action.type === CLOSE_DEBUG_LOG_ERROR_MODAL) { + return { + ...state, + debugLogErrorModalProps: undefined, + }; + } + + if (action.type === SHOW_DEBUG_LOG_ERROR_MODAL) { + return { + ...state, + debugLogErrorModalProps: action.payload, + }; + } + if (action.type === TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL) { return { ...state, diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index 50ab3ada372..6a9bbfbafc2 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -32,6 +32,7 @@ import { SmartCallLinkPendingParticipantModal } from './CallLinkPendingParticipa import { SmartAttachmentNotAvailableModal } from './AttachmentNotAvailableModal'; import { SmartProfileNameWarningModal } from './ProfileNameWarningModal'; import { SmartDraftGifMessageSendModal } from './DraftGifMessageSendModal'; +import { DebugLogErrorModal } from '../../components/DebugLogErrorModal'; function renderCallLinkAddNameModal(): JSX.Element { return ; @@ -128,6 +129,7 @@ export const SmartGlobalModalContainer = memo( confirmLeaveCallModalState, contactModalState, criticalIdlePrimaryDeviceModal, + debugLogErrorModalProps, deleteMessagesProps, draftGifMessageSendModalProps, editHistoryMessages, @@ -153,6 +155,7 @@ export const SmartGlobalModalContainer = memo( } = useSelector(getGlobalModalsState); const { + closeDebugLogErrorModal, closeErrorModal, closeMediaPermissionsModal, hideCriticalIdlePrimaryDeviceModal, @@ -210,6 +213,18 @@ export const SmartGlobalModalContainer = memo( [closeErrorModal, i18n] ); + const renderDebugLogErrorModal = useCallback( + ({ description }: { description?: string }) => ( + window.IPC.showDebugLog()} + /> + ), + [closeDebugLogErrorModal, i18n] + ); + return ( void; }): JSX.Element { return ( - + + + ); } From 76de8cb71b7211b49e0cf7658bda2f702cbfaa62 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Mon, 11 Aug 2025 15:21:21 -0400 Subject: [PATCH 27/54] Treat 403 from CDN0 the same as a 404 from others CDNs --- .../util/downloadAttachment_test.ts | 61 +++++++++++++++++++ ts/util/downloadAttachment.ts | 7 +++ 2 files changed, 68 insertions(+) diff --git a/ts/test-electron/util/downloadAttachment_test.ts b/ts/test-electron/util/downloadAttachment_test.ts index 7da302aeb36..301a0480473 100644 --- a/ts/test-electron/util/downloadAttachment_test.ts +++ b/ts/test-electron/util/downloadAttachment_test.ts @@ -122,6 +122,67 @@ describe('utils/downloadAttachment', () => { ]); }); + it('throw permanently missing error if attachment fails with 403 from cdn 0 and no backup information', async () => { + const stubDownload = sinon + .stub() + .onFirstCall() + .throws(new HTTPError('not found', { code: 403, headers: {} })); + + const attachment = { ...baseAttachment, cdnNumber: 0 }; + await assert.isRejected( + downloadAttachment({ + attachment, + options: { + hasMediaBackups: true, + onSizeUpdate: noop, + abortSignal: abortController.signal, + }, + dependencies: { + downloadAttachmentFromLocalBackup: stubDownload, + downloadAttachmentFromServer: stubDownload, + }, + }), + AttachmentPermanentlyUndownloadableError + ); + + assert.equal(stubDownload.callCount, 1); + assertDownloadArgs(stubDownload.getCall(0).args, [ + fakeServer, + { attachment, mediaTier: MediaTier.STANDARD }, + { + variant: AttachmentVariant.Default, + onSizeUpdate: noop, + abortSignal: abortController.signal, + logPrefix: '[REDACTED]est', + }, + ]); + }); + + it('throw permanently missing error if attachment fails with 403 with no cdn number and no backup information', async () => { + const stubDownload = sinon + .stub() + .onFirstCall() + .throws(new HTTPError('not found', { code: 403, headers: {} })); + + // nullish cdn number gets converted to 0 + const attachment = { ...baseAttachment, cdnNumber: undefined }; + await assert.isRejected( + downloadAttachment({ + attachment, + options: { + hasMediaBackups: true, + onSizeUpdate: noop, + abortSignal: abortController.signal, + }, + dependencies: { + downloadAttachmentFromLocalBackup: stubDownload, + downloadAttachmentFromServer: stubDownload, + }, + }), + AttachmentPermanentlyUndownloadableError + ); + }); + it('downloads from backup tier first if there is backup information', async () => { const stubDownload = sinon.stub(); const attachment = backupableAttachment; diff --git a/ts/util/downloadAttachment.ts b/ts/util/downloadAttachment.ts index 725fb4c066b..fd020edc63f 100644 --- a/ts/util/downloadAttachment.ts +++ b/ts/util/downloadAttachment.ts @@ -138,6 +138,13 @@ export async function downloadAttachment({ // then start returning 404 if (error instanceof HTTPError && error.code === 404) { throw new AttachmentPermanentlyUndownloadableError(`HTTP ${error.code}`); + } else if ( + error instanceof HTTPError && + // CDN 0 can return 403 which means the same as 404 from other CDNs + error.code === 403 && + (attachment.cdnNumber == null || attachment.cdnNumber === 0) + ) { + throw new AttachmentPermanentlyUndownloadableError(`HTTP ${error.code}`); } else { throw error; } From 5566c940f66133cacdc8ee60c7e091c963aba8ce Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:52:52 -0700 Subject: [PATCH 28/54] Linux builds: Use ubuntu 22 image --- reproducible-builds/Dockerfile | 7 ++----- reproducible-builds/docker/sources.list | 6 +++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/reproducible-builds/Dockerfile b/reproducible-builds/Dockerfile index 32b81091337..c17d5ecb51b 100644 --- a/reproducible-builds/Dockerfile +++ b/reproducible-builds/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:focal-20240530@sha256:fa17826afb526a9fc7250e0fbcbfd18d03fe7a54849472f86879d8bf562c629e +FROM ubuntu:jammy-20250714@sha256:1ec65b2719518e27d4d25f104d93f9fac60dc437f81452302406825c46fcc9cb # Allows package builders like FPM (used for creating the .deb package # on linux) to make their build timestamps determistic. Otherwise, a fresh @@ -32,9 +32,6 @@ RUN apt install -oAcquire::https::Verify-Peer=false -y ca-certificates RUN apt update RUN apt install -y git curl g++ g++-10 gcc gcc-10 make python3 tar xz-utils -# On Ubuntu 20 GCC 9 is the default but we need 10 to build -RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 100 \ - --slave /usr/bin/g++ g++ /usr/bin/g++-10 # --- # Install nvm @@ -53,7 +50,7 @@ ENV PATH=$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH ENV CI=true # Install pnpm -RUN npm install -g pnpm@10.3.0 +RUN npm install -g pnpm@10.6.4 RUN git config --global --add safe.directory /project diff --git a/reproducible-builds/docker/sources.list b/reproducible-builds/docker/sources.list index 150fe17fd09..6e016491614 100644 --- a/reproducible-builds/docker/sources.list +++ b/reproducible-builds/docker/sources.list @@ -1,3 +1,3 @@ -deb [snapshot=20240829T060900Z] http://archive.ubuntu.com/ubuntu/ focal main universe -deb [snapshot=20240829T060900Z] http://archive.ubuntu.com/ubuntu/ focal-updates main universe -deb [snapshot=20240829T060900Z] http://security.ubuntu.com/ubuntu focal-security main universe \ No newline at end of file +deb [snapshot=20250811T060900Z] http://archive.ubuntu.com/ubuntu/ jammy main universe +deb [snapshot=20250811T060900Z] http://archive.ubuntu.com/ubuntu/ jammy-updates main universe +deb [snapshot=20250811T060900Z] http://security.ubuntu.com/ubuntu jammy-security main universe \ No newline at end of file From a771c293dc79ca27e1a238604999ee1134b25fe4 Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:55:09 -0700 Subject: [PATCH 29/54] Add license-comments eslint rule --- .eslint/rules/license-comments.js | 73 ++++++++++++++++++++++++++ .eslintrc.js | 1 + eslint-local-rules.js | 1 + ts/state/smart/MessageSearchResult.tsx | 2 +- 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 .eslint/rules/license-comments.js diff --git a/.eslint/rules/license-comments.js b/.eslint/rules/license-comments.js new file mode 100644 index 00000000000..c5554a04145 --- /dev/null +++ b/.eslint/rules/license-comments.js @@ -0,0 +1,73 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +const COMMENT_LINE_1_EXACT = /^ Copyright \d{4} Signal Messenger, LLC$/; +const COMMENT_LINE_2_EXACT = /^ SPDX-License-Identifier: AGPL-3.0-only$/; + +const COMMENT_LINE_1_LOOSE = /Copyright (\d{4}) Signal Messenger, LLC/; +const COMMENT_LINE_2_LOOSE = /SPDX-License-Identifier: AGPL-3.0-only/; + +/** @type {import("eslint").Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + hasSuggestions: false, + fixable: true, + schema: [], + }, + create(context) { + return { + Program(node) { + let comment1 = node.comments.at(0); + let comment2 = node.comments.at(1); + + if ( + comment1?.type === 'Line' && + comment2?.type === 'Line' && + COMMENT_LINE_1_EXACT.test(comment1.value) && + COMMENT_LINE_2_EXACT.test(comment2.value) + ) { + return; + } + + context.report({ + node, + message: 'Missing license comment', + + fix(fixer) { + let year = null; + let remove = []; + + for (let comment of node.comments) { + let match1 = comment.value.match(COMMENT_LINE_1_LOOSE); + let match2 = comment.value.match(COMMENT_LINE_2_LOOSE); + + if (match1 != null) { + year = match1[1]; + } + + if (match1 != null || match2 != null) { + remove.push(comment); + } + } + + year ??= new Date().getFullYear().toString(); + + let insert = + `// Copyright ${year} Signal Messenger, LLC\n` + + '// SPDX-License-Identifier: AGPL-3.0-only\n'; + + return [ + fixer.replaceTextRange([0, 0], insert), + ...remove.map(comment => { + return fixer.replaceTextRange( + [comment.range[0], comment.range[1]], + '' + ); + }), + ]; + }, + }); + }, + }; + }, +}; diff --git a/.eslintrc.js b/.eslintrc.js index 837b7082e9a..c52d76b4be0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -179,6 +179,7 @@ const rules = { additionalHooks: '^(useSpring|useSprings)$', }, ], + 'local-rules/license-comments': 'error', }; const typescriptRules = { diff --git a/eslint-local-rules.js b/eslint-local-rules.js index 1259cee9a2b..4d4aafdb4d8 100644 --- a/eslint-local-rules.js +++ b/eslint-local-rules.js @@ -3,5 +3,6 @@ /* eslint-disable global-require */ module.exports = { + 'license-comments': require('./.eslint/rules/license-comments'), 'type-alias-readonlydeep': require('./.eslint/rules/type-alias-readonlydeep'), }; diff --git a/ts/state/smart/MessageSearchResult.tsx b/ts/state/smart/MessageSearchResult.tsx index 3cc24e59d11..0e2d8414af2 100644 --- a/ts/state/smart/MessageSearchResult.tsx +++ b/ts/state/smart/MessageSearchResult.tsx @@ -1,5 +1,5 @@ // Copyright 2019 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-onlyå +// SPDX-License-Identifier: AGPL-3.0-only import React, { memo } from 'react'; import { useSelector } from 'react-redux'; import { MessageSearchResult } from '../../components/conversationList/MessageSearchResult'; From 4272f5f522f650ea39b9c023c3908853b4f77af5 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 12 Aug 2025 06:55:29 +1000 Subject: [PATCH 30/54] Remove backbone as a dependency Co-authored-by: Yash 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> --- .eslintrc.js | 2 +- ACKNOWLEDGMENTS.md | 25 - danger/rules.ts | 2 - danger/rules/migrateBackboneToRedux.ts | 54 -- .../rules/pnpmLockDepsShouldHaveIntegrity.ts | 2 +- package.json | 4 - patches/@types+backbone+1.4.22.patch | 49 -- patches/backbone+1.6.0.patch | 775 ------------------ pnpm-lock.yaml | 32 - sticker-creator/.eslintrc.cjs | 2 +- ts/CI.ts | 2 +- ts/CI/benchmarkConversationOpen.ts | 2 +- ts/ConversationController.ts | 602 +++++++++++--- ts/SignalProtocolStore.ts | 12 +- ts/backbone/reliable_trigger.ts | 159 ---- ts/background.ts | 167 +--- ts/components/CallsList.tsx | 7 + ts/components/conversation/Timeline.tsx | 2 +- ts/groups.ts | 6 +- ts/messageModifiers/DeletesForMe.ts | 2 +- ts/messageModifiers/MessageReceipts.ts | 2 +- ts/messages/saveAndNotify.ts | 2 +- ts/model-types.d.ts | 8 +- ts/models/conversations.ts | 594 +++++--------- ts/reactions/enqueueReactionForSend.ts | 2 +- ts/services/backups/import.ts | 5 +- ts/services/contactSync.ts | 10 +- ts/services/profiles.ts | 24 +- ts/services/storage.ts | 98 +-- ts/services/storageRecordOps.ts | 9 +- ts/services/username.ts | 2 +- ts/services/writeProfile.ts | 2 +- ts/shims/contactVerification.ts | 4 +- ts/shims/events.ts | 2 +- ts/signal.ts | 3 - ts/sql/Interface.ts | 4 - ts/sql/Server.ts | 22 - ts/state/ducks/composer.ts | 12 +- ts/state/ducks/conversations.ts | 36 +- ts/state/ducks/stickers.ts | 4 +- ts/state/getInitialState.ts | 2 +- ts/state/initializeRedux.ts | 2 +- ts/state/selectors/conversations.ts | 2 +- ts/state/smart/App.tsx | 3 +- ts/state/smart/Preferences.tsx | 2 +- .../backbone/reliable_trigger_test.ts | 138 ---- ts/test-electron/models/conversations_test.ts | 5 +- .../services/MessageCache_test.ts | 6 +- .../state/ducks/conversations_test.ts | 2 +- .../updateConversationsWithUuidLookup_test.ts | 2 +- .../benchmarks/call_history_search_bench.ts | 7 + ts/textsecure/SocketManager.ts | 2 +- ts/textsecure/UpdateKeysListener.ts | 2 +- ts/textsecure/Utils.ts | 2 +- ts/textsecure/WebAPI.ts | 12 +- ts/textsecure/storage/User.ts | 2 +- ts/util/callDisposition.ts | 10 +- ts/util/checkOurPniIdentityKey.ts | 6 +- ts/util/cleanup.ts | 2 +- ts/util/getSignalConnections.ts | 6 +- ts/util/handleMessageSend.ts | 2 +- ts/util/onDeviceNameChangeSync.ts | 2 +- ts/util/onStoryRecipientUpdate.ts | 2 +- ts/util/sendStoryMessage.ts | 2 +- ts/util/validateConversation.ts | 14 +- ts/window.d.ts | 14 +- ts/windows/main/phase1-ipc.ts | 57 +- ts/windows/main/phase2-dependencies.ts | 2 - ts/windows/main/start.ts | 6 +- 69 files changed, 961 insertions(+), 2108 deletions(-) delete mode 100644 danger/rules/migrateBackboneToRedux.ts delete mode 100644 patches/@types+backbone+1.4.22.patch delete mode 100644 patches/backbone+1.6.0.patch delete mode 100644 ts/backbone/reliable_trigger.ts delete mode 100644 ts/test-electron/backbone/reliable_trigger_test.ts diff --git a/.eslintrc.js b/.eslintrc.js index c52d76b4be0..3d4f0f5e1c2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -104,7 +104,7 @@ const rules = { // Prefer functional components with default params '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': [ 'error', { diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index c1c70fbf34d..9276fccf101 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -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 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 Apache License diff --git a/danger/rules.ts b/danger/rules.ts index 134bc7c9a99..58b4a2a0620 100644 --- a/danger/rules.ts +++ b/danger/rules.ts @@ -3,7 +3,6 @@ import { run } from 'endanger'; -import migrateBackboneToRedux from './rules/migrateBackboneToRedux'; import packageJsonVersionsShouldBePinned from './rules/packageJsonVersionsShouldBePinned'; import pnpmLockDepsShouldHaveIntegrity from './rules/pnpmLockDepsShouldHaveIntegrity'; @@ -19,7 +18,6 @@ function isGitDeletedError(error: unknown) { async function main() { try { await run( - migrateBackboneToRedux(), packageJsonVersionsShouldBePinned(), pnpmLockDepsShouldHaveIntegrity() ); diff --git a/danger/rules/migrateBackboneToRedux.ts b/danger/rules/migrateBackboneToRedux.ts deleted file mode 100644 index 00e4b0bf274..00000000000 --- a/danger/rules/migrateBackboneToRedux.ts +++ /dev/null @@ -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, - }); - } - } - } - }, - }); -} diff --git a/danger/rules/pnpmLockDepsShouldHaveIntegrity.ts b/danger/rules/pnpmLockDepsShouldHaveIntegrity.ts index 93a3ada09ac..704b1c7ea9c 100644 --- a/danger/rules/pnpmLockDepsShouldHaveIntegrity.ts +++ b/danger/rules/pnpmLockDepsShouldHaveIntegrity.ts @@ -20,7 +20,7 @@ function has( return Object.hasOwn(value, key); } -export default function migrateBackboneToRedux() { +export default function pnpmLockDepsShouldHaveIntegrity() { return new Rule({ match: { files: ['pnpm-lock.yaml'], diff --git a/package.json b/package.json index 70f965a462c..00f05cff5bd 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,6 @@ "@tanstack/react-virtual": "3.11.2", "@types/dom-mediacapture-transform": "0.1.11", "@types/fabric": "4.5.3", - "backbone": "1.6.0", "blob-util": "2.0.2", "blueimp-load-image": "5.16.0", "blurhash": "2.0.5", @@ -256,7 +255,6 @@ "@storybook/types": "8.1.11", "@tailwindcss/cli": "4.1.7", "@tailwindcss/postcss": "4.1.7", - "@types/backbone": "1.4.22", "@types/blueimp-load-image": "5.16.6", "@types/chai": "4.3.16", "@types/chai-as-promised": "7.1.4", @@ -378,7 +376,6 @@ "react-contextmenu>react-dom": "18.3.1" }, "patchedDependencies": { - "@types/backbone@1.4.22": "patches/@types+backbone+1.4.22.patch", "casual@1.6.2": "patches/casual+1.6.2.patch", "protobufjs@7.3.2": "patches/protobufjs+7.3.2.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", "websocket@1.0.34": "patches/websocket+1.0.34.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", "zod@3.23.8": "patches/zod+3.23.8.patch", "app-builder-lib": "patches/app-builder-lib.patch", diff --git a/patches/@types+backbone+1.4.22.patch b/patches/@types+backbone+1.4.22.patch deleted file mode 100644 index 04ebf95e844..00000000000 --- a/patches/@types+backbone+1.4.22.patch +++ /dev/null @@ -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 | undefined; - } - -- type CombinedModelConstructorOptions = Model> = ModelConstructorOptions & E; -+ type CombinedModelConstructorOptions = Model> = ModelConstructorOptions & E; - - interface ModelSetOptions extends Silenceable, Validable {} - -@@ -204,7 +204,7 @@ declare namespace Backbone { - */ - static extend(properties: any, classProperties?: any): any; - -- attributes: Partial; -+ attributes: T; - changed: Partial; - 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; -- id: string | number; -+ id: string; - idAttribute: string; - validationError: any; - -@@ -251,7 +251,7 @@ declare namespace Backbone { - * return super.get("name"); - * } - */ -- get>(attributeName: A): T[A] | undefined; -+ get>(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; - save(attributes?: Partial | null, options?: ModelSaveOptions): JQueryXHR; - unset(attribute: _StringKey, options?: Silenceable): this; -- validate(attributes: Partial, options?: any): any; -+ validate(attributes: T, options?: any): any; - private _validate(attributes: Partial, options: any): boolean; - - // mixins from underscore diff --git a/patches/backbone+1.6.0.patch b/patches/backbone+1.6.0.patch deleted file mode 100644 index 66490892131..00000000000 --- a/patches/backbone+1.6.0.patch +++ /dev/null @@ -1,775 +0,0 @@ -diff --git a/backbone-min.js b/backbone-min.js -deleted file mode 100644 -index 9bcce86..0000000 ---- a/backbone-min.js -+++ /dev/null -@@ -1,2 +0,0 @@ --(function(r){var n=typeof self=="object"&&self.self===self&&self||typeof global=="object"&&global.global===global&&global;if(typeof define==="function"&&define.amd){define(["underscore","jquery","exports"],function(t,e,i){n.Backbone=r(n,i,t,e)})}else if(typeof exports!=="undefined"){var t=require("underscore"),e;try{e=require("jquery")}catch(t){}r(n,exports,t,e)}else{n.Backbone=r(n,{},n._,n.jQuery||n.Zepto||n.ender||n.$)}})(function(t,h,x,e){var i=t.Backbone;var a=Array.prototype.slice;h.VERSION="1.6.0";h.$=e;h.noConflict=function(){t.Backbone=i;return this};h.emulateHTTP=false;h.emulateJSON=false;var r=h.Events={};var o=/\s+/;var l;var u=function(t,e,i,r,n){var s=0,a;if(i&&typeof i==="object"){if(r!==void 0&&"context"in n&&n.context===void 0)n.context=r;for(a=x.keys(i);sthis.length)r=this.length;if(r<0)r+=this.length+1;var n=[];var s=[];var a=[];var o=[];var h={};var l=e.add;var u=e.merge;var c=e.remove;var f=false;var d=this.comparator&&r==null&&e.sort!==false;var v=x.isString(this.comparator)?this.comparator:null;var p,g;for(g=0;g0&&!e.silent)delete e.index;return i},_isModel:function(t){return t instanceof g},_addReference:function(t,e){this._byId[t.cid]=t;var i=this.modelId(t.attributes,t.idAttribute);if(i!=null)this._byId[i]=t;t.on("all",this._onModelEvent,this)},_removeReference:function(t,e){delete this._byId[t.cid];var i=this.modelId(t.attributes,t.idAttribute);if(i!=null)delete this._byId[i];if(this===t.collection)delete t.collection;t.off("all",this._onModelEvent,this)},_onModelEvent:function(t,e,i,r){if(e){if((t==="add"||t==="remove")&&i!==this)return;if(t==="destroy")this.remove(e,r);if(t==="changeId"){var n=this.modelId(e.previousAttributes(),e.idAttribute);var s=this.modelId(e.attributes,e.idAttribute);if(n!=null)delete this._byId[n];if(s!=null)this._byId[s]=e}}this.trigger.apply(this,arguments)},_forwardPristineError:function(t,e,i){if(this.has(t))return;this._onModelEvent("error",t,e,i)}});var y=typeof Symbol==="function"&&Symbol.iterator;if(y){m.prototype[y]=m.prototype.values}var b=function(t,e){this._collection=t;this._kind=e;this._index=0};var S=1;var I=2;var k=3;if(y){b.prototype[y]=function(){return this}}b.prototype.next=function(){if(this._collection){if(this._index7);this._useHashChange=this._wantsHashChange&&this._hasHashChange;this._wantsPushState=!!this.options.pushState;this._hasPushState=!!(this.history&&this.history.pushState);this._usePushState=this._wantsPushState&&this._hasPushState;this.fragment=this.getFragment();this.root=("/"+this.root+"/").replace(L,"/");if(this._wantsHashChange&&this._wantsPushState){if(!this._hasPushState&&!this.atRoot()){var e=this.root.slice(0,-1)||"/";this.location.replace(e+"#"+this.getPath());return true}else if(this._hasPushState&&this.atRoot()){this.navigate(this.getHash(),{replace:true})}}if(!this._hasHashChange&&this._wantsHashChange&&!this._usePushState){this.iframe=document.createElement("iframe");this.iframe.src="javascript:0";this.iframe.style.display="none";this.iframe.tabIndex=-1;var i=document.body;var r=i.insertBefore(this.iframe,i.firstChild).contentWindow;r.document.open();r.document.close();r.location.hash="#"+this.fragment}var n=window.addEventListener||function(t,e){return attachEvent("on"+t,e)};if(this._usePushState){n("popstate",this.checkUrl,false)}else if(this._useHashChange&&!this.iframe){n("hashchange",this.checkUrl,false)}else if(this._wantsHashChange){this._checkUrlInterval=setInterval(this.checkUrl,this.interval)}if(!this.options.silent)return this.loadUrl()},stop:function(){var t=window.removeEventListener||function(t,e){return detachEvent("on"+t,e)};if(this._usePushState){t("popstate",this.checkUrl,false)}else if(this._useHashChange&&!this.iframe){t("hashchange",this.checkUrl,false)}if(this.iframe){document.body.removeChild(this.iframe);this.iframe=null}if(this._checkUrlInterval)clearInterval(this._checkUrlInterval);B.started=false},route:function(t,e){this.handlers.unshift({route:t,callback:e})},checkUrl:function(t){var e=this.getFragment();if(e===this.fragment&&this.iframe){e=this.getHash(this.iframe.contentWindow)}if(e===this.fragment){if(!this.matchRoot())return this.notfound();return false}if(this.iframe)this.navigate(e);this.loadUrl()},loadUrl:function(e){if(!this.matchRoot())return this.notfound();e=this.fragment=this.getFragment(e);return x.some(this.handlers,function(t){if(t.route.test(e)){t.callback(e);return true}})||this.notfound()},notfound:function(){this.trigger("notfound");return false},navigate:function(t,e){if(!B.started)return false;if(!e||e===true)e={trigger:!!e};t=this.getFragment(t||"");var i=this.root;if(!this._trailingSlash&&(t===""||t.charAt(0)==="?")){i=i.slice(0,-1)||"/"}var r=i+t;t=t.replace(W,"");var n=this.decodeFragment(t);if(this.fragment===n)return;this.fragment=n;if(this._usePushState){this.history[e.replace?"replaceState":"pushState"]({},document.title,r)}else if(this._wantsHashChange){this._updateHash(this.location,t,e.replace);if(this.iframe&&t!==this.getHash(this.iframe.contentWindow)){var s=this.iframe.contentWindow;if(!e.replace){s.document.open();s.document.close()}this._updateHash(s.location,t,e.replace)}}else{return this.location.assign(r)}if(e.trigger)return this.loadUrl(t)},_updateHash:function(t,e,i){if(i){var r=t.href.replace(/(javascript:|#).*$/,"");t.replace(r+"#"+e)}else{t.hash="#"+e}}});h.history=new B;var D=function(t,e){var i=this;var r;if(t&&x.has(t,"constructor")){r=t.constructor}else{r=function(){return i.apply(this,arguments)}}x.extend(r,i,e);r.prototype=x.create(i.prototype,t);r.prototype.constructor=r;r.__super__=i.prototype;return r};g.extend=m.extend=O.extend=A.extend=B.extend=D;var V=function(){throw new Error('A "url" property or function must be specified')};var G=function(e,i){var r=i.error;i.error=function(t){if(r)r.call(i.context,e,t,i);e.trigger("error",e,t,i)}};h._debug=function(){return{root:t,_:x}};return h}); --//# sourceMappingURL=backbone-min.js.map -\ No newline at end of file -diff --git a/backbone-min.js.map b/backbone-min.js.map -deleted file mode 100644 -index 2ba6c35..0000000 ---- a/backbone-min.js.map -+++ /dev/null -@@ -1 +0,0 @@ --{"version":3,"sources":["backbone.js"],"names":["factory","root","self","global","define","amd","_","$","exports","Backbone","require","e","jQuery","Zepto","ender","previousBackbone","slice","Array","prototype","VERSION","noConflict","this","emulateHTTP","emulateJSON","Events","eventSplitter","_listening","eventsApi","iteratee","events","name","callback","opts","i","names","context","keys","length","test","split","on","_events","onApi","ctx","listening","listeners","_listeners","id","interop","listenTo","obj","_listenId","uniqueId","listeningTo","_listeningTo","Listening","error","tryCatchOn","options","handlers","count","push","off","offApi","stopListening","ids","isEmpty","cleanup","remaining","j","handler","_callback","once","onceMap","bind","listenToOnce","map","offer","apply","arguments","trigger","Math","max","args","triggerApi","objEvents","allEvents","all","triggerEvents","concat","ev","l","a1","a2","a3","call","listener","unbind","extend","Model","attributes","attrs","preinitialize","cid","cidPrefix","collection","parse","defaults","result","set","changed","initialize","validationError","idAttribute","toJSON","clone","sync","get","attr","escape","has","matches","key","val","_validate","unset","silent","changes","changing","_changing","_previousAttributes","current","prev","isEqual","prevId","_pending","clear","hasChanged","changedAttributes","diff","old","previous","previousAttributes","fetch","model","success","resp","serverAttrs","wrapError","save","validate","wait","method","isNew","patch","xhr","destroy","defer","url","base","urlError","replace","encodeURIComponent","constructor","isValid","Collection","models","comparator","_reset","reset","setOptions","add","remove","merge","addOptions","splice","array","insert","at","min","tail","singular","isArray","removed","_removeModels","added","merged","_isModel","toAdd","toMerge","toRemove","modelMap","sort","sortable","sortAttr","isString","existing","_prepareModel","_addReference","orderChanged","some","m","index","_removeReference","previousModels","pop","unshift","shift","_byId","modelId","where","first","findWhere","Error","isFunction","sortBy","pluck","create","callbackOpts","_forwardPristineError","values","CollectionIterator","ITERATOR_VALUES","ITERATOR_KEYS","entries","ITERATOR_KEYSVALUES","indexOf","_onModelEvent","event","$$iterator","Symbol","iterator","kind","_collection","_kind","_index","next","value","done","View","pick","viewOptions","_ensureElement","delegateEventSplitter","tagName","selector","$el","find","render","_removeElement","setElement","element","undelegateEvents","_setElement","delegateEvents","el","match","delegate","eventName","undelegate","_createElement","document","createElement","className","_setAttributes","addMethod","attribute","cb","defaultVal","addUnderscoreMethods","Class","methods","each","instance","isObject","modelMatcher","matcher","collectionMethods","forEach","collect","reduce","foldl","inject","reduceRight","foldr","detect","filter","select","reject","every","any","include","includes","contains","invoke","toArray","size","head","take","initial","rest","drop","last","without","difference","shuffle","lastIndexOf","chain","sample","partition","groupBy","countBy","indexBy","findIndex","findLastIndex","modelMethods","pairs","invert","omit","config","Base","mixin","mappings","functions","memo","type","methodMap","params","dataType","data","contentType","JSON","stringify","_method","beforeSend","setRequestHeader","processData","textStatus","errorThrown","ajax","update","delete","read","Router","routes","_bindRoutes","optionalParam","namedParam","splatParam","escapeRegExp","route","isRegExp","_routeToRegExp","router","history","fragment","_extractParameters","execute","navigate","optional","RegExp","exec","param","decodeURIComponent","History","checkUrl","window","location","routeStripper","rootStripper","pathStripper","started","interval","atRoot","path","pathname","getSearch","matchRoot","decodeFragment","rootPath","decodeURI","href","getHash","getPath","charAt","getFragment","_usePushState","_wantsHashChange","start","_trailingSlash","trailingSlash","hashChange","_hasHashChange","documentMode","_useHashChange","_wantsPushState","pushState","_hasPushState","iframe","src","style","display","tabIndex","body","iWindow","insertBefore","firstChild","contentWindow","open","close","hash","addEventListener","attachEvent","_checkUrlInterval","setInterval","loadUrl","stop","removeEventListener","detachEvent","removeChild","clearInterval","notfound","decodedFragment","title","_updateHash","assign","protoProps","staticProps","parent","child","__super__","_debug"],"mappings":"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"} -\ No newline at end of file -diff --git a/backbone-min.map b/backbone-min.map -deleted file mode 100644 -index 2ba6c35..0000000 ---- a/backbone-min.map -+++ /dev/null -@@ -1 +0,0 @@ --{"version":3,"sources":["backbone.js"],"names":["factory","root","self","global","define","amd","_","$","exports","Backbone","require","e","jQuery","Zepto","ender","previousBackbone","slice","Array","prototype","VERSION","noConflict","this","emulateHTTP","emulateJSON","Events","eventSplitter","_listening","eventsApi","iteratee","events","name","callback","opts","i","names","context","keys","length","test","split","on","_events","onApi","ctx","listening","listeners","_listeners","id","interop","listenTo","obj","_listenId","uniqueId","listeningTo","_listeningTo","Listening","error","tryCatchOn","options","handlers","count","push","off","offApi","stopListening","ids","isEmpty","cleanup","remaining","j","handler","_callback","once","onceMap","bind","listenToOnce","map","offer","apply","arguments","trigger","Math","max","args","triggerApi","objEvents","allEvents","all","triggerEvents","concat","ev","l","a1","a2","a3","call","listener","unbind","extend","Model","attributes","attrs","preinitialize","cid","cidPrefix","collection","parse","defaults","result","set","changed","initialize","validationError","idAttribute","toJSON","clone","sync","get","attr","escape","has","matches","key","val","_validate","unset","silent","changes","changing","_changing","_previousAttributes","current","prev","isEqual","prevId","_pending","clear","hasChanged","changedAttributes","diff","old","previous","previousAttributes","fetch","model","success","resp","serverAttrs","wrapError","save","validate","wait","method","isNew","patch","xhr","destroy","defer","url","base","urlError","replace","encodeURIComponent","constructor","isValid","Collection","models","comparator","_reset","reset","setOptions","add","remove","merge","addOptions","splice","array","insert","at","min","tail","singular","isArray","removed","_removeModels","added","merged","_isModel","toAdd","toMerge","toRemove","modelMap","sort","sortable","sortAttr","isString","existing","_prepareModel","_addReference","orderChanged","some","m","index","_removeReference","previousModels","pop","unshift","shift","_byId","modelId","where","first","findWhere","Error","isFunction","sortBy","pluck","create","callbackOpts","_forwardPristineError","values","CollectionIterator","ITERATOR_VALUES","ITERATOR_KEYS","entries","ITERATOR_KEYSVALUES","indexOf","_onModelEvent","event","$$iterator","Symbol","iterator","kind","_collection","_kind","_index","next","value","done","View","pick","viewOptions","_ensureElement","delegateEventSplitter","tagName","selector","$el","find","render","_removeElement","setElement","element","undelegateEvents","_setElement","delegateEvents","el","match","delegate","eventName","undelegate","_createElement","document","createElement","className","_setAttributes","addMethod","attribute","cb","defaultVal","addUnderscoreMethods","Class","methods","each","instance","isObject","modelMatcher","matcher","collectionMethods","forEach","collect","reduce","foldl","inject","reduceRight","foldr","detect","filter","select","reject","every","any","include","includes","contains","invoke","toArray","size","head","take","initial","rest","drop","last","without","difference","shuffle","lastIndexOf","chain","sample","partition","groupBy","countBy","indexBy","findIndex","findLastIndex","modelMethods","pairs","invert","omit","config","Base","mixin","mappings","functions","memo","type","methodMap","params","dataType","data","contentType","JSON","stringify","_method","beforeSend","setRequestHeader","processData","textStatus","errorThrown","ajax","update","delete","read","Router","routes","_bindRoutes","optionalParam","namedParam","splatParam","escapeRegExp","route","isRegExp","_routeToRegExp","router","history","fragment","_extractParameters","execute","navigate","optional","RegExp","exec","param","decodeURIComponent","History","checkUrl","window","location","routeStripper","rootStripper","pathStripper","started","interval","atRoot","path","pathname","getSearch","matchRoot","decodeFragment","rootPath","decodeURI","href","getHash","getPath","charAt","getFragment","_usePushState","_wantsHashChange","start","_trailingSlash","trailingSlash","hashChange","_hasHashChange","documentMode","_useHashChange","_wantsPushState","pushState","_hasPushState","iframe","src","style","display","tabIndex","body","iWindow","insertBefore","firstChild","contentWindow","open","close","hash","addEventListener","attachEvent","_checkUrlInterval","setInterval","loadUrl","stop","removeEventListener","detachEvent","removeChild","clearInterval","notfound","decodedFragment","title","_updateHash","assign","protoProps","staticProps","parent","child","__super__","_debug"],"mappings":"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"} -\ No newline at end of file -diff --git a/backbone.js b/backbone.js -index aa36f1e..9dfedbb 100644 ---- a/backbone.js -+++ b/backbone.js -@@ -12,24 +12,8 @@ - var root = typeof self == 'object' && self.self === self && self || - typeof global == 'object' && global.global === global && global; - -- // Set up Backbone appropriately for the environment. Start with AMD. -- if (typeof define === 'function' && define.amd) { -- define(['underscore', 'jquery', 'exports'], function(_, $, exports) { -- // Export global even in AMD case in case this script is loaded with -- // others that may still expect a global Backbone. -- root.Backbone = factory(root, exports, _, $); -- }); -- -- // Next for Node.js or CommonJS. jQuery may not be needed as a module. -- } else if (typeof exports !== 'undefined') { -- var _ = require('underscore'), $; -- try { $ = require('jquery'); } catch (e) {} -- factory(root, exports, _, $); -- -- // Finally, as a browser global. -- } else { -- root.Backbone = factory(root, {}, root._, root.jQuery || root.Zepto || root.ender || root.$); -- } -+ var _ = require('lodash'), $; -+ factory(root, exports, _, $); - - })(function(root, Backbone, _, $) { - -@@ -46,10 +30,6 @@ - // Current version of the library. Keep in sync with `package.json`. - Backbone.VERSION = '1.6.0'; - -- // For Backbone's purposes, jQuery, Zepto, Ender, or My Library (kidding) owns -- // the `$` variable. -- Backbone.$ = $; -- - // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable - // to its previous owner. Returns a reference to this Backbone object. - Backbone.noConflict = function() { -@@ -1319,174 +1299,6 @@ - return {value: void 0, done: true}; - }; - -- // Backbone.View -- // ------------- -- -- // Backbone Views are almost more convention than they are actual code. A View -- // is simply a JavaScript object that represents a logical chunk of UI in the -- // DOM. This might be a single item, an entire list, a sidebar or panel, or -- // even the surrounding frame which wraps your whole app. Defining a chunk of -- // UI as a **View** allows you to define your DOM events declaratively, without -- // having to worry about render order ... and makes it easy for the view to -- // react to specific changes in the state of your models. -- -- // Creating a Backbone.View creates its initial element outside of the DOM, -- // if an existing element is not provided... -- var View = Backbone.View = function(options) { -- this.cid = _.uniqueId('view'); -- this.preinitialize.apply(this, arguments); -- _.extend(this, _.pick(options, viewOptions)); -- this._ensureElement(); -- this.initialize.apply(this, arguments); -- }; -- -- // Cached regex to split keys for `delegate`. -- var delegateEventSplitter = /^(\S+)\s*(.*)$/; -- -- // List of view options to be set as properties. -- var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName', 'events']; -- -- // Set up all inheritable **Backbone.View** properties and methods. -- _.extend(View.prototype, Events, { -- -- // The default `tagName` of a View's element is `"div"`. -- tagName: 'div', -- -- // jQuery delegate for element lookup, scoped to DOM elements within the -- // current view. This should be preferred to global lookups where possible. -- $: function(selector) { -- return this.$el.find(selector); -- }, -- -- // preinitialize is an empty function by default. You can override it with a function -- // or object. preinitialize will run before any instantiation logic is run in the View -- preinitialize: function(){}, -- -- // Initialize is an empty function by default. Override it with your own -- // initialization logic. -- initialize: function(){}, -- -- // **render** is the core function that your view should override, in order -- // to populate its element (`this.el`), with the appropriate HTML. The -- // convention is for **render** to always return `this`. -- render: function() { -- return this; -- }, -- -- // Remove this view by taking the element out of the DOM, and removing any -- // applicable Backbone.Events listeners. -- remove: function() { -- this._removeElement(); -- this.stopListening(); -- return this; -- }, -- -- // Remove this view's element from the document and all event listeners -- // attached to it. Exposed for subclasses using an alternative DOM -- // manipulation API. -- _removeElement: function() { -- this.$el.remove(); -- }, -- -- // Change the view's element (`this.el` property) and re-delegate the -- // view's events on the new element. -- setElement: function(element) { -- this.undelegateEvents(); -- this._setElement(element); -- this.delegateEvents(); -- return this; -- }, -- -- // Creates the `this.el` and `this.$el` references for this view using the -- // given `el`. `el` can be a CSS selector or an HTML string, a jQuery -- // context or an element. Subclasses can override this to utilize an -- // alternative DOM manipulation API and are only required to set the -- // `this.el` property. -- _setElement: function(el) { -- this.$el = el instanceof Backbone.$ ? el : Backbone.$(el); -- this.el = this.$el[0]; -- }, -- -- // Set callbacks, where `this.events` is a hash of -- // -- // *{"event selector": "callback"}* -- // -- // { -- // 'mousedown .title': 'edit', -- // 'click .button': 'save', -- // 'click .open': function(e) { ... } -- // } -- // -- // pairs. Callbacks will be bound to the view, with `this` set properly. -- // Uses event delegation for efficiency. -- // Omitting the selector binds the event to `this.el`. -- delegateEvents: function(events) { -- events || (events = _.result(this, 'events')); -- if (!events) return this; -- this.undelegateEvents(); -- for (var key in events) { -- var method = events[key]; -- if (!_.isFunction(method)) method = this[method]; -- if (!method) continue; -- var match = key.match(delegateEventSplitter); -- this.delegate(match[1], match[2], method.bind(this)); -- } -- return this; -- }, -- -- // Add a single event listener to the view's element (or a child element -- // using `selector`). This only works for delegate-able events: not `focus`, -- // `blur`, and not `change`, `submit`, and `reset` in Internet Explorer. -- delegate: function(eventName, selector, listener) { -- this.$el.on(eventName + '.delegateEvents' + this.cid, selector, listener); -- return this; -- }, -- -- // Clears all callbacks previously bound to the view by `delegateEvents`. -- // You usually don't need to use this, but may wish to if you have multiple -- // Backbone views attached to the same DOM element. -- undelegateEvents: function() { -- if (this.$el) this.$el.off('.delegateEvents' + this.cid); -- return this; -- }, -- -- // A finer-grained `undelegateEvents` for removing a single delegated event. -- // `selector` and `listener` are both optional. -- undelegate: function(eventName, selector, listener) { -- this.$el.off(eventName + '.delegateEvents' + this.cid, selector, listener); -- return this; -- }, -- -- // Produces a DOM element to be assigned to your view. Exposed for -- // subclasses using an alternative DOM manipulation API. -- _createElement: function(tagName) { -- return document.createElement(tagName); -- }, -- -- // Ensure that the View has a DOM element to render into. -- // If `this.el` is a string, pass it through `$()`, take the first -- // matching element, and re-assign it to `el`. Otherwise, create -- // an element from the `id`, `className` and `tagName` properties. -- _ensureElement: function() { -- if (!this.el) { -- var attrs = _.extend({}, _.result(this, 'attributes')); -- if (this.id) attrs.id = _.result(this, 'id'); -- if (this.className) attrs['class'] = _.result(this, 'className'); -- this.setElement(this._createElement(_.result(this, 'tagName'))); -- this._setAttributes(attrs); -- } else { -- this.setElement(_.result(this, 'el')); -- } -- }, -- -- // Set attributes from a hash on this view's element. Exposed for -- // subclasses using an alternative DOM manipulation API. -- _setAttributes: function(attributes) { -- this.$el.attr(attributes); -- } -- -- }); -- - // Proxy Backbone class methods to Underscore functions, wrapping the model's - // `attributes` object or collection's `models` array behind the scenes. - // -@@ -1575,523 +1387,6 @@ - addUnderscoreMethods(Base, _, methods, attribute); - }); - -- // Backbone.sync -- // ------------- -- -- // Override this function to change the manner in which Backbone persists -- // models to the server. You will be passed the type of request, and the -- // model in question. By default, makes a RESTful Ajax request -- // to the model's `url()`. Some possible customizations could be: -- // -- // * Use `setTimeout` to batch rapid-fire updates into a single request. -- // * Send up the models as XML instead of JSON. -- // * Persist models via WebSockets instead of Ajax. -- // -- // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests -- // as `POST`, with a `_method` parameter containing the true HTTP method, -- // as well as all requests with the body as `application/x-www-form-urlencoded` -- // instead of `application/json` with the model in a param named `model`. -- // Useful when interfacing with server-side languages like **PHP** that make -- // it difficult to read the body of `PUT` requests. -- Backbone.sync = function(method, model, options) { -- var type = methodMap[method]; -- -- // Default options, unless specified. -- _.defaults(options || (options = {}), { -- emulateHTTP: Backbone.emulateHTTP, -- emulateJSON: Backbone.emulateJSON -- }); -- -- // Default JSON-request options. -- var params = {type: type, dataType: 'json'}; -- -- // Ensure that we have a URL. -- if (!options.url) { -- params.url = _.result(model, 'url') || urlError(); -- } -- -- // Ensure that we have the appropriate request data. -- if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { -- params.contentType = 'application/json'; -- params.data = JSON.stringify(options.attrs || model.toJSON(options)); -- } -- -- // For older servers, emulate JSON by encoding the request into an HTML-form. -- if (options.emulateJSON) { -- params.contentType = 'application/x-www-form-urlencoded'; -- params.data = params.data ? {model: params.data} : {}; -- } -- -- // For older servers, emulate HTTP by mimicking the HTTP method with `_method` -- // And an `X-HTTP-Method-Override` header. -- if (options.emulateHTTP && (type === 'PUT' || type === 'DELETE' || type === 'PATCH')) { -- params.type = 'POST'; -- if (options.emulateJSON) params.data._method = type; -- var beforeSend = options.beforeSend; -- options.beforeSend = function(xhr) { -- xhr.setRequestHeader('X-HTTP-Method-Override', type); -- if (beforeSend) return beforeSend.apply(this, arguments); -- }; -- } -- -- // Don't process data on a non-GET request. -- if (params.type !== 'GET' && !options.emulateJSON) { -- params.processData = false; -- } -- -- // Pass along `textStatus` and `errorThrown` from jQuery. -- var error = options.error; -- options.error = function(xhr, textStatus, errorThrown) { -- options.textStatus = textStatus; -- options.errorThrown = errorThrown; -- if (error) error.call(options.context, xhr, textStatus, errorThrown); -- }; -- -- // Make the request, allowing the user to override any Ajax options. -- var xhr = options.xhr = Backbone.ajax(_.extend(params, options)); -- model.trigger('request', model, xhr, options); -- return xhr; -- }; -- -- // Map from CRUD to HTTP for our default `Backbone.sync` implementation. -- var methodMap = { -- 'create': 'POST', -- 'update': 'PUT', -- 'patch': 'PATCH', -- 'delete': 'DELETE', -- 'read': 'GET' -- }; -- -- // Set the default implementation of `Backbone.ajax` to proxy through to `$`. -- // Override this if you'd like to use a different library. -- Backbone.ajax = function() { -- return Backbone.$.ajax.apply(Backbone.$, arguments); -- }; -- -- // Backbone.Router -- // --------------- -- -- // Routers map faux-URLs to actions, and fire events when routes are -- // matched. Creating a new one sets its `routes` hash, if not set statically. -- var Router = Backbone.Router = function(options) { -- options || (options = {}); -- this.preinitialize.apply(this, arguments); -- if (options.routes) this.routes = options.routes; -- this._bindRoutes(); -- this.initialize.apply(this, arguments); -- }; -- -- // Cached regular expressions for matching named param parts and splatted -- // parts of route strings. -- var optionalParam = /\((.*?)\)/g; -- var namedParam = /(\(\?)?:\w+/g; -- var splatParam = /\*\w+/g; -- var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; -- -- // Set up all inheritable **Backbone.Router** properties and methods. -- _.extend(Router.prototype, Events, { -- -- // preinitialize is an empty function by default. You can override it with a function -- // or object. preinitialize will run before any instantiation logic is run in the Router. -- preinitialize: function(){}, -- -- // Initialize is an empty function by default. Override it with your own -- // initialization logic. -- initialize: function(){}, -- -- // Manually bind a single named route to a callback. For example: -- // -- // this.route('search/:query/p:num', 'search', function(query, num) { -- // ... -- // }); -- // -- route: function(route, name, callback) { -- if (!_.isRegExp(route)) route = this._routeToRegExp(route); -- if (_.isFunction(name)) { -- callback = name; -- name = ''; -- } -- if (!callback) callback = this[name]; -- var router = this; -- Backbone.history.route(route, function(fragment) { -- var args = router._extractParameters(route, fragment); -- if (router.execute(callback, args, name) !== false) { -- router.trigger.apply(router, ['route:' + name].concat(args)); -- router.trigger('route', name, args); -- Backbone.history.trigger('route', router, name, args); -- } -- }); -- return this; -- }, -- -- // Execute a route handler with the provided parameters. This is an -- // excellent place to do pre-route setup or post-route cleanup. -- execute: function(callback, args, name) { -- if (callback) callback.apply(this, args); -- }, -- -- // Simple proxy to `Backbone.history` to save a fragment into the history. -- navigate: function(fragment, options) { -- Backbone.history.navigate(fragment, options); -- return this; -- }, -- -- // Bind all defined routes to `Backbone.history`. We have to reverse the -- // order of the routes here to support behavior where the most general -- // routes can be defined at the bottom of the route map. -- _bindRoutes: function() { -- if (!this.routes) return; -- this.routes = _.result(this, 'routes'); -- var route, routes = _.keys(this.routes); -- while ((route = routes.pop()) != null) { -- this.route(route, this.routes[route]); -- } -- }, -- -- // Convert a route string into a regular expression, suitable for matching -- // against the current location hash. -- _routeToRegExp: function(route) { -- route = route.replace(escapeRegExp, '\\$&') -- .replace(optionalParam, '(?:$1)?') -- .replace(namedParam, function(match, optional) { -- return optional ? match : '([^/?]+)'; -- }) -- .replace(splatParam, '([^?]*?)'); -- return new RegExp('^' + route + '(?:\\?([\\s\\S]*))?$'); -- }, -- -- // Given a route, and a URL fragment that it matches, return the array of -- // extracted decoded parameters. Empty or unmatched parameters will be -- // treated as `null` to normalize cross-browser behavior. -- _extractParameters: function(route, fragment) { -- var params = route.exec(fragment).slice(1); -- return _.map(params, function(param, i) { -- // Don't decode the search params. -- if (i === params.length - 1) return param || null; -- return param ? decodeURIComponent(param) : null; -- }); -- } -- -- }); -- -- // Backbone.History -- // ---------------- -- -- // Handles cross-browser history management, based on either -- // [pushState](http://diveintohtml5.info/history.html) and real URLs, or -- // [onhashchange](https://developer.mozilla.org/en-US/docs/DOM/window.onhashchange) -- // and URL fragments. If the browser supports neither (old IE, natch), -- // falls back to polling. -- var History = Backbone.History = function() { -- this.handlers = []; -- this.checkUrl = this.checkUrl.bind(this); -- -- // Ensure that `History` can be used outside of the browser. -- if (typeof window !== 'undefined') { -- this.location = window.location; -- this.history = window.history; -- } -- }; -- -- // Cached regex for stripping a leading hash/slash and trailing space. -- var routeStripper = /^[#\/]|\s+$/g; -- -- // Cached regex for stripping leading and trailing slashes. -- var rootStripper = /^\/+|\/+$/g; -- -- // Cached regex for stripping urls of hash. -- var pathStripper = /#.*$/; -- -- // Has the history handling already been started? -- History.started = false; -- -- // Set up all inheritable **Backbone.History** properties and methods. -- _.extend(History.prototype, Events, { -- -- // The default interval to poll for hash changes, if necessary, is -- // twenty times a second. -- interval: 50, -- -- // Are we at the app root? -- atRoot: function() { -- var path = this.location.pathname.replace(/[^\/]$/, '$&/'); -- return path === this.root && !this.getSearch(); -- }, -- -- // Does the pathname match the root? -- matchRoot: function() { -- var path = this.decodeFragment(this.location.pathname); -- var rootPath = path.slice(0, this.root.length - 1) + '/'; -- return rootPath === this.root; -- }, -- -- // Unicode characters in `location.pathname` are percent encoded so they're -- // decoded for comparison. `%25` should not be decoded since it may be part -- // of an encoded parameter. -- decodeFragment: function(fragment) { -- return decodeURI(fragment.replace(/%25/g, '%2525')); -- }, -- -- // In IE6, the hash fragment and search params are incorrect if the -- // fragment contains `?`. -- getSearch: function() { -- var match = this.location.href.replace(/#.*/, '').match(/\?.+/); -- return match ? match[0] : ''; -- }, -- -- // Gets the true hash value. Cannot use location.hash directly due to bug -- // in Firefox where location.hash will always be decoded. -- getHash: function(window) { -- var match = (window || this).location.href.match(/#(.*)$/); -- return match ? match[1] : ''; -- }, -- -- // Get the pathname and search params, without the root. -- getPath: function() { -- var path = this.decodeFragment( -- this.location.pathname + this.getSearch() -- ).slice(this.root.length - 1); -- return path.charAt(0) === '/' ? path.slice(1) : path; -- }, -- -- // Get the cross-browser normalized URL fragment from the path or hash. -- getFragment: function(fragment) { -- if (fragment == null) { -- if (this._usePushState || !this._wantsHashChange) { -- fragment = this.getPath(); -- } else { -- fragment = this.getHash(); -- } -- } -- return fragment.replace(routeStripper, ''); -- }, -- -- // Start the hash change handling, returning `true` if the current URL matches -- // an existing route, and `false` otherwise. -- start: function(options) { -- if (History.started) throw new Error('Backbone.history has already been started'); -- History.started = true; -- -- // Figure out the initial configuration. Do we need an iframe? -- // Is pushState desired ... is it available? -- this.options = _.extend({root: '/'}, this.options, options); -- this.root = this.options.root; -- this._trailingSlash = this.options.trailingSlash; -- this._wantsHashChange = this.options.hashChange !== false; -- this._hasHashChange = 'onhashchange' in window && (document.documentMode === void 0 || document.documentMode > 7); -- this._useHashChange = this._wantsHashChange && this._hasHashChange; -- this._wantsPushState = !!this.options.pushState; -- this._hasPushState = !!(this.history && this.history.pushState); -- this._usePushState = this._wantsPushState && this._hasPushState; -- this.fragment = this.getFragment(); -- -- // Normalize root to always include a leading and trailing slash. -- this.root = ('/' + this.root + '/').replace(rootStripper, '/'); -- -- // Transition from hashChange to pushState or vice versa if both are -- // requested. -- if (this._wantsHashChange && this._wantsPushState) { -- -- // If we've started off with a route from a `pushState`-enabled -- // browser, but we're currently in a browser that doesn't support it... -- if (!this._hasPushState && !this.atRoot()) { -- var rootPath = this.root.slice(0, -1) || '/'; -- this.location.replace(rootPath + '#' + this.getPath()); -- // Return immediately as browser will do redirect to new url -- return true; -- -- // Or if we've started out with a hash-based route, but we're currently -- // in a browser where it could be `pushState`-based instead... -- } else if (this._hasPushState && this.atRoot()) { -- this.navigate(this.getHash(), {replace: true}); -- } -- -- } -- -- // Proxy an iframe to handle location events if the browser doesn't -- // support the `hashchange` event, HTML5 history, or the user wants -- // `hashChange` but not `pushState`. -- if (!this._hasHashChange && this._wantsHashChange && !this._usePushState) { -- this.iframe = document.createElement('iframe'); -- this.iframe.src = 'javascript:0'; -- this.iframe.style.display = 'none'; -- this.iframe.tabIndex = -1; -- var body = document.body; -- // Using `appendChild` will throw on IE < 9 if the document is not ready. -- var iWindow = body.insertBefore(this.iframe, body.firstChild).contentWindow; -- iWindow.document.open(); -- iWindow.document.close(); -- iWindow.location.hash = '#' + this.fragment; -- } -- -- // Add a cross-platform `addEventListener` shim for older browsers. -- var addEventListener = window.addEventListener || function(eventName, listener) { -- return attachEvent('on' + eventName, listener); -- }; -- -- // Depending on whether we're using pushState or hashes, and whether -- // 'onhashchange' is supported, determine how we check the URL state. -- if (this._usePushState) { -- addEventListener('popstate', this.checkUrl, false); -- } else if (this._useHashChange && !this.iframe) { -- addEventListener('hashchange', this.checkUrl, false); -- } else if (this._wantsHashChange) { -- this._checkUrlInterval = setInterval(this.checkUrl, this.interval); -- } -- -- if (!this.options.silent) return this.loadUrl(); -- }, -- -- // Disable Backbone.history, perhaps temporarily. Not useful in a real app, -- // but possibly useful for unit testing Routers. -- stop: function() { -- // Add a cross-platform `removeEventListener` shim for older browsers. -- var removeEventListener = window.removeEventListener || function(eventName, listener) { -- return detachEvent('on' + eventName, listener); -- }; -- -- // Remove window listeners. -- if (this._usePushState) { -- removeEventListener('popstate', this.checkUrl, false); -- } else if (this._useHashChange && !this.iframe) { -- removeEventListener('hashchange', this.checkUrl, false); -- } -- -- // Clean up the iframe if necessary. -- if (this.iframe) { -- document.body.removeChild(this.iframe); -- this.iframe = null; -- } -- -- // Some environments will throw when clearing an undefined interval. -- if (this._checkUrlInterval) clearInterval(this._checkUrlInterval); -- History.started = false; -- }, -- -- // Add a route to be tested when the fragment changes. Routes added later -- // may override previous routes. -- route: function(route, callback) { -- this.handlers.unshift({route: route, callback: callback}); -- }, -- -- // Checks the current URL to see if it has changed, and if it has, -- // calls `loadUrl`, normalizing across the hidden iframe. -- checkUrl: function(e) { -- var current = this.getFragment(); -- -- // If the user pressed the back button, the iframe's hash will have -- // changed and we should use that for comparison. -- if (current === this.fragment && this.iframe) { -- current = this.getHash(this.iframe.contentWindow); -- } -- -- if (current === this.fragment) { -- if (!this.matchRoot()) return this.notfound(); -- return false; -- } -- if (this.iframe) this.navigate(current); -- this.loadUrl(); -- }, -- -- // Attempt to load the current URL fragment. If a route succeeds with a -- // match, returns `true`. If no defined routes matches the fragment, -- // returns `false`. -- loadUrl: function(fragment) { -- // If the root doesn't match, no routes can match either. -- if (!this.matchRoot()) return this.notfound(); -- fragment = this.fragment = this.getFragment(fragment); -- return _.some(this.handlers, function(handler) { -- if (handler.route.test(fragment)) { -- handler.callback(fragment); -- return true; -- } -- }) || this.notfound(); -- }, -- -- // When no route could be matched, this method is called internally to -- // trigger the `'notfound'` event. It returns `false` so that it can be used -- // in tail position. -- notfound: function() { -- this.trigger('notfound'); -- return false; -- }, -- -- // Save a fragment into the hash history, or replace the URL state if the -- // 'replace' option is passed. You are responsible for properly URL-encoding -- // the fragment in advance. -- // -- // The options object can contain `trigger: true` if you wish to have the -- // route callback be fired (not usually desirable), or `replace: true`, if -- // you wish to modify the current URL without adding an entry to the history. -- navigate: function(fragment, options) { -- if (!History.started) return false; -- if (!options || options === true) options = {trigger: !!options}; -- -- // Normalize the fragment. -- fragment = this.getFragment(fragment || ''); -- -- // Strip trailing slash on the root unless _trailingSlash is true -- var rootPath = this.root; -- if (!this._trailingSlash && (fragment === '' || fragment.charAt(0) === '?')) { -- rootPath = rootPath.slice(0, -1) || '/'; -- } -- var url = rootPath + fragment; -- -- // Strip the fragment of the query and hash for matching. -- fragment = fragment.replace(pathStripper, ''); -- -- // Decode for matching. -- var decodedFragment = this.decodeFragment(fragment); -- -- if (this.fragment === decodedFragment) return; -- this.fragment = decodedFragment; -- -- // If pushState is available, we use it to set the fragment as a real URL. -- if (this._usePushState) { -- this.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, url); -- -- // If hash changes haven't been explicitly disabled, update the hash -- // fragment to store history. -- } else if (this._wantsHashChange) { -- this._updateHash(this.location, fragment, options.replace); -- if (this.iframe && fragment !== this.getHash(this.iframe.contentWindow)) { -- var iWindow = this.iframe.contentWindow; -- -- // Opening and closing the iframe tricks IE7 and earlier to push a -- // history entry on hash-tag change. When replace is true, we don't -- // want this. -- if (!options.replace) { -- iWindow.document.open(); -- iWindow.document.close(); -- } -- -- this._updateHash(iWindow.location, fragment, options.replace); -- } -- -- // If you've told us that you explicitly don't want fallback hashchange- -- // based history, then `navigate` becomes a page refresh. -- } else { -- return this.location.assign(url); -- } -- if (options.trigger) return this.loadUrl(fragment); -- }, -- -- // Update the hash location, either replacing the current entry, or adding -- // a new one to the browser history. -- _updateHash: function(location, fragment, replace) { -- if (replace) { -- var href = location.href.replace(/(javascript:|#).*$/, ''); -- location.replace(href + '#' + fragment); -- } else { -- // Some browsers require that `hash` contains a leading #. -- location.hash = '#' + fragment; -- } -- } -- -- }); -- -- // Create the default Backbone.history. -- Backbone.history = new History; - - // Helpers - // ------- -@@ -2128,7 +1423,7 @@ - }; - - // Set up inheritance for the model, collection, router, view and history. -- Model.extend = Collection.extend = Router.extend = View.extend = History.extend = extend; -+ Model.extend = Collection.extend = extend; - - // Throw an error when a URL is needed, and none is supplied. - var urlError = function() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0289851047b..b1d6d57e749 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,9 +15,6 @@ overrides: react-contextmenu>react-dom: 18.3.1 patchedDependencies: - '@types/backbone@1.4.22': - hash: 9dace206a9f53e0e3b0203051b26aec1e92ad49744b156ad8076946356c6c8e7 - path: patches/@types+backbone+1.4.22.patch '@types/express@4.17.21': hash: 85d9b3f3cac67003e41b22245281f53b51d7d1badd0bcc222d547ab802599bae path: patches/@types+express+4.17.21.patch @@ -36,9 +33,6 @@ patchedDependencies: app-builder-lib: hash: b412b44a47bb3d2be98e6edffed5dc4286cc62ac3c02fef42d1557927baa2420 path: patches/app-builder-lib.patch - backbone@1.6.0: - hash: 342b4b6012f8aecfa041554256444cb25af75bc933cf2ab1e91c4f66a8e47a31 - path: patches/backbone+1.6.0.patch casual@1.6.2: hash: b88b5052437cbdc1882137778b76ca5037f71b2a030ae9ef39dc97f51670d599 path: patches/casual+1.6.2.patch @@ -158,9 +152,6 @@ importers: '@types/fabric': specifier: 4.5.3 version: 4.5.3(patch_hash=e5f339ecf72fbab1c91505e7713e127a7184bfe8164aa3a9afe9bf45a0ad6b89) - backbone: - specifier: 1.6.0 - version: 1.6.0(patch_hash=342b4b6012f8aecfa041554256444cb25af75bc933cf2ab1e91c4f66a8e47a31) blob-util: specifier: 2.0.2 version: 2.0.2 @@ -504,9 +495,6 @@ importers: '@tailwindcss/postcss': specifier: 4.1.7 version: 4.1.7 - '@types/backbone': - specifier: 1.4.22 - version: 1.4.22(patch_hash=9dace206a9f53e0e3b0203051b26aec1e92ad49744b156ad8076946356c6c8e7) '@types/blueimp-load-image': specifier: 5.16.6 version: 5.16.6 @@ -3780,9 +3768,6 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} - '@types/backbone@1.4.22': - resolution: {integrity: sha512-i79hj6XPfsJ37yBHUb9560luep8SPoAbGcpA9TeW1R6Jufk4hHZn5q0l2xuTVtugBcoLlxGQ5qOjaNLBPmqaAg==} - '@types/blueimp-load-image@5.16.6': resolution: {integrity: sha512-e7s6CdDCUoBQdCe62Q6OS+DF68M8+ABxCEMh2Isjt4Fl3xuddljCHMN8mak48AMSVGGwUUtNRaZbkzgL5PEWew==} @@ -4046,9 +4031,6 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@types/underscore@1.13.0': - resolution: {integrity: sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==} - '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -4625,9 +4607,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - backbone@1.6.0: - resolution: {integrity: sha512-13PUjmsgw/49EowNcQvfG4gmczz1ximTMhUktj0Jfrjth0MVaTxehpU+qYYX4MxnuIuhmvBLC6/ayxuAGnOhbA==} - bail@1.0.5: resolution: {integrity: sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==} @@ -14587,11 +14566,6 @@ snapshots: dependencies: '@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/body-parser@1.19.5': @@ -14890,8 +14864,6 @@ snapshots: '@types/stack-utils@2.0.3': {} - '@types/underscore@1.13.0': {} - '@types/unist@2.0.11': {} '@types/use-sync-external-store@0.0.6': {} @@ -15611,10 +15583,6 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 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: {} balanced-match@1.0.2: {} diff --git a/sticker-creator/.eslintrc.cjs b/sticker-creator/.eslintrc.cjs index d7d9c5029e5..08ad8ff7e0a 100644 --- a/sticker-creator/.eslintrc.cjs +++ b/sticker-creator/.eslintrc.cjs @@ -96,7 +96,7 @@ module.exports = { // Prefer functional components with default params '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': [ 'error', { diff --git a/ts/CI.ts b/ts/CI.ts index ba068dd3f36..3e8006cd6b5 100644 --- a/ts/CI.ts +++ b/ts/CI.ts @@ -220,7 +220,7 @@ export function getCI({ } function unlink() { - window.Whisper.events.trigger('unlinkAndDisconnect'); + window.Whisper.events.emit('unlinkAndDisconnect'); } function print(...args: ReadonlyArray) { diff --git a/ts/CI/benchmarkConversationOpen.ts b/ts/CI/benchmarkConversationOpen.ts index fac9f1f9a98..26d7d466381 100644 --- a/ts/CI/benchmarkConversationOpen.ts +++ b/ts/CI/benchmarkConversationOpen.ts @@ -96,7 +96,7 @@ export async function populateConversationWithMessages({ postSaveUpdates, }); - conversation.set('active_at', Date.now()); + conversation.set({ active_at: Date.now() }); await DataWriter.updateConversation(conversation.attributes); log.info(`${logId}: populating conversation complete`); } diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 3df134a8796..eaca25b2b01 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -4,15 +4,6 @@ import { debounce, pick, uniq, without } from 'lodash'; import PQueue from 'p-queue'; 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 { createLogger } from './logging/log'; @@ -21,8 +12,12 @@ import { getAuthorId } from './messages/helpers'; import { maybeDeriveGroupV2Id } from './groups'; import { assertDev, strictAssert } from './util/assert'; import { drop } from './util/drop'; -import { isGroup, isGroupV1, isGroupV2 } from './util/whatTypeOfConversation'; -import type { ServiceIdString, AciString, PniString } from './types/ServiceId'; +import { + isDirectConversation, + isGroup, + isGroupV1, + isGroupV2, +} from './util/whatTypeOfConversation'; import { isServiceIdString, normalizePni, @@ -42,6 +37,18 @@ import { isTestOrMockEnvironment } from './environment'; import { isConversationAccepted } from './util/isConversationAccepted'; import { areWePending } from './util/groupMembershipUtils'; 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'); @@ -129,11 +136,7 @@ async function safeCombineConversations( const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; -const { - getAllConversations, - getAllGroupsInvolvingServiceId, - getMessagesBySentAt, -} = DataReader; +const { getAllConversations, getMessagesBySentAt } = DataReader; const { migrateConversationMessages, @@ -143,57 +146,197 @@ const { updateConversations, } = 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 { #_initialFetchComplete = false; #isReadOnly = false; - private _initialPromise: undefined | Promise; + #_initialPromise: undefined | Promise; + #_conversations: Array = []; #_conversationOpenStart = new Map(); #_hasQueueEmptied = false; #_combineConversationsQueue = new PQueue({ concurrency: 1 }); #_signalConversationId: undefined | string; - constructor(private _conversations: ConversationModelCollectionType) { - const debouncedUpdateUnreadCount = debounce( - this.updateUnreadCount.bind(this), - SECOND, - { - leading: true, - maxWait: SECOND, - trailing: true, + #delayBeforeUpdatingRedux: (() => number) | undefined; + #isAppStillLoading: (() => boolean) | undefined; + + // lookups + #_byE164: Record = Object.create(null); + #_byServiceId: Record = Object.create(null); + #_byPni: Record = Object.create(null); + #_byGroupId: Record = Object.create(null); + #_byId: Record = 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(); + 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 - window.Whisper.events.on('updateUnreadCount', debouncedUpdateUnreadCount); - this._conversations.on( - 'add remove change:active_at change:unreadCount change:markedUnread change:isArchived change:muteExpiresAt', - debouncedUpdateUnreadCount + window.Whisper.events.on( + 'updateUnreadCount', + this.#debouncedUpdateUnreadCount ); + } - // 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. - this._conversations.on('add', (model: ConversationModel): void => { - // Don't modify conversations in backup integration testing - if (isTestOrMockEnvironment()) { - return; + registerDelayBeforeUpdatingRedux( + delayBeforeUpdatingRedux: () => number + ): void { + this.#delayBeforeUpdatingRedux = delayBeforeUpdatingRedux; + } + registerIsAppStillLoading(isAppStillLoading: () => boolean): void { + 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 { if (!this.#_hasQueueEmptied) { return; @@ -203,7 +346,7 @@ export class ConversationController { window.storage.get('badge-count-muted-conversations') || false; const unreadStats = countAllConversationsUnreadStats( - this._conversations.map( + this.#_conversations.map( (conversation): ConversationPropsForUnreadStats => { // Need to pull this out manually into the Redux shape // because `conversation.format()` can return cached props by the @@ -251,24 +394,39 @@ export class ConversationController { 'ConversationController.get() needs complete initial fetch' ); } + if (!id) { + return undefined; + } - // This function takes null just fine. Backbone typings are too restrictive. - return this._conversations.get(id as string); + return ( + this.#_byE164[id] || + this.#_byE164[`+${id}`] || + this.#_byServiceId[id] || + this.#_byPni[id] || + this.#_byGroupId[id] || + this.#_byId[id] + ); } getAll(): Array { - return this._conversations.models; + return this.#_conversations; } dangerouslyCreateAndAdd( - attributes: Partial + attributes: ConversationAttributesType ): ConversationModel { - return this._conversations.add(attributes); + const model = new ConversationModel(attributes); + this.#addConversation(model); + return model; } dangerouslyRemoveById(id: string): void { - this._conversations.remove(id); - this._conversations.resetLookups(); + const model = this.get(id); + if (!model) { + return; + } + + this.#removeConversation(model); } getOrCreate( @@ -292,7 +450,7 @@ export class ConversationController { ); } - let conversation = this._conversations.get(identifier); + let conversation = this.get(identifier); if (conversation) { return conversation; } @@ -304,44 +462,64 @@ export class ConversationController { const id = generateUuid(); if (type === 'group') { - conversation = this._conversations.add({ + conversation = new ConversationModel({ id, serviceId: undefined, e164: undefined, groupId: identifier, type, version: 2, + expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION, + unreadCount: 0, + verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT, + messageCount: 0, + sentMessageCount: 0, ...additionalInitialProps, }); + this.#addConversation(conversation); } else if (isServiceIdString(identifier)) { - conversation = this._conversations.add({ + conversation = new ConversationModel({ id, serviceId: identifier, e164: undefined, groupId: undefined, type, version: 2, + expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION, + unreadCount: 0, + verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT, + messageCount: 0, + sentMessageCount: 0, ...additionalInitialProps, }); + this.#addConversation(conversation); } else { - conversation = this._conversations.add({ + conversation = new ConversationModel({ id, serviceId: undefined, e164: identifier, groupId: undefined, type, version: 2, + expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION, + unreadCount: 0, + verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT, + messageCount: 0, + sentMessageCount: 0, ...additionalInitialProps, }); + this.#addConversation(conversation); } const create = async () => { - if (!conversation.isValid()) { - const validationError = conversation.validationError || {}; + const validationErrorString = validateConversation( + conversation.attributes + ); + if (validationErrorString) { log.error( 'Contact is not valid. Not saving, but adding to collection:', conversation.idForLogging(), - Errors.toLogFormat(validationError) + validationErrorString ); return conversation; @@ -755,7 +933,7 @@ export class ConversationController { (targetOldServiceIds.pni !== pni || (aci && targetOldServiceIds.aci !== aci)) ) { - targetConversation.unset('needsTitleTransition'); + targetConversation.set({ needsTitleTransition: undefined }); mergePromises.push( targetConversation.addPhoneNumberDiscoveryIfNeeded( 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 // 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 // conflict case, to keep the one with activity the most recently. - for (let i = models.length - 1; i >= 0; i -= 1) { - const conversation = models[i]; + for (let i = this.#_conversations.length - 1; i >= 0; i -= 1) { + const conversation = this.#_conversations[i]; assertDev( conversation, 'Expected conversation to be found in array during iteration' @@ -1090,15 +1266,14 @@ export class ConversationController { } else { activeAt = obsoleteActiveAt || currentActiveAt; } - current.set('active_at', activeAt); + current.set({ active_at: activeAt }); - current.set( - 'expireTimerVersion', - Math.max( + current.set({ + expireTimerVersion: Math.max( obsolete.get('expireTimerVersion') ?? 1, current.get('expireTimerVersion') ?? 1 - ) - ); + ), + }); const obsoleteExpireTimer = obsolete.get('expireTimer'); const currentExpireTimer = current.get('expireTimer'); @@ -1106,7 +1281,7 @@ export class ConversationController { !currentExpireTimer || (obsoleteExpireTimer && obsoleteExpireTimer < currentExpireTimer) ) { - current.set('expireTimer', obsoleteExpireTimer); + current.set({ expireTimer: obsoleteExpireTimer }); } const currentHadMessages = (current.get('messageCount') ?? 0) > 0; @@ -1136,11 +1311,11 @@ export class ConversationController { >; keys.forEach(key => { 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 if (key === 'draftAttachments') { - obsolete.set(key, undefined); + obsolete.set({ [key]: undefined }); } } }); @@ -1244,8 +1419,7 @@ export class ConversationController { log.warn( `${logId}: Eliminate old conversation from ConversationController lookups` ); - this._conversations.remove(obsolete); - this._conversations.resetLookups(); + this.#removeConversation(obsolete); current.captureChange('combineConversations'); drop(current.updateLastMessage()); @@ -1305,22 +1479,25 @@ export class ConversationController { return null; } - async getAllGroupsInvolvingServiceId( + getAllGroupsInvolvingServiceId( serviceId: ServiceIdString - ): Promise> { - const groups = await getAllGroupsInvolvingServiceId(serviceId); - return groups.map(group => { - const existing = this.get(group.id); - if (existing) { - return existing; - } + ): Array { + return this.#_conversations + .map(conversation => { + if (!isGroup(conversation.attributes)) { + return; + } + if (!conversation.hasMember(serviceId)) { + return; + } - return this._conversations.add(group); - }); + return conversation; + }) + .filter(isNotNil); } getByDerivedGroupV2Id(groupId: string): ConversationModel | undefined { - return this._conversations.find( + return this.#_conversations.find( item => item.get('derivedGroupV2Id') === groupId ); } @@ -1336,14 +1513,18 @@ export class ConversationController { } reset(): void { - delete this._initialPromise; + const { removeAllConversations } = window.reduxActions.conversations; + + this.#_initialPromise = undefined; this.#_initialFetchComplete = false; - this._conversations.reset([]); + this.#_conversations = []; + removeAllConversations(); + this.#resetLookups(); } load(): Promise { - this._initialPromise ||= this.#doLoad(); - return this._initialPromise; + this.#_initialPromise ||= this.#doLoad(); + return this.#_initialPromise; } // A number of things outside conversation.attributes affect conversation re-rendering. @@ -1354,7 +1535,7 @@ export class ConversationController { let count = 0; const conversations = identifiers ? identifiers.map(identifier => this.get(identifier)).filter(isNotNil) - : this._conversations.models.slice(); + : this.#_conversations.slice(); log.info( `forceRerender: Starting to loop through ${conversations.length} conversations` ); @@ -1366,7 +1547,7 @@ export class ConversationController { conversation.oldCachedProps = conversation.cachedProps; conversation.cachedProps = null; - conversation.trigger('props-change', conversation, false); + this.conversationUpdated(conversation, conversation.attributes); count += 1; } @@ -1426,8 +1607,10 @@ export class ConversationController { ); } - conversation.set('avatar', undefined); - conversation.set('profileAvatar', undefined); + conversation.set({ + avatar: undefined, + profileAvatar: undefined, + }); drop(updateConversation(conversation.attributes)); numberOfConversationsMigrated += 1; } @@ -1449,7 +1632,7 @@ export class ConversationController { } log.warn(`Repairing ${convo.idForLogging()}'s isPinned`); - convo.set('isPinned', true); + convo.set({ isPinned: true }); drop(updateConversation(convo.attributes)); } @@ -1469,7 +1652,7 @@ export class ConversationController { await updateConversations( sharedWith.map(c => { - c.unset('shareMyPhoneNumber'); + c.set({ shareMyPhoneNumber: undefined }); return c.attributes; }) ); @@ -1496,15 +1679,14 @@ export class ConversationController { // eslint-disable-next-line no-await-in-loop await removeConversation(convo.id); - this._conversations.remove(convo); - this._conversations.resetLookups(); + this.#removeConversation(convo); } } async #doLoad(): Promise { log.info('starting initial fetch'); - if (this._conversations.length) { + if (this.#_conversations.length) { throw new Error('ConversationController: Already loaded!'); } @@ -1540,14 +1722,16 @@ export class ConversationController { this.#_initialFetchComplete = true; // Hydrate the final set of conversations - batchDispatch(() => { - this._conversations.add( - collection.filter(conversation => !conversation.isTemporary) + + collection + .filter(conversation => !conversation.isTemporary) + .forEach(conversation => + this.#_conversations.push(new ConversationModel(conversation)) ); - }); + this.#generateLookups(); await Promise.all( - this._conversations.map(async conversation => { + this.#_conversations.map(async conversation => { try { // Hydrate contactCollection, now that initial fetch is complete conversation.fetchContacts(); @@ -1587,13 +1771,14 @@ export class ConversationController { ); log.info( 'done with initial fetch, ' + - `got ${this._conversations.length} conversations` + `got ${this.#_conversations.length} conversations` ); } catch (error) { log.error('initial fetch failed', Errors.toLogFormat(error)); throw error; } } + async archiveSessionsForConversation( conversationId: string | undefined ): Promise { @@ -1635,4 +1820,203 @@ export class ConversationController { 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); + } } diff --git a/ts/SignalProtocolStore.ts b/ts/SignalProtocolStore.ts index 11048b43784..7436955009f 100644 --- a/ts/SignalProtocolStore.ts +++ b/ts/SignalProtocolStore.ts @@ -2621,11 +2621,13 @@ export class SignalProtocolStore extends EventEmitter { async removeAllConfiguration(): Promise { // Conversations. These properties are not present in redux. - window.getConversations().forEach(conversation => { - conversation.unset('storageID'); - conversation.unset('needsStorageServiceSync'); - conversation.unset('storageUnknownFields'); - conversation.unset('senderKeyInfo'); + window.ConversationController.getAll().forEach(conversation => { + conversation.set({ + storageID: undefined, + needsStorageServiceSync: undefined, + storageUnknownFields: undefined, + senderKeyInfo: undefined, + }); }); await DataWriter.removeAllConfiguration(); diff --git a/ts/backbone/reliable_trigger.ts b/ts/backbone/reliable_trigger.ts deleted file mode 100644 index 2ddc1008100..00000000000 --- a/ts/backbone/reliable_trigger.ts +++ /dev/null @@ -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; - 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, - rest: ReadonlyArray -) { - 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, - name: string, - args: Array -) { - 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>; - }, ->(this: T, name: string, ...args: Array): 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 }); -}); diff --git a/ts/background.ts b/ts/background.ts index 71113a8aedc..d00496f9e70 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1,12 +1,11 @@ // Copyright 2020 Signal Messenger, LLC // 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 PQueue from 'p-queue'; import pMap from 'p-map'; import { v7 as generateUuid } from 'uuid'; -import { batch as batchDispatch } from 'react-redux'; import * as Registration from './util/registration'; import MessageReceiver from './textsecure/MessageReceiver'; @@ -25,8 +24,6 @@ import * as Bytes from './Bytes'; import * as Timers from './Timers'; import * as indexedDb from './indexeddb'; import type { MenuOptionsType } from './types/menu'; -import type { Receipt } from './types/Receipt'; -import { ReceiptType } from './types/Receipt'; import { SocketStatus } from './types/SocketStatus'; import { DEFAULT_CONVERSATION_COLOR } from './types/Colors'; import { ThemeType } from './types/Util'; @@ -153,10 +150,7 @@ import { deleteAllLogs } from './util/deleteAllLogs'; import { startInteractionMode } from './services/InteractionMode'; import { ReactionSource } from './reactions/ReactionSource'; import { singleProtoJobQueue } from './jobs/singleProtoJobQueue'; -import { - conversationJobQueue, - conversationQueueJobEnum, -} from './jobs/conversationJobQueue'; +import { conversationJobQueue } from './jobs/conversationJobQueue'; import { SeenStatus } from './MessageSeenStatus'; import MessageSender from './textsecure/SendMessage'; import type AccountManager from './textsecure/AccountManager'; @@ -304,29 +298,7 @@ export async function startApp(): Promise { const onRetryRequestQueue = new PQueue({ concurrency: 1 }); onRetryRequestQueue.pause(); - window.Whisper.deliveryReceiptQueue = new PQueue({ - concurrency: 1, - timeout: durations.MINUTE * 30, - }); window.Whisper.deliveryReceiptQueue.pause(); - window.Whisper.deliveryReceiptBatcher = createBatcher({ - 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') { window.addEventListener('dblclick', (event: Event) => { @@ -441,7 +413,7 @@ export async function startApp(): Promise { }); accountManager.addEventListener('endRegistration', () => { - window.Whisper.events.trigger('userChanged', false); + window.Whisper.events.emit('userChanged', false); drop(window.storage.put('postRegistrationSyncsStatus', 'incomplete')); registrationCompleted?.resolve(); @@ -596,6 +568,23 @@ export async function startApp(): Promise { storage: window.storage, 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( handler: (event: E) => Promise | void @@ -1215,114 +1204,6 @@ export async function startApp(): Promise { function setupAppState() { 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(); - 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) => { const newDeviceId = window.textsecure.storage.user.getDeviceId(); const newNumber = window.textsecure.storage.user.getNumber(); @@ -1332,7 +1213,7 @@ export async function startApp(): Promise { window.ConversationController.getOurConversation(); if (ourConversation?.get('e164') !== newNumber) { - ourConversation?.set('e164', newNumber); + ourConversation?.set({ e164: newNumber }); } window.reduxActions.user.userChanged({ @@ -1566,7 +1447,7 @@ export async function startApp(): Promise { window.IPC.setMenuBarVisibility(!hideMenuBar); startTimeTravelDetector(() => { - window.Whisper.events.trigger('timetravel'); + window.Whisper.events.emit('timetravel'); }); updateExpiringMessagesService(); @@ -3145,7 +3026,7 @@ export async function startApp(): Promise { } async function unlinkAndDisconnect(): Promise { - window.Whisper.events.trigger('unauthorized'); + window.Whisper.events.emit('unauthorized'); log.warn( 'unlinkAndDisconnect: Client is no longer authorized; ' + @@ -3192,7 +3073,7 @@ export async function startApp(): Promise { const ourConversation = window.ConversationController.getOurConversation(); if (ourConversation) { - ourConversation.unset('username'); + ourConversation.set({ username: undefined }); await DataWriter.updateConversation(ourConversation.attributes); } diff --git a/ts/components/CallsList.tsx b/ts/components/CallsList.tsx index 5f7976dafe2..f277bae69be 100644 --- a/ts/components/CallsList.tsx +++ b/ts/components/CallsList.tsx @@ -554,6 +554,9 @@ export function CallsList({ }; let timer = setTimeout(() => { + if (controller.signal.aborted) { + return; + } setSearchState(prevSearchState => { if (prevSearchState.state === 'init') { return defaultPendingState; @@ -561,6 +564,10 @@ export function CallsList({ return prevSearchState; }); timer = setTimeout(() => { + if (controller.signal.aborted) { + return; + } + // Show loading indicator after a delay setSearchState(defaultPendingState); }, 300); diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 0d92fa1e669..db2b99fba91 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -140,7 +140,7 @@ type PropsHousekeepingType = { }; export type PropsActionsType = { - // From Backbone + // From Model acknowledgeGroupMemberNameCollisions: ( conversationId: string, groupNameCollisions: ReadonlyDeep diff --git a/ts/groups.ts b/ts/groups.ts index c6ea6e9a99b..3cfaf320828 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -3304,7 +3304,11 @@ async function updateGroup( }); if (idChanged) { - conversation.trigger('idUpdated', conversation, 'groupId', previousId); + window.ConversationController.idUpdated( + conversation, + 'groupId', + previousId + ); } // Save these most recent updates to conversation diff --git a/ts/messageModifiers/DeletesForMe.ts b/ts/messageModifiers/DeletesForMe.ts index 087f041151b..3e0d9a388ef 100644 --- a/ts/messageModifiers/DeletesForMe.ts +++ b/ts/messageModifiers/DeletesForMe.ts @@ -104,7 +104,7 @@ export async function onDelete(item: DeleteForMeAttributesType): Promise { let result: boolean; 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. result = await deleteAttachmentFromMessage( conversation.id, diff --git a/ts/messageModifiers/MessageReceipts.ts b/ts/messageModifiers/MessageReceipts.ts index 49dcb138dae..faeee892b60 100644 --- a/ts/messageModifiers/MessageReceipts.ts +++ b/ts/messageModifiers/MessageReceipts.ts @@ -297,7 +297,7 @@ const deleteSentProtoBatcher = createWaitBatcher({ // `deleteSentProtoRecipient` has already updated the database so there // is no need in calling `updateConversation` - convo.unset('shareMyPhoneNumber'); + convo.set({ shareMyPhoneNumber: undefined }); } }, }); diff --git a/ts/messages/saveAndNotify.ts b/ts/messages/saveAndNotify.ts index c1701feff69..cecee2576f2 100644 --- a/ts/messages/saveAndNotify.ts +++ b/ts/messages/saveAndNotify.ts @@ -57,7 +57,7 @@ export async function saveAndNotify( conversation.incrementSentMessageCount(); } - window.Whisper.events.trigger('incrementProgress'); + window.Whisper.events.emit('incrementProgress'); confirm(); if (!isStory(message.attributes)) { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 3c4f20ba7d6..9adac64d9bc 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -1,14 +1,12 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import * as Backbone from 'backbone'; import type { ReadonlyDeep } from 'type-fest'; import type { GroupV2ChangeType } from './groups'; import type { DraftBodyRanges, RawBodyRange } from './types/BodyRange'; import type { CustomColorType, ConversationColorType } from './types/Colors'; import type { SendMessageChallengeData } from './textsecure/Errors'; -import type { ConversationModel } from './models/conversations'; import type { ProfileNameChangeType } from './util/getStringForProfileChange'; import type { CapabilitiesType } from './textsecure/WebAPI'; import type { ReadStatus } from './messages/MessageReadStatus'; @@ -486,7 +484,7 @@ export type ConversationAttributesType = { groupInviteLinkPassword?: string; previousGroupV1Id?: string; previousGroupV1Members?: Array; - acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle; + acknowledgedGroupNameCollisions?: ReadonlyDeep; // Used only when user is waiting for approval to join via link isTemporary?: boolean; @@ -561,7 +559,3 @@ export type ShallowChallengeError = CustomError & { readonly retryAfter: number; readonly data: SendMessageChallengeData; }; - -export declare class ConversationModelCollectionType extends Backbone.Collection { - resetLookups(): void; -} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index cc14618adfb..f93ccd5238c 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1,8 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { compact, has, isNumber, throttle, debounce } from 'lodash'; -import { batch as batchDispatch } from 'react-redux'; +import { compact, isNumber, throttle, debounce } from 'lodash'; import { v4 as generateGuid } from 'uuid'; import PQueue from 'p-queue'; @@ -193,22 +192,10 @@ import { getTypingIndicatorSetting } from '../types/Util'; import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer'; import { maybeNotify } from '../messages/maybeNotify'; import { missingCaseError } from '../util/missingCaseError'; +import * as Message from '../types/Message2'; 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 { getConversationRangeCenteredOnMessage, getOlderMessagesByConversation, @@ -228,15 +215,6 @@ const SEND_REPORTING_THRESHOLD_MS = 25; 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; type CachedIdenticon = { @@ -245,11 +223,13 @@ type CachedIdenticon = { readonly path?: string; readonly url: string; }; +type StringKey = keyof T & string; -export class ConversationModel extends window.Backbone - .Model { +export class ConversationModel { static COLORS: string; + #_attributes: ConversationAttributesType; + cachedProps?: ConversationType | null; oldCachedProps?: ConversationType | null; @@ -263,7 +243,7 @@ export class ConversationModel extends window.Backbone } >; - contactCollection?: Backbone.Collection; + contactCollection?: Array; debouncedUpdateLastMessage: (() => void) & { flush(): void }; @@ -305,19 +285,70 @@ export class ConversationModel extends window.Backbone #lastIsTyping?: boolean; #muteTimer?: NodeJS.Timeout; - #isInReduxBatch = false; #privVerifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus; #isShuttingDown = false; #savePromises = new Set>(); - override defaults(): Partial { - return { - unreadCount: 0, - verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT, - messageCount: 0, - sentMessageCount: 0, - expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION, + public get id(): string { + return this.#_attributes.id; + } + + public get>( + key: keyName + ): ConversationAttributesType[keyName] { + return this.attributes[key]; + } + public set( + attributes: Partial, + { 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 = [ + '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 { + return this.#_attributes; } idForLogging(): string { @@ -328,20 +359,8 @@ export class ConversationModel extends window.Backbone return getSendTarget(this.attributes); } - getContactCollection(): Backbone.Collection { - const collection = new window.Backbone.Collection(); - 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) { - super(attributes); + this.#_attributes = attributes; // Note that we intentionally don't use `initialize()` method because it // isn't compatible with esnext output of esbuild. @@ -354,7 +373,7 @@ export class ConversationModel extends window.Backbone 'ConversationModel.initialize: normalizing serviceId from ' + `${serviceId} to ${normalizedServiceId}` ); - this.set('serviceId', normalizedServiceId); + this.set({ serviceId: normalizedServiceId }); } if (isValidE164(attributes.id, false)) { @@ -374,71 +393,35 @@ export class ConversationModel extends window.Backbone 200 ); - this.contactCollection = this.getContactCollection(); - 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() - ); + this.contactCollection = []; const sealedSender = this.get('sealedSender'); if (sealedSender === undefined) { 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); - this.on('change:active_at', this.#onActiveAtChange); + if ( + // @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.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 // conversation for the first time. this.isFetchingUUID = this.isSMSOnly(); @@ -468,7 +451,7 @@ export class ConversationModel extends window.Backbone const migratedColor = this.getColor(); 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 // 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 @@ -942,8 +925,7 @@ export class ConversationModel extends window.Backbone } if (blocked && !wasBlocked) { - // 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) { this.captureChange('block'); @@ -975,7 +957,7 @@ export class ConversationModel extends window.Backbone if (unblocked && wasBlocked) { // 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) { this.captureChange('unblock'); @@ -1213,7 +1195,7 @@ export class ConversationModel extends window.Backbone ); this.isFetchingUUID = true; - this.trigger('change', this, { force: true }); + window.ConversationController.conversationUpdated(this, this.attributes); try { // Attempt to fetch UUID @@ -1225,7 +1207,7 @@ export class ConversationModel extends window.Backbone } finally { // No redux update here this.isFetchingUUID = false; - this.trigger('change', this, { force: true }); + window.ConversationController.conversationUpdated(this, this.attributes); log.info( `Done fetching uuid for a sms-only conversation ${this.idForLogging()}` @@ -1240,14 +1222,6 @@ export class ConversationModel extends window.Backbone this.setRegistered(); } - override isValid(): boolean { - return ( - isDirectConversation(this.attributes) || - isGroupV1(this.attributes) || - isGroupV2(this.attributes) - ); - } - async maybeMigrateV1Group(): Promise { if (!isGroupV1(this.attributes)) { return; @@ -2078,7 +2052,7 @@ export class ConversationModel extends window.Backbone return; } - this.set('e164', e164 || undefined); + this.set({ e164: e164 || undefined }); // This user changed their phone number if (oldValue && e164 && this.get('sharingPhoneNumber')) { @@ -2086,7 +2060,7 @@ export class ConversationModel extends window.Backbone } drop(DataWriter.updateConversation(this.attributes)); - this.trigger('idUpdated', this, 'e164', oldValue); + window.ConversationController.idUpdated(this, 'e164', oldValue); this.captureChange('updateE164'); } @@ -2096,14 +2070,13 @@ export class ConversationModel extends window.Backbone return; } - this.set( - 'serviceId', - serviceId + this.set({ + serviceId: serviceId ? normalizeServiceId(serviceId, 'Conversation.updateServiceId') - : undefined - ); + : undefined, + }); 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 // 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; } - this.set( - 'pni', - pni ? normalizePni(pni, 'Conversation.updatePni') : undefined - ); + this.set({ + pni: pni ? normalizePni(pni, 'Conversation.updatePni') : undefined, + }); const newPniSignatureVerified = pni ? pniSignatureVerified : false; if (this.get('pniSignatureVerified') !== newPniSignatureVerified) { log.warn( `updatePni/${this.idForLogging()}: setting ` + `pniSignatureVerified to ${newPniSignatureVerified}` ); - this.set('pniSignatureVerified', newPniSignatureVerified); + this.set({ pniSignatureVerified: newPniSignatureVerified }); this.captureChange('pniSignatureVerified'); } @@ -2211,16 +2183,16 @@ export class ConversationModel extends window.Backbone } drop(DataWriter.updateConversation(this.attributes)); - this.trigger('idUpdated', this, 'pni', oldValue); + window.ConversationController.idUpdated(this, 'pni', oldValue); this.captureChange('updatePni'); } updateGroupId(groupId?: string): void { const oldValue = this.get('groupId'); if (groupId && groupId !== oldValue) { - this.set('groupId', groupId); + this.set({ groupId }); 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; } - this.set('reportingToken', newValue); + this.set({ reportingToken: newValue }); await DataWriter.updateConversation(this.attributes); } @@ -3021,7 +2993,7 @@ export class ConversationModel extends window.Backbone return false; } - if (contacts.length === 1 && isMe(contacts.first()?.attributes)) { + if (contacts.length === 1 && isMe(contacts[0]?.attributes)) { return false; } @@ -3158,9 +3130,7 @@ export class ConversationModel extends window.Backbone onMemberVerifiedChange(): void { // 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() - this.trigger('change:verified', this); - this.trigger('change', this, { force: true }); + window.ConversationController.conversationUpdated(this, this.attributes); } async toggleVerified(): Promise { @@ -3527,7 +3497,7 @@ export class ConversationModel extends window.Backbone const notificationId = await this.addNotification( 'universal-timer-notification' ); - this.set('pendingUniversalTimer', notificationId); + this.set({ pendingUniversalTimer: notificationId }); } async maybeApplyUniversalTimer(): Promise { @@ -3560,7 +3530,7 @@ export class ConversationModel extends window.Backbone return false; } - this.set('pendingUniversalTimer', undefined); + this.set({ pendingUniversalTimer: undefined }); log.info( `maybeRemoveUniversalTimer(${this.idForLogging()}): removed notification` ); @@ -3593,7 +3563,7 @@ export class ConversationModel extends window.Backbone const notificationId = await this.addNotification( 'contact-removed-notification' ); - this.set('pendingRemovedContactNotification', notificationId); + this.set({ pendingRemovedContactNotification: notificationId }); await DataWriter.updateConversation(this.attributes); } @@ -3603,7 +3573,7 @@ export class ConversationModel extends window.Backbone return false; } - this.set('pendingRemovedContactNotification', undefined); + this.set({ pendingRemovedContactNotification: undefined }); log.info( `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( name: string, callback: (abortSignal: AbortSignal) => Promise @@ -3838,6 +3804,8 @@ export class ConversationModel extends window.Backbone } async sendStickerMessage(packId: string, stickerId: number): Promise { + const { readStickerData } = window.Signal.Migrations; + const packData = Stickers.getStickerPack(packId); const stickerData = Stickers.getSticker(packId, stickerId); 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({ message, dontAddMessage, @@ -3952,57 +3908,53 @@ export class ConversationModel extends window.Backbone now: number; extraReduxActions?: () => void; }): void { - this.batchReduxChanges(() => { - const { clearUnreadMetrics } = window.reduxActions.conversations; - clearUnreadMetrics(this.id); + const { clearUnreadMetrics } = window.reduxActions.conversations; + clearUnreadMetrics(this.id); - const enabledProfileSharing = Boolean(!this.get('profileSharing')); - const unarchivedConversation = Boolean(this.get('isArchived')); + const enabledProfileSharing = Boolean(!this.get('profileSharing')); + const unarchivedConversation = Boolean(this.get('isArchived')); - log.info( - `beforeMessageSend(${this.idForLogging()}): ` + - `clearDraft(${!dontClearDraft}) addMessage(${!dontAddMessage})` - ); + log.info( + `beforeMessageSend(${this.idForLogging()}): ` + + `clearDraft(${!dontClearDraft}) addMessage(${!dontAddMessage})` + ); - if (!dontAddMessage) { - this.#doAddSingleMessage(message, { isJustSent: true }); - } + if (!dontAddMessage) { + this.#doAddSingleMessage(message, { isJustSent: true }); + } - const draftProperties = dontClearDraft - ? {} - : { - draft: '', - draftEditMessage: undefined, - draftBodyRanges: [], - draftTimestamp: null, - quotedMessageId: undefined, - }; - const lastMessageProperties = this.getLastMessageData(message, message); - const isEditMessage = Boolean(message.editHistory); + const draftProperties = dontClearDraft + ? {} + : { + draft: '', + draftEditMessage: undefined, + draftBodyRanges: [], + draftTimestamp: null, + quotedMessageId: undefined, + }; + const lastMessageProperties = this.getLastMessageData(message, message); + const isEditMessage = Boolean(message.editHistory); - this.set({ - ...draftProperties, - ...lastMessageProperties, - ...(enabledProfileSharing ? { profileSharing: true } : {}), - ...(dontAddMessage - ? {} - : this.incrementSentMessageCount({ dry: true })), - // If it's an edit message we don't want to optimistically set the - // active_at & timestamp to now. We want it to stay the same. - active_at: isEditMessage ? this.get('active_at') : now, - timestamp: isEditMessage ? this.get('timestamp') : now, - ...(unarchivedConversation ? { isArchived: false } : {}), - }); + this.set({ + ...draftProperties, + ...lastMessageProperties, + ...(enabledProfileSharing ? { profileSharing: true } : {}), + ...(dontAddMessage ? {} : this.incrementSentMessageCount({ dry: true })), + // If it's an edit message we don't want to optimistically set the + // active_at & timestamp to now. We want it to stay the same. + active_at: isEditMessage ? this.get('active_at') : now, + timestamp: isEditMessage ? this.get('timestamp') : now, + ...(unarchivedConversation ? { isArchived: false } : {}), + }); - if (enabledProfileSharing) { - this.captureChange('beforeMessageSend/mandatoryProfileSharing'); - } - if (unarchivedConversation) { - this.captureChange('beforeMessageSend/unarchive'); - } + if (enabledProfileSharing) { + this.captureChange('beforeMessageSend/mandatoryProfileSharing'); + } + if (unarchivedConversation) { + this.captureChange('beforeMessageSend/unarchive'); + } - extraReduxActions?.(); - }); + extraReduxActions?.(); } async enqueueMessageForSend( @@ -4037,6 +3989,9 @@ export class ConversationModel extends window.Backbone extraReduxActions?: () => void; } = {} ): Promise { + const { deleteAttachmentData, upgradeMessageSchema } = + window.Signal.Migrations; + if (this.isGroupV1AndDisabled()) { return; } @@ -4266,7 +4221,7 @@ export class ConversationModel extends window.Backbone log.info(`maybeClearUsername(${this.idForLogging()}): clearing username`); - this.unset('username'); + this.set({ username: undefined }); if (this.get('needsTitleTransition') && getProfileName(this.attributes)) { log.info( @@ -4274,7 +4229,7 @@ export class ConversationModel extends window.Backbone ); const { type, e164, username } = this.attributes; - this.unset('needsTitleTransition'); + this.set({ needsTitleTransition: undefined }); await this.addNotification('title-transition-notification', { readStatus: ReadStatus.Read, @@ -4310,7 +4265,7 @@ export class ConversationModel extends window.Backbone log.info(`updateUsername(${this.idForLogging()}): updating username`); - this.set('username', username); + this.set({ username }); this.captureChange('updateUsername'); if (shouldSave) { @@ -4469,7 +4424,7 @@ export class ConversationModel extends window.Backbone async #onActiveAtChange(): Promise { if (this.get('active_at') && this.get('messagesDeleted')) { - this.set('messagesDeleted', false); + this.set({ messagesDeleted: false }); await DataWriter.updateConversation(this.attributes); } } @@ -4713,7 +4668,7 @@ export class ConversationModel extends window.Backbone 'updateExpirationTimer: Resetting expireTimerVersion since this is initialSync' ); // 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; @@ -5006,6 +4961,12 @@ export class ConversationModel extends window.Backbone decryptionKey?: Uint8Array | null | undefined; forceFetch?: boolean; }): Promise { + const { + deleteAttachmentData, + doesAttachmentExist, + writeNewAttachmentData, + } = window.Signal.Migrations; + const { avatarUrl, decryptionKey, forceFetch } = options; if (isMe(this.attributes)) { if (avatarUrl) { @@ -5106,7 +5067,7 @@ export class ConversationModel extends window.Backbone const { type, e164, username } = this.attributes; - this.unset('needsTitleTransition'); + this.set({ needsTitleTransition: undefined }); await this.addNotification('title-transition-notification', { readStatus: ReadStatus.Read, @@ -5122,7 +5083,7 @@ export class ConversationModel extends window.Backbone } // 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. // 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 { - const members = this.getMembers(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.contactCollection!.reset(members); + this.contactCollection = this.getMembers(); } async destroyMessages({ @@ -5423,7 +5381,7 @@ export class ConversationModel extends window.Backbone } const newVersion = expireTimerVersion + 1; - this.set('expireTimerVersion', newVersion); + this.set({ expireTimerVersion: newVersion }); await DataWriter.updateConversation(this.attributes); } @@ -5511,6 +5469,8 @@ export class ConversationModel extends window.Backbone url: string; absolutePath?: string; }> { + const { getAbsoluteTempPath } = window.Signal.Migrations; + const saveToDisk = shouldSaveNotificationAvatarToDisk(); const avatarUrl = getLocalAvatarUrl(this.attributes); if (avatarUrl) { @@ -5532,6 +5492,13 @@ export class ConversationModel extends window.Backbone } async #getTemporaryAvatarPath(): Promise { + const { + copyIntoTempDirectory, + deleteAttachmentData, + getAbsoluteAttachmentPath, + getAbsoluteTempPath, + } = window.Signal.Migrations; + const avatar = getAvatar(this.attributes); if (avatar?.path == null) { return undefined; @@ -5672,13 +5639,19 @@ export class ConversationModel extends window.Backbone ); // User was not previously typing before. State change! if (!record) { - this.trigger('change', this, { force: true }); + window.ConversationController.conversationUpdated( + this, + this.attributes + ); } } else { delete this.contactTypingTimers[typingToken]; if (record) { // 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]; // 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; } - const validationError = this.validate(); - if (validationError) { + const validationErrorString = validateConversation(this.attributes); + if (validationErrorString) { log.error( `not pinning ${this.idForLogging()} because of ` + - `validation error ${validationError}` + `validation error ${validationErrorString}` ); return; } @@ -5719,7 +5692,7 @@ export class ConversationModel extends window.Backbone this.writePinnedConversations([...pinnedConversationIds]); - this.set('isPinned', true); + this.set({ isPinned: true }); if (this.get('isArchived')) { this.set({ isArchived: false }); @@ -5742,7 +5715,7 @@ export class ConversationModel extends window.Backbone this.writePinnedConversations([...pinnedConversationIds]); - this.set('isPinned', false); + this.set({ isPinned: false }); drop(DataWriter.updateConversation(this.attributes)); } @@ -5771,7 +5744,7 @@ export class ConversationModel extends window.Backbone acknowledgeGroupMemberNameCollisions( groupNameCollisions: ReadonlyDeep ): void { - this.set('acknowledgedGroupNameCollisions', groupNameCollisions); + this.set({ acknowledgedGroupNameCollisions: groupNameCollisions }); drop(DataWriter.updateConversation(this.attributes)); } @@ -5851,176 +5824,3 @@ export class ConversationModel extends window.Backbone 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, options?: Backbone.Silenceable) { - window.Backbone.Collection.prototype.reset.call(this, models, options); - this.resetLookups(); - }, - - resetLookups() { - this.eraseLookups(); - this.generateLookups(this.models); - }, - - generateLookups(models: ReadonlyArray) { - 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 - | Array - ) { - let hydratedData: Array | 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); - }, -}); diff --git a/ts/reactions/enqueueReactionForSend.ts b/ts/reactions/enqueueReactionForSend.ts index ebff115e3e8..cc7f42382f5 100644 --- a/ts/reactions/enqueueReactionForSend.ts +++ b/ts/reactions/enqueueReactionForSend.ts @@ -73,7 +73,7 @@ export async function enqueueReactionForSend({ ) { log.info('Enabling profile sharing for reaction send'); if (!messageConversation.get('profileSharing')) { - messageConversation.set('profileSharing', true); + messageConversation.set({ profileSharing: true }); await DataWriter.updateConversation(messageConversation.attributes); } await messageConversation.restoreContact(); diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index e3c00406fd4..84ee30b4075 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -703,8 +703,9 @@ export class BackupImportStream extends Writable { svrPin, }: Backups.IAccountData): Promise { strictAssert(this.#ourConversation === undefined, 'Duplicate AccountData'); - const me = - window.ConversationController.getOurConversationOrThrow().attributes; + const me = { + ...window.ConversationController.getOurConversationOrThrow().attributes, + }; this.#ourConversation = me; const { storage } = window; diff --git a/ts/services/contactSync.ts b/ts/services/contactSync.ts index 6e876b702b2..ca3f13f2174 100644 --- a/ts/services/contactSync.ts +++ b/ts/services/contactSync.ts @@ -97,7 +97,7 @@ async function updateConversationFromContactSync( ); } - window.Whisper.events.trigger('incrementProgress'); + window.Whisper.events.emit('incrementProgress'); } const queue = new PQueue({ concurrency: 1 }); @@ -182,11 +182,11 @@ async function doContactSync({ type: 'private', }; - const validationError = validateConversation(partialConversation); - if (validationError) { + const validationErrorString = validateConversation(partialConversation); + if (validationErrorString) { log.error( `${logId}: Invalid contact received`, - Errors.toLogFormat(validationError) + Errors.toLogFormat(validationErrorString) ); continue; } @@ -261,7 +261,7 @@ async function doContactSync({ await Promise.all(promises); await window.storage.put('synced_at', Date.now()); - window.Whisper.events.trigger('contactSync:complete'); + window.Whisper.events.emit('contactSync:complete'); if (isInitialSync) { isInitialSync = false; } diff --git a/ts/services/profiles.ts b/ts/services/profiles.ts index 36d119a461f..11c2de204d5 100644 --- a/ts/services/profiles.ts +++ b/ts/services/profiles.ts @@ -553,7 +553,7 @@ async function doGetProfile( // Record that the accessKey we have in the conversation is invalid const sealedSender = c.get('sealedSender'); 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. @@ -580,7 +580,7 @@ async function doGetProfile( if (error.code === 404) { log.info(`${logId}: Profile not found`); - c.set('profileLastFetchedAt', Date.now()); + c.set({ profileLastFetchedAt: Date.now() }); if (!isVersioned || ignoreProfileKey) { log.info(`${logId}: Marking conversation unregistered`); @@ -655,20 +655,20 @@ async function doGetProfile( if (isFieldDefined(profile.about)) { if (updatedDecryptionKey != null) { const decrypted = decryptField(profile.about, updatedDecryptionKey); - c.set('about', formatTextField(decrypted)); + c.set({ about: formatTextField(decrypted) }); } } else { - c.unset('about'); + c.set({ about: undefined }); } // Step #: Save profile `aboutEmoji` to conversation if (isFieldDefined(profile.aboutEmoji)) { if (updatedDecryptionKey != null) { const decrypted = decryptField(profile.aboutEmoji, updatedDecryptionKey); - c.set('aboutEmoji', formatTextField(decrypted)); + c.set({ aboutEmoji: formatTextField(decrypted) }); } } else { - c.unset('aboutEmoji'); + c.set({ aboutEmoji: undefined }); } // Step #: Save profile `phoneNumberSharing` to conversation @@ -681,10 +681,10 @@ async function doGetProfile( // It should be one byte, but be conservative about it and // set `sharingPhoneNumber` to `false` in all cases except [0x01]. const sharingPhoneNumber = decrypted.length === 1 && decrypted[0] === 1; - c.set('sharingPhoneNumber', sharingPhoneNumber); + c.set({ sharingPhoneNumber }); } } else { - c.unset('sharingPhoneNumber'); + c.set({ sharingPhoneNumber: undefined }); } // Step #: Save our own `paymentAddress` to Storage @@ -697,7 +697,7 @@ async function doGetProfile( if (profile.capabilities != null) { c.set({ capabilities: profile.capabilities }); } else { - c.unset('capabilities'); + c.set({ capabilities: undefined }); } // Step #: Save our own `observedCapabilities` to Storage and trigger sync if changed @@ -752,7 +752,7 @@ async function doGetProfile( })), }); } else { - c.unset('badges'); + c.set({ badges: undefined }); } // Step #: Save updated (or clear if missing) profile `credential` to conversation @@ -771,7 +771,7 @@ async function doGetProfile( log.warn( `${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 if ( diff --git a/ts/services/storage.ts b/ts/services/storage.ts index f0db9865496..b6b2bb0c404 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -85,6 +85,7 @@ import { fromPniUuidBytesOrUntaggedString } from '../util/ServiceId'; import { isDone as isRegistrationDone } from '../util/registration'; import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue'; import { isMockEnvironment } from '../environment'; +import { validateConversation } from '../util/validateConversation'; 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) { - const conversation = conversations.models[i]; + const conversation = conversations[i]; let identifierType; let storageRecord; @@ -267,10 +268,12 @@ async function generateManifest( let shouldDrop = false; let dropReason: string | undefined; - const validationError = conversation.validate(); - if (validationError) { + const validationErrorString = validateConversation( + conversation.attributes + ); + if (validationErrorString) { shouldDrop = true; - dropReason = `local validation error=${validationError}`; + dropReason = `local validation error=${validationErrorString}`; } else if (conversation.isUnregisteredAndStale()) { shouldDrop = true; dropReason = 'unregistered and stale'; @@ -294,7 +297,7 @@ async function generateManifest( `dropping contact=${recordID} ` + `due to ${dropReason}` ); - conversation.unset('storageID'); + conversation.set({ storageID: undefined }); deleteKeys.add(droppedID); continue; } @@ -1267,7 +1270,7 @@ async function processManifest( const localVersions = new Map(); let localRecordCount = 0; - const conversations = window.getConversations(); + const conversations = window.ConversationController.getAll(); conversations.forEach((conversation: ConversationModel) => { const storageID = conversation.get('storageID'); if (storageID) { @@ -1387,44 +1390,45 @@ async function processManifest( // new storageID for that record, and upload. // This might happen if a device pushes a manifest which doesn't contain // the keys that we have in our local database. - window.getConversations().forEach((conversation: ConversationModel) => { - const storageID = conversation.get('storageID'); - if (storageID && !remoteKeys.has(storageID)) { - const storageVersion = conversation.get('storageVersion'); - const missingKey = redactStorageID( - storageID, - 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' + window.ConversationController.getAll().forEach( + (conversation: ConversationModel) => { + const storageID = conversation.get('storageID'); + if (storageID && !remoteKeys.has(storageID)) { + const storageVersion = conversation.get('storageVersion'); + const missingKey = redactStorageID( + storageID, + storageVersion, + conversation ); - conversation.setUnregistered({ - timestamp: Date.now() - getMessageQueueTime(), - fromStorageService: true, - // Saving below - shouldSave: false, - }); - } else { - log.info( - `process(${version}): localKey=${missingKey} ` + - 'was not in remote manifest' - ); + // 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 + 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 { @@ -2192,10 +2196,12 @@ export async function eraseAllStorageServiceState({ window.reduxActions.user.eraseStorageServiceState(); // Conversations. These properties are not present in redux. - window.getConversations().forEach(conversation => { - conversation.unset('storageID'); - conversation.unset('needsStorageServiceSync'); - conversation.unset('storageUnknownFields'); + window.ConversationController.getAll().forEach(conversation => { + conversation.set({ + storageID: undefined, + needsStorageServiceSync: undefined, + storageUnknownFields: undefined, + }); }); // Then make sure outstanding conversation saves are flushed @@ -2290,7 +2296,7 @@ export const runStorageServiceSyncJob = debounce( await sync({ reason }); // Notify listeners about sync completion - window.Whisper.events.trigger('storageService:syncComplete'); + window.Whisper.events.emit('storageService:syncComplete'); }, `sync v${window.storage.get('manifestVersion')}` ) diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 69c2438b3af..df3023b31df 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -212,7 +212,7 @@ function addUnknownFields( // If the record doesn't have unknown fields attached but we have them // saved locally then we need to clear it out details.push('clearing unknown fields'); - conversation.unset('storageUnknownFields'); + conversation.set({ storageUnknownFields: undefined }); } } @@ -1487,9 +1487,10 @@ export async function mergeAccountRecord( } if (pinnedConversations) { - const modelPinnedConversations = window - .getConversations() - .filter(convo => Boolean(convo.get('isPinned'))); + const modelPinnedConversations = + window.ConversationController.getAll().filter(convo => + Boolean(convo.get('isPinned')) + ); const modelPinnedConversationIds = modelPinnedConversations.map(convo => convo.get('id') diff --git a/ts/services/username.ts b/ts/services/username.ts index 511fdc78aa3..e83f238bff2 100644 --- a/ts/services/username.ts +++ b/ts/services/username.ts @@ -210,7 +210,7 @@ async function updateUsernameAndSyncProfile( ): Promise { 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); try { diff --git a/ts/services/writeProfile.ts b/ts/services/writeProfile.ts index 020f10dc214..4cae4668061 100644 --- a/ts/services/writeProfile.ts +++ b/ts/services/writeProfile.ts @@ -135,7 +135,7 @@ export async function writeProfile( maybeProfileAvatarUpdate = { profileAvatar: undefined }; } - // Update backbone, update DB, run storage service upload + // Update model, update DB, run storage service upload model.set({ about: aboutText, aboutEmoji, diff --git a/ts/shims/contactVerification.ts b/ts/shims/contactVerification.ts index eb7857006a9..0de5b868e85 100644 --- a/ts/shims/contactVerification.ts +++ b/ts/shims/contactVerification.ts @@ -2,14 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-only export async function toggleVerification(id: string): Promise { - const contact = window.getConversations().get(id); + const contact = window.ConversationController.get(id); if (contact) { await contact.toggleVerified(); } } export async function reloadProfiles(id: string): Promise { - const contact = window.getConversations().get(id); + const contact = window.ConversationController.get(id); if (contact) { await contact.getProfiles(); } diff --git a/ts/shims/events.ts b/ts/shims/events.ts index d69262d4758..9f1436e1e01 100644 --- a/ts/shims/events.ts +++ b/ts/shims/events.ts @@ -8,7 +8,7 @@ import { explodePromise } from '../util/explodePromise'; // Matching Whisper.events.trigger API // eslint-disable-next-line @typescript-eslint/no-explicit-any export function trigger(name: string, ...rest: Array): void { - window.Whisper.events.trigger(name, ...rest); + window.Whisper.events.emit(name, ...rest); } export const waitForEvent = ( diff --git a/ts/signal.ts b/ts/signal.ts index 4865d527ea3..dda19da8bd6 100644 --- a/ts/signal.ts +++ b/ts/signal.ts @@ -7,7 +7,6 @@ import type { ReadonlyDeep } from 'type-fest'; import * as Crypto from './Crypto'; import * as Curve from './Curve'; -import { start as conversationControllerStart } from './ConversationController'; import * as Groups from './groups'; import OS from './util/os/osMain'; import { isProduction } from './util/version'; @@ -486,8 +485,6 @@ export const setup = (options: { Components, Crypto, Curve, - // Note: used in test/index.html, and not type-checked! - conversationControllerStart, Groups, Migrations, OS, diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 9ddf3082598..7e0e154ab20 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -699,10 +699,6 @@ type ReadableInterface = { getAllConversations: () => Array; getAllConversationIds: () => Array; - getAllGroupsInvolvingServiceId: ( - serviceId: ServiceIdString - ) => Array; - getGroupSendCombinedEndorsementExpiration: (groupId: string) => number | null; getGroupSendEndorsementsData: ( groupId: string diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 6533b23dc38..869103ebcad 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -372,7 +372,6 @@ export const DataReader: ServerReadableInterface = { getAllConversations, getAllConversationIds, - getAllGroupsInvolvingServiceId, getGroupSendCombinedEndorsementExpiration, getGroupSendEndorsementsData, @@ -1945,27 +1944,6 @@ function getAllConversationIds(db: ReadableDB): Array { return rows.map(row => row.id); } -function getAllGroupsInvolvingServiceId( - db: ReadableDB, - serviceId: ServiceIdString -): Array { - 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( db: ReadableDB, { diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index fc135bbf91b..1fb89bb3f94 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -900,8 +900,10 @@ function addPendingAttachment( const conversation = window.ConversationController.get(conversationId); if (conversation) { - conversation.attributes.draftAttachments = nextAttachments; - conversation.attributes.draftChanged = true; + conversation.set({ + draftAttachments: nextAttachments, + draftChanged: true, + }); drop(DataWriter.updateConversation(conversation.attributes)); } }; @@ -1202,8 +1204,10 @@ function removeAttachment( const conversation = window.ConversationController.get(conversationId); if (conversation) { - conversation.attributes.draftAttachments = nextAttachments; - conversation.attributes.draftChanged = true; + conversation.set({ + draftAttachments: nextAttachments, + draftChanged: true, + }); await DataWriter.updateConversation(conversation.attributes); } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index d8eb2701f80..884029d310c 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -1541,12 +1541,10 @@ async function getAvatarsAndUpdateConversation( const nextAvatars = getNextAvatarsData(avatars, nextAvatarId); // We don't save buffers to the db, but we definitely want it in-memory so // we don't have to re-generate them. - // - // Mutating here because we don't want to trigger a model change - // because we're updating redux here manually ourselves. Au revoir Backbone! - conversation.attributes.avatars = nextAvatars.map(avatarData => - omit(avatarData, ['buffer']) - ); + + conversation.set({ + avatars: nextAvatars.map(avatarData => omit(avatarData, ['buffer'])), + }); await DataWriter.updateConversation(conversation.attributes); return nextAvatars; @@ -1922,15 +1920,12 @@ function discardEditMessage( conversationId: string ): ThunkAction { return () => { - window.ConversationController.get(conversationId)?.set( - { - draftEditMessage: undefined, - draftBodyRanges: undefined, - draft: undefined, - quotedMessageId: undefined, - }, - { unset: true } - ); + window.ConversationController.get(conversationId)?.set({ + draftEditMessage: undefined, + draftBodyRanges: undefined, + draft: undefined, + quotedMessageId: undefined, + }); }; } @@ -2036,7 +2031,7 @@ function generateNewGroupLink( /** * 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. * * In practice, it's similar to an already-connected thunk action. Later on we will @@ -2229,9 +2224,8 @@ function myProfileChanged( avatarUpdateOptions ); - // writeProfile above updates the backbone model which in turn updates - // redux through it's on:change event listener. Once we lose Backbone - // we'll need to manually sync these new changes. + // writeProfile above updates the model which in turn updates + // redux through it's on:change event listener. // We just want to clear whatever error was there before: dispatch({ @@ -2267,7 +2261,7 @@ function removeCustomColorOnConversations( ): ThunkAction { return async dispatch => { const conversationsToUpdate: Array = []; - window.getConversations().forEach(conversation => { + window.ConversationController.getAll().forEach(conversation => { if (conversation.get('customColorId') === colorId) { conversation.set({ conversationColor: undefined, @@ -2301,7 +2295,7 @@ function resetAllChatColors(): ThunkAction< // Calling this with no args unsets all the colors in the db await DataWriter.updateAllConversationColors(); - window.getConversations().forEach(conversation => { + window.ConversationController.getAll().forEach(conversation => { conversation.set({ conversationColor: undefined, customColor: undefined, diff --git a/ts/state/ducks/stickers.ts b/ts/state/ducks/stickers.ts index 7a0cddbc02b..e81ffb66e5e 100644 --- a/ts/state/ducks/stickers.ts +++ b/ts/state/ducks/stickers.ts @@ -182,7 +182,7 @@ function stickerPackAdded( ): StickerPackAddedAction { 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 ( status === 'error' && attemptedStatus === 'installed' && @@ -336,7 +336,7 @@ function stickerPackUpdated( ): StickerPackUpdatedAction { 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 ( status === 'error' && attemptedStatus === 'installed' && diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index 93d9962f59e..5c49e511b7b 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -113,7 +113,7 @@ export function getInitialState( } export function generateConversationsState(): ConversationsStateType { - const convoCollection = window.getConversations(); + const convoCollection = window.ConversationController.getAll(); const formattedConversations = convoCollection.map(conversation => conversation.format() ); diff --git a/ts/state/initializeRedux.ts b/ts/state/initializeRedux.ts index 2a11ace4845..90bd7130273 100644 --- a/ts/state/initializeRedux.ts +++ b/ts/state/initializeRedux.ts @@ -44,7 +44,7 @@ export function initializeRedux(data: ReduxInitData): void { window.reduxStore = store; // 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 = { accounts: bindActionCreators(actionCreators.accounts, store.dispatch), app: bindActionCreators(actionCreators.app, store.dispatch), diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 64b7543a9c3..201da258097 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -834,7 +834,7 @@ export const getComposeSelectedContacts = createSelector( // What needs to happen to pull that selector logic here? // 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 -// Backbone-based prop-generation functions expect to get Conversation information +// model-based prop-generation functions expect to get Conversation information // directly via ConversationController export function _conversationSelector( conversation?: ConversationType diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index 2093a32e4be..90d1a71ea79 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -100,8 +100,7 @@ async function uploadProfile({ lastName: string; }): Promise { const us = window.ConversationController.getOurConversationOrThrow(); - us.set('profileName', firstName); - us.set('profileFamilyName', lastName); + us.set({ profileName: firstName, profileFamilyName: lastName }); us.captureChange('standaloneProfile'); await DataWriter.updateConversation(us.attributes); diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx index 00b2e06fd89..4de0d52ccc1 100644 --- a/ts/state/smart/Preferences.tsx +++ b/ts/state/smart/Preferences.tsx @@ -577,7 +577,7 @@ export function SmartPreferences(): JSX.Element | null { createItemsAccess('call-ringtone-notification', true); const [hasCountMutedConversations, onCountMutedConversationsChange] = createItemsAccess('badge-count-muted-conversations', false, () => { - window.Whisper.events.trigger('updateUnreadCount'); + window.Whisper.events.emit('updateUnreadCount'); }); const [hasHideMenuBar, onHideMenuBarChange] = createItemsAccess( 'hide-menu-bar', diff --git a/ts/test-electron/backbone/reliable_trigger_test.ts b/ts/test-electron/backbone/reliable_trigger_test.ts deleted file mode 100644 index f21b349f55b..00000000000 --- a/ts/test-electron/backbone/reliable_trigger_test.ts +++ /dev/null @@ -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 = []; - model.on('event', (...eventArgs) => { - args = eventArgs; - }); - - model.trigger('event', 1); - - assert.strictEqual(args[0], 1); - }); - it('calls clients with 2 args', () => { - let args: Array = []; - 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 = []; - 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 = []; - 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); - }); - }); -}); diff --git a/ts/test-electron/models/conversations_test.ts b/ts/test-electron/models/conversations_test.ts index 80b6850c26a..7736b8428ea 100644 --- a/ts/test-electron/models/conversations_test.ts +++ b/ts/test-electron/models/conversations_test.ts @@ -10,6 +10,7 @@ import { IMAGE_PNG } from '../../types/MIME'; import { generateAci, generatePni } from '../../types/ServiceId'; import { MessageModel } from '../../models/messages'; import { DurationInSeconds } from '../../util/durations'; +import { ConversationModel } from '../../models/conversations'; describe('Conversations', () => { async function resetConversationController(): Promise { @@ -32,7 +33,7 @@ describe('Conversations', () => { it('updates lastMessage even in race conditions with db', async () => { // Creating a fake conversation - const conversation = new window.Whisper.Conversation({ + const conversation = new ConversationModel({ avatars: [], id: generateUuid(), e164: '+15551234567', @@ -111,7 +112,7 @@ describe('Conversations', () => { it('only produces attachments on a quote with an image', async () => { // Creating a fake conversation - const conversation = new window.Whisper.Conversation({ + const conversation = new ConversationModel({ avatars: [], id: generateUuid(), e164: '+15551234567', diff --git a/ts/test-electron/services/MessageCache_test.ts b/ts/test-electron/services/MessageCache_test.ts index 2f09138fcda..960ab6385ee 100644 --- a/ts/test-electron/services/MessageCache_test.ts +++ b/ts/test-electron/services/MessageCache_test.ts @@ -86,8 +86,8 @@ describe('MessageCache', () => { }); }); - describe('register: syncing with backbone', () => { - it('backbone to redux', () => { + describe('register: syncing with models', () => { + it('model to redux', () => { const message1 = new MessageModel({ conversationId: 'xyz', 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({ conversationId: 'xyz', id: uuid(), diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index bc4161809dc..57ca34d9e14 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -130,7 +130,7 @@ describe('both/state/ducks/conversations', () => { sinonSandbox = sinon.createSandbox(); - sinonSandbox.stub(window.Whisper.events, 'trigger'); + sinonSandbox.stub(window.Whisper.events, 'emit'); createGroupStub = sinon.stub(); }); diff --git a/ts/test-electron/updateConversationsWithUuidLookup_test.ts b/ts/test-electron/updateConversationsWithUuidLookup_test.ts index a6991a2ba89..88179db6d11 100644 --- a/ts/test-electron/updateConversationsWithUuidLookup_test.ts +++ b/ts/test-electron/updateConversationsWithUuidLookup_test.ts @@ -68,7 +68,7 @@ describe('updateConversationsWithUuidLookup', () => { return { conversation: convoUuid, mergePromises: [] }; } - convoE164.unset('e164'); + convoE164.set({ e164: undefined }); convoUuid.updateE164(e164); return { conversation: convoUuid, mergePromises: [] }; } diff --git a/ts/test-mock/benchmarks/call_history_search_bench.ts b/ts/test-mock/benchmarks/call_history_search_bench.ts index b9c44ef6c0c..25737fdffc0 100644 --- a/ts/test-mock/benchmarks/call_history_search_bench.ts +++ b/ts/test-mock/benchmarks/call_history_search_bench.ts @@ -140,6 +140,9 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { const CallsTabDetailsTitle = CallsTabDetails.locator( '.ConversationDetailsHeader__title' ); + const AnyCallListAvatar = CallsTabSidebar.locator( + '.CallsList__ItemAvatar' + ).first(); debug('waiting for unread badge to hit correct value', unreadCount); await CallsNavTabUnread.getByText(`${unreadCount} unread`).waitFor(); @@ -147,6 +150,9 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { debug('opening calls tab'); await CallsNavTab.click(); + await CreateCallLink.waitFor(); + await AnyCallListAvatar.waitFor(); + async function measure(runId: number): Promise { // setup const searchContact = contacts[runId % contacts.length]; @@ -182,6 +188,7 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { await NewCallDetailsTitle.waitFor(); await SearchBar.clear(); await CreateCallLink.waitFor(); + await AnyCallListAvatar.waitFor(); // measure const end = Date.now(); diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts index fb2fac0a34a..942b9e018ec 100644 --- a/ts/textsecure/SocketManager.ts +++ b/ts/textsecure/SocketManager.ts @@ -365,7 +365,7 @@ export class SocketManager extends EventListener { error instanceof LibSignalErrorBase && error.code === ErrorCode.AppExpired ) { - window.Whisper.events.trigger('httpResponse499'); + window.Whisper.events.emit('httpResponse499'); return; } else if ( error instanceof LibSignalErrorBase && diff --git a/ts/textsecure/UpdateKeysListener.ts b/ts/textsecure/UpdateKeysListener.ts index c10963a7665..e811a442405 100644 --- a/ts/textsecure/UpdateKeysListener.ts +++ b/ts/textsecure/UpdateKeysListener.ts @@ -64,7 +64,7 @@ export class UpdateKeysListener { (error.code === 422 || error.code === 403) ) { log.error(`run: Got a ${error.code} uploading PNI keys; unlinking`); - window.Whisper.events.trigger('unlinkAndDisconnect'); + window.Whisper.events.emit('unlinkAndDisconnect'); } else { const errorString = error instanceof HTTPError diff --git a/ts/textsecure/Utils.ts b/ts/textsecure/Utils.ts index 9dd44d65c1f..99bbd5b698f 100644 --- a/ts/textsecure/Utils.ts +++ b/ts/textsecure/Utils.ts @@ -5,7 +5,7 @@ import type { HTTPError } from './Errors'; export async function handleStatusCode(status: number): Promise { if (status === 499) { - window.Whisper.events.trigger('httpResponse499'); + window.Whisper.events.emit('httpResponse499'); } } diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 14d34c54047..54e69c67862 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -470,7 +470,7 @@ async function _promiseAjax( if (!unauthenticated && response.status === 401) { 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', () => { - window.Whisper.events.trigger('socketStatusChange'); + window.Whisper.events.emit('socketStatusChange'); }); socketManager.on('online', () => { - window.Whisper.events.trigger('online'); + window.Whisper.events.emit('online'); }); socketManager.on('offline', () => { - window.Whisper.events.trigger('offline'); + window.Whisper.events.emit('offline'); }); socketManager.on('authError', () => { - window.Whisper.events.trigger('unlinkAndDisconnect'); + window.Whisper.events.emit('unlinkAndDisconnect'); }); socketManager.on('firstEnvelope', incoming => { - window.Whisper.events.trigger('firstEnvelope', incoming); + window.Whisper.events.emit('firstEnvelope', incoming); }); socketManager.on('serverAlerts', alerts => { diff --git a/ts/textsecure/storage/User.ts b/ts/textsecure/storage/User.ts index 8854f7a82b5..93beba6ffbe 100644 --- a/ts/textsecure/storage/User.ts +++ b/ts/textsecure/storage/User.ts @@ -58,7 +58,7 @@ export class User { ]); // Notify redux about phone number change - window.Whisper.events.trigger('userChanged', true); + window.Whisper.events.emit('userChanged', true); } public getNumber(): string | undefined { diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts index d846190fef1..f5dcf5963e3 100644 --- a/ts/util/callDisposition.ts +++ b/ts/util/callDisposition.ts @@ -1262,10 +1262,12 @@ async function saveCallHistory({ ); }); - conversation.set( - 'active_at', - Math.max(conversation.get('active_at') ?? 0, callHistory.timestamp) - ); + conversation.set({ + active_at: Math.max( + conversation.get('active_at') ?? 0, + callHistory.timestamp + ), + }); if (canConversationBeUnarchived(conversation.attributes)) { conversation.setArchived(false); diff --git a/ts/util/checkOurPniIdentityKey.ts b/ts/util/checkOurPniIdentityKey.ts index 879e0eb306f..5af465e4556 100644 --- a/ts/util/checkOurPniIdentityKey.ts +++ b/ts/util/checkOurPniIdentityKey.ts @@ -15,20 +15,20 @@ export async function checkOurPniIdentityKey(): Promise { const { pni: remotePni } = await server.whoami(); if (remotePni !== ourPni) { log.warn(`remote pni mismatch, ${remotePni} != ${ourPni}`); - window.Whisper.events.trigger('unlinkAndDisconnect'); + window.Whisper.events.emit('unlinkAndDisconnect'); return; } const localKeyPair = await window.storage.protocol.getIdentityKeyPair(ourPni); if (!localKeyPair) { log.warn(`no local key pair for ${ourPni}, unlinking`); - window.Whisper.events.trigger('unlinkAndDisconnect'); + window.Whisper.events.emit('unlinkAndDisconnect'); return; } const { identityKey: remoteKey } = await server.getKeysForServiceId(ourPni); if (!constantTimeEqual(localKeyPair.publicKey.serialize(), remoteKey)) { log.warn(`local/remote key mismatch for ${ourPni}, unlinking`); - window.Whisper.events.trigger('unlinkAndDisconnect'); + window.Whisper.events.emit('unlinkAndDisconnect'); } } diff --git a/ts/util/cleanup.ts b/ts/util/cleanup.ts index eee32546db0..c5e513d78b9 100644 --- a/ts/util/cleanup.ts +++ b/ts/util/cleanup.ts @@ -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 * with deleteMessageData. */ export function cleanupMessageFromMemory(message: MessageAttributesType): void { diff --git a/ts/util/getSignalConnections.ts b/ts/util/getSignalConnections.ts index 8f25be696a3..42991770329 100644 --- a/ts/util/getSignalConnections.ts +++ b/ts/util/getSignalConnections.ts @@ -26,7 +26,7 @@ export function isSignalConnection( } export function getSignalConnections(): Array { - return window - .getConversations() - .filter(conversation => isSignalConnection(conversation.attributes)); + return window.ConversationController.getAll().filter(conversation => + isSignalConnection(conversation.attributes) + ); } diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts index 57022fb5b90..4e8d9629758 100644 --- a/ts/util/handleMessageSend.ts +++ b/ts/util/handleMessageSend.ts @@ -116,7 +116,7 @@ function processError(error: unknown): void { log.warn( `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)); } } diff --git a/ts/util/onDeviceNameChangeSync.ts b/ts/util/onDeviceNameChangeSync.ts index f2811d620b1..60e3098fff9 100644 --- a/ts/util/onDeviceNameChangeSync.ts +++ b/ts/util/onDeviceNameChangeSync.ts @@ -84,7 +84,7 @@ async function fetchAndUpdateDeviceName() { } await window.storage.user.setDeviceName(newName); - window.Whisper.events.trigger('deviceNameChanged'); + window.Whisper.events.emit('deviceNameChanged'); log.info( 'fetchAndUpdateDeviceName: successfully updated new device name locally' ); diff --git a/ts/util/onStoryRecipientUpdate.ts b/ts/util/onStoryRecipientUpdate.ts index 36a2c418af6..65681292245 100644 --- a/ts/util/onStoryRecipientUpdate.ts +++ b/ts/util/onStoryRecipientUpdate.ts @@ -214,7 +214,7 @@ export async function onStoryRecipientUpdate( }); if (handledMessages.length) { - window.Whisper.events.trigger('incrementProgress'); + window.Whisper.events.emit('incrementProgress'); confirm(); } }) diff --git a/ts/util/sendStoryMessage.ts b/ts/util/sendStoryMessage.ts index a84e0f6a2f4..df93910b895 100644 --- a/ts/util/sendStoryMessage.ts +++ b/ts/util/sendStoryMessage.ts @@ -241,7 +241,7 @@ export async function sendStoryMessage( group => group.getStorySendMode() !== StorySendMode.Always ); for (const group of groupsToUpdate) { - group.set('storySendMode', StorySendMode.Always); + group.set({ storySendMode: StorySendMode.Always }); } void DataWriter.updateConversations( groupsToUpdate.map(group => group.attributes) diff --git a/ts/util/validateConversation.ts b/ts/util/validateConversation.ts index 041f9ec69b4..99339bb4b89 100644 --- a/ts/util/validateConversation.ts +++ b/ts/util/validateConversation.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ValidateConversationType } from '../model-types.d'; -import { isDirectConversation } from './whatTypeOfConversation'; +import { + isDirectConversation, + isGroupV1, + isGroupV2, +} from './whatTypeOfConversation'; import { isServiceIdString } from '../types/ServiceId'; export function validateConversation( @@ -22,6 +26,14 @@ export function validateConversation( return error; } + if ( + !isDirectConversation(attributes) && + !isGroupV1(attributes) && + !isGroupV2(attributes) + ) { + return 'Conversation is not direct, groupv1 or groupv2'; + } + return null; } diff --git a/ts/window.d.ts b/ts/window.d.ts index b1398cf572d..aa485ca043d 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -3,15 +3,14 @@ // 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 * as Backbone from 'backbone'; import type { SystemPreferences } from 'electron'; import type PQueue from 'p-queue/dist'; import type { assert } from 'chai'; import type { PhoneNumber, PhoneNumberFormat } from 'google-libphonenumber'; import type { MochaOptions } from 'mocha'; -import type { ConversationModelCollectionType } from './model-types.d'; import type { textsecure } from './textsecure'; import type { Storage } from './textsecure/Storage'; import type { @@ -34,7 +33,6 @@ import type { Receipt } from './types/Receipt'; import type { ConversationController } from './ConversationController'; import type { ReduxActions } from './state/types'; import type { createApp } from './state/roots/createApp'; -import type { ConversationModel } from './models/conversations'; import type { BatcherType } from './util/batcher'; import type { ConfirmationDialog } from './components/ConfirmationDialog'; import type { SignalProtocolStore } from './SignalProtocolStore'; @@ -183,7 +181,6 @@ export type SignalCoreType = { createApp: typeof createApp; }; }; - conversationControllerStart: () => void; challengeHandler?: ChallengeHandler; // Only for debugging in Dev Tools @@ -206,7 +203,6 @@ declare global { enterMouseMode: () => void; getAccountManager: () => AccountManager; getAppInstance: () => string | undefined; - getConversations: () => ConversationModelCollectionType; getBuildCreation: () => number; getBuildExpiration: () => number; getHostName: () => string; @@ -247,9 +243,6 @@ declare global { // The types below have been somewhat organized. See DESKTOP-4801 // ======================================================================== - // Backbone - Backbone: typeof Backbone; - ConversationController: ConversationController; Events: IPCEventsType; FontFace: typeof FontFace; @@ -331,10 +324,7 @@ declare global { } export type WhisperType = { - Conversation: typeof ConversationModel; - ConversationCollection: typeof ConversationModelCollectionType; - deliveryReceiptQueue: PQueue; deliveryReceiptBatcher: BatcherType; - events: Backbone.Events; + events: EventEmitter; }; diff --git a/ts/windows/main/phase1-ipc.ts b/ts/windows/main/phase1-ipc.ts index 49cb2b4a3c3..b3342af4141 100644 --- a/ts/windows/main/phase1-ipc.ts +++ b/ts/windows/main/phase1-ipc.ts @@ -1,9 +1,11 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import EventEmitter from 'node:events'; import { ipcRenderer as ipc } from 'electron'; 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 { parseIntWithFallback } from '../../util/parseIntWithFallback'; @@ -24,6 +26,15 @@ import { AggregatedStats } from '../../textsecure/WebsocketResources'; import { UNAUTHENTICATED_CHANNEL_NAME } from '../../textsecure/SocketManager'; import { isProduction } from '../../util/version'; 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'); @@ -47,6 +58,32 @@ window.Flags = Flags; window.RETRY_DELAY = false; +window.Whisper = { + events: new EventEmitter(), + deliveryReceiptQueue: new PQueue({ + concurrency: 1, + timeout: MINUTE * 30, + }), + deliveryReceiptBatcher: createBatcher({ + 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.getTitle = () => title; window.getAppInstance = () => config.appInstance; @@ -272,35 +309,35 @@ ipc.on('additional-log-data-request', async event => { }); ipc.on('open-settings-tab', () => { - window.Whisper.events.trigger('openSettingsTab'); + window.Whisper.events.emit('openSettingsTab'); }); ipc.on('set-up-as-new-device', () => { - window.Whisper.events.trigger('setupAsNewDevice'); + window.Whisper.events.emit('setupAsNewDevice'); }); ipc.on('set-up-as-standalone', () => { - window.Whisper.events.trigger('setupAsStandalone'); + window.Whisper.events.emit('setupAsStandalone'); }); ipc.on('stage-local-backup-for-import', () => { - window.Whisper.events.trigger('stageLocalBackupForImport'); + window.Whisper.events.emit('stageLocalBackupForImport'); }); ipc.on('challenge:response', (_event, response) => { - window.Whisper.events.trigger('challengeResponse', response); + window.Whisper.events.emit('challengeResponse', response); }); ipc.on('power-channel:suspend', () => { - window.Whisper.events.trigger('powerMonitorSuspend'); + window.Whisper.events.emit('powerMonitorSuspend'); }); ipc.on('power-channel:resume', () => { - window.Whisper.events.trigger('powerMonitorResume'); + window.Whisper.events.emit('powerMonitorResume'); }); ipc.on('power-channel:lock-screen', () => { - window.Whisper.events.trigger('powerMonitorLockScreen'); + window.Whisper.events.emit('powerMonitorLockScreen'); }); ipc.on( @@ -328,7 +365,7 @@ ipc.on('window:set-menu-options', (_event, options) => { if (!window.Whisper.events) { return; } - window.Whisper.events.trigger('setMenuOptions', options); + window.Whisper.events.emit('setMenuOptions', options); }); window.sendChallengeRequest = request => ipc.send('challenge:request', request); diff --git a/ts/windows/main/phase2-dependencies.ts b/ts/windows/main/phase2-dependencies.ts index e6af85308c9..77ed1f9631d 100644 --- a/ts/windows/main/phase2-dependencies.ts +++ b/ts/windows/main/phase2-dependencies.ts @@ -1,7 +1,6 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import Backbone from 'backbone'; import { PhoneNumberUtil, PhoneNumberFormat } from 'google-libphonenumber'; import * as moment from 'moment'; // @ts-expect-error -- no types @@ -21,7 +20,6 @@ const log = createLogger('phase2-dependencies'); initializeLogging(); window.nodeSetImmediate = setImmediate; -window.Backbone = Backbone; window.textsecure = textsecure; const { config } = window.SignalContext; diff --git a/ts/windows/main/start.ts b/ts/windows/main/start.ts index 9df71aaa264..34d6ab4d22b 100644 --- a/ts/windows/main/start.ts +++ b/ts/windows/main/start.ts @@ -1,7 +1,7 @@ // Copyright 2017 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { clone, has } from 'lodash'; +import { has } from 'lodash'; import { contextBridge } from 'electron'; import { createLogger } from '../../logging/log'; @@ -17,7 +17,6 @@ import '../preload'; import './phase2-dependencies'; import './phase3-post-signal'; import './phase4-test'; -import '../../backbone/reliable_trigger'; import type { CdsLookupOptionsType, @@ -25,7 +24,6 @@ import type { } from '../../textsecure/WebAPI'; import type { FeatureFlagType } from '../../window.d'; import type { StorageAccessType } from '../../types/Storage.d'; -import { start as startConversationController } from '../../ConversationController'; import { initMessageCleanup } from '../../services/messageStateCleanup'; import { Environment, getEnvironment } from '../../environment'; import { isProduction } from '../../util/version'; @@ -52,9 +50,7 @@ if (window.SignalContext.config.proxyUrl) { log.info('Using provided proxy url'); } -window.Whisper.events = clone(window.Backbone.Events); initMessageCleanup(); -startConversationController(); if ( !isProduction(window.SignalContext.getVersion()) || From cfcd9d5bf716a200d343274588bb49667c749bdc Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:46:23 -0700 Subject: [PATCH 31/54] Get Tailwind ready for general usage --- .eslint/rules/enforce-tw.js | 125 ++++++++++++++++++ .eslint/rules/enforce-tw.test.js | 42 ++++++ .eslint/rules/enforce-tw.worker.js | 50 +++++++ .eslintrc.js | 10 +- .gitignore | 1 + .prettierrc.js | 4 +- .storybook/main.ts | 2 +- .storybook/preview.tsx | 2 +- eslint-local-rules.js | 1 + package.json | 4 +- pnpm-lock.yaml | 31 ++--- stylesheets/_modules.scss | 4 - .../components/DisappearingTimerSelect.scss | 26 ---- stylesheets/manifest.scss | 1 - .../tailwind-config.css | 2 +- ts/axo/AxoButton.stories.tsx | 7 +- ts/axo/AxoButton.tsx | 56 ++++---- ts/axo/AxoContextMenu.stories.tsx | 7 +- ts/axo/AxoContextMenu.tsx | 5 +- ts/axo/AxoDropdownMenu.stories.tsx | 3 +- ts/axo/AxoDropdownMenu.tsx | 5 +- ts/axo/AxoSelect.stories.tsx | 31 +++-- ts/axo/AxoSelect.tsx | 38 +++--- ts/axo/AxoSymbol.stories.tsx | 39 ++++-- ts/axo/AxoSymbol.tsx | 11 +- ts/axo/_internal/AxoBaseMenu.tsx | 66 ++++----- ts/axo/{_internal/css.tsx => tw.tsx} | 10 +- ts/components/CallingLobby.tsx | 1 + ts/components/DisappearingTimerSelect.tsx | 42 +++--- .../conversation/TimelineDateHeader.tsx | 1 + .../conversation/TimelineFloatingHeader.tsx | 1 + .../media-gallery/LoadingIndicator.tsx | 1 + 32 files changed, 439 insertions(+), 190 deletions(-) create mode 100644 .eslint/rules/enforce-tw.js create mode 100644 .eslint/rules/enforce-tw.test.js create mode 100644 .eslint/rules/enforce-tw.worker.js delete mode 100644 stylesheets/components/DisappearingTimerSelect.scss rename ts/axo/tailwind.css => stylesheets/tailwind-config.css (99%) rename ts/axo/{_internal/css.tsx => tw.tsx} (66%) diff --git a/.eslint/rules/enforce-tw.js b/.eslint/rules/enforce-tw.js new file mode 100644 index 00000000000..08f5d229a99 --- /dev/null +++ b/.eslint/rules/enforce-tw.js @@ -0,0 +1,125 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +const { createSyncFn } = require('synckit'); + +const worker = createSyncFn(require.resolve('./enforce-tw.worker.js')); + +/** @type {import("eslint").Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + hasSuggestions: true, + fixable: true, + schema: [], + }, + create(context) { + function check(input, node) { + if (typeof input !== 'string') { + throw new Error(`Unexpected input ${input} for node type ${node.type}`); + } + + const tailwindClasses = worker(input.split(/\s+/)); + + for (const tailwindClass of tailwindClasses) { + const index = input.indexOf(tailwindClass) + 1; + const length = tailwindClass.length; + context.report({ + node, + loc: { + start: { + line: node.loc.start.line, + column: node.loc.start.column + index, + }, + end: { + line: node.loc.end.line, + column: node.loc.start.column + index + length, + }, + }, + message: 'Tailwind classes must be wrapped with tw()', + }); + } + } + + function traverse(node) { + if (node.type === 'Literal') { + if (typeof node.value === 'string') { + check(node.value, node); + } + // ignore other literals + } else if (node.type === 'TemplateLiteral') { + for (let element of node.quasis) { + traverse(element); + } + for (let expression of node.expressions) { + traverse(expression); + } + } else if (node.type === 'TemplateElement') { + check(node.value.cooked, node); + } else if (node.type === 'JSXExpressionContainer') { + traverse(node.expression); + } else if (node.type === 'ConditionalExpression') { + // ignore node.test + traverse(node.consequent); + traverse(node.alternate); + } else if (node.type === 'LogicalExpression') { + if (node.operator === '||' || node.operator === '??') { + traverse(node.left); + } + traverse(node.right); + } else if (node.type === 'BinaryExpression') { + if (node.operator === '+') { + traverse(node.left); + traverse(node.right); + } else { + throw new Error(`Unexpected binary operator: ${node.operator}`); + } + } else if (node.type === 'ObjectExpression') { + for (let prop of node.properties) { + traverse(prop); + } + } else if (node.type === 'Property') { + if (node.key.type === 'Identifier') { + if (!node.computed) { + check(node.key.name, node.key); + } + // ignore computed + } else if (node.key.type === 'Literal') { + traverse(node.key); + } else if (node.key.type === 'TemplateLiteral') { + traverse(node.key); + } else if (node.key.type === 'CallExpression') { + // ignore + } else { + throw new Error(`Unexpected property key type: ${node.key.type}`); + } + } else if (node.type === 'ArrayExpression') { + for (let element of node.elements) { + traverse(element); + } + } else if (node.type === 'Identifier') { + // ignore + } else if (node.type === 'CallExpression') { + // ignore + } else if (node.type === 'MemberExpression') { + // ignore + } else { + throw new Error(`Unexpected traverse node type: ${node.type}`); + } + } + + return { + CallExpression(node) { + if (node.callee.type !== 'Identifier') return; + if (node.callee.name !== 'classNames') return; + for (let arg of node.arguments) { + traverse(arg); + } + }, + JSXAttribute(node) { + if (node.name.type !== 'JSXIdentifier') return; + if (node.name.name !== 'className') return; + traverse(node.value); + }, + }; + }, +}; diff --git a/.eslint/rules/enforce-tw.test.js b/.eslint/rules/enforce-tw.test.js new file mode 100644 index 00000000000..b16eeb48937 --- /dev/null +++ b/.eslint/rules/enforce-tw.test.js @@ -0,0 +1,42 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +const rule = require('./enforce-tw'); +const RuleTester = require('eslint').RuleTester; + +const message = 'Tailwind classes must be wrapped with tw()'; + +// avoid triggering mocha's global leak detection +require('@typescript-eslint/parser'); + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, +}); + +ruleTester.run('enforce-tw', rule, { + valid: [ + { code: `classNames("foo")` }, + { code: `
` }, + { code: `tw("flex")` }, + ], + invalid: [ + { code: `classNames("flex")`, errors: [{ message }] }, + { code: `
`, errors: [{ message }] }, + { code: `
`, errors: [{ message }] }, + { code: `classNames("foo", "flex")`, errors: [{ message }] }, + { code: `classNames(cond ? "foo" : "flex")`, errors: [{ message }] }, + { code: `classNames(cond ? "flex" : "foo")`, errors: [{ message }] }, + { code: `classNames(cond && "flex")`, errors: [{ message }] }, + { code: `classNames(cond || "flex")`, errors: [{ message }] }, + { code: `classNames(cond ?? "flex")`, errors: [{ message }] }, + { code: `classNames("foo" + "flex")`, errors: [{ message }] }, + { code: `classNames("flex" + "foo")`, errors: [{ message }] }, + ], +}); diff --git a/.eslint/rules/enforce-tw.worker.js b/.eslint/rules/enforce-tw.worker.js new file mode 100644 index 00000000000..348bb628f79 --- /dev/null +++ b/.eslint/rules/enforce-tw.worker.js @@ -0,0 +1,50 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +const { runAsWorker } = require('synckit'); +const enhancedResolve = require('enhanced-resolve'); +const tailwind = require('tailwindcss'); +const path = require('node:path'); +const fs = require('node:fs'); + +const rootDir = path.join(__dirname, '../..'); +const tailwindCssPath = path.join(rootDir, 'stylesheets/tailwind-config.css'); + +async function loadDesignSystem() { + const tailwindCss = fs.readFileSync(tailwindCssPath, 'utf-8'); + const resolver = enhancedResolve.create.sync({ + conditionNames: ['style'], + extensions: ['.css'], + mainFields: ['style'], + }); + + const designSystem = await tailwind.__unstable__loadDesignSystem( + tailwindCss, + { + base: path.dirname(tailwindCssPath), + loadStylesheet(id, base) { + const resolved = resolver(base, id); + if (!resolved) { + return { base: '', content: '' }; + } + return { + base: path.dirname(resolved), + content: fs.readFileSync(resolved, 'utf-8'), + }; + }, + } + ); + + return designSystem; +} + +let cachedDesignSystem = null; + +runAsWorker(async classNames => { + cachedDesignSystem ??= await loadDesignSystem(); + const designSystem = cachedDesignSystem; + const css = designSystem.candidatesToCss(classNames); + const tailwindClassNames = classNames.filter((_, index) => { + return css.at(index) !== null; + }); + return tailwindClassNames; +}); diff --git a/.eslintrc.js b/.eslintrc.js index 3d4f0f5e1c2..a74991b1691 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -319,15 +319,19 @@ module.exports = { }, }, { - files: ['ts/axo/**/*.tsx'], + files: ['ts/**/*.tsx'], plugins: ['better-tailwindcss'], settings: { 'better-tailwindcss': { - entryPoint: './ts/axo/tailwind.css', - callees: ['css'], + entryPoint: './stylesheets/tailwind-config.css', + callees: ['tw'], + attributes: [], + variables: [], }, }, rules: { + 'local-rules/enforce-tw': 'error', + // stylistic: Enforce consistent line wrapping for tailwind classes. (recommended, autofix) 'better-tailwindcss/enforce-consistent-line-wrapping': 'off', // stylistic: Enforce a consistent order for tailwind classes. (recommended, autofix) diff --git a/.gitignore b/.gitignore index fc7032e043d..f4d3becb92e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ js/components.js js/util_worker.js libtextsecure/components.js stylesheets/*.css +!stylesheets/tailwind-config.css !stylesheets/webrtc_internals.css /storybook-static/ preload.bundle.* diff --git a/.prettierrc.js b/.prettierrc.js index d2c896f89f3..3500e8cd2c8 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -11,8 +11,8 @@ module.exports = { files: ['./ts/axo/**.tsx'], plugins: ['prettier-plugin-tailwindcss'], options: { - tailwindStylesheet: './ts/axo/tailwind.css', - tailwindFunctions: ['css'], + tailwindStylesheet: './stylesheets/tailwind-config.css', + tailwindFunctions: ['tw'], }, }, ], diff --git a/.storybook/main.ts b/.storybook/main.ts index 02b54337840..2457014b748 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -70,7 +70,7 @@ const config: StorybookConfig = { }); config.module!.rules!.push({ - test: /tailwind\.css$/, + test: /tailwind-config\.css$/, use: [ { loader: 'postcss-loader', diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 51cdbc3c185..cb9b3bbc092 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -6,7 +6,7 @@ import '../ts/window.d.ts'; import React, { StrictMode } from 'react'; import '../stylesheets/manifest.scss'; -import '../ts/axo/tailwind.css'; +import '../stylesheets/tailwind-config.css'; import * as styles from './styles.scss'; import messages from '../_locales/en/messages.json'; diff --git a/eslint-local-rules.js b/eslint-local-rules.js index 4d4aafdb4d8..11aedcb3fed 100644 --- a/eslint-local-rules.js +++ b/eslint-local-rules.js @@ -5,4 +5,5 @@ module.exports = { 'license-comments': require('./.eslint/rules/license-comments'), 'type-alias-readonlydeep': require('./.eslint/rules/type-alias-readonlydeep'), + 'enforce-tw': require('./.eslint/rules/enforce-tw'), }; diff --git a/package.json b/package.json index 00f05cff5bd..cd495ae5b25 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "build:esbuild:prod": "node scripts/esbuild.js --prod", "build:styles": "pnpm run \"/^build:styles:.*/\"", "build:styles:sass": "sass stylesheets/manifest.scss:stylesheets/manifest.css stylesheets/manifest_bridge.scss:stylesheets/manifest_bridge.css --fatal-deprecation=1.80.7", - "build:styles:tailwind": "tailwindcss -i ./ts/axo/tailwind.css -o ./stylesheets/tailwind.css", + "build:styles:tailwind": "tailwindcss -i ./stylesheets/tailwind-config.css -o ./stylesheets/tailwind.css", "build:electron": "electron-builder --config.extraMetadata.environment=$SIGNAL_ENV", "build:release": "cross-env SIGNAL_ENV=production pnpm run build:electron --config.directories.output=release", "build:release-win32-all": "pnpm run build:release --arm64 --x64", @@ -311,6 +311,7 @@ "electron-builder": "26.0.14", "electron-mocha": "13.0.1", "endanger": "7.0.4", + "enhanced-resolve": "5.18.3", "enquirer": "2.4.1", "esbuild": "0.24.0", "eslint": "8.56.0", @@ -355,6 +356,7 @@ "stylelint-config-recommended-scss": "14.1.0", "stylelint-use-logical-spec": "5.0.1", "svgo": "3.3.2", + "synckit": "0.11.11", "tailwindcss": "4.1.7", "terser-webpack-plugin": "5.3.10", "ts-node": "10.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1d6d57e749..ae772561236 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -663,6 +663,9 @@ importers: endanger: specifier: 7.0.4 version: 7.0.4(danger@12.3.3(encoding@0.1.13)) + enhanced-resolve: + specifier: 5.18.3 + version: 5.18.3 enquirer: specifier: 2.4.1 version: 2.4.1 @@ -795,6 +798,9 @@ importers: svgo: specifier: 3.3.2 version: 3.3.2 + synckit: + specifier: 0.11.11 + version: 0.11.11 tailwindcss: specifier: 4.1.7 version: 4.1.7 @@ -5683,12 +5689,8 @@ packages: endent@2.1.0: resolution: {integrity: sha512-r8VyPX7XL8U01Xgnb1CjZ3XV+z90cXIJ9JPE/R9SEC9vpw2P6CfsRPJmp20DppC5N7ZAMCmjYkJIa744Iyg96w==} - enhanced-resolve@5.18.1: - resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} - engines: {node: '>=10.13.0'} - - enhanced-resolve@5.18.2: - resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} enquirer@2.4.1: @@ -14397,7 +14399,7 @@ snapshots: '@parcel/watcher': 2.5.1 '@tailwindcss/node': 4.1.7 '@tailwindcss/oxide': 4.1.7 - enhanced-resolve: 5.18.1 + enhanced-resolve: 5.18.3 mri: 1.2.0 picocolors: 1.1.1 tailwindcss: 4.1.7 @@ -14405,7 +14407,7 @@ snapshots: '@tailwindcss/node@4.1.7': dependencies: '@ampproject/remapping': 2.3.0 - enhanced-resolve: 5.18.1 + enhanced-resolve: 5.18.3 jiti: 2.4.2 lightningcss: 1.30.1 magic-string: 0.30.17 @@ -16789,12 +16791,7 @@ snapshots: fast-json-parse: 1.0.3 objectorarray: 1.0.5 - enhanced-resolve@5.18.1: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.2.1 - - enhanced-resolve@5.18.2: + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 tapable: 2.2.1 @@ -17069,7 +17066,7 @@ snapshots: eslint-plugin-better-tailwindcss@3.7.2(patch_hash=a94affa4d170a27c4cfd44f7ac30ea11ae285cb4e270a5d930dd28cc79901b4f)(eslint@8.56.0)(tailwindcss@4.1.7): dependencies: '@eslint/css-tree': 3.6.3 - enhanced-resolve: 5.18.2 + enhanced-resolve: 5.18.3 eslint: 8.56.0 jiti: 2.4.2 postcss: 8.5.6 @@ -22035,7 +22032,7 @@ snapshots: tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 - enhanced-resolve: 5.18.2 + enhanced-resolve: 5.18.3 tapable: 2.2.1 tsconfig-paths: 4.2.0 @@ -22498,7 +22495,7 @@ snapshots: acorn: 8.14.0 browserslist: 4.24.4 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.1 + enhanced-resolve: 5.18.3 es-module-lexer: 1.6.0 eslint-scope: 5.1.1 events: 3.3.0 diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index b6056a57dcf..35121977b85 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5994,10 +5994,6 @@ button.module-calling-participants-list__contact { &__label { margin-inline-end: 12px; } - - .module-disappearing-timer-select { - width: 144px; - } } } } diff --git a/stylesheets/components/DisappearingTimerSelect.scss b/stylesheets/components/DisappearingTimerSelect.scss deleted file mode 100644 index c03c9c07b24..00000000000 --- a/stylesheets/components/DisappearingTimerSelect.scss +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -@use '../mixins'; -@use '../variables'; - -.module-disappearing-timer-select { - position: relative; - - &__info { - position: absolute; - - margin-top: 4px; - padding-inline-start: 14px; - - @include mixins.font-subtitle; - - @include mixins.light-theme { - color: variables.$color-gray-60; - } - - @include mixins.dark-theme { - color: variables.$color-gray-25; - } - } -} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 7388a648351..f2e41e88e90 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -95,7 +95,6 @@ @use 'components/DebugLogWindow.scss'; @use 'components/DeleteMessagesModal.scss'; @use 'components/DisappearingTimeDialog.scss'; -@use 'components/DisappearingTimerSelect.scss'; @use 'components/DonationErrorModal.scss'; @use 'components/DonationForm.scss'; @use 'components/DonationInterruptedModal.scss'; diff --git a/ts/axo/tailwind.css b/stylesheets/tailwind-config.css similarity index 99% rename from ts/axo/tailwind.css rename to stylesheets/tailwind-config.css index d43508958ba..cb683accca9 100644 --- a/ts/axo/tailwind.css +++ b/stylesheets/tailwind-config.css @@ -217,7 +217,7 @@ font-style: normal; font-weight: 300 400 700; font-display: block; - src: url('../../fonts/signal-symbols/SignalSymbolsVariable.woff2'); + src: url('../fonts/signal-symbols/SignalSymbolsVariable.woff2'); } @layer base { diff --git a/ts/axo/AxoButton.stories.tsx b/ts/axo/AxoButton.stories.tsx index f638af85634..63f02814642 100644 --- a/ts/axo/AxoButton.stories.tsx +++ b/ts/axo/AxoButton.stories.tsx @@ -8,6 +8,7 @@ import { _getAllAxoButtonSizes, AxoButton, } from './AxoButton'; +import { tw } from './tw'; export default { title: 'Axo/AxoButton', @@ -17,14 +18,14 @@ export function Basic(): JSX.Element { const variants = _getAllAxoButtonVariants(); const sizes = _getAllAxoButtonSizes(); return ( -
+
{sizes.map(size => { return (
-

Size: {size}

+

Size: {size}

{variants.map(variant => { return ( -
+
; +} as const satisfies Record; const AxoButtonVariants = { // default - secondary: css( + secondary: tw( AxoButtonTypes.default, 'bg-fill-secondary text-label-primary', 'pressed:bg-fill-secondary-pressed', 'disabled:text-label-disabled' ), - primary: css( + primary: tw( AxoButtonTypes.default, 'bg-color-fill-primary text-label-primary-on-color', 'pressed:bg-color-fill-primary-pressed', 'disabled:text-label-disabled-on-color' ), - affirmative: css( + affirmative: tw( AxoButtonTypes.default, 'bg-color-fill-affirmative text-label-primary-on-color', 'pressed:bg-color-fill-affirmative-pressed', 'disabled:text-label-disabled-on-color' ), - destructive: css( + destructive: tw( AxoButtonTypes.default, 'bg-color-fill-destructive text-label-primary-on-color', 'pressed:bg-color-fill-destructive-pressed', @@ -63,61 +63,61 @@ const AxoButtonVariants = { ), // subtle - 'subtle-primary': css( + 'subtle-primary': tw( AxoButtonTypes.subtle, 'text-color-label-primary', 'disabled:text-color-label-primary-disabled' ), - 'subtle-affirmative': css( + 'subtle-affirmative': tw( AxoButtonTypes.subtle, 'text-color-label-affirmative', 'disabled:text-color-label-affirmative-disabled' ), - 'subtle-destructive': css( + 'subtle-destructive': tw( AxoButtonTypes.subtle, 'text-color-label-destructive', 'disabled:text-color-label-destructive-disabled' ), // floating - 'floating-secondary': css( + 'floating-secondary': tw( AxoButtonTypes.floating, 'text-label-primary', 'disabled:text-label-disabled' ), - 'floating-primary': css( + 'floating-primary': tw( AxoButtonTypes.floating, 'text-color-label-primary', 'disabled:text-color-label-primary-disabled' ), - 'floating-affirmative': css( + 'floating-affirmative': tw( AxoButtonTypes.floating, 'text-color-label-affirmative', 'disabled:text-color-label-affirmative-disabled' ), - 'floating-destructive': css( + 'floating-destructive': tw( AxoButtonTypes.floating, 'text-color-label-destructive', 'disabled:text-color-label-destructive-disabled' ), // borderless - 'borderless-secondary': css( + 'borderless-secondary': tw( AxoButtonTypes.borderless, 'text-label-primary', 'disabled:text-label-disabled' ), - 'borderless-primary': css( + 'borderless-primary': tw( AxoButtonTypes.borderless, 'text-color-label-primary', 'disabled:text-color-label-primary-disabled' ), - 'borderless-affirmative': css( + 'borderless-affirmative': tw( AxoButtonTypes.borderless, 'text-color-label-affirmative', 'disabled:text-color-label-affirmative-disabled' ), - 'borderless-destructive': css( + 'borderless-destructive': tw( AxoButtonTypes.borderless, 'text-color-label-destructive', 'disabled:text-color-label-destructive-disabled' @@ -125,10 +125,10 @@ const AxoButtonVariants = { }; const AxoButtonSizes = { - large: css('px-4 py-2 type-body-medium font-medium'), - medium: css('px-3 py-1.5 type-body-medium font-medium'), - small: css('px-2 py-1 type-body-small font-medium'), -} as const satisfies Record; + large: tw('px-4 py-2 type-body-medium font-medium'), + medium: tw('px-3 py-1.5 type-body-medium font-medium'), + small: tw('px-2 py-1 type-body-small font-medium'), +} as const satisfies Record; type BaseButtonAttrs = Omit< ButtonHTMLAttributes, @@ -171,7 +171,7 @@ export const AxoButton: FC = memo(