From dc4f2c3c15c38191b1ddd821acd47ad359cdc65b Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Thu, 20 Nov 2025 11:08:43 -0600 Subject: [PATCH 01/53] Fix missing string Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> --- _locales/en/messages.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 4b8190cc7..ada2113c1 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1524,7 +1524,7 @@ }, "icu:Toast--PinnedMessageNotFound": { "messageformat": "Pinned message not found", - "description": "(Deleted 2025/11/19) Toast shown when user tries to view a pinned message that no longer exists" + "description": "Toast shown when user tries to view a pinned message that no longer exists" }, "icu:Toast--PollNotFound": { "messageformat": "Poll not found", From d5e5b937e63b1fd9ded672f21635fcb574a8903f Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:12:39 -0600 Subject: [PATCH 02/53] Optimize getUnreadByConversationAndMarkRead Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- ts/sql/Server.node.ts | 81 ++++++++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index 462953624..be232bff3 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -3334,16 +3334,32 @@ export function _storyIdPredicate( storyId: string | undefined, includeStoryReplies: boolean ): QueryFragment { + return _storyIdPredicateAndInfo(storyId, includeStoryReplies).predicate; +} + +function _storyIdPredicateAndInfo( + storyId: string | undefined, + includeStoryReplies: boolean +): { + predicate: QueryFragment; + isFilteringOnStoryId: boolean; +} { // This is unintuitive, but 'including story replies' means that we need replies to // lots of different stories. So, we remove the storyId check with a clause that will // always be true. We don't just return TRUE because we want to use our passed-in // $storyId parameter. if (includeStoryReplies && storyId === undefined) { - return sqlFragment`NULL IS NULL`; + return { + predicate: sqlFragment`NULL IS NULL`, + isFilteringOnStoryId: false, + }; } // In contrast to: replies to a specific story - return sqlFragment`storyId IS ${storyId ?? null}`; + return { + predicate: sqlFragment`storyId IS ${storyId ?? null}`, + isFilteringOnStoryId: true, + }; } function getUnreadByConversationAndMarkRead( @@ -3367,6 +3383,9 @@ function getUnreadByConversationAndMarkRead( return db.transaction(() => { const expirationStartTimestamp = Math.min(now, readAt ?? Infinity); + const { predicate: storyReplyFilter, isFilteringOnStoryId } = + _storyIdPredicateAndInfo(storyId, includeStoryReplies); + const updateExpirationFragment = sqlFragment` UPDATE messages INDEXED BY messages_conversationId_expirationStartTimestamp @@ -3374,7 +3393,7 @@ function getUnreadByConversationAndMarkRead( expirationStartTimestamp = ${expirationStartTimestamp} WHERE conversationId = ${conversationId} AND - (${_storyIdPredicate(storyId, includeStoryReplies)}) AND + ${storyReplyFilter} AND type IN ('incoming', 'poll-terminate') AND hasExpireTimer IS 1 AND received_at <= ${readMessageReceivedAt} @@ -3403,56 +3422,68 @@ function getUnreadByConversationAndMarkRead( updateLateExpirationStartParams ); + const indexToUse = isFilteringOnStoryId + ? sqlFragment`messages_unseen_with_story` + : sqlFragment`messages_unseen_no_story`; + const [selectQuery, selectParams] = sql` SELECT - ${sqlJoin(MESSAGE_COLUMNS_FRAGMENTS)} + id, readStatus, expirationStartTimestamp, sent_at, source, sourceServiceId, type FROM messages + INDEXED BY ${indexToUse} WHERE conversationId = ${conversationId} AND seenStatus = ${SeenStatus.Unseen} AND isStory = 0 AND - (${_storyIdPredicate(storyId, includeStoryReplies)}) AND + ${storyReplyFilter} AND received_at <= ${readMessageReceivedAt} ORDER BY received_at DESC, sent_at DESC; `; const rows = db .prepare(selectQuery) - .all(selectParams); - - const statusJsonPatch = JSON.stringify({ - readStatus: ReadStatus.Read, - seenStatus: SeenStatus.Seen, - }); + .all< + Pick< + MessageTypeUnhydrated, + | 'id' + | 'readStatus' + | 'expirationStartTimestamp' + | 'sent_at' + | 'source' + | 'sourceServiceId' + | 'type' + > + >(selectParams); const [updateStatusQuery, updateStatusParams] = sql` UPDATE messages + INDEXED BY ${indexToUse} SET readStatus = ${ReadStatus.Read}, - seenStatus = ${SeenStatus.Seen}, - json = json_patch(json, ${statusJsonPatch}) + seenStatus = ${SeenStatus.Seen} WHERE conversationId = ${conversationId} AND seenStatus = ${SeenStatus.Unseen} AND isStory = 0 AND - (${_storyIdPredicate(storyId, includeStoryReplies)}) AND + ${storyReplyFilter} AND received_at <= ${readMessageReceivedAt}; - `; + `; db.prepare(updateStatusQuery).run(updateStatusParams); - return hydrateMessages(db, rows).map(msg => { + return rows.map(msg => { return { - originalReadStatus: msg.readStatus, + originalReadStatus: + msg.readStatus == null ? undefined : (msg.readStatus as ReadStatus), readStatus: ReadStatus.Read, seenStatus: SeenStatus.Seen, - ...pick(msg, [ - 'expirationStartTimestamp', - 'id', - 'sent_at', - 'source', - 'sourceServiceId', - 'type', - ]), + id: msg.id, + expirationStartTimestamp: dropNull(msg.expirationStartTimestamp), + sent_at: msg.sent_at || 0, + source: dropNull(msg.source), + sourceServiceId: dropNull(msg.sourceServiceId) as + | ServiceIdString + | undefined, + type: msg.type as MessageType['type'], }; }); })(); From db6661614e28192bac60e130d257410d89319516 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:37:17 -0600 Subject: [PATCH 03/53] Remove "Paste & match style" on plain text inputs Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com> --- app/spell_check.main.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/spell_check.main.ts b/app/spell_check.main.ts index db897f716..c939e35e9 100644 --- a/app/spell_check.main.ts +++ b/app/spell_check.main.ts @@ -201,7 +201,15 @@ export const setup = ( template.push({ label: i18n('icu:editMenuPaste'), role: 'paste' }); } - if (editFlags.canPaste && !isImage) { + // It seems like `canEditRichly` is unreliable for `contenteditable` + // But `formControlType` will tell us if the element is some native + // input/button/etc, and any `contenteditable` should be "none" + const isLikelyRichTextEditor = params.formControlType === 'none'; + if ( + editFlags.canPaste && + (editFlags.canEditRichly || isLikelyRichTextEditor) && + !isImage + ) { template.push({ label: i18n('icu:editMenuPasteAndMatchStyle'), role: 'pasteAndMatchStyle', From c97362840b68ba5320cf596851d11b646c172d71 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:42:34 -0600 Subject: [PATCH 04/53] Remove references to missing .svg files Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> --- stylesheets/_mixins.scss | 18 +----------------- stylesheets/components/Quote.scss | 11 +++++------ 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index fff8c0e92..e3e54e66c 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -248,18 +248,6 @@ } $rtl-icon-map: ( - 'chevron-left-16.svg': 'chevron-right-16.svg', - 'chevron-right-16.svg': 'chevron-left-16.svg', - - 'chevron-left-20.svg': 'chevron-right-20.svg', - 'chevron-right-20.svg': 'chevron-left-20.svg', - - 'chevron-left-24.svg': 'chevron-right-24.svg', - 'chevron-right-24.svg': 'chevron-left-24.svg', - - 'arrow-left-32.svg': 'arrow-right-32.svg', - 'arrow-right-32.svg': 'arrow-left-32.svg', - // v3 icons 'chevron-left.svg': 'chevron-right.svg', 'chevron-right.svg': 'chevron-left.svg', @@ -269,11 +257,7 @@ $rtl-icon-map: ( 'chevron-right-compact-bold.svg': 'chevron-left-compact-bold.svg', 'chevron-right-bold.svg': 'chevron-left-bold.svg', 'arrow-left.svg': 'arrow-right.svg', - 'arrow-right.svg': 'arrow-left.svg', - - // Ignored cases: - 'phone-right-outline-24.svg': '', - 'phone-right-solid-24.svg': '', + 'arrow-right.svg': 'arrow-left.svg' ); @function get-rtl-svg($svg) { diff --git a/stylesheets/components/Quote.scss b/stylesheets/components/Quote.scss index 6f64de49c..dbfab1521 100644 --- a/stylesheets/components/Quote.scss +++ b/stylesheets/components/Quote.scss @@ -386,13 +386,12 @@ align-items: center; } .module-quote__generic-file__icon { - background: url('../images/file-gradient.svg'); - background-size: 75%; + background: url('../images/generic-file.svg'); + background-size: contain; + margin-inline-end: 6px; background-repeat: no-repeat; - height: 28px; - width: 36px; - margin-inline: -4px -6px; - margin-bottom: 5px; + height: 40px; + width: 30px; } .module-quote__generic-file__text { @include mixins.font-body-2; From a62f457ef4135c91ca8035a5edd0f72c67f02c1b Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:46:12 -0600 Subject: [PATCH 05/53] Update behavior for soon-to-expire attachments on backup CDN Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- ts/jobs/AttachmentDownloadManager.preload.ts | 11 ++++ ts/services/backups/export.preload.ts | 6 +- ts/services/backups/import.preload.ts | 12 +++- ts/services/backups/util/expiration.std.ts | 22 +++++++ .../AttachmentDownloadManager_test.preload.ts | 7 +++ .../util/downloadAttachment_test.preload.ts | 63 +++++++++++++++++++ ts/util/downloadAttachment.preload.ts | 24 ++++++- 7 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 ts/services/backups/util/expiration.std.ts diff --git a/ts/jobs/AttachmentDownloadManager.preload.ts b/ts/jobs/AttachmentDownloadManager.preload.ts index 39ea59055..712a90fa7 100644 --- a/ts/jobs/AttachmentDownloadManager.preload.ts +++ b/ts/jobs/AttachmentDownloadManager.preload.ts @@ -86,6 +86,7 @@ import { getMessageQueueTime as doGetMessageQueueTime } from '../util/getMessage import { JobCancelReason } from './types.std.js'; import { isAbortError } from '../util/isAbortError.std.js'; import { itemStorage } from '../textsecure/Storage.preload.js'; +import { calculateExpirationTimestamp } from '../util/expirationTimer.std.js'; const { noop, omit, throttle } = lodash; @@ -541,6 +542,8 @@ export async function runDownloadAttachmentJob({ maxAttachmentSizeInKib: options.maxAttachmentSizeInKib, maxTextAttachmentSizeInKib: options.maxTextAttachmentSizeInKib, dependencies, + messageExpiresAt: + calculateExpirationTimestamp(message.attributes) ?? null, }); if (result.downloadedVariant === AttachmentVariant.ThumbnailFromBackup) { @@ -714,10 +717,12 @@ export async function runDownloadAttachmentJobInner({ maxAttachmentSizeInKib, maxTextAttachmentSizeInKib, hasMediaBackups, + messageExpiresAt, dependencies, }: { job: AttachmentDownloadJobType; dependencies: Omit; + messageExpiresAt: number | null; } & RunDownloadAttachmentJobOptions): Promise { const { messageId, attachment, attachmentType } = job; @@ -778,6 +783,7 @@ export async function runDownloadAttachmentJobInner({ abortSignal, dependencies, logId, + messageExpiresAt, }); await addAttachmentToMessage( messageId, @@ -847,6 +853,7 @@ export async function runDownloadAttachmentJobInner({ abortSignal, hasMediaBackups, logId, + messageExpiresAt, }, }); @@ -924,6 +931,7 @@ export async function runDownloadAttachmentJobInner({ abortSignal, dependencies, logId, + messageExpiresAt, }); await addAttachmentToMessage( @@ -981,10 +989,12 @@ async function downloadBackupThumbnail({ abortSignal, logId, dependencies, + messageExpiresAt, }: { attachment: AttachmentType; abortSignal: AbortSignal; logId: string; + messageExpiresAt: number | null; dependencies: { downloadAttachment: typeof downloadAttachmentUtil; }; @@ -995,6 +1005,7 @@ async function downloadBackupThumbnail({ onSizeUpdate: noop, variant: AttachmentVariant.ThumbnailFromBackup, abortSignal, + messageExpiresAt, hasMediaBackups: true, logId, }, diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index 2f2724fcb..abdcc84b5 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -179,6 +179,7 @@ import { import { KIBIBYTE } from '../../types/AttachmentSize.std.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; import { ChatFolderType } from '../../types/ChatFolder.std.js'; +import { expiresTooSoonForBackup } from './util/expiration.std.js'; const { isNumber } = lodash; @@ -1353,8 +1354,9 @@ export class BackupExportStream extends Readable { } const expirationTimestamp = calculateExpirationTimestamp(message); - if (expirationTimestamp != null && expirationTimestamp <= this.#now + DAY) { - // Message expires too soon + if ( + expiresTooSoonForBackup({ messageExpiresAt: expirationTimestamp ?? null }) + ) { return undefined; } diff --git a/ts/services/backups/import.preload.ts b/ts/services/backups/import.preload.ts index 07eecab6a..24479dfce 100644 --- a/ts/services/backups/import.preload.ts +++ b/ts/services/backups/import.preload.ts @@ -166,6 +166,7 @@ import { updateBackupMediaDownloadProgress } from '../../util/updateBackupMediaD import { itemStorage } from '../../textsecure/Storage.preload.js'; import { ChatFolderType } from '../../types/ChatFolder.std.js'; import type { ChatFolderId, ChatFolder } from '../../types/ChatFolder.std.js'; +import { expiresTooSoonForBackup } from './util/expiration.std.js'; const { isNumber } = lodash; @@ -690,11 +691,16 @@ export class BackupImportStream extends Writable { const conversation = this.#conversations.get(attributes.conversationId); if (conversation && isConversationAccepted(conversation)) { const model = new MessageModel(attributes); + const attachmentsAreLikelyExpired = expiresTooSoonForBackup({ + messageExpiresAt: calculateExpirationTimestamp(attributes) ?? null, + }); + attachmentDownloadJobPromises.push( queueAttachmentDownloads(model, { - source: this.#isMediaEnabledBackup() - ? AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA - : AttachmentDownloadSource.BACKUP_IMPORT_NO_MEDIA, + source: + this.#isMediaEnabledBackup() && !attachmentsAreLikelyExpired + ? AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA + : AttachmentDownloadSource.BACKUP_IMPORT_NO_MEDIA, isManualDownload: false, }) ); diff --git a/ts/services/backups/util/expiration.std.ts b/ts/services/backups/util/expiration.std.ts new file mode 100644 index 000000000..6daaf4954 --- /dev/null +++ b/ts/services/backups/util/expiration.std.ts @@ -0,0 +1,22 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { DAY } from '../../../util/durations/constants.std.js'; + +// Messages that expire with 24 hours are excluded from regular backups (and their +// attachments may not be backed up), but they will be present in link & sync backups +const EXCLUDE_MESSAGE_FROM_BACKUP_IF_EXPIRING_WITHIN_MS = DAY; + +export function expiresTooSoonForBackup({ + messageExpiresAt, +}: { + messageExpiresAt: number | null; +}): boolean { + if (messageExpiresAt == null) { + return false; + } + return ( + messageExpiresAt <= + Date.now() + EXCLUDE_MESSAGE_FROM_BACKUP_IF_EXPIRING_WITHIN_MS + ); +} diff --git a/ts/test-electron/services/AttachmentDownloadManager_test.preload.ts b/ts/test-electron/services/AttachmentDownloadManager_test.preload.ts index 0107038ea..2ea0a3f2a 100644 --- a/ts/test-electron/services/AttachmentDownloadManager_test.preload.ts +++ b/ts/test-electron/services/AttachmentDownloadManager_test.preload.ts @@ -907,6 +907,7 @@ describe('AttachmentDownloadManager.runDownloadAttachmentJobInner', () => { abortSignal: abortController.signal, maxAttachmentSizeInKib: 100 * MEBIBYTE, maxTextAttachmentSizeInKib: 2 * MEBIBYTE, + messageExpiresAt: null, dependencies: { deleteAttachmentData, deleteDownloadData, @@ -946,6 +947,7 @@ describe('AttachmentDownloadManager.runDownloadAttachmentJobInner', () => { abortSignal: abortController.signal, maxAttachmentSizeInKib: 100 * MEBIBYTE, maxTextAttachmentSizeInKib: 2 * MEBIBYTE, + messageExpiresAt: null, dependencies: { deleteAttachmentData, deleteDownloadData, @@ -1002,6 +1004,7 @@ describe('AttachmentDownloadManager.runDownloadAttachmentJobInner', () => { abortSignal: abortController.signal, maxAttachmentSizeInKib: 100 * MEBIBYTE, maxTextAttachmentSizeInKib: 2 * MEBIBYTE, + messageExpiresAt: null, dependencies: { deleteAttachmentData, deleteDownloadData, @@ -1040,6 +1043,7 @@ describe('AttachmentDownloadManager.runDownloadAttachmentJobInner', () => { abortSignal: abortController.signal, maxAttachmentSizeInKib: 100 * MEBIBYTE, maxTextAttachmentSizeInKib: 2 * MEBIBYTE, + messageExpiresAt: null, dependencies: { deleteAttachmentData, deleteDownloadData, @@ -1079,6 +1083,7 @@ describe('AttachmentDownloadManager.runDownloadAttachmentJobInner', () => { abortSignal: abortController.signal, maxAttachmentSizeInKib: 100 * MEBIBYTE, maxTextAttachmentSizeInKib: 2 * MEBIBYTE, + messageExpiresAt: null, dependencies: { deleteAttachmentData, deleteDownloadData, @@ -1116,6 +1121,7 @@ describe('AttachmentDownloadManager.runDownloadAttachmentJobInner', () => { abortSignal: abortController.signal, maxAttachmentSizeInKib: 100 * MEBIBYTE, maxTextAttachmentSizeInKib: 2 * MEBIBYTE, + messageExpiresAt: null, dependencies: { deleteAttachmentData, deleteDownloadData, @@ -1172,6 +1178,7 @@ describe('AttachmentDownloadManager.runDownloadAttachmentJobInner', () => { abortSignal: abortController.signal, maxAttachmentSizeInKib: 100 * MEBIBYTE, maxTextAttachmentSizeInKib: 2 * MEBIBYTE, + messageExpiresAt: null, dependencies: { deleteAttachmentData, deleteDownloadData, diff --git a/ts/test-electron/util/downloadAttachment_test.preload.ts b/ts/test-electron/util/downloadAttachment_test.preload.ts index c005ce39d..2042bb2c4 100644 --- a/ts/test-electron/util/downloadAttachment_test.preload.ts +++ b/ts/test-electron/util/downloadAttachment_test.preload.ts @@ -22,6 +22,7 @@ import { toHex, toBase64 } from '../../Bytes.std.js'; import { generateAttachmentKeys } from '../../AttachmentCrypto.node.js'; import { getRandomBytes } from '../../Crypto.node.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; +import { DAY, HOUR } from '../../util/durations/constants.std.js'; const { noop } = lodash; @@ -56,6 +57,7 @@ describe('utils/downloadAttachment', () => { hasMediaBackups: true, onSizeUpdate: noop, abortSignal: abortController.signal, + messageExpiresAt: null, logId: '', }, dependencies: { @@ -89,6 +91,7 @@ describe('utils/downloadAttachment', () => { hasMediaBackups: true, onSizeUpdate: noop, abortSignal: abortController.signal, + messageExpiresAt: Date.now() + 2 * DAY, logId: '', }, dependencies: { @@ -111,6 +114,60 @@ describe('utils/downloadAttachment', () => { ]); }); + it('throw permanently missing error if attachment fails with 404 and expiring from backup tier', async () => { + const stubDownload = sinon + .stub() + .throws(new HTTPError('not found', { code: 404, headers: {} })); + + const attachment = backupableAttachment; + await assert.isRejected( + downloadAttachment({ + attachment, + options: { + hasMediaBackups: true, + onSizeUpdate: noop, + abortSignal: abortController.signal, + messageExpiresAt: Date.now() + HOUR, + logId: '', + }, + dependencies: { + downloadAttachmentFromLocalBackup: stubDownload, + downloadAttachmentFromServer: stubDownload, + }, + }), + AttachmentPermanentlyUndownloadableError + ); + + assert.equal(stubDownload.callCount, 2); + }); + + it('throw permanently missing error if attachment fails with 404 and expiring from backup tier, if no transit tier info', async () => { + const stubDownload = sinon + .stub() + .throws(new HTTPError('not found', { code: 404, headers: {} })); + + const attachment = { ...backupableAttachment, cdnKey: undefined }; + await assert.isRejected( + downloadAttachment({ + attachment, + options: { + hasMediaBackups: true, + onSizeUpdate: noop, + abortSignal: abortController.signal, + messageExpiresAt: Date.now() + HOUR, + logId: '', + }, + dependencies: { + downloadAttachmentFromLocalBackup: stubDownload, + downloadAttachmentFromServer: stubDownload, + }, + }), + AttachmentPermanentlyUndownloadableError + ); + + assert.equal(stubDownload.callCount, 1); + }); + it('throw permanently missing error if attachment fails with 403 from cdn 0 and no backup information', async () => { const stubDownload = sinon .stub() @@ -125,6 +182,7 @@ describe('utils/downloadAttachment', () => { hasMediaBackups: true, onSizeUpdate: noop, abortSignal: abortController.signal, + messageExpiresAt: null, logId: '', }, dependencies: { @@ -162,6 +220,7 @@ describe('utils/downloadAttachment', () => { hasMediaBackups: true, onSizeUpdate: noop, abortSignal: abortController.signal, + messageExpiresAt: null, logId: '', }, dependencies: { @@ -182,6 +241,7 @@ describe('utils/downloadAttachment', () => { hasMediaBackups: true, onSizeUpdate: noop, abortSignal: abortController.signal, + messageExpiresAt: null, logId: '', }, dependencies: { @@ -214,6 +274,7 @@ describe('utils/downloadAttachment', () => { hasMediaBackups: true, onSizeUpdate: noop, abortSignal: abortController.signal, + messageExpiresAt: null, logId: '', }, dependencies: { @@ -258,6 +319,7 @@ describe('utils/downloadAttachment', () => { hasMediaBackups: true, onSizeUpdate: noop, abortSignal: abortController.signal, + messageExpiresAt: null, logId: '', }, dependencies: { @@ -300,6 +362,7 @@ describe('utils/downloadAttachment', () => { hasMediaBackups: true, onSizeUpdate: noop, abortSignal: abortController.signal, + messageExpiresAt: null, logId: '', }, dependencies: { diff --git a/ts/util/downloadAttachment.preload.ts b/ts/util/downloadAttachment.preload.ts index 5af5bc641..228f01542 100644 --- a/ts/util/downloadAttachment.preload.ts +++ b/ts/util/downloadAttachment.preload.ts @@ -3,6 +3,7 @@ import { ErrorCode, LibSignalErrorBase } from '@signalapp/libsignal-client'; import { hasRequiredInformationForRemoteBackup, + hasRequiredInformationToDownloadFromTransitTier, wasImportedFromLocalBackup, } from './Attachment.std.js'; import { @@ -24,6 +25,7 @@ import type { ReencryptedAttachmentV2 } from '../AttachmentCrypto.node.js'; import * as RemoteConfig from '../RemoteConfig.dom.js'; import { ToastType } from '../types/Toast.dom.js'; import { isAbortError } from './isAbortError.std.js'; +import { expiresTooSoonForBackup } from '../services/backups/util/expiration.std.js'; const log = createLogger('downloadAttachment'); @@ -35,6 +37,7 @@ export async function downloadAttachment({ abortSignal, hasMediaBackups, logId: _logId, + messageExpiresAt, }, dependencies = { downloadAttachmentFromServer: doDownloadAttachment, @@ -48,6 +51,7 @@ export async function downloadAttachment({ abortSignal: AbortSignal; hasMediaBackups: boolean; logId: string; + messageExpiresAt: number | null; }; dependencies?: { downloadAttachmentFromServer: typeof doDownloadAttachment; @@ -61,7 +65,11 @@ export async function downloadAttachment({ const isBackupable = hasRequiredInformationForRemoteBackup(attachment); const mightBeOnBackupTierNow = isBackupable && hasMediaBackups; - const mightBeOnBackupTierInTheFuture = isBackupable; + const mightBeExpiredFromBackupTier = expiresTooSoonForBackup({ + messageExpiresAt, + }); + const mightBeOnBackupTierInTheFuture = + isBackupable && !mightBeExpiredFromBackupTier; if (wasImportedFromLocalBackup(attachment)) { log.info(`${logId}: Downloading attachment from local backup`); @@ -109,9 +117,13 @@ export async function downloadAttachment({ } const shouldFallbackToTransitTier = - variant !== AttachmentVariant.ThumbnailFromBackup; + variant !== AttachmentVariant.ThumbnailFromBackup && + hasRequiredInformationToDownloadFromTransitTier(attachment); - if (RemoteConfig.isEnabled('desktop.internalUser')) { + if ( + RemoteConfig.isEnabled('desktop.internalUser') && + !mightBeExpiredFromBackupTier + ) { window.reduxActions.toast.showToast({ toastType: ToastType.UnableToDownloadFromBackupTier, }); @@ -124,6 +136,12 @@ export async function downloadAttachment({ `${logId}: attachment not found on backup CDN`, shouldFallbackToTransitTier ? 'will try transit tier' : '' ); + + if (!mightBeOnBackupTierInTheFuture && !shouldFallbackToTransitTier) { + throw new AttachmentPermanentlyUndownloadableError( + `HTTP ${error.code}` + ); + } } else { // We also just log this error instead of throwing, since we want to still try to // find it on the attachment tier. From 3b9459b683dcae29a22ff09ce4bc48a867628fbb Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:16:28 -0600 Subject: [PATCH 06/53] Fix zoom reset on app init by removing preferred size handler Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> --- app/main.main.ts | 1 - ts/services/ZoomFactorService.main.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/app/main.main.ts b/app/main.main.ts index 34eb460a2..e5e94300a 100644 --- a/app/main.main.ts +++ b/app/main.main.ts @@ -220,7 +220,6 @@ const defaultWebPrefs = { getEnvironment() !== Environment.PackagedApp || !isProduction(app.getVersion()), spellcheck: false, - enablePreferredSizeMode: true, }; const DISABLE_IPV6 = process.argv.some(arg => arg === '--disable-ipv6'); diff --git a/ts/services/ZoomFactorService.main.ts b/ts/services/ZoomFactorService.main.ts index e20ca3896..0141a4f5d 100644 --- a/ts/services/ZoomFactorService.main.ts +++ b/ts/services/ZoomFactorService.main.ts @@ -117,14 +117,12 @@ export class ZoomFactorService extends EventEmitter { return; } - window.webContents.on('preferred-size-changed', onWindowChange); window.webContents.on('zoom-changed', onWindowChange); this.on('zoomFactorChanged', onServiceChange); this.#isListeningForZoom = true; }; const stopListenForZoomEvents = () => { - window.webContents.off('preferred-size-changed', onWindowChange); window.webContents.off('zoom-changed', onWindowChange); this.off('zoomFactorChanged', onServiceChange); this.#isListeningForZoom = false; From ee23bfa3f93054be71429299bdae5cdc53895a15 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:16:43 -0600 Subject: [PATCH 07/53] Media Gallery improvements Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> --- ts/components/Lightbox.dom.stories.tsx | 16 ++++++ ts/components/StoryViewsNRepliesModal.dom.tsx | 1 + ts/components/conversation/Message.dom.tsx | 8 ++- .../MessageDetail.dom.stories.tsx | 1 + .../conversation/Quote.dom.stories.tsx | 1 + .../conversation/Timeline.dom.stories.tsx | 1 + .../TimelineMessage.dom.stories.tsx | 1 + .../AudioListItem.dom.stories.tsx | 1 + .../media-gallery/AudioListItem.dom.tsx | 29 +++++++++- .../DocumentListItem.dom.stories.tsx | 1 + .../media-gallery/DocumentListItem.dom.tsx | 10 +++- .../media-gallery/ListItem.dom.tsx | 7 ++- .../MediaGridItem.dom.stories.tsx | 4 ++ .../media-gallery/utils/mocks.std.ts | 4 ++ .../media-gallery/utils/storybook.dom.tsx | 3 ++ ts/sql/Server.node.ts | 21 ++++++++ ts/state/ducks/audioPlayer.preload.ts | 17 +++--- ts/state/ducks/lightbox.preload.ts | 8 +++ ts/state/ducks/mediaGallery.preload.ts | 42 ++++++++------- ts/state/selectors/audioPlayer.preload.ts | 26 +++++---- ts/state/selectors/message.preload.ts | 2 + ts/state/smart/MediaItem.preload.tsx | 10 +++- .../groupMediaItemsByDate.std.ts | 6 +++ ts/types/MediaItem.std.ts | 8 ++- ts/util/Attachment.std.ts | 13 ----- ts/util/isVoiceMessagePlayed.std.ts | 54 +++++++++++++++++++ 26 files changed, 235 insertions(+), 60 deletions(-) create mode 100644 ts/util/isVoiceMessagePlayed.std.ts diff --git a/ts/components/Lightbox.dom.stories.tsx b/ts/components/Lightbox.dom.stories.tsx index 3ac906f49..7a1c7b541 100644 --- a/ts/components/Lightbox.dom.stories.tsx +++ b/ts/components/Lightbox.dom.stories.tsx @@ -58,6 +58,10 @@ function createMediaItem( // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }, ...overrideProps, }; @@ -110,6 +114,10 @@ export function Multimedia(): JSX.Element { // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }, }, { @@ -130,6 +138,10 @@ export function Multimedia(): JSX.Element { // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }, }, createMediaItem({ @@ -170,6 +182,10 @@ export function MissingMedia(): JSX.Element { // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }, }, ], diff --git a/ts/components/StoryViewsNRepliesModal.dom.tsx b/ts/components/StoryViewsNRepliesModal.dom.tsx index 84f5f0ab9..4613b5da3 100644 --- a/ts/components/StoryViewsNRepliesModal.dom.tsx +++ b/ts/components/StoryViewsNRepliesModal.dom.tsx @@ -675,6 +675,7 @@ function ReplyOrReactionMessage({ id={reply.id} interactionMode="mouse" isSpoilerExpanded={isSpoilerExpanded} + isVoiceMessagePlayed={false} messageExpanded={messageExpanded} readStatus={reply.readStatus} renderingContext="StoryViewsNRepliesModal" diff --git a/ts/components/conversation/Message.dom.tsx b/ts/components/conversation/Message.dom.tsx index 73b0c87dc..494e7db2a 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -70,7 +70,6 @@ import { isGIF, isImage, isImageAttachment, - isPlayed, isVideo, } from '../../util/Attachment.std.js'; import type { EmbeddedContactForUIType } from '../../types/EmbeddedContact.std.js'; @@ -250,6 +249,7 @@ export type PropsData = { isSelectMode: boolean; isSMS: boolean; isSpoilerExpanded?: Record; + isVoiceMessagePlayed: boolean; canEndPoll?: boolean; direction: DirectionType; timestamp: number; @@ -1145,11 +1145,11 @@ export class Message extends React.PureComponent { i18n, id, isSticker, + isVoiceMessagePlayed, kickOffAttachmentDownload, markAttachmentAsCorrupted, pushPanelForConversation, quote, - readStatus, renderAudioAttachment, renderingContext, retryMessageSend, @@ -1282,8 +1282,6 @@ export class Message extends React.PureComponent { } if (isAttachmentAudio) { - const played = isPlayed(direction, status, readStatus); - return renderAudioAttachment({ i18n, buttonRef: this.audioButtonRef, @@ -1299,7 +1297,7 @@ export class Message extends React.PureComponent { expirationTimestamp, id, conversationId, - played, + played: isVoiceMessagePlayed, pushPanelForConversation, status, textPending: textAttachment?.pending, diff --git a/ts/components/conversation/MessageDetail.dom.stories.tsx b/ts/components/conversation/MessageDetail.dom.stories.tsx index b710d3207..651b6f080 100644 --- a/ts/components/conversation/MessageDetail.dom.stories.tsx +++ b/ts/components/conversation/MessageDetail.dom.stories.tsx @@ -36,6 +36,7 @@ const defaultMessage: MessageDataPropsType = { isSelectMode: false, isSMS: false, isSpoilerExpanded: {}, + isVoiceMessagePlayed: false, previews: [], readStatus: ReadStatus.Read, status: 'sent', diff --git a/ts/components/conversation/Quote.dom.stories.tsx b/ts/components/conversation/Quote.dom.stories.tsx index d6a89d9d0..120165cc8 100644 --- a/ts/components/conversation/Quote.dom.stories.tsx +++ b/ts/components/conversation/Quote.dom.stories.tsx @@ -108,6 +108,7 @@ const defaultMessageProps: TimelineMessagesProps = { isSelectMode: false, isSMS: false, isSpoilerExpanded: {}, + isVoiceMessagePlayed: false, toggleSelectMessage: action('toggleSelectMessage'), cancelAttachmentDownload: action('default--cancelAttachmentDownload'), kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'), diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index 3b7e48f3f..3873fc397 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -71,6 +71,7 @@ function mockMessageTimelineItem( isSelectMode: false, isSMS: false, isSpoilerExpanded: {}, + isVoiceMessagePlayed: false, previews: [], readStatus: ReadStatus.Read, canRetryDeleteForEveryone: true, diff --git a/ts/components/conversation/TimelineMessage.dom.stories.tsx b/ts/components/conversation/TimelineMessage.dom.stories.tsx index b059204ba..7e0ca9bab 100644 --- a/ts/components/conversation/TimelineMessage.dom.stories.tsx +++ b/ts/components/conversation/TimelineMessage.dom.stories.tsx @@ -286,6 +286,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ isTapToView: overrideProps.isTapToView, isTapToViewError: overrideProps.isTapToViewError, isTapToViewExpired: overrideProps.isTapToViewExpired, + isVoiceMessagePlayed: false, cancelAttachmentDownload: action('cancelAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), diff --git a/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx b/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx index 23b1f7b0f..be42d9a50 100644 --- a/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx @@ -27,6 +27,7 @@ export function Multiple(): JSX.Element { i18n={i18n} key={index} mediaItem={mediaItem} + isPlayed={Math.random() > 0.5} authorTitle="Alice" onClick={action('onClick')} onShowMessage={action('onShowMessage')} diff --git a/ts/components/conversation/media-gallery/AudioListItem.dom.tsx b/ts/components/conversation/media-gallery/AudioListItem.dom.tsx index 748768c7c..7b1fb06f0 100644 --- a/ts/components/conversation/media-gallery/AudioListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/AudioListItem.dom.tsx @@ -3,6 +3,8 @@ import React from 'react'; import { noop } from 'lodash'; +import type { Transition } from 'framer-motion'; +import { motion } from 'framer-motion'; import { tw } from '../../../axo/tw.dom.js'; import { formatFileSize } from '../../../util/formatFileSize.std.js'; @@ -17,6 +19,13 @@ const BAR_COUNT = 7; const MAX_PEAK_HEIGHT = 22; const MIN_PEAK_HEIGHT = 2; +const DOT_TRANSITION: Transition = { + type: 'spring', + mass: 0.5, + stiffness: 350, + damping: 20, +}; + export type DataProps = Readonly<{ mediaItem: MediaItemType; onClick: (status: AttachmentStatusType['state']) => void; @@ -29,12 +38,14 @@ export type Props = DataProps & i18n: LocalizerType; theme?: ThemeType; authorTitle: string; + isPlayed: boolean; }>; export function AudioListItem({ i18n, mediaItem, authorTitle, + isPlayed, onClick, onShowMessage, }: Props): JSX.Element { @@ -95,13 +106,29 @@ export function AudioListItem({ ); + const dot = ( + + ); + return ( +
+ {subtitle.join(' · ')} +
+ {dot} + + } readyLabel={i18n('icu:startDownload')} onClick={onClick} onShowMessage={onShowMessage} diff --git a/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx b/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx index 71aa71d90..be577b4a3 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx @@ -27,6 +27,7 @@ export function Multiple(): JSX.Element { i18n={i18n} key={mediaItem.attachment.fileName} mediaItem={mediaItem} + authorTitle="Alice" onClick={action('onClick')} onShowMessage={action('onShowMessage')} /> diff --git a/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx b/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx index 6d51d110a..5890b9c31 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx @@ -17,6 +17,7 @@ import { ListItem } from './ListItem.dom.js'; export type Props = { i18n: LocalizerType; mediaItem: MediaItemType; + authorTitle: string; onClick: (status: AttachmentStatusType['state']) => void; onShowMessage: () => void; }; @@ -24,6 +25,7 @@ export type Props = { export function DocumentListItem({ i18n, mediaItem, + authorTitle, onClick, onShowMessage, }: Props): JSX.Element { @@ -50,12 +52,18 @@ export function DocumentListItem({ ); + const title = new Array(); + if (fileName) { + title.push(fileName); + } + title.push(authorTitle); + return ( } - title={fileName} + title={title.join(' · ')} subtitle={subtitle} readyLabel={i18n('icu:startDownload')} onClick={onClick} diff --git a/ts/components/conversation/media-gallery/ListItem.dom.tsx b/ts/components/conversation/media-gallery/ListItem.dom.tsx index 9826de28f..eff53cb24 100644 --- a/ts/components/conversation/media-gallery/ListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/ListItem.dom.tsx @@ -12,6 +12,7 @@ import { SpinnerV2 } from '../../SpinnerV2.dom.js'; import { tw } from '../../../axo/tw.dom.js'; import { AriaClickable } from '../../../axo/AriaClickable.dom.js'; import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js'; +import { UserText } from '../../UserText.dom.js'; import { useAttachmentStatus, type AttachmentStatusType, @@ -21,7 +22,7 @@ export type Props = { i18n: LocalizerType; mediaItem: GenericMediaItemType; thumbnail: React.ReactNode; - title: React.ReactNode; + title: string; subtitle: React.ReactNode; readyLabel: string; onClick: (status: AttachmentStatusType['state']) => void; @@ -129,7 +130,9 @@ export function ListItem({ >
{thumbnail}
-

{title}

+

+ +

{subtitle}
diff --git a/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx b/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx index aee61b9fe..0286b45ae 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx @@ -65,6 +65,10 @@ const createMediaItem = ( // Unused for now source: undefined, sourceServiceId: undefined, + readStatus: undefined, + isErased: false, + errors: undefined, + sendStateByConversationId: undefined, }, }); diff --git a/ts/components/conversation/media-gallery/utils/mocks.std.ts b/ts/components/conversation/media-gallery/utils/mocks.std.ts index 7e48f2764..3baeb688b 100644 --- a/ts/components/conversation/media-gallery/utils/mocks.std.ts +++ b/ts/components/conversation/media-gallery/utils/mocks.std.ts @@ -88,6 +88,10 @@ function createRandomMessage( // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }; } diff --git a/ts/components/conversation/media-gallery/utils/storybook.dom.tsx b/ts/components/conversation/media-gallery/utils/storybook.dom.tsx index 841958c22..24ba269e8 100644 --- a/ts/components/conversation/media-gallery/utils/storybook.dom.tsx +++ b/ts/components/conversation/media-gallery/utils/storybook.dom.tsx @@ -9,6 +9,7 @@ import type { PropsType } from '../../../../state/smart/MediaItem.preload.js'; import { getSafeDomain } from '../../../../types/LinkPreview.std.js'; import type { AttachmentStatusType } from '../../../../hooks/useAttachmentStatus.std.js'; import { missingCaseError } from '../../../../util/missingCaseError.std.js'; +import { isVoiceMessagePlayed } from '../../../../util/isVoiceMessagePlayed.std.js'; import { LinkPreviewItem } from '../LinkPreviewItem.dom.js'; import { MediaGridItem } from '../MediaGridItem.dom.js'; import { DocumentListItem } from '../DocumentListItem.dom.js'; @@ -31,6 +32,7 @@ export function MediaItem({ mediaItem, onItemClick }: PropsType): JSX.Element { sqlFragment` SELECT message_attachments.*, + messages.json -> '$.sendStateByConversationId' AS messageSendState, + messages.json -> '$.errors' AS messageErrors, + messages.isErased AS messageIsErased, + messages.readStatus AS messageReadStatus, messages.source AS messageSource, messages.sourceServiceId AS messageSourceServiceId FROM message_attachments @@ -5399,6 +5403,10 @@ function getSortedMedia( const results: Array< MessageAttachmentDBType & { + messageSendState: string | null; + messageErrors: string | null; + messageIsErased: number | null; + messageReadStatus: ReadStatus | null; messageSource: string | null; messageSourceServiceId: ServiceIdString | null; } @@ -5410,6 +5418,10 @@ function getSortedMedia( messageType, messageSource, messageSourceServiceId, + messageSendState, + messageErrors, + messageIsErased, + messageReadStatus, sentAt, receivedAt, receivedAtMs, @@ -5425,6 +5437,11 @@ function getSortedMedia( receivedAt, receivedAtMs: receivedAtMs ?? undefined, sentAt, + sendStateByConversationId: + messageSendState == null ? undefined : JSON.parse(messageSendState), + errors: messageErrors == null ? undefined : JSON.parse(messageErrors), + isErased: messageIsErased === 1, + readStatus: messageReadStatus ?? undefined, }, index: orderInMessage, attachment: convertAttachmentDBFieldsToAttachmentType(attachment), @@ -5487,6 +5504,10 @@ function getOlderLinkPreviews( receivedAt: message.received_at, receivedAtMs: message.received_at_ms ?? undefined, sentAt: message.sent_at, + errors: message.errors, + sendStateByConversationId: message.sendStateByConversationId, + readStatus: message.readStatus, + isErased: !!message.isErased, }, preview: message.preview[0], }; diff --git a/ts/state/ducks/audioPlayer.preload.ts b/ts/state/ducks/audioPlayer.preload.ts index 1bb4723a8..324bbc730 100644 --- a/ts/state/ducks/audioPlayer.preload.ts +++ b/ts/state/ducks/audioPlayer.preload.ts @@ -28,7 +28,6 @@ import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl.std.js'; import { assertDev } from '../../util/assert.std.js'; import { drop } from '../../util/drop.std.js'; import { Sound, SoundType } from '../../util/Sound.std.js'; -import { getMessageById } from '../../messages/getMessageById.preload.js'; import { DataReader } from '../../sql/Client.preload.js'; const stateChangeConfirmUpSound = new Sound({ @@ -110,12 +109,16 @@ async function getNextVoiceNote({ return undefined; } - const next = await getMessageById(results[0].message.id); - if (next == null) { - return undefined; - } - - return extractVoiceNoteForPlayback(next.attributes, ourConversationId); + const { message, attachment } = results[0]; + return extractVoiceNoteForPlayback( + { + ...message, + attachments: [attachment], + sent_at: message.sentAt, + received_at: message.receivedAt, + }, + ourConversationId + ); } // Actions diff --git a/ts/state/ducks/lightbox.preload.ts b/ts/state/ducks/lightbox.preload.ts index cb271f9ae..89a7d1e5d 100644 --- a/ts/state/ducks/lightbox.preload.ts +++ b/ts/state/ducks/lightbox.preload.ts @@ -233,6 +233,10 @@ function showLightboxForViewOnceMedia( sentAt: message.get('sent_at'), source: message.get('source'), sourceServiceId: message.get('sourceServiceId'), + isErased: !!message.get('isErased'), + readStatus: message.get('readStatus'), + sendStateByConversationId: message.get('sendStateByConversationId'), + errors: message.get('errors'), }, }, ]; @@ -338,6 +342,10 @@ function showLightbox(opts: { source: message.get('source'), sourceServiceId: message.get('sourceServiceId'), sentAt, + isErased: !!message.get('isErased'), + errors: message.get('errors'), + readStatus: message.get('readStatus'), + sendStateByConversationId: message.get('sendStateByConversationId'), }, type: 'media' as const, attachment: getPropsForAttachment( diff --git a/ts/state/ducks/mediaGallery.preload.ts b/ts/state/ducks/mediaGallery.preload.ts index b51ed5282..a100275a4 100644 --- a/ts/state/ducks/mediaGallery.preload.ts +++ b/ts/state/ducks/mediaGallery.preload.ts @@ -5,6 +5,7 @@ import lodash from 'lodash'; import type { ThunkAction } from 'redux-thunk'; import type { ReadonlyDeep } from 'type-fest'; +import type { ReadonlyMessageAttributesType } from '../../model-types.d.ts'; import { createLogger } from '../../logging/log.std.js'; import { DataReader } from '../../sql/Client.preload.js'; import type { @@ -111,6 +112,25 @@ function _sortItems< ]); } +function _cleanMessage( + message: ReadonlyMessageAttributesType +): MediaItemMessageType { + return { + id: message.id, + type: message.type, + source: message.source, + sourceServiceId: message.sourceServiceId, + conversationId: message.conversationId, + receivedAt: message.received_at, + receivedAtMs: message.received_at_ms, + sentAt: message.sent_at, + isErased: !!message.isErased, + errors: message.errors, + readStatus: message.readStatus, + sendStateByConversationId: message.sendStateByConversationId, + }; +} + function _cleanAttachments( type: 'media' | 'audio' | 'documents', rawMedia: ReadonlyArray @@ -428,16 +448,7 @@ export function reducer( return { index, attachment, - message: { - id: message.id, - type: message.type, - source: message.source, - sourceServiceId: message.sourceServiceId, - conversationId: message.conversationId, - receivedAt: message.received_at, - receivedAtMs: message.received_at_ms, - sentAt: message.sent_at, - }, + message: _cleanMessage(message), }; }); @@ -460,16 +471,7 @@ export function reducer( ? [ { preview: message.preview[0], - message: { - id: message.id, - type: message.type, - source: message.source, - sourceServiceId: message.sourceServiceId, - conversationId: message.conversationId, - receivedAt: message.received_at, - receivedAtMs: message.received_at_ms, - sentAt: message.sent_at, - }, + message: _cleanMessage(message), }, ] : [] diff --git a/ts/state/selectors/audioPlayer.preload.ts b/ts/state/selectors/audioPlayer.preload.ts index 2e3b11c06..699305464 100644 --- a/ts/state/selectors/audioPlayer.preload.ts +++ b/ts/state/selectors/audioPlayer.preload.ts @@ -8,11 +8,7 @@ import { getUserConversationId, getUserNumber, } from './user.std.js'; -import { - getMessagePropStatus, - getSource, - getSourceServiceId, -} from './message.preload.js'; +import { getSource, getSourceServiceId } from './message.preload.js'; import { getConversationByIdSelector, getConversations, @@ -26,7 +22,7 @@ import type { ReadonlyMessageAttributesType } from '../../model-types.d.ts'; import { getMessageIdForLogging } from '../../util/idForLogging.preload.js'; import * as Attachment from '../../util/Attachment.std.js'; import type { ActiveAudioPlayerStateType } from '../ducks/audioPlayer.preload.js'; -import { isPlayed } from '../../util/Attachment.std.js'; +import { isVoiceMessagePlayed } from '../../util/isVoiceMessagePlayed.std.js'; import type { ServiceIdString } from '../../types/ServiceId.std.js'; const log = createLogger('audioPlayer'); @@ -81,7 +77,20 @@ export const selectVoiceNoteTitle = createSelector( ); export function extractVoiceNoteForPlayback( - message: ReadonlyMessageAttributesType, + message: Pick< + ReadonlyMessageAttributesType, + | 'id' + | 'type' + | 'attachments' + | 'isErased' + | 'errors' + | 'readStatus' + | 'sendStateByConversationId' + | 'sent_at' + | 'received_at' + | 'source' + | 'sourceServiceId' + >, ourConversationId: string | undefined ): VoiceNoteForPlayback | undefined { const { type } = message; @@ -98,13 +107,12 @@ export function extractVoiceNoteForPlayback( const voiceNoteUrl = attachment.path ? getLocalAttachmentUrl(attachment) : undefined; - const status = getMessagePropStatus(message, ourConversationId); return { id: message.id, url: voiceNoteUrl, type, - isPlayed: isPlayed(type, status, message.readStatus), + isPlayed: isVoiceMessagePlayed(message, ourConversationId), messageIdForLogging: getMessageIdForLogging(message), sentAt: message.sent_at, receivedAt: message.received_at, diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index 1be04bcbd..a7464c480 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -87,6 +87,7 @@ import { getLocalAttachmentUrl, AttachmentDisposition, } from '../../util/getLocalAttachmentUrl.std.js'; +import { isVoiceMessagePlayed } from '../../util/isVoiceMessagePlayed.std.js'; import { isPermanentlyUndownloadable } from '../../jobs/AttachmentDownloadManager.preload.js'; import { getAccountSelector } from './accounts.std.js'; @@ -977,6 +978,7 @@ export const getPropsForMessage = ( isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid, isTapToViewExpired: isMessageTapToView && isIncoming(message) && message.isErased, + isVoiceMessagePlayed: isVoiceMessagePlayed(message, ourConversationId), readStatus: message.readStatus ?? ReadStatus.Read, selectedReaction, status: getMessagePropStatus(message, ourConversationId), diff --git a/ts/state/smart/MediaItem.preload.tsx b/ts/state/smart/MediaItem.preload.tsx index ed1312115..c90b43222 100644 --- a/ts/state/smart/MediaItem.preload.tsx +++ b/ts/state/smart/MediaItem.preload.tsx @@ -11,7 +11,12 @@ import { getSafeDomain } from '../../types/LinkPreview.std.js'; import type { GenericMediaItemType } from '../../types/MediaItem.std.js'; import type { AttachmentStatusType } from '../../hooks/useAttachmentStatus.std.js'; import { missingCaseError } from '../../util/missingCaseError.std.js'; -import { getIntl, getTheme } from '../selectors/user.std.js'; +import { isVoiceMessagePlayed } from '../../util/isVoiceMessagePlayed.std.js'; +import { + getIntl, + getTheme, + getUserConversationId, +} from '../selectors/user.std.js'; import { getConversationSelector } from '../selectors/conversations.dom.js'; import { useConversationsActions } from '../ducks/conversations.preload.js'; @@ -26,6 +31,7 @@ export const MediaItem = memo(function MediaItem({ }: PropsType) { const i18n = useSelector(getIntl); const theme = useSelector(getTheme); + const ourConversationId = useSelector(getUserConversationId); const getConversation = useSelector(getConversationSelector); const { showConversation } = useConversationsActions(); @@ -57,6 +63,7 @@ export const MediaItem = memo(function MediaItem({ { receivedAt: date.getTime(), receivedAtMs: date.getTime(), sentAt: date.getTime(), + + // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }, attachment: fakeAttachment({ fileName: 'fileName', diff --git a/ts/types/MediaItem.std.ts b/ts/types/MediaItem.std.ts index 0f94214c7..04242b427 100644 --- a/ts/types/MediaItem.std.ts +++ b/ts/types/MediaItem.std.ts @@ -1,7 +1,9 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { MessageAttributesType } from '../model-types.d.ts'; +import type { MessageAttributesType, CustomError } from '../model-types.d.ts'; +import type { SendStateByConversationId } from '../messages/MessageSendState.std.js'; +import type { ReadStatus } from '../messages/MessageReadStatus.std.js'; import type { AttachmentForUIType } from './Attachment.std.js'; import type { LinkPreviewForUIType } from './message/LinkPreviews.std.js'; import type { ServiceIdString } from './ServiceId.std.js'; @@ -15,6 +17,10 @@ export type MediaItemMessageType = Readonly<{ sentAt: number; source: string | undefined; sourceServiceId: ServiceIdString | undefined; + isErased: boolean; + sendStateByConversationId: SendStateByConversationId | undefined; + readStatus: ReadStatus | undefined; + errors: ReadonlyArray | undefined; }>; export type MediaItemType = { diff --git a/ts/util/Attachment.std.ts b/ts/util/Attachment.std.ts index 355890a75..43dee1624 100644 --- a/ts/util/Attachment.std.ts +++ b/ts/util/Attachment.std.ts @@ -24,8 +24,6 @@ import { } from './GoogleChrome.std.js'; import type { LocalizerType } from '../types/Util.std.js'; import { ThemeType } from '../types/Util.std.js'; -import { ReadStatus } from '../messages/MessageReadStatus.std.js'; -import type { MessageStatusType } from '../types/message/MessageStatus.std.js'; import { isMoreRecentThan } from './timestamp.std.js'; import { DAY } from './durations/index.std.js'; import { @@ -270,17 +268,6 @@ export function isAudio(attachments?: ReadonlyArray): boolean { ); } -export function isPlayed( - direction: 'outgoing' | 'incoming', - status: MessageStatusType | undefined, - readStatus: ReadStatus | undefined -): boolean { - if (direction === 'outgoing') { - return status === 'viewed'; - } - return readStatus === ReadStatus.Viewed; -} - export function canRenderAudio( attachments?: ReadonlyArray ): boolean { diff --git a/ts/util/isVoiceMessagePlayed.std.ts b/ts/util/isVoiceMessagePlayed.std.ts new file mode 100644 index 000000000..9332fc62b --- /dev/null +++ b/ts/util/isVoiceMessagePlayed.std.ts @@ -0,0 +1,54 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ReadonlyMessageAttributesType } from '../model-types.d.ts'; +import { isIncoming, isOutgoing } from '../messages/helpers.std.js'; +import { ReadStatus } from '../messages/MessageReadStatus.std.js'; +import { + isSent, + isViewed, + isMessageJustForMe, + getHighestSuccessfulRecipientStatus, +} from '../messages/MessageSendState.std.js'; + +export function isVoiceMessagePlayed( + message: Pick< + ReadonlyMessageAttributesType, + 'type' | 'isErased' | 'errors' | 'readStatus' | 'sendStateByConversationId' + >, + ourConversationId: string | undefined +): boolean { + if (message.isErased) { + return false; + } + + if (message.errors != null && message.errors.length > 0) { + return false; + } + + if (isIncoming(message)) { + return message.readStatus === ReadStatus.Viewed; + } + + if (isOutgoing(message)) { + const { sendStateByConversationId = {} } = message; + + if (isMessageJustForMe(sendStateByConversationId, ourConversationId)) { + return isSent( + getHighestSuccessfulRecipientStatus( + sendStateByConversationId, + undefined + ) + ); + } + + return isViewed( + getHighestSuccessfulRecipientStatus( + sendStateByConversationId, + ourConversationId + ) + ); + } + + return false; +} From 12cf169d7b267aa367aef4dc56ca3993ac4dec2a Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:20:17 -0600 Subject: [PATCH 08/53] Only fetch CDN object metadata for remote backups Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- ts/CI.preload.ts | 2 ++ ts/services/backups/index.preload.ts | 41 ++++++++++++++-------- ts/test-electron/backup/helpers.preload.ts | 1 + 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/ts/CI.preload.ts b/ts/CI.preload.ts index 5d92942c3..ee44c8b52 100644 --- a/ts/CI.preload.ts +++ b/ts/CI.preload.ts @@ -20,6 +20,7 @@ import { strictAssert } from './util/assert.std.js'; import { MessageModel } from './models/messages.preload.js'; import type { SocketStatuses } from './textsecure/SocketManager.preload.js'; import { itemStorage } from './textsecure/Storage.preload.js'; +import { BackupLevel } from './services/backups/types.std.js'; const log = createLogger('CI'); @@ -220,6 +221,7 @@ export function getCI({ } async function uploadBackup() { + await itemStorage.put('backupTier', BackupLevel.Paid); await backupsService.upload(); await AttachmentBackupManager.waitForIdle(); diff --git a/ts/services/backups/index.preload.ts b/ts/services/backups/index.preload.ts index 3ed176b2f..1ca301f3e 100644 --- a/ts/services/backups/index.preload.ts +++ b/ts/services/backups/index.preload.ts @@ -869,6 +869,11 @@ export class BackupsService { log.info('fetchAndSaveBackupCdnObjectMetadata: clearing existing metadata'); await DataWriter.clearAllBackupCdnObjectMetadata(); + strictAssert( + areRemoteBackupsTurnedOn(), + 'Remote backups must be turned on to fetch cdn metadata' + ); + let cursor: string | undefined; const PAGE_SIZE = 1000; let numObjects = 0; @@ -1081,25 +1086,33 @@ export class BackupsService { const start = Date.now(); try { + if (options.type === 'remote') { + strictAssert( + areRemoteBackupsTurnedOn(), + 'Remote backups must be turned on for a remote export' + ); + } + // TODO (DESKTOP-7168): Update mock-server to support this endpoint if (window.SignalCI || options.type === 'cross-client-integration-test') { strictAssert( isTestOrMockEnvironment(), - 'exportBackup: Plaintext backups can be exported only in test harness' + 'exportBackup: cross-client-integration tests must only be run in test harness' ); - } else if ( - !isOnline() && - (options.type === 'local-encrypted' || - options.type === 'plaintext-export') - ) { - log.info( - `exportBackup: Skipping CDN update; offline at type is ${options.type}` - ); - } else { - // We first fetch the latest info on what's on the CDN, since this affects the - // filePointers we will generate during export - log.info('exportBackup: Fetching latest backup CDN metadata'); - await this.fetchAndSaveBackupCdnObjectMetadata(); + } + + switch (options.type) { + case 'remote': + log.info('exportBackup: Fetching latest backup CDN metadata'); + await this.fetchAndSaveBackupCdnObjectMetadata(); + break; + case 'cross-client-integration-test': + case 'local-encrypted': + case 'plaintext-export': + // no need to fetch what's on backup CDN + break; + default: + throw missingCaseError(options); } const { aesKey, macKey } = getKeyMaterial(); diff --git a/ts/test-electron/backup/helpers.preload.ts b/ts/test-electron/backup/helpers.preload.ts index 2c04838fd..5522ea45d 100644 --- a/ts/test-electron/backup/helpers.preload.ts +++ b/ts/test-electron/backup/helpers.preload.ts @@ -240,6 +240,7 @@ export async function asymmetricRoundtripHarness( ourAci: OUR_ACI, postSaveUpdates, }); + await itemStorage.put('backupTier', options.backupLevel); await backupsService.exportToDisk(targetOutputFile, { type: 'remote', From 528471cfd9946dafa1f71f3e6c058c2b30655f4a Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:20:36 -0600 Subject: [PATCH 09/53] Drop invalid keyChange messages on export Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- ts/services/backups/export.preload.ts | 15 ++++++++++++--- ts/util/markConversationRead.preload.ts | 6 ++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index abdcc84b5..5343d3659 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -1902,12 +1902,21 @@ export class BackupExportStream extends Readable { const simpleUpdate = new Backups.SimpleChatUpdate(); simpleUpdate.type = Backups.SimpleChatUpdate.Type.IDENTITY_UPDATE; - if (message.key_changed) { + const conversation = window.ConversationController.get( + message.conversationId + ); + if ( + conversation && + isGroup(conversation.attributes) && + message.key_changed + ) { const target = window.ConversationController.get(message.key_changed); if (!target) { - throw new Error( - 'toChatItemUpdate/keyCahnge: key_changed conversation not found!' + log.warn( + 'toChatItemUpdate/keyChange: key_changed conversation not found!', + message.key_changed ); + return { kind: NonBubbleResultKind.Drop }; } // This will override authorId on the original chatItem patch.authorId = this.#getOrPushPrivateRecipient(target.attributes); diff --git a/ts/util/markConversationRead.preload.ts b/ts/util/markConversationRead.preload.ts index 303f28c00..609236151 100644 --- a/ts/util/markConversationRead.preload.ts +++ b/ts/util/markConversationRead.preload.ts @@ -181,11 +181,9 @@ export async function markConversationRead( return undefined; } + // This is expected for directionless messages which are inserted as Read but Unseen + // (e.g. keyChange) if (!isAciString(senderAci)) { - log.warn( - `${logId}: message sourceServiceId timestamp is not aci` + - `type=${messageSyncData.type}` - ); return undefined; } From 36290d6a71052bf4d5988831cfaaee28fb8166b4 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 24 Nov 2025 12:47:58 -0600 Subject: [PATCH 10/53] More Media Gallery fixes Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> --- ts/components/FileThumbnail.dom.tsx | 2 +- .../media-gallery/AttachmentSection.dom.tsx | 6 +++--- .../media-gallery/LinkPreviewItem.dom.tsx | 2 +- .../conversation/media-gallery/MediaGallery.dom.tsx | 11 ++++++++++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/ts/components/FileThumbnail.dom.tsx b/ts/components/FileThumbnail.dom.tsx index a4c7a7e4e..030cb67b7 100644 --- a/ts/components/FileThumbnail.dom.tsx +++ b/ts/components/FileThumbnail.dom.tsx @@ -18,7 +18,7 @@ export function FileThumbnail(props: PropsType): JSX.Element { className={tw( 'flex items-center justify-center', 'relative', - 'mx-1.5 h-10 w-7.5', + 'mx-0.75 h-10 w-7.5', 'bg-contain bg-center bg-no-repeat', 'bg-[url(../images/generic-file.svg)]' )} diff --git a/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx b/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx index c90971f45..5f1558067 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx @@ -75,7 +75,7 @@ export function AttachmentSection({ case 'media': return (
-

{header}

+

{header}

{verified.entries.map(mediaItem => { return ( @@ -94,8 +94,8 @@ export function AttachmentSection({ case 'audio': case 'link': return ( -
-

{header}

+
+

{header}

{verified.entries.map(mediaItem => { return ( diff --git a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx index 550b2d01c..aec3c95fd 100644 --- a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx +++ b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx @@ -64,7 +64,7 @@ export function LinkPreviewItem({
diff --git a/ts/components/conversation/media-gallery/MediaGallery.dom.tsx b/ts/components/conversation/media-gallery/MediaGallery.dom.tsx index 40598bb3c..5baa95456 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.dom.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.dom.tsx @@ -168,8 +168,17 @@ function MediaSection({ ); }); + const isGrid = mediaItems.at(0)?.type === 'media'; + return ( -
{sections}
+
+ {sections} +
); } From 607442fa6be14fcc38f81cf55d23d44192cc2184 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 24 Nov 2025 12:48:27 -0600 Subject: [PATCH 11/53] More Media Gallery fixes Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> From dc58b717f0c31f0455084ab1b64b770cf70329cf Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 24 Nov 2025 12:48:36 -0600 Subject: [PATCH 12/53] Drop recipient without any identifier Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- ts/services/backups/export.preload.ts | 76 +++++++++++++-------------- 1 file changed, 37 insertions(+), 39 deletions(-) diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index 5343d3659..a486c9ff1 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -183,7 +183,7 @@ import { expiresTooSoonForBackup } from './util/expiration.std.js'; const { isNumber } = lodash; -const log = createLogger('export'); +const log = createLogger('backupExport'); // Temporarily limited to preserve the received_at order const MAX_CONCURRENCY = 1; @@ -297,14 +297,14 @@ export class BackupExportStream extends Readable { await pauseWriteAccess(); try { await this.#unsafeRun(); - } catch (error) { - this.emit('error', error); - } finally { await resumeWriteAccess(); // TODO (DESKTOP-7344): Clear & add backup jobs in a single transaction const { type } = this.options; switch (type) { case 'remote': + log.info( + `Enqueuing ${this.#attachmentBackupJobs.length} remote attachment backup jobs` + ); await DataWriter.clearAllAttachmentBackupJobs(); await Promise.all( this.#attachmentBackupJobs.map(job => { @@ -325,17 +325,17 @@ export class BackupExportStream extends Readable { case 'plaintext-export': case 'local-encrypted': case 'cross-client-integration-test': - log.info( - `Type is ${this.options.type}, not doing anything with ${this.#attachmentBackupJobs.length} attachment jobs` - ); break; default: - // eslint-disable-next-line no-unsafe-finally throw missingCaseError(type); } - + log.info('finished successfully'); + } catch (error) { + await resumeWriteAccess(); + log.error('errored', toLogFormat(error)); + this.emit('error', error); + } finally { drop(AttachmentBackupManager.start()); - log.info('BackupExportStream: finished'); } })() ); @@ -530,7 +530,7 @@ export class BackupExportStream extends Readable { for (const { attributes } of window.ConversationController.getAll()) { if (isGroupV1(attributes)) { - log.warn('backups: skipping gv1 conversation'); + log.warn('skipping gv1 conversation'); continue; } @@ -545,7 +545,7 @@ export class BackupExportStream extends Readable { const index = pinnedConversationIds.indexOf(attributes.id); if (index === -1) { const convoId = getConversationIdForLogging(attributes); - log.warn(`backups: ${convoId} is pinned, but is not on the list`); + log.warn(`${convoId} is pinned, but is not on the list`); } pinnedOrder = Math.max(1, index + 1); } @@ -622,7 +622,7 @@ export class BackupExportStream extends Readable { const recipientId = this.#roomIdToRecipientId.get(roomId); if (!recipientId) { log.warn( - `backups: Dropping ad-hoc call; recipientId for roomId ${roomId.slice(-2)} not found` + `Dropping ad-hoc call; recipientId for roomId ${roomId.slice(-2)} not found` ); continue; } @@ -635,10 +635,7 @@ export class BackupExportStream extends Readable { try { callId = Long.fromString(callIdStr); } catch (error) { - log.warn( - 'backups: Dropping ad-hoc call; invalid callId', - toLogFormat(error) - ); + log.warn('Dropping ad-hoc call; invalid callId', toLogFormat(error)); continue; } @@ -719,7 +716,7 @@ export class BackupExportStream extends Readable { } else if (chatFolder.folderType === ChatFolderType.CUSTOM) { folderType = Backups.ChatFolder.FolderType.CUSTOM; } else { - log.warn('backups: Dropping chat folder; unknown folder type'); + log.warn('Dropping chat folder; unknown folder type'); continue; } @@ -808,7 +805,7 @@ export class BackupExportStream extends Readable { await this.#flush(); - log.warn('backups: final stats', { + log.warn('final stats', { ...this.#stats, attachmentBackupJobs: this.#attachmentBackupJobs.length, }); @@ -818,14 +815,14 @@ export class BackupExportStream extends Readable { const result = this.#jsonExporter.finish(); if (result?.errorMessage) { log.warn( - 'backups: jsonExporter.finish() returned validation error:', + 'jsonExporter.finish() returned validation error:', result.errorMessage ); } } catch (error) { // We only warn because this isn't that big of a deal - the export is complete. // All we need from the exporter at the end is any validation errors it found. - log.warn('backups: jsonExporter returned error', toLogFormat(error)); + log.warn('jsonExporter returned error', toLogFormat(error)); } } @@ -896,13 +893,13 @@ export class BackupExportStream extends Readable { this.#flushResolve = resolve; const start = Date.now(); - log.info('backups: flush paused due to pushback'); + log.info('flush paused due to pushback'); try { await pTimeout(promise, FLUSH_TIMEOUT); } finally { const duration = Date.now() - start; if (duration > REPORTING_THRESHOLD) { - log.info(`backups: flush resumed after ${duration}ms`); + log.info(`flush resumed after ${duration}ms`); } this.#flushResolve = undefined; } @@ -1132,24 +1129,25 @@ export class BackupExportStream extends Readable { avatarColor: toAvatarColor(convo.color), }; } else if (isDirectConversation(convo)) { - // Skip story onboarding conversation and other internal conversations. - if ( - convo.serviceId != null && - (isSignalServiceId(convo.serviceId) || - !isServiceIdString(convo.serviceId)) - ) { + if (convo.serviceId != null && isSignalServiceId(convo.serviceId)) { + return undefined; + } + + if (convo.serviceId != null && !isServiceIdString(convo.serviceId)) { log.warn( - 'backups: skipping conversation with invalid serviceId', + 'skipping conversation with invalid serviceId', convo.serviceId ); return undefined; } if (convo.e164 != null && !isValidE164(convo.e164, true)) { - log.warn( - 'backups: skipping conversation with invalid e164', - convo.serviceId - ); + log.warn('skipping conversation with invalid e164', convo.serviceId); + return undefined; + } + + if (convo.serviceId == null && convo.e164 == null) { + log.warn('skipping conversation with neither serviceId nor e164'); return undefined; } @@ -1330,13 +1328,13 @@ export class BackupExportStream extends Readable { ); if (conversation && isGroupV1(conversation.attributes)) { - log.warn('backups: skipping gv1 message'); + log.warn('skipping gv1 message'); return undefined; } const chatId = this.#getRecipientId({ id: message.conversationId }); if (chatId === undefined) { - log.warn('backups: message chat not found'); + log.warn('message chat not found'); return undefined; } @@ -2630,7 +2628,7 @@ export class BackupExportStream extends Readable { e164: quote.author, }); } else { - log.warn('backups: quote has no author id'); + log.warn('quote has no author id'); return null; } @@ -2644,7 +2642,7 @@ export class BackupExportStream extends Readable { } else { quoteType = Backups.Quote.Type.NORMAL; if (quote.text == null && quote.attachments.length === 0) { - log.warn('backups: normal quote has no text or attachments'); + log.warn('normal quote has no text or attachments'); return null; } } @@ -2877,7 +2875,7 @@ export class BackupExportStream extends Readable { for (const [id, entry] of Object.entries(sendStateByConversationId)) { const target = window.ConversationController.get(id); if (!target) { - log.warn(`backups: no send target for a message ${sentAt}`); + log.warn(`no send target for a message ${sentAt}`); continue; } From 235b37a7d255a728b84112491055c88d0e1a1942 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 24 Nov 2025 12:49:02 -0600 Subject: [PATCH 13/53] Trim body on export if body attachment remains Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- ts/services/backups/export.preload.ts | 85 +++++++++++-------- .../backup/attachments_test.preload.ts | 8 +- 2 files changed, 51 insertions(+), 42 deletions(-) diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index a486c9ff1..d637a17cd 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -161,7 +161,11 @@ import { } from '../../util/callLinksRingrtc.node.js'; import { SeenStatus } from '../../MessageSeenStatus.std.js'; import { migrateAllMessages } from '../../messages/migrateMessageData.preload.js'; -import { isBodyTooLong, trimBody } from '../../util/longAttachment.std.js'; +import { + isBodyTooLong, + MAX_MESSAGE_BODY_BYTE_LENGTH, + trimBody, +} from '../../util/longAttachment.std.js'; import { generateBackupsSubscriberData } from '../../util/backupSubscriptionData.preload.js'; import { getEnvironment, @@ -195,7 +199,7 @@ const FLUSH_TIMEOUT = 30 * MINUTE; // Threshold for reporting slow flushes const REPORTING_THRESHOLD = SECOND; -const BACKUP_LONG_ATTACHMENT_TEXT_LIMIT = 128 * KIBIBYTE; +const MAX_BACKUP_MESSAGE_BODY_BYTE_LENGTH = 128 * KIBIBYTE; const BACKUP_QUOTE_BODY_LIMIT = 2048; type GetRecipientIdOptionsType = @@ -2986,7 +2990,7 @@ export class BackupExportStream extends Readable { }): Promise { if ( message.body && - isBodyTooLong(message.body, BACKUP_LONG_ATTACHMENT_TEXT_LIMIT) + isBodyTooLong(message.body, MAX_BACKUP_MESSAGE_BODY_BYTE_LENGTH) ) { log.warn(`${message.timestamp}: Message body is too long; will truncate`); } @@ -3005,26 +3009,7 @@ export class BackupExportStream extends Readable { }) ) : undefined, - longText: - // We only include the bodyAttachment if it's not downloaded; otherwise all text - // is inlined - message.bodyAttachment && !isDownloaded(message.bodyAttachment) - ? await this.#processAttachment({ - attachment: message.bodyAttachment, - messageReceivedAt: message.received_at, - }) - : undefined, - text: - message.body != null - ? { - body: message.body - ? trimBody(message.body, BACKUP_LONG_ATTACHMENT_TEXT_LIMIT) - : undefined, - bodyRanges: message.bodyRanges?.map(range => - this.#toBodyRange(range) - ), - } - : undefined, + ...(await this.#toTextAndLongTextFields(message)), linkPreview: message.preview ? await Promise.all( message.preview.map(async preview => { @@ -3067,25 +3052,51 @@ export class BackupExportStream extends Readable { if (message.storyReaction) { result.emoji = message.storyReaction.emoji; } else { - result.textReply = { - longText: message.bodyAttachment + result.textReply = await this.#toTextAndLongTextFields(message); + } + return result; + } + + async #toTextAndLongTextFields( + message: Pick< + MessageAttributesType, + 'bodyAttachment' | 'body' | 'bodyRanges' | 'received_at' + > + ): Promise<{ + longText: Backups.IFilePointer | undefined; + text: Backups.IText | undefined; + }> { + const includeLongTextAttachment = + message.bodyAttachment && !isDownloaded(message.bodyAttachment); + const includeText = + Boolean(message.body) || Boolean(message.bodyRanges?.length); + + return { + longText: + // We only include the bodyAttachment if it's not downloaded; otherwise all text + // is inlined + includeLongTextAttachment && message.bodyAttachment ? await this.#processAttachment({ attachment: message.bodyAttachment, messageReceivedAt: message.received_at, }) : undefined, - text: - message.body != null - ? { - body: message.body ? trimBody(message.body) : undefined, - bodyRanges: message.bodyRanges?.map(range => - this.#toBodyRange(range) - ), - } - : undefined, - }; - } - return result; + text: includeText + ? { + body: message.body + ? trimBody( + message.body, + includeLongTextAttachment + ? MAX_MESSAGE_BODY_BYTE_LENGTH + : MAX_BACKUP_MESSAGE_BODY_BYTE_LENGTH + ) + : undefined, + bodyRanges: message.bodyRanges?.map(range => + this.#toBodyRange(range) + ), + } + : undefined, + }; } async #toViewOnceMessage({ diff --git a/ts/test-electron/backup/attachments_test.preload.ts b/ts/test-electron/backup/attachments_test.preload.ts index b9ed5281b..bf8d92caa 100644 --- a/ts/test-electron/backup/attachments_test.preload.ts +++ b/ts/test-electron/backup/attachments_test.preload.ts @@ -299,9 +299,7 @@ describe('backup/attachments', () => { } ); }); - it('includes bodyAttachment if it has not downloaded', async () => { - const truncatedBody = 'a'.repeat(2 * KIBIBYTE); - + it('includes bodyAttachment if it has not downloaded, and truncates body to 2 KIB', async () => { const attachment = omit( composeAttachment(1, { contentType: LONG_MESSAGE, @@ -319,13 +317,13 @@ describe('backup/attachments', () => { await asymmetricRoundtripHarness( [ composeMessage(1, { - body: truncatedBody, + body: 'a'.repeat(3 * KIBIBYTE), bodyAttachment: attachment, }), ], [ composeMessage(1, { - body: truncatedBody, + body: 'a'.repeat(2 * KIBIBYTE), bodyAttachment: attachment, }), ], From 2cac01d20d4ca6836c64e4294cef3d2c19440379 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:35:17 -0600 Subject: [PATCH 14/53] Add call summary support for all calls Co-authored-by: emir-signal Co-authored-by: Miriam Zimmerman Co-authored-by: Jim Gustafson --- ACKNOWLEDGMENTS.md | 29 +++++- package.json | 2 +- pnpm-lock.yaml | 10 +- ts/services/calling.preload.ts | 145 ++++++++++++++++++++++++++--- ts/state/ducks/calling.preload.ts | 23 ++--- ts/types/Calling.std.ts | 2 +- ts/util/callDisposition.preload.ts | 8 +- 7 files changed, 177 insertions(+), 42 deletions(-) diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 735ec23d1..34a8d05c3 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -14719,7 +14719,7 @@ For more information on this, and how to apply and follow the GNU AGPL, see ``` -## libsignal-account-keys 0.1.0, libsignal-core 0.1.0, mrp 2.59.4, protobuf 2.59.4, ringrtc 2.59.4, regex-aot 0.1.0, partial-default-derive 0.1.0 +## libsignal-account-keys 0.1.0, libsignal-core 0.1.0, mrp 2.60.1, protobuf 2.60.1, ringrtc 2.60.1, regex-aot 0.1.0, partial-default-derive 0.1.0 ``` GNU AFFERO GENERAL PUBLIC LICENSE @@ -17520,6 +17520,33 @@ SOFTWARE. ``` +## strum 0.27.2, strum_macros 0.27.2 + +``` +MIT License + +Copyright (c) 2019 Peter Glotfelty + +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. + +``` + ## zeroize_derive 1.4.2 ``` diff --git a/package.json b/package.json index c980f1d65..ef6a0de07 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@signalapp/minimask": "1.0.1", "@signalapp/mute-state-change": "workspace:1.0.0", "@signalapp/quill-cjs": "2.1.2", - "@signalapp/ringrtc": "2.59.4", + "@signalapp/ringrtc": "2.60.1", "@signalapp/sqlcipher": "2.4.4", "@signalapp/windows-ucv": "1.0.1", "@tanstack/react-virtual": "3.11.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08a469624..98589e3cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,8 +136,8 @@ importers: specifier: 2.1.2 version: 2.1.2 '@signalapp/ringrtc': - specifier: 2.59.4 - version: 2.59.4 + specifier: 2.60.1 + version: 2.60.1 '@signalapp/sqlcipher': specifier: 2.4.4 version: 2.4.4 @@ -3506,8 +3506,8 @@ packages: resolution: {integrity: sha512-y2sgqdivlrG41J4Zvt/82xtH/PZjDlgItqlD2g/Cv3ZbjlR6cGhTNXbfNygCJB8nXj+C7I28pjt1Zm3k0pv2mg==} engines: {npm: '>=8.2.3'} - '@signalapp/ringrtc@2.59.4': - resolution: {integrity: sha512-Ml9ArS8gi8RJvdat0AuLXggGWMGkOsBizKkz2MtI5XALK0o09wh0r/Qdr0fmp4ewJqnqWGJ31tfr+gXoelG8gQ==} + '@signalapp/ringrtc@2.60.1': + resolution: {integrity: sha512-+gqJsAnIVHf4kt+GzFkD+BSk5i/O/+vZNixcB/I90E+EvhEeQl7WJCp/54slbzybHghNRwuMUSoug/o0XrUf1A==} hasBin: true '@signalapp/sqlcipher@2.4.4': @@ -14324,7 +14324,7 @@ snapshots: lodash: 4.17.21 quill-delta: 5.1.0 - '@signalapp/ringrtc@2.59.4': + '@signalapp/ringrtc@2.60.1': dependencies: https-proxy-agent: 7.0.6 tar: 6.2.1 diff --git a/ts/services/calling.preload.ts b/ts/services/calling.preload.ts index a508076ba..15cd08174 100644 --- a/ts/services/calling.preload.ts +++ b/ts/services/calling.preload.ts @@ -22,6 +22,8 @@ import { CallLinkEpoch, CallLogLevel, CallState, + CallEndReason, + CallRejectReason, ConnectionState, DataMode, JoinState, @@ -67,13 +69,13 @@ import { isMe } from '../util/whatTypeOfConversation.dom.js'; import { getAbsoluteTempPath } from '../util/migrations.preload.js'; import type { AvailableIODevicesType, - CallEndedReason, IceServerType, IceServerCacheType, MediaDeviceSettings, PresentedSource, } from '../types/Calling.std.js'; import { + CallEndedReason, GroupCallConnectionState, GroupCallJoinState, ScreenShareStatus, @@ -132,7 +134,6 @@ import { formatLocalDeviceState, formatPeekInfo, getPeerIdFromConversation, - getLocalCallEventFromCallEndedReason, getCallDetailsFromEndedDirectCall, getCallEventDetails, getLocalCallEventFromJoinState, @@ -528,8 +529,8 @@ export class CallingClass { this.#handleOutputDeviceChanged.bind(this); RingRTC.handleInputDeviceChanged = this.#handleInputDeviceChanged.bind(this); - RingRTC.handleAutoEndedIncomingCallRequest = - this.#handleAutoEndedIncomingCallRequest.bind(this); + RingRTC.handleRejectedIncomingCallRequest = + this.#handleRejectedIncomingCallRequest.bind(this); RingRTC.handleLogMessage = this.#handleLogMessage.bind(this); RingRTC.handleSendHttpRequest = this.#handleSendHttpRequest.bind(this); RingRTC.handleSendCallMessage = this.#handleSendCallMessage.bind(this); @@ -710,6 +711,8 @@ export class CallingClass { ); enableLocalCameraIfNecessary(); + RingRTC.setMicrophoneWarmupEnabled(hasLocalAudio); + log.info(`${logId}: Returning direct call`); return { callMode: CallMode.Direct, @@ -736,6 +739,8 @@ export class CallingClass { groupCall.setOutgoingAudioMuted(!hasLocalAudio); groupCall.setOutgoingVideoMuted(!hasLocalVideo); + RingRTC.setMicrophoneWarmupEnabled(hasLocalAudio); + enableLocalCameraIfNecessary(); log.info(`${logId}: Returning group call`); @@ -760,6 +765,8 @@ export class CallingClass { this.#stopDeviceReselectionTimer(); this.#lastMediaDeviceSettings = undefined; + RingRTC.setMicrophoneWarmupEnabled(false); + if (conversationId) { this.#getGroupCall(conversationId)?.disconnect(); } @@ -1066,6 +1073,8 @@ export class CallingClass { groupCall.setOutgoingAudioMuted(!hasLocalAudio); groupCall.setOutgoingVideoMuted(!hasLocalVideo); + RingRTC.setMicrophoneWarmupEnabled(hasLocalAudio); + if (hasLocalVideo) { drop(this.enableLocalCamera(CallMode.Group)); } @@ -1706,7 +1715,7 @@ export class CallingClass { requestGroupMembers: groupCall => { groupCall.setGroupMembers(this.#getGroupCallMembers(conversationId)); }, - onEnded: (groupCall, endedReason) => { + onEnded: (groupCall, endedReason, _summary) => { const localDeviceState = groupCall.getLocalDeviceState(); const peekInfo = groupCall.getPeekInfo(); @@ -1717,6 +1726,8 @@ export class CallingClass { peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)' ); + // TODO: handle call summary + this.#reduxInterface?.groupCallEnded({ conversationId, endedReason, @@ -1771,6 +1782,14 @@ export class CallingClass { this.#reduxInterface?.joinedAdhocCall(peerId); drop(this.#sendProfileKeysForAdhocCall({ roomId: peerId, peekInfo })); } + + // If it's just us, warm up + const call = this.#getGroupCall(peerId); + if (call?.getRemoteDeviceStates()?.length === 0) { + RingRTC.setMicrophoneWarmupEnabled( + !call?.getLocalDeviceState()?.audioMuted + ); + } } async #sendProfileKeysForAdhocCall({ @@ -1965,6 +1984,77 @@ export class CallingClass { } } + #convertRingRtcCallRejectReason( + rejectReason: CallRejectReason + ): CallEndedReason { + switch (rejectReason) { + case CallRejectReason.GlareHandlingFailure: + return CallEndedReason.GlareFailure; + case CallRejectReason.ReceivedOfferExpired: + return CallEndedReason.ReceivedOfferExpired; + case CallRejectReason.ReceivedOfferWithGlare: + return CallEndedReason.ReceivedOfferWithGlare; + case CallRejectReason.ReceivedOfferWhileActive: + return CallEndedReason.ReceivedOfferWhileActive; + default: + throw missingCaseError(rejectReason); + } + } + + #convertRingRtcDirectCallEndReason( + callEndReason: CallEndReason + ): CallEndedReason { + switch (callEndReason) { + case CallEndReason.LocalHangup: + return CallEndedReason.LocalHangup; + case CallEndReason.RemoteHangup: + return CallEndedReason.RemoteHangup; + case CallEndReason.RemoteHangupNeedPermission: + return CallEndedReason.RemoteHangupNeedPermission; + case CallEndReason.RemoteHangupAccepted: + return CallEndedReason.AcceptedOnAnotherDevice; + case CallEndReason.RemoteHangupDeclined: + return CallEndedReason.DeclinedOnAnotherDevice; + case CallEndReason.RemoteHangupBusy: + return CallEndedReason.BusyOnAnotherDevice; + case CallEndReason.RemoteBusy: + return CallEndedReason.Busy; + case CallEndReason.RemoteGlare: + return CallEndedReason.Glare; + case CallEndReason.RemoteReCall: + return CallEndedReason.ReCall; + case CallEndReason.Timeout: + return CallEndedReason.Timeout; + case CallEndReason.InternalFailure: + return CallEndedReason.InternalFailure; + case CallEndReason.SignalingFailure: + return CallEndedReason.SignalingFailure; + case CallEndReason.ConnectionFailure: + return CallEndedReason.ConnectionFailure; + // The rest of the values are unexpected in this context. + case CallEndReason.AppDroppedCall: + case CallEndReason.DeviceExplicitlyDisconnected: + case CallEndReason.ServerExplicitlyDisconnected: + case CallEndReason.DeniedRequestToJoinCall: + case CallEndReason.RemovedFromCall: + case CallEndReason.CallManagerIsBusy: + case CallEndReason.SfuClientFailedToJoin: + case CallEndReason.FailedToCreatePeerConnectionFactory: + case CallEndReason.FailedToNegotiatedSrtpKeys: + case CallEndReason.FailedToCreatePeerConnection: + case CallEndReason.FailedToStartPeerConnection: + case CallEndReason.FailedToUpdatePeerConnection: + case CallEndReason.FailedToSetMaxSendBitrate: + case CallEndReason.IceFailedWhileConnecting: + case CallEndReason.IceFailedAfterConnected: + case CallEndReason.ServerChangedDemuxId: + case CallEndReason.HasMaxDevices: + return CallEndedReason.UnexpectedReason; + default: + throw missingCaseError(callEndReason); + } + } + // See the comment in types/Calling.ts to explain why we have to do this conversion. #convertRingRtcJoinState(joinState: JoinState): GroupCallJoinState { switch (joinState) { @@ -2327,6 +2417,7 @@ export class CallingClass { this.videoRenderer.disable(); call.setOutgoingAudioMuted(true); call.setOutgoingVideoMuted(true); + RingRTC.setMicrophoneWarmupEnabled(false); if ( excludeRinging && @@ -2341,6 +2432,7 @@ export class CallingClass { // This ensures that we turn off our devices. call.setOutgoingAudioMuted(true); call.setOutgoingVideoMuted(true); + RingRTC.setMicrophoneWarmupEnabled(false); call.disconnect(); } else { throw missingCaseError(call); @@ -3401,10 +3493,10 @@ export class CallingClass { } } - async #handleAutoEndedIncomingCallRequest( + async #handleRejectedIncomingCallRequest( callIdValue: CallId, remoteUserId: UserId, - callEndedReason: CallEndedReason, + callRejectReason: CallRejectReason, ageInSeconds: number, wasVideoCall: boolean, receivedAtCounter: number | undefined, @@ -3412,7 +3504,7 @@ export class CallingClass { ) { const conversation = window.ConversationController.get(remoteUserId); if (!conversation) { - log.warn('handleAutoEndedIncomingCallRequest: Conversation not found'); + log.warn('handleRejectedIncomingCallRequest: Conversation not found'); return; } @@ -3422,6 +3514,20 @@ export class CallingClass { }); log.info(logId); + if (callRejectReason === CallRejectReason.ReceivedOfferWhileActive) { + // This is a special case where we won't update our local call, because we have + // an ongoing active call. The ended call would stomp on the active call. + log.info( + `${logId}: Got offer while active for conversation ${conversation?.idForLogging()}` + ); + return; + } + + // Attempt to translate the rejection reason to its CallEndedReason + // counterpart. + const callEndedReason = + this.#convertRingRtcCallRejectReason(callRejectReason); + const callId = Long.fromValue(callIdValue).toString(); const peerId = getPeerIdFromConversation(conversation.attributes); @@ -3440,17 +3546,18 @@ export class CallingClass { wasVideoCall, timestamp ); - const localCallEvent = - getLocalCallEventFromCallEndedReason(callEndedReason); + + // We classify all of the call rejection events as 'Missed'. const callEvent = getCallEventDetails( callDetails, - localCallEvent, - 'CallingClass.handleAutoEndedIncomingCallRequest' + LocalCallEvent.Missed, + 'CallingClass.handleRejectedIncomingCallRequest' ); if (!this.#reduxInterface) { log.error(`${logId}: Unable to update redux for call`); } + this.#reduxInterface?.callStateChange({ acceptedTime: null, callEndedReason, @@ -3504,7 +3611,15 @@ export class CallingClass { delete this.#callsLookup[conversationId]; } - const localCallEvent = getLocalCallEventFromDirectCall(call); + const callEndedReason = + call.endedReason != null + ? this.#convertRingRtcDirectCallEndReason(call.endedReason) + : undefined; + + const localCallEvent = getLocalCallEventFromDirectCall( + call, + callEndedReason + ); if (localCallEvent != null) { const peerId = getPeerIdFromConversation(conversation.attributes); const callDetails = getCallDetailsFromDirectCall(peerId, call); @@ -3516,10 +3631,12 @@ export class CallingClass { await updateCallHistoryFromLocalEvent(callEvent, null, null); } + // TODO: handle CallSummary here. + reduxInterface.callStateChange({ conversationId, callState: call.state, - callEndedReason: call.endedReason, + callEndedReason, acceptedTime, }); }; diff --git a/ts/state/ducks/calling.preload.ts b/ts/state/ducks/calling.preload.ts index 788685ae0..4700f6f55 100644 --- a/ts/state/ducks/calling.preload.ts +++ b/ts/state/ducks/calling.preload.ts @@ -7,7 +7,7 @@ import type { ReadonlyDeep } from 'type-fest'; import { CallLinkEpoch, CallLinkRootKey, - GroupCallEndReason, + CallEndReason, type Reaction as CallReaction, } from '@signalapp/ringrtc'; import { getOwn } from '../../util/getOwn.std.js'; @@ -799,7 +799,7 @@ type DirectCallAudioLevelsChangeActionType = ReadonlyDeep<{ type GroupCallEndedActionPayloadType = ReadonlyDeep<{ conversationId: string; - endedReason: GroupCallEndReason; + endedReason: CallEndReason; }>; export type GroupCallEndedActionType = ReadonlyDeep<{ @@ -1256,18 +1256,7 @@ function callStateChange( CallStateChangeFulfilledActionType > { return async dispatch => { - const { conversationId, callState, acceptedTime, callEndedReason } = - payload; - - // This is a special case were we won't update our local call, because we have an - // ongoing active call. The ended call would stomp on the active call. - if (callEndedReason === CallEndedReason.ReceivedOfferWhileActive) { - const conversation = window.ConversationController.get(conversationId); - log.info( - `callStateChange: Got offer while active for conversation ${conversation?.idForLogging()}` - ); - return; - } + const { callState, acceptedTime, callEndedReason } = payload; const wasAccepted = acceptedTime != null; const isEnded = callState === CallState.Ended && callEndedReason != null; @@ -1485,7 +1474,7 @@ function groupCallEnded( > { return (dispatch, getState) => { const { endedReason } = payload; - if (endedReason === GroupCallEndReason.DeniedRequestToJoinCall) { + if (endedReason === CallEndReason.DeniedRequestToJoinCall) { const i18n = getIntl(getState()); dispatch({ type: SHOW_ERROR_MODAL, @@ -1497,7 +1486,7 @@ function groupCallEnded( }); return; } - if (endedReason === GroupCallEndReason.RemovedFromCall) { + if (endedReason === CallEndReason.RemovedFromCall) { const i18n = getIntl(getState()); dispatch({ type: SHOW_ERROR_MODAL, @@ -1509,7 +1498,7 @@ function groupCallEnded( }); return; } - if (endedReason === GroupCallEndReason.HasMaxDevices) { + if (endedReason === CallEndReason.HasMaxDevices) { const i18n = getIntl(getState()); dispatch({ type: SHOW_ERROR_MODAL, diff --git a/ts/types/Calling.std.ts b/ts/types/Calling.std.ts index c8cd27c53..679f9851a 100644 --- a/ts/types/Calling.std.ts +++ b/ts/types/Calling.std.ts @@ -124,7 +124,6 @@ export enum CallState { Ended = 'ended', } -// Must be kept in sync with RingRTC.CallEndedReason export enum CallEndedReason { LocalHangup = 'LocalHangup', RemoteHangup = 'RemoteHangup', @@ -144,6 +143,7 @@ export enum CallEndedReason { AcceptedOnAnotherDevice = 'AcceptedOnAnotherDevice', DeclinedOnAnotherDevice = 'DeclinedOnAnotherDevice', BusyOnAnotherDevice = 'BusyOnAnotherDevice', + UnexpectedReason = 'UnexpectedReason', } // Must be kept in sync with RingRTC's ConnectionState diff --git a/ts/util/callDisposition.preload.ts b/ts/util/callDisposition.preload.ts index 3154d2fe6..275e423b4 100644 --- a/ts/util/callDisposition.preload.ts +++ b/ts/util/callDisposition.preload.ts @@ -427,6 +427,7 @@ const endedReasonToEvent: Record = { [CallEndedReason.Timeout]: LocalCallEvent.Missed, [CallEndedReason.Declined]: LocalCallEvent.Missed, [CallEndedReason.DeclinedOnAnotherDevice]: LocalCallEvent.Missed, + [CallEndedReason.UnexpectedReason]: LocalCallEvent.Missed, }; export function getLocalCallEventFromCallEndedReason( @@ -437,15 +438,16 @@ export function getLocalCallEventFromCallEndedReason( } export function getLocalCallEventFromDirectCall( - call: Call + call: Call, + callEndedReason: CallEndedReason | undefined ): LocalCallEvent | null { log.info('getLocalCallEventFromDirectCall', call.state); if (call.state === CallState.Accepted) { return LocalCallEvent.Accepted; } if (call.state === CallState.Ended) { - strictAssert(call.endedReason != null, 'Call ended without reason'); - return getLocalCallEventFromCallEndedReason(call.endedReason); + strictAssert(callEndedReason, 'Call ended without reason'); + return getLocalCallEventFromCallEndedReason(callEndedReason); } if (call.state === CallState.Ringing) { return LocalCallEvent.Ringing; From 276ff8d4858bc9861e4673d0ca44fd74f8c2965b Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 24 Nov 2025 15:02:47 -0600 Subject: [PATCH 15/53] Further tweaks for media gallery Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> --- _locales/en/messages.json | 8 +++ stylesheets/_modules.scss | 38 ------------ .../media-gallery/AttachmentSection.dom.tsx | 4 +- .../media-gallery/EmptyState.dom.tsx | 4 +- .../media-gallery/LinkPreviewItem.dom.tsx | 3 +- .../media-gallery/ListItem.dom.tsx | 5 +- .../media-gallery/MediaGallery.dom.tsx | 58 +++++++++++-------- 7 files changed, 53 insertions(+), 67 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index ada2113c1..fdffb210e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6792,6 +6792,10 @@ "messageformat": "No Links", "description": "Title of the empty state view of media gallery for links tab" }, + "icu:MediaGallery__EmptyState__description--links-2": { + "messageformat": "Links that you send and receive will appear here", + "description": "Description of the empty state view of media gallery for links tab" + }, "icu:MediaGallery__EmptyState__description--documents": { "messageformat": "Links that you send and receive will appear here", "description": "Description of the empty state view of media gallery for links tab" @@ -6800,6 +6804,10 @@ "messageformat": "No Files", "description": "Title of the empty state view of media gallery for files tab" }, + "icu:MediaGallery__EmptyState__description--documents-2": { + "messageformat": "Files that you send and receive will appear here", + "description": "Description of the empty state view of media gallery for files tab" + }, "icu:MediaGallery__EmptyState__description--links": { "messageformat": "Files that you send and receive will appear here", "description": "Description of the empty state view of media gallery for files tab" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index f9da93b4d..59c05fb32 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2464,44 +2464,6 @@ button.ConversationDetails__action-button { margin-inline-start: 16px; } -// Module: Media Gallery - -.module-media-gallery { - display: flex; - flex-direction: column; - flex-grow: 1; - width: 100%; - height: 100%; - outline: none; -} - -.module-media-gallery__content { - flex-grow: 1; - overflow-y: auto; - overflow-x: hidden; - padding: 20px; -} - -.module-media-gallery__scroll-observer { - position: absolute; - bottom: 0; - height: 30px; - - &::after { - content: ''; - height: 1px; // Always show the element to not mess with the height of the scroll area - display: block; - } -} - -.module-media-gallery__sections { - min-width: 0; - - display: flex; - flex-grow: 1; - flex-direction: column; -} - // Module: Message Request Actions .module-message-request-actions { diff --git a/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx b/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx index 5f1558067..f78b7cadc 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx @@ -94,8 +94,8 @@ export function AttachmentSection({ case 'audio': case 'link': return ( -
-

{header}

+
+

{header}

{verified.entries.map(mediaItem => { return ( diff --git a/ts/components/conversation/media-gallery/EmptyState.dom.tsx b/ts/components/conversation/media-gallery/EmptyState.dom.tsx index dab170fe6..c9d6c7200 100644 --- a/ts/components/conversation/media-gallery/EmptyState.dom.tsx +++ b/ts/components/conversation/media-gallery/EmptyState.dom.tsx @@ -29,12 +29,12 @@ export function EmptyState({ i18n, tab }: Props): JSX.Element { case TabViews.Documents: title = i18n('icu:MediaGallery__EmptyState__title--documents'); description = i18n( - 'icu:MediaGallery__EmptyState__description--documents' + 'icu:MediaGallery__EmptyState__description--documents-2' ); break; case TabViews.Links: title = i18n('icu:MediaGallery__EmptyState__title--links'); - description = i18n('icu:MediaGallery__EmptyState__description--links'); + description = i18n('icu:MediaGallery__EmptyState__description--links-2'); break; default: throw missingCaseError(tab); diff --git a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx index aec3c95fd..6c5a67793 100644 --- a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx +++ b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx @@ -64,7 +64,8 @@ export function LinkPreviewItem({
diff --git a/ts/components/conversation/media-gallery/ListItem.dom.tsx b/ts/components/conversation/media-gallery/ListItem.dom.tsx index eff53cb24..1fda151a4 100644 --- a/ts/components/conversation/media-gallery/ListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/ListItem.dom.tsx @@ -124,7 +124,10 @@ export function ListItem({ return ( diff --git a/ts/components/conversation/media-gallery/MediaGallery.dom.tsx b/ts/components/conversation/media-gallery/MediaGallery.dom.tsx index 5baa95456..652219e7b 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.dom.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.dom.tsx @@ -1,7 +1,7 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { Fragment, useEffect, useRef, useCallback } from 'react'; import moment from 'moment'; @@ -133,7 +133,12 @@ function MediaSection({ } const now = Date.now(); - const sections = groupMediaItemsByDate(now, mediaItems).map(section => { + const groupedItems = groupMediaItemsByDate(now, mediaItems); + + const isGrid = mediaItems.at(0)?.type === 'media'; + + const sections = groupedItems.map((section, index) => { + const isLast = index === groupedItems.length - 1; const first = section.mediaItems[0]; const { message } = first; const date = moment(message.receivedAtMs || message.receivedAt); @@ -158,25 +163,24 @@ function MediaSection({ const header = getHeader(); return ( - + + + {!isGrid && !isLast && ( +
+ )} +
); }); - const isGrid = mediaItems.at(0)?.type === 'media'; - return ( -
+
{sections}
); @@ -308,7 +312,11 @@ export function MediaGallery({ ]); return ( -
+
{renderMiniPlayer()} -
+
+
); }} -
); } From 22947a465015c242ff6d0ab95b3fababaf569c2a Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 24 Nov 2025 15:16:08 -0600 Subject: [PATCH 16/53] Improvements to plaintext export Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- ts/CI.preload.ts | 10 +- ts/components/PlaintextExportWorkflow.dom.tsx | 2 +- ts/components/Preferences.dom.stories.tsx | 1 - ts/services/backups/export.preload.ts | 3 + ts/services/backups/import.preload.ts | 3 +- ts/services/backups/index.preload.ts | 272 ++++++++++-------- ts/services/backups/types.std.ts | 7 +- ts/state/smart/Preferences.preload.tsx | 3 +- .../backup/filePointer_test.preload.ts | 4 +- 9 files changed, 168 insertions(+), 137 deletions(-) diff --git a/ts/CI.preload.ts b/ts/CI.preload.ts index ee44c8b52..e4c9b146c 100644 --- a/ts/CI.preload.ts +++ b/ts/CI.preload.ts @@ -202,13 +202,11 @@ export function getCI({ } async function exportLocalBackup(backupsBaseDir: string): Promise { - const { snapshotDir } = await backupsService.exportLocalBackup( + const { snapshotDir } = await backupsService.exportLocalEncryptedBackup({ backupsBaseDir, - { - type: 'local-encrypted', - localBackupSnapshotDir: backupsBaseDir, - } - ); + onProgress: () => null, + abortSignal: new AbortController().signal, + }); return snapshotDir; } diff --git a/ts/components/PlaintextExportWorkflow.dom.tsx b/ts/components/PlaintextExportWorkflow.dom.tsx index ec43f886f..b895f87da 100644 --- a/ts/components/PlaintextExportWorkflow.dom.tsx +++ b/ts/components/PlaintextExportWorkflow.dom.tsx @@ -78,7 +78,7 @@ export function PlaintextExportWorkflow({