diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5904528c56..1f6811117c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -8540,6 +8540,10 @@ "messageformat": "Blocked Groups", "description": "Header for groups section on the blocked preferences subpage" }, + "icu:Preferences--blocked--blocked-on": { + "messageformat": "Blocked on {blockedAt}", + "description": "A second line shown under the contact's name in block list if we have a timestamp for that blocked contact or group" + }, "icu:Preferences--signal-backups": { "messageformat": "Signal Secure Backups", "description": "Feature name for message backups using the Signal service." diff --git a/protos/Backups.proto b/protos/Backups.proto index 2d48916201..64c6b301ef 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -275,6 +275,7 @@ message Contact { optional AvatarColor avatarColor = 21; // Opaque blob containing key transparency data for the contact optional bytes keyTransparencyData = 22; + uint64 blockedAtTimestamp = 23; // if `blocked` is true, 0 means unknown block time } message Group { @@ -291,6 +292,7 @@ message Group { GroupSnapshot snapshot = 5; bool blocked = 6; optional AvatarColor avatarColor = 7; + uint64 blockedAtTimestamp = 8; // if `blocked` is true, 0 means unknown block time // These are simply plaintext copies of the groups proto from Groups.proto. // They should be kept completely in-sync with Groups.proto. @@ -413,7 +415,7 @@ message CallLink { string name = 3; Restrictions restrictions = 4; uint64 expirationMs = 5; - reserved 6; // was epoch, never used + reserved /*epoch*/ 6; } message AdHocCall { @@ -1083,7 +1085,6 @@ message GroupChangeChatUpdate { GroupExpirationTimerUpdate groupExpirationTimerUpdate = 34; GroupMemberLabelAccessLevelChangeUpdate groupMemberLabelAccessLevelChangeUpdate = 35; GroupTerminateChangeUpdate groupTerminateChangeUpdate = 36; - // next: 37 } } @@ -1319,6 +1320,12 @@ message StickerPack { message ChatStyle { message Gradient { + // Color ordering: + // 0 degrees: bottom-to-top + // 90 degrees: left-to-right + // 180 degrees: top-to-bottom + // 270 degrees: right-to-left + uint32 angle = 1; // degrees repeated fixed32 colors = 2; // 0xAARRGGBB repeated float positions = 3; // percent from 0 to 1 diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 8b3eb855b4..9eaad78dcc 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -579,10 +579,29 @@ message SyncMessage { } message Blocked { - repeated string numbers = 1; + repeated string numbers = 1; // deprecated: this field will be removed in a future release. repeated string acis = 3; - repeated bytes groupIds = 2; - repeated bytes acisBinary = 4; // 16-byte UUID + repeated bytes groupIds = 2; // deprecated: this field will be removed in a future release. + repeated bytes acisBinary = 4; // deprecated: this field will be removed in a future release. + + message BlockedE164 { + optional string e164 = 1; + optional uint64 timestamp = 2; + } + + message BlockedAci { + optional bytes aciBinary = 1; // 16-byte UUID + optional uint64 timestamp = 2; + } + + message BlockedGroup { + optional bytes groupId = 1; + optional uint64 timestamp = 2; + } + + repeated BlockedE164 blockedE164s = 5; + repeated BlockedAci blockedAcis = 6; + repeated BlockedGroup blockedGroups = 7; } message Request { diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 64b365f190..15ae836e1d 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -144,7 +144,8 @@ message ContactRecord { optional AvatarColor avatarColor = 24; bytes aciBinary = 25; // 16-byte UUID bytes pniBinary = 26; // 16-byte UUID - // Next ID: 27 + uint64 blockedAtTimestamp = 27; // if `blocked` is true, 0 means unknown block time + // Next ID: 28 } message GroupV1Record { @@ -175,6 +176,7 @@ message GroupV2Record { StorySendMode storySendMode = 10; optional AvatarColor avatarColor = 11; bytes verifiedNameHash = 12; // SHA-256 of UTF-8 encoded decrypted group title that was last verified + uint64 blockedAtTimestamp = 13; // if `blocked` is true, 0 means unknown block time } message Payments { @@ -306,6 +308,7 @@ message AccountRecord { optional uint64 releaseNotesChatMutedUntilTimestamp = 49; optional bool releaseNotesChatBlocked = 50; optional bool releaseNotesChatMarkedUnread = 51; + optional uint64 releaseNotesChatBlockedAt = 52; // only set if known (>0) } message StoryDistributionListRecord { diff --git a/ts/ConversationController.preload.ts b/ts/ConversationController.preload.ts index 890999d327..ca9ea077d7 100644 --- a/ts/ConversationController.preload.ts +++ b/ts/ConversationController.preload.ts @@ -1332,6 +1332,22 @@ export class ConversationController { ), }); + if (obsolete.isBlocked()) { + const e164 = obsolete.get('e164'); + const e164Block = e164 + ? itemStorage.blocked.getBlockedNumbers().get(e164) + : undefined; + + const serviceId = obsolete.get('serviceId'); + const serviceIdBlock = serviceId + ? itemStorage.blocked.getBlockedServiceIds().get(serviceId) + : undefined; + + const timestamp = serviceIdBlock?.blockedAt ?? e164Block?.blockedAt; + + current.block({ viaStorageServiceSync: false, timestamp }); + } + const obsoleteExpireTimer = obsolete.get('expireTimer'); const currentExpireTimer = current.get('expireTimer'); if ( diff --git a/ts/background.preload.ts b/ts/background.preload.ts index a2944075e4..565cf5ce8c 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -1454,7 +1454,9 @@ async function startApp(): Promise { log.info('Blocked uuids cleanup: starting...'); const blockedUuids = itemStorage.get(BLOCKED_UUIDS_ID, []); - const blockedAcis = blockedUuids.filter(isAciString); + const blockedAcis = blockedUuids.filter(item => + isAciString(item.serviceId) + ); const diff = blockedUuids.length - blockedAcis.length; if (diff > 0) { log.warn( @@ -1463,14 +1465,20 @@ async function startApp(): Promise { await itemStorage.put(BLOCKED_UUIDS_ID, blockedAcis); } - if (blockedAcis.some(isSignalServiceId)) { + const signalItem = blockedAcis.find(item => + isSignalServiceId(item.serviceId) + ); + if (signalItem) { log.warn( 'Release notes chat block migration: found in blocked list. Moving.' ); - await itemStorage.blocked.setReleaseNotesChatBlocked(true); + await itemStorage.blocked.setReleaseNotesChatBlocked( + true, + signalItem.blockedAt + ); await itemStorage.put( BLOCKED_UUIDS_ID, - blockedAcis.filter(aci => !isSignalServiceId(aci)) + blockedAcis.filter(item => !isSignalServiceId(item.serviceId)) ); log.info('Release notes chat block migration: complete'); } diff --git a/ts/components/Preferences.dom.stories.tsx b/ts/components/Preferences.dom.stories.tsx index 803a980c05..3cde449004 100644 --- a/ts/components/Preferences.dom.stories.tsx +++ b/ts/components/Preferences.dom.stories.tsx @@ -1055,47 +1055,59 @@ Internal.args = { export const PrivacyBlocked1Contact = Template.bind({}); PrivacyBlocked1Contact.args = { - blockedContacts: [getDefaultConversation()], + blockedContacts: [ + { conversation: getDefaultConversation(), blockedAt: undefined }, + ], settingsLocation: { page: SettingsPage.Privacy }, }; export const PrivacyBlocked1Group = Template.bind({}); PrivacyBlocked1Group.args = { - blockedGroups: [getDefaultConversation()], + blockedGroups: [ + { conversation: getDefaultConversation(), blockedAt: undefined }, + ], settingsLocation: { page: SettingsPage.Privacy }, }; -export const PrivacyBlocked1Both = Template.bind({}); -PrivacyBlocked1Both.args = { - blockedContacts: [getDefaultConversation()], - blockedGroups: [getDefaultConversation()], +export const PrivacyBlocked1BothWithBlockedAt = Template.bind({}); +PrivacyBlocked1BothWithBlockedAt.args = { + blockedContacts: [ + { conversation: getDefaultConversation(), blockedAt: Date.now() }, + ], + blockedGroups: [ + { conversation: getDefaultConversation(), blockedAt: Date.now() - DAY }, + ], settingsLocation: { page: SettingsPage.Privacy }, }; export const PrivacyBlockedManyContacts = Template.bind({}); PrivacyBlockedManyContacts.args = { - blockedContacts: new Array(55) - .fill(undefined) - .map(() => getDefaultConversation()), + blockedContacts: new Array(55).fill(undefined).map(() => ({ + conversation: getDefaultConversation(), + blockedAt: undefined, + })), settingsLocation: { page: SettingsPage.Privacy }, }; export const PrivacyBlockedManyGroups = Template.bind({}); PrivacyBlockedManyGroups.args = { - blockedGroups: new Array(55) - .fill(undefined) - .map(() => getDefaultConversation()), + blockedGroups: new Array(55).fill(undefined).map(() => ({ + conversation: getDefaultConversation(), + blockedAt: undefined, + })), settingsLocation: { page: SettingsPage.Privacy }, }; export const PrivacyBlockedManyBoth = Template.bind({}); PrivacyBlockedManyBoth.args = { - blockedContacts: new Array(20) - .fill(undefined) - .map(() => getDefaultConversation()), - blockedGroups: new Array(20) - .fill(undefined) - .map(() => getDefaultConversation()), + blockedContacts: new Array(20).fill(undefined).map(() => ({ + conversation: getDefaultConversation(), + blockedAt: undefined, + })), + blockedGroups: new Array(20).fill(undefined).map(() => ({ + conversation: getDefaultConversation(), + blockedAt: undefined, + })), settingsLocation: { page: SettingsPage.Privacy }, }; diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index a5ffcb4fb6..fecf0da4e8 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -103,12 +103,18 @@ import { TitlebarDragArea } from './TitlebarDragArea.dom.tsx'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges.preload.ts'; import { Emoji } from '../axo/emoji.std.ts'; import { AxoConfirmDialog } from '../axo/AxoConfirmDialog.dom.tsx'; +import moment from 'moment'; const { isNumber, noop, partition } = lodash; type CheckboxChangeHandlerType = (value: boolean) => unknown; type SelectChangeHandlerType = (value: T) => unknown; +export type BlockedConversation = { + conversation: ConversationType; + blockedAt: number | undefined; +}; + export type PropsDataType = { // Settings backupKey: string | undefined; @@ -127,8 +133,8 @@ export type PropsDataType = { pauseBackupMediaDownload: VoidFunction; cancelBackupMediaDownload: VoidFunction; resumeBackupMediaDownload: VoidFunction; - blockedContacts: ReadonlyArray; - blockedGroups: ReadonlyArray; + blockedContacts: ReadonlyArray; + blockedGroups: ReadonlyArray; customColors: Record; defaultConversationColor: DefaultConversationColorType; deviceName?: string; @@ -2252,20 +2258,29 @@ export function Preferences({ {blockedContacts.length > 0 ? ( - {blockedContacts.map(item => { + {blockedContacts.map(({ conversation, blockedAt }) => { return (
-
{item.title}
+
+
{conversation.title}
+ {isNumber(blockedAt) && blockedAt > 0 ? ( +
+ {i18n('icu:Preferences--blocked--blocked-on', { + blockedAt: moment(blockedAt).format('ll'), + })} +
+ ) : undefined} +
); })} @@ -2273,20 +2288,29 @@ export function Preferences({ ) : undefined} {blockedGroups.length > 0 ? ( - {blockedGroups.map(item => { + {blockedGroups.map(({ conversation, blockedAt }) => { return (
+
{' '} +
+
{conversation.title}
+ {isNumber(blockedAt) && blockedAt > 0 ? ( +
+ {i18n('icu:Preferences--blocked--blocked-on', { + blockedAt: moment(blockedAt).format('ll'), + })} +
+ ) : undefined}{' '}
-
{item.title}
); })} diff --git a/ts/groups.preload.ts b/ts/groups.preload.ts index d2dbcaa4b6..eca2de87b9 100644 --- a/ts/groups.preload.ts +++ b/ts/groups.preload.ts @@ -2518,7 +2518,7 @@ export async function initiateMigrationToGroupV2( }); if (itemStorage.blocked.isGroupBlocked(previousGroupV1Id)) { - await itemStorage.blocked.addBlockedGroup(groupId); + await itemStorage.blocked.addBlockedGroup(groupId, undefined); } // Save these most recent updates to conversation @@ -2862,7 +2862,7 @@ export async function respondToGroupV2Migration({ ); if (itemStorage.blocked.isGroupBlocked(previousGroupV1Id)) { - await itemStorage.blocked.addBlockedGroup(groupId); + await itemStorage.blocked.addBlockedGroup(groupId, undefined); } if (wereWePreviouslyAMember) { @@ -3001,7 +3001,7 @@ export async function respondToGroupV2Migration({ }); if (itemStorage.blocked.isGroupBlocked(previousGroupV1Id)) { - await itemStorage.blocked.addBlockedGroup(groupId); + await itemStorage.blocked.addBlockedGroup(groupId, undefined); } // Save these most recent updates to conversation diff --git a/ts/messageModifiers/MessageRequests.dom.ts b/ts/messageModifiers/MessageRequests.dom.ts index f3bea8eae6..3c6aafc550 100644 --- a/ts/messageModifiers/MessageRequests.dom.ts +++ b/ts/messageModifiers/MessageRequests.dom.ts @@ -73,6 +73,7 @@ export async function onResponse( receivedAtCounter, receivedAtMs, timestamp: sentAt, + blockedAt: sentAt, }) ); diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts index 690a916478..fa84d9423b 100644 --- a/ts/models/conversations.preload.ts +++ b/ts/models/conversations.preload.ts @@ -1004,7 +1004,13 @@ export class ConversationModel { return isBlocked(this.attributes); } - block({ viaStorageServiceSync = false } = {}): void { + block({ + viaStorageServiceSync, + timestamp, + }: { + viaStorageServiceSync: boolean; + timestamp: number | undefined; + }): void { if (isMe(this.attributes)) { log.error(`${this.idForLogging()}: Refusing to block Note to Self`); return; @@ -1015,22 +1021,22 @@ export class ConversationModel { const serviceId = this.getServiceId(); if (isSignalConversation(this)) { - drop(itemStorage.blocked.setReleaseNotesChatBlocked(true)); + drop(itemStorage.blocked.setReleaseNotesChatBlocked(true, timestamp)); blocked = true; } else if (serviceId && isAciString(serviceId)) { - drop(itemStorage.blocked.addBlockedServiceId(serviceId)); + drop(itemStorage.blocked.addBlockedServiceId(serviceId, timestamp)); blocked = true; } const e164 = this.get('e164'); if (e164) { - drop(itemStorage.blocked.addBlockedNumber(e164)); + drop(itemStorage.blocked.addBlockedNumber(e164, timestamp)); blocked = true; } const groupId = this.get('groupId'); if (groupId) { - drop(itemStorage.blocked.addBlockedGroup(groupId)); + drop(itemStorage.blocked.addBlockedGroup(groupId, timestamp)); blocked = true; } @@ -1049,7 +1055,7 @@ export class ConversationModel { const serviceId = this.getServiceId(); if (serviceId && isSignalServiceId(serviceId)) { - drop(itemStorage.blocked.setReleaseNotesChatBlocked(false)); + drop(itemStorage.blocked.setReleaseNotesChatBlocked(false, undefined)); unblocked = true; } else if (serviceId && isAciString(serviceId)) { drop(itemStorage.blocked.removeBlockedServiceId(serviceId)); @@ -1117,10 +1123,11 @@ export class ConversationModel { ? { source: MessageRequestResponseSource.STORAGE_SERVICE, learnedAtMs: Date.now(), + blockedAt: undefined, } : { source: MessageRequestResponseSource.LOCAL, - timestamp: Date.now(), + blockedAt: Date.now(), }, { shouldSave: false } ); @@ -2198,8 +2205,24 @@ export class ConversationModel { return; } + const wasBlockedByNumber = oldValue + ? itemStorage.blocked.getBlockedNumbers().get(oldValue) + : undefined; + const serviceId = this.get('serviceId'); + const wasBlockedByServiceId = serviceId + ? itemStorage.blocked.getBlockedServiceIds().get(serviceId) + : undefined; + this.set({ e164: e164 || undefined }); + if (wasBlockedByNumber || wasBlockedByServiceId) { + this.block({ + viaStorageServiceSync: false, + timestamp: + wasBlockedByNumber?.blockedAt ?? wasBlockedByServiceId?.blockedAt, + }); + } + // This user changed their phone number if (oldValue && e164) { void this.addChangeNumberNotification(oldValue, e164); @@ -2216,11 +2239,27 @@ export class ConversationModel { return; } + const wasBlockedByServiceId = oldValue + ? itemStorage.blocked.getBlockedServiceIds().get(oldValue) + : undefined; + const e164 = this.get('e164'); + const wasBlockedByNumber = e164 + ? itemStorage.blocked.getBlockedNumbers().get(e164) + : undefined; + this.set({ serviceId: serviceId ? normalizeServiceId(serviceId, 'Conversation.updateServiceId') : undefined, }); + + if (wasBlockedByServiceId || wasBlockedByNumber) { + this.block({ + viaStorageServiceSync: false, + timestamp: + wasBlockedByNumber?.blockedAt ?? wasBlockedByServiceId?.blockedAt, + }); + } drop(DataWriter.updateConversation(this.attributes)); window.ConversationController.idUpdated(this, 'serviceId', oldValue); @@ -2464,9 +2503,9 @@ export class ConversationModel { const { source } = responseInfo; switch (source) { case MessageRequestResponseSource.LOCAL: - receivedAtMs = responseInfo.timestamp; + receivedAtMs = responseInfo.blockedAt; receivedAtCounter = incrementMessageCounter(); - timestamp = responseInfo.timestamp; + timestamp = responseInfo.blockedAt; break; case MessageRequestResponseSource.MRR_SYNC: receivedAtMs = responseInfo.receivedAtMs; @@ -2607,7 +2646,10 @@ export class ConversationModel { isSpam?: boolean; }) => { if (isBlock) { - this.block({ viaStorageServiceSync }); + this.block({ + viaStorageServiceSync, + timestamp: responseInfo.blockedAt, + }); } if (isBlock || isDelete) { diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index c4bc4447cb..e37761adf5 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -1324,6 +1324,10 @@ export class BackupExportStream extends Readable { strictAssert(recipientId != null, 'recipientId must exist'); + const blockedItem = convo.serviceId + ? itemStorage.blocked.getBlockedServiceIds().get(convo.serviceId) + : undefined; + return { id: recipientId, destination: { @@ -1332,8 +1336,9 @@ export class BackupExportStream extends Readable { pni, e164, username: convo.username || null, - blocked: convo.serviceId - ? itemStorage.blocked.isServiceIdBlocked(convo.serviceId) + blocked: Boolean(blockedItem), + blockedAtTimestamp: blockedItem?.blockedAt + ? BigInt(blockedItem.blockedAt) : null, visibility, registration: convo.discoveredUnregisteredAt @@ -1399,6 +1404,11 @@ export class BackupExportStream extends Readable { const recipientId = this.#getNewRecipientId({ id: convo.id, }); + + const blockedItem = convo.groupId + ? itemStorage.blocked.getBlockedGroups().get(convo.groupId) + : undefined; + return { id: recipientId, destination: { @@ -1407,9 +1417,10 @@ export class BackupExportStream extends Readable { whitelisted: convo.profileSharing ?? null, hideStory: convo.hideStory === true, storySendMode, - blocked: convo.groupId - ? itemStorage.blocked.isGroupBlocked(convo.groupId) - : false, + blocked: Boolean(blockedItem), + blockedAtTimestamp: blockedItem?.blockedAt + ? BigInt(blockedItem.blockedAt) + : null, avatarColor: toAvatarColor(convo.color) ?? null, snapshot: { title: { diff --git a/ts/services/backups/import.preload.ts b/ts/services/backups/import.preload.ts index 54dcef495b..80079efd5d 100644 --- a/ts/services/backups/import.preload.ts +++ b/ts/services/backups/import.preload.ts @@ -1171,10 +1171,20 @@ export class BackupImportStream extends Writable { if (contact.blocked) { if (serviceId) { - await itemStorage.blocked.addBlockedServiceId(serviceId); + await itemStorage.blocked.addBlockedServiceId( + serviceId, + contact.blockedAtTimestamp + ? getCheckedTimestampFromLong(contact.blockedAtTimestamp) + : undefined + ); } if (e164) { - await itemStorage.blocked.addBlockedNumber(e164); + await itemStorage.blocked.addBlockedNumber( + e164, + contact.blockedAtTimestamp + ? getCheckedTimestampFromLong(contact.blockedAtTimestamp) + : undefined + ); } } @@ -1355,7 +1365,12 @@ export class BackupImportStream extends Writable { }; if (group.blocked) { - await itemStorage.blocked.addBlockedGroup(groupId); + await itemStorage.blocked.addBlockedGroup( + groupId, + group.blockedAtTimestamp + ? getCheckedTimestampFromLong(group.blockedAtTimestamp) + : undefined + ); } return attrs; diff --git a/ts/services/storageRecordOps.preload.ts b/ts/services/storageRecordOps.preload.ts index 41514a0392..1dc6e11644 100644 --- a/ts/services/storageRecordOps.preload.ts +++ b/ts/services/storageRecordOps.preload.ts @@ -1,7 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import lodash, { omit, partition, without } from 'lodash'; +import lodash, { isNumber, omit, partition, without } from 'lodash'; import { ServiceId } from '@signalapp/libsignal-client'; import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes.std.ts'; @@ -159,6 +159,13 @@ export type MergeResultType = Readonly<{ details: ReadonlyArray; }>; +function makeBigInt(value: number | undefined): bigint | undefined { + if (!isNumber(value)) { + return undefined; + } + + return BigInt(value); +} function toRecordVerified(verified: number): Proto.ContactRecord.IdentityState { const VERIFIED_ENUM = signalProtocolStore.VerifiedStatus; const STATE_ENUM = Proto.ContactRecord.IdentityState; @@ -304,6 +311,7 @@ export async function toContactRecord( const username = conversation.get('username'); const ourID = window.ConversationController.getOurConversationId(); const pni = conversation.getPni(); + const e164 = conversation.get('e164'); const profileKey = conversation.get('profileKey'); const serviceId = aci ?? pni; @@ -312,8 +320,15 @@ export async function toContactRecord( const nicknameFamilyName = conversation.get('nicknameFamilyName'); const hideStory = conversation.get('hideStory'); + const blockedServiceId = serviceId + ? itemStorage.blocked.getBlockedServiceIds().get(serviceId) + : undefined; + const blockedE164 = e164 + ? itemStorage.blocked.getBlockedNumbers().get(e164) + : undefined; + return { - e164: conversation.get('e164') ?? null, + e164: e164 ?? null, aciBinary: isProtoBinaryEncodingEnabled() && aci ? toAciObject(aci).getRawUuidBytes() @@ -347,7 +362,11 @@ export async function toContactRecord( systemGivenName: conversation.get('systemGivenName') || null, systemFamilyName: conversation.get('systemFamilyName') || null, systemNickname: conversation.get('systemNickname') || null, - blocked: conversation.isBlocked(), + blocked: Boolean(blockedServiceId || blockedE164), + blockedAtTimestamp: + makeBigInt(blockedServiceId?.blockedAt) ?? + makeBigInt(blockedE164?.blockedAt) ?? + null, hidden: conversation.get('removalStage') !== undefined, whitelisted: Boolean(conversation.get('profileSharing')), archived: Boolean(conversation.get('isArchived')), @@ -542,6 +561,11 @@ export function toAccountRecord({ storyViewReceiptsEnabledValue = Proto.OptionalBool.UNSET; } + const releaseNotesChatBlocked = signalConversation?.isBlocked() ?? null; + const releaseNotesChatBlockedAt = releaseNotesChatBlocked + ? itemStorage.blocked.whenWasReleaseNotesChatBlocked() + : undefined; + return { profileKey: profileKey ? Bytes.fromBase64(profileKey) : null, givenName: ourConversation.get('profileName') || null, @@ -617,7 +641,10 @@ export function toAccountRecord({ signalConversation?.get('muteExpiresAt'), MAX_VALUE ), - releaseNotesChatBlocked: signalConversation?.isBlocked() ?? null, + releaseNotesChatBlocked, + releaseNotesChatBlockedAt: releaseNotesChatBlockedAt + ? BigInt(releaseNotesChatBlockedAt) + : null, $unknown: conversationUnknownFieldsToRecord(ourConversation), }; @@ -650,13 +677,19 @@ export function toGroupV2Record( throw missingCaseError(localStorySendMode); } + const groupId = conversation.get('groupId'); const avatarColor = conversation.get('colorFromPrimary'); const masterKey = conversation.get('masterKey'); const verifiedNameHash = conversation.get('groupVerifiedNameHash'); + const blockedItem = groupId + ? itemStorage.blocked.getBlockedGroups().get(groupId) + : undefined; + return { masterKey: masterKey != null ? Bytes.fromBase64(masterKey) : null, - blocked: conversation.isBlocked(), + blocked: Boolean(blockedItem), + blockedAtTimestamp: makeBigInt(blockedItem?.blockedAt) ?? null, whitelisted: Boolean(conversation.get('profileSharing')), archived: Boolean(conversation.get('isArchived')), markedUnread: Boolean(conversation.get('markedUnread')), @@ -924,7 +957,11 @@ export function toNotificationProfileRecord( } async function applyMessageRequestState( - record: { blocked: boolean; whitelisted: boolean }, + record: { + blocked: boolean; + blockedAtTimestamp: bigint | undefined; + whitelisted: boolean; + }, conversation: ConversationModel ): Promise { const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; @@ -935,6 +972,7 @@ async function applyMessageRequestState( { source: MessageRequestResponseSource.STORAGE_SERVICE, learnedAtMs: Date.now(), + blockedAt: dropNull(toNumber(record.blockedAtTimestamp)), }, { shouldSave: false } ); @@ -946,6 +984,7 @@ async function applyMessageRequestState( { source: MessageRequestResponseSource.STORAGE_SERVICE, learnedAtMs: Date.now(), + blockedAt: undefined, }, { shouldSave: false } ); @@ -1601,6 +1640,7 @@ export async function mergeAccountRecord( automaticKeyVerificationDisabled, releaseNotesChatArchived, releaseNotesChatBlocked, + releaseNotesChatBlockedAt, releaseNotesChatMarkedUnread, releaseNotesChatMutedUntilTimestamp, } = accountRecord; @@ -2056,6 +2096,7 @@ export async function mergeAccountRecord( { blocked: releaseNotesChatBlocked, whitelisted: !releaseNotesChatBlocked, + blockedAtTimestamp: dropNull(releaseNotesChatBlockedAt), }, signalConversation ); diff --git a/ts/sql/migrations/1770-add-blocked-at.std.ts b/ts/sql/migrations/1770-add-blocked-at.std.ts new file mode 100644 index 0000000000..4daac01be1 --- /dev/null +++ b/ts/sql/migrations/1770-add-blocked-at.std.ts @@ -0,0 +1,128 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { getById, createOrUpdate } from '../util.std.ts'; +import { toLogFormat } from '../../types/errors.std.ts'; + +import type { ServiceIdString } from '../../types/ServiceId.std.ts'; +import type { WritableDB } from '../Interface.std.ts'; +import type { LoggerType } from '../../types/Logging.std.ts'; + +const ITEMS_TABLE = 'items'; +const BLOCKED_NUMBER_KEY = 'blocked'; +const BLOCKED_SERVICE_IDS_KEY = 'blocked-uuids'; +const BLOCKED_GROUP_KEY = 'blocked-groups'; + +// Old types + +type OldBlockedNumberList = ReadonlyArray; +type OldBlockedServiceIdsList = ReadonlyArray; +type OldBlockedGroupList = ReadonlyArray; + +type Item = { + id: string; + value: T; +}; + +// New types: + +type BlockedNumber = { + blockedAt: number | undefined; + e164: string; +}; +type BlockedNumberList = ReadonlyArray; + +type BlockedServiceId = { + blockedAt: number | undefined; + serviceId: ServiceIdString; +}; +type BlockedServiceIdList = ReadonlyArray; + +type BlockedGroup = { + blockedAt: number | undefined; + groupId: string; +}; +type BlockedGroupList = ReadonlyArray; + +export default function updateToSchemaVersion1770( + db: WritableDB, + logger: LoggerType +): void { + const logId = 'updateToSchemaVersion1770'; + + try { + const blockedNumbers = getById(db, ITEMS_TABLE, BLOCKED_NUMBER_KEY) as Item< + OldBlockedNumberList | undefined + >; + if (blockedNumbers?.value) { + const updatedNumbers: BlockedNumberList = blockedNumbers.value.map( + e164 => ({ + e164, + blockedAt: undefined, + }) + ); + + const item: Item = { + id: BLOCKED_NUMBER_KEY, + value: updatedNumbers, + }; + createOrUpdate(db, ITEMS_TABLE, item); + } + } catch (error) { + logger.error( + `${logId}: Failed to update '${BLOCKED_NUMBER_KEY}' item`, + toLogFormat(error) + ); + } + + try { + const blockedServiceIds = getById( + db, + ITEMS_TABLE, + BLOCKED_SERVICE_IDS_KEY + ) as Item; + if (blockedServiceIds?.value) { + const updatedServiceIds: BlockedServiceIdList = + blockedServiceIds.value.map(serviceId => ({ + serviceId, + blockedAt: undefined, + })); + + const item: Item = { + id: BLOCKED_SERVICE_IDS_KEY, + value: updatedServiceIds, + }; + createOrUpdate(db, ITEMS_TABLE, item); + } + } catch (error) { + logger.error( + `${logId}: Failed to update '${BLOCKED_SERVICE_IDS_KEY}' item`, + toLogFormat(error) + ); + } + + try { + const blockedGroups = getById(db, ITEMS_TABLE, BLOCKED_GROUP_KEY) as Item< + OldBlockedGroupList | undefined + >; + if (blockedGroups?.value) { + const updatedGroups: BlockedGroupList = blockedGroups.value.map( + groupId => ({ + groupId, + blockedAt: undefined, + }) + ); + + const item: Item = { + id: BLOCKED_GROUP_KEY, + value: updatedGroups, + }; + createOrUpdate(db, ITEMS_TABLE, item); + } + } catch (error) { + logger.error( + `${logId}: Failed to update '${BLOCKED_GROUP_KEY}' item`, + toLogFormat(error) + ); + } +} diff --git a/ts/sql/migrations/index.node.ts b/ts/sql/migrations/index.node.ts index 3ff6a0d36d..b8e317bf60 100644 --- a/ts/sql/migrations/index.node.ts +++ b/ts/sql/migrations/index.node.ts @@ -153,6 +153,7 @@ import updateToSchemaVersion1730 from './1730-protected-attachments-dedupe-token import updateToSchemaVersion1740 from './1740-cleanup-groups.node.ts'; import updateToSchemaVersion1750 from './1750-fts-url.std.ts'; import updateToSchemaVersion1760 from './1760-delete-story-reply-attachment.std.ts'; +import updateToSchemaVersion1770 from './1770-add-blocked-at.std.ts'; import { DataWriter } from '../Server.node.ts'; import { strictAssert } from '../../util/assert.std.ts'; @@ -1662,6 +1663,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1670, update: updateToSchemaVersion1670 }, { version: 1680, update: updateToSchemaVersion1680 }, { version: 1690, update: updateToSchemaVersion1690 }, + { version: 1700, update: updateToSchemaVersion1700 }, { version: 1710, update: updateToSchemaVersion1710 }, { version: 1720, update: updateToSchemaVersion1720 }, @@ -1669,6 +1671,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1740, update: updateToSchemaVersion1740 }, { version: 1750, update: updateToSchemaVersion1750 }, { version: 1760, update: updateToSchemaVersion1760 }, + { version: 1770, update: updateToSchemaVersion1770 }, ]; class DBVersionFromFutureError extends Error { diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index b112a28ead..2e952aed75 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -3770,7 +3770,7 @@ async function syncMessageRequestResponse( response, { source: MessageRequestResponseSource.LOCAL, - timestamp: Date.now(), + blockedAt: Date.now(), }, { shouldSave } ); @@ -4009,7 +4009,7 @@ function acceptConversation( messageRequestEnum.ACCEPT, { source: MessageRequestResponseSource.LOCAL, - timestamp: Date.now(), + blockedAt: Date.now(), }, { shouldSave: true } ); @@ -4085,7 +4085,7 @@ function blockConversation( messageRequestEnum.BLOCK, { source: MessageRequestResponseSource.LOCAL, - timestamp: Date.now(), + blockedAt: Date.now(), }, { shouldSave: true } ); diff --git a/ts/state/smart/Preferences.preload.tsx b/ts/state/smart/Preferences.preload.tsx index 6fd861d197..ae84fce0ee 100644 --- a/ts/state/smart/Preferences.preload.tsx +++ b/ts/state/smart/Preferences.preload.tsx @@ -9,7 +9,6 @@ import type { MutableRefObject, JSX } from 'react'; import { useItemsActions } from '../ducks/items.preload.ts'; import { useConversationsActions } from '../ducks/conversations.preload.ts'; -import type { ConversationType } from '../ducks/conversations.preload.ts'; import { getConversationSelector, getConversationsWithCustomColorSelector, @@ -100,29 +99,30 @@ import { SmartNotificationProfilesCreateFlow, SmartNotificationProfilesHome, } from './PreferencesNotificationProfiles.preload.tsx'; - -import type { SettingsLocation } from '../../types/Nav.std.ts'; -import type { StorageAccessType } from '../../types/Storage.d.ts'; -import type { ThemeType } from '../../util/preload.preload.ts'; -import type { WidthBreakpoint } from '../../components/_util.std.ts'; +import { isLocalBackupsEnabled } from '../../util/isLocalBackupsEnabled.preload.ts'; +import { getBackupKeyHash } from '../../services/backups/crypto.preload.ts'; +import { Emoji } from '../../axo/emoji.std.ts'; +import { AppProvider } from '../../windows/AppProvider.dom.tsx'; +import { useMegaphonesActions } from '../ducks/megaphones.preload.ts'; import { DialogType } from '../../types/Dialogs.std.ts'; import { promptOSAuth } from '../../util/promptOSAuth.preload.ts'; -import type { StateType } from '../reducer.preload.ts'; import { pauseBackupMediaDownload, resumeBackupMediaDownload, cancelBackupMediaDownload, } from '../../util/backupMediaDownload.preload.ts'; + +import type { SettingsLocation } from '../../types/Nav.std.ts'; +import type { StorageAccessType } from '../../types/Storage.d.ts'; +import type { ThemeType } from '../../util/preload.preload.ts'; +import type { WidthBreakpoint } from '../../components/_util.std.ts'; +import type { StateType } from '../reducer.preload.ts'; import { DonationsErrorBoundary } from '../../components/DonationsErrorBoundary.dom.tsx'; import type { SmartPreferencesChatFoldersPageProps } from './PreferencesChatFoldersPage.preload.tsx'; import type { SmartPreferencesEditChatFolderPageProps } from './PreferencesEditChatFolderPage.preload.tsx'; import type { ExternalProps as SmartNotificationProfilesProps } from './PreferencesNotificationProfiles.preload.tsx'; -import { useMegaphonesActions } from '../ducks/megaphones.preload.ts'; import type { ZoomFactorType } from '../../types/StorageKeys.std.ts'; -import { isLocalBackupsEnabled } from '../../util/isLocalBackupsEnabled.preload.ts'; -import { getBackupKeyHash } from '../../services/backups/crypto.preload.ts'; -import { Emoji } from '../../axo/emoji.std.ts'; -import { AppProvider } from '../../windows/AppProvider.dom.tsx'; +import type { BlockedConversation } from '../../components/Preferences.dom.tsx'; const DEFAULT_NOTIFICATION_SETTING = 'message'; @@ -591,29 +591,43 @@ export function SmartPreferences(): JSX.Element | null { const defaultConversationColor = items.defaultConversationColor || DEFAULT_CONVERSATION_COLOR; - const blockedContacts: Array = useMemo(() => { - const result = new Set(); + const blockedContacts: Array = useMemo(() => { + const result = new Map(); (items['blocked-uuids'] ?? []).forEach(item => { - result.add(conversationSelector(item)); + const conversation = conversationSelector(item.serviceId); + result.set(conversation.id, { + conversation, + blockedAt: item.blockedAt, + }); }); (items.blocked ?? []).forEach(item => { - const conversation = conversationSelector(item); - if (!result.has(conversation)) { - result.add(conversation); + const conversation = conversationSelector(item.e164); + if (!result.has(conversation.id)) { + result.set(conversation.id, { + conversation, + blockedAt: item.blockedAt, + }); } }); if (items.releaseNotesChatBlocked) { - result.add(conversationSelector(SIGNAL_ACI)); + const conversation = conversationSelector(SIGNAL_ACI); + result.set(conversation.id, { + conversation, + blockedAt: undefined /* TODO */, + }); } - return Array.from(result); + return Array.from(result.values()); }, [items, conversationSelector]); - const blockedGroups: Array = useMemo(() => { - const result: Array = []; + const blockedGroups: Array = useMemo(() => { + const result: Array = []; (items['blocked-groups'] ?? []).forEach(item => { - result.push(conversationSelector(item)); + result.push({ + conversation: conversationSelector(item.groupId), + blockedAt: item.blockedAt, + }); }); return result; }, [items, conversationSelector]); diff --git a/ts/test-electron/MessageReceiver_test.preload.ts b/ts/test-electron/MessageReceiver_test.preload.ts index be668132ae..c40e301da8 100644 --- a/ts/test-electron/MessageReceiver_test.preload.ts +++ b/ts/test-electron/MessageReceiver_test.preload.ts @@ -2,14 +2,15 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import sinon from 'sinon'; +import { v4 as generateUuid } from 'uuid'; +import { Aci } from '@signalapp/libsignal-client'; import MessageReceiver from '../textsecure/MessageReceiver.preload.ts'; import { IncomingWebSocketRequest, ServerRequestType, } from '../textsecure/WebsocketResources.preload.ts'; -import type { DecryptionErrorEvent } from '../textsecure/messageReceiverEvents.std.ts'; -import type { AciString } from '../types/ServiceId.std.ts'; import { toAciObject } from '../util/ServiceId.node.ts'; import { SignalService as Proto } from '../protobuf/index.std.ts'; import * as Crypto from '../Crypto.node.ts'; @@ -17,6 +18,25 @@ import { toBase64 } from '../Bytes.std.ts'; import { signalProtocolStore } from '../SignalProtocolStore.preload.ts'; import { itemStorage } from '../textsecure/Storage.preload.ts'; import { generateAci } from '../test-helpers/serviceIdUtils.std.ts'; +import { DataWriter } from '../sql/Client.preload.ts'; + +import type { DecryptionErrorEvent } from '../textsecure/messageReceiverEvents.std.ts'; +import { + normalizePni, + normalizeServiceId, + type AciString, +} from '../types/ServiceId.std.ts'; +import type { ProcessedEnvelope } from '../textsecure/Types.d.ts'; +import type { ConversationModel } from '../models/conversations.preload.ts'; +import type { + ConversationAttributesType, + ConversationAttributesTypeType, +} from '../model-types.d.ts'; +import { + ReceivedTimestampMs, + SentTimestampMs, + ServerTimestampMs, +} from '@signalapp/types'; describe('MessageReceiver', () => { const someAci = generateAci(); @@ -25,6 +45,13 @@ describe('MessageReceiver', () => { let oldAci: AciString | undefined; let oldDeviceId: number | undefined; + const fakeTrustRootPublicKey = Crypto.getRandomBytes(33); + fakeTrustRootPublicKey.set([5], 0); // first byte is the key type (5) + + before(async () => { + await window.ConversationController.load(); + }); + beforeEach(async () => { oldAci = itemStorage.user.getAci(); oldDeviceId = itemStorage.user.getDeviceId(); @@ -41,9 +68,6 @@ describe('MessageReceiver', () => { describe('connecting', () => { it('generates decryption-error event when it cannot decrypt', async () => { - const fakeTrustRootPublicKey = Crypto.getRandomBytes(33); - fakeTrustRootPublicKey.set([5], 0); // first byte is the key type (5) - const messageReceiver = new MessageReceiver({ storage: itemStorage, serverTrustRoots: [toBase64(fakeTrustRootPublicKey)], @@ -96,4 +120,356 @@ describe('MessageReceiver', () => { await messageReceiver.drain(); }); }); + + describe('handleBlocked', () => { + const now = Date.now(); + + const ME_E164 = '+18005551110'; + const E164_1 = '+18005551111'; + const E164_2 = '+18005551112'; + const E164_3 = '+18005551113'; + const ME_UUID = generateUuid(); + const UUID_1 = generateUuid(); + const UUID_2 = generateUuid(); + const UUID_3 = generateUuid(); + const GROUP_1 = Crypto.getRandomBytes(32).toBase64(); + const GROUP_2 = Crypto.getRandomBytes(32).toBase64(); + const GROUP_3 = Crypto.getRandomBytes(32).toBase64(); + + function addConversation( + identifier: string, + type: ConversationAttributesTypeType = 'private', + additionalAttributes?: Partial + ): ConversationModel { + const conversation = window.ConversationController.getOrCreate( + identifier, + type, + additionalAttributes + ); + conversation.applyMessageRequestResponse = sinon.spy(); + return conversation; + } + + beforeEach(async () => { + await DataWriter._removeAllConversations(); + window.ConversationController.reset(); + await window.ConversationController.load(); + + await DataWriter.removeAllItems(); + itemStorage.reset(); + await itemStorage.fetch(); + + const e1 = addConversation(E164_1); + e1.block({ viaStorageServiceSync: false, timestamp: now + 1 }); + const e2 = addConversation(E164_2); + e2.block({ viaStorageServiceSync: false, timestamp: now + 2 }); + addConversation(E164_3); + + const u1 = addConversation(UUID_1); + u1.block({ viaStorageServiceSync: false, timestamp: now + 10 + 1 }); + const u2 = addConversation(UUID_2); + u2.block({ viaStorageServiceSync: false, timestamp: now + 10 + 2 }); + addConversation(UUID_3); + + const g1 = addConversation(GROUP_1, 'group'); + g1.block({ viaStorageServiceSync: false, timestamp: now + 20 + 1 }); + const g2 = addConversation(GROUP_2, 'group'); + g2.block({ viaStorageServiceSync: false, timestamp: now + 20 + 2 }); + addConversation(GROUP_3, 'group'); + }); + + afterEach(async () => { + await DataWriter._removeAllConversations(); + window.ConversationController.reset(); + await window.ConversationController.load(); + + await DataWriter.removeAllItems(); + itemStorage.reset(); + await itemStorage.fetch(); + }); + + it('handles modern fields', async () => { + const messageReceiver = new MessageReceiver({ + storage: itemStorage, + serverTrustRoots: [toBase64(fakeTrustRootPublicKey)], + }); + + const processedEnvelope: ProcessedEnvelope = { + id: generateUuid(), + receivedAtCounter: 1, + receivedAtDate: ReceivedTimestampMs.fromNumber(now - 1), + messageAgeSec: 1, + + type: Proto.Envelope.Type.DOUBLE_RATCHET, + source: ME_E164, + sourceServiceId: normalizeServiceId(ME_UUID, 'test1'), + sourceDevice: 1, + destinationServiceId: normalizeServiceId(ME_UUID, 'test2'), + updatedPni: normalizePni(generateUuid(), 'test3'), + timestamp: SentTimestampMs.fromNumber(now - 2), + content: Crypto.getRandomBytes(200), + serverGuid: generateUuid(), + serverTimestamp: ServerTimestampMs.fromNumber(now - 3), + groupId: undefined, + urgent: false, + story: false, + reportingToken: undefined, + }; + const blocked: Proto.SyncMessage.Blocked = { + numbers: [], + acis: [], + groupIds: [], + acisBinary: [], + blockedE164s: [ + { e164: E164_1, timestamp: BigInt(now + 30 + 1), $unknown: [] }, + { e164: E164_3, timestamp: BigInt(now + 30 + 3), $unknown: [] }, + ], + blockedAcis: [ + { + aciBinary: Aci.parseFromServiceIdString(UUID_1).getRawUuidBytes(), + timestamp: BigInt(now + 40 + 1), + $unknown: [], + }, + { + aciBinary: Aci.parseFromServiceIdString(UUID_3).getRawUuidBytes(), + timestamp: BigInt(now + 40 + 3), + $unknown: [], + }, + ], + blockedGroups: [ + { + groupId: Uint8Array.fromBase64(GROUP_1), + timestamp: BigInt(now + 50 + 1), + $unknown: [], + }, + { + groupId: Uint8Array.fromBase64(GROUP_3), + timestamp: BigInt(now + 50 + 3), + $unknown: [], + }, + ], + $unknown: [], + }; + + await messageReceiver._handleBlocked(processedEnvelope, blocked); + + const e1 = window.ConversationController.get(E164_1); + assert.isTrue(e1?.isBlocked(), 'e1 should be blocked'); + const e1BlockItem = itemStorage.blocked.getBlockedNumbers().get(E164_1); + assert.strictEqual( + e1BlockItem?.blockedAt, + now + 30 + 1, + 'e1 should have an updated blockedAt' + ); + + const e2 = window.ConversationController.get(E164_2); + assert.isFalse(e2?.isBlocked(), 'e2 should not be blocked'); + const e3 = window.ConversationController.get(E164_3); + assert.isTrue(e3?.isBlocked(), 'e3 should be blocked'); + const e3BlockItem = itemStorage.blocked.getBlockedNumbers().get(E164_3); + assert.strictEqual( + e3BlockItem?.blockedAt, + now + 30 + 3, + 'e3 should take new blockedAt from sync' + ); + + const u1 = window.ConversationController.get(UUID_1); + assert.isTrue(u1?.isBlocked(), 'u1 should be blocked'); + const u1BlockItem = itemStorage.blocked + .getBlockedServiceIds() + .get(UUID_1); + assert.strictEqual( + u1BlockItem?.blockedAt, + now + 40 + 1, + 'u1 should have an updated blockedAt' + ); + + const u2 = window.ConversationController.get(UUID_2); + assert.isFalse(u2?.isBlocked(), 'u2 should not be blocked'); + + const u3 = window.ConversationController.get(UUID_3); + assert.isTrue(u3?.isBlocked(), 'u3 should be blocked'); + const u3BlockItem = itemStorage.blocked + .getBlockedServiceIds() + .get(UUID_3); + assert.strictEqual( + u3BlockItem?.blockedAt, + now + 40 + 3, + 'u3 should take new blockedAt from sync' + ); + + const g1 = window.ConversationController.get(GROUP_1); + assert.isTrue(g1?.isBlocked(), 'g1 should be blocked'); + const g1BlockItem = itemStorage.blocked.getBlockedGroups().get(GROUP_1); + assert.strictEqual( + g1BlockItem?.blockedAt, + now + 50 + 1, + 'g1 should have an updated blockedAt' + ); + + const g2 = window.ConversationController.get(GROUP_2); + assert.isFalse(g2?.isBlocked(), 'g2 should not be blocked'); + + const g3 = window.ConversationController.get(GROUP_3); + assert.isTrue(g3?.isBlocked(), 'g3 should be blocked'); + const g3BlockItem = itemStorage.blocked.getBlockedGroups().get(GROUP_3); + assert.strictEqual( + g3BlockItem?.blockedAt, + now + 50 + 3, + 'g3 should take new blockedAt from sync' + ); + }); + + it('handles legacy fields', async () => { + const messageReceiver = new MessageReceiver({ + storage: itemStorage, + serverTrustRoots: [toBase64(fakeTrustRootPublicKey)], + }); + + const processedEnvelope: ProcessedEnvelope = { + id: generateUuid(), + receivedAtCounter: 1, + receivedAtDate: ReceivedTimestampMs.fromNumber(now - 1), + messageAgeSec: 1, + + type: Proto.Envelope.Type.DOUBLE_RATCHET, + source: ME_E164, + sourceServiceId: normalizeServiceId(ME_UUID, 'test1'), + sourceDevice: 1, + destinationServiceId: normalizeServiceId(ME_UUID, 'test2'), + updatedPni: normalizePni(generateUuid(), 'test3'), + timestamp: SentTimestampMs.fromNumber(now - 2), + content: Crypto.getRandomBytes(200), + serverGuid: generateUuid(), + serverTimestamp: ServerTimestampMs.fromNumber(now - 3), + groupId: undefined, + urgent: false, + story: false, + reportingToken: undefined, + }; + const blocked: Proto.SyncMessage.Blocked = { + numbers: [E164_1, E164_3], + acis: [UUID_1, UUID_3], + groupIds: [ + Uint8Array.fromBase64(GROUP_1), + Uint8Array.fromBase64(GROUP_3), + ], + acisBinary: [], + blockedE164s: [], + blockedAcis: [], + blockedGroups: [], + $unknown: [], + }; + + await messageReceiver._handleBlocked(processedEnvelope, blocked); + + const e1 = window.ConversationController.get(E164_1); + assert.isTrue(e1?.isBlocked(), 'e1 should be blocked'); + const e1BlockItem = itemStorage.blocked.getBlockedNumbers().get(E164_1); + assert.strictEqual( + e1BlockItem?.blockedAt, + now + 1, + 'e1 should keep its blockedAt' + ); + + const e2 = window.ConversationController.get(E164_2); + assert.isFalse(e2?.isBlocked(), 'e2 should not be blocked'); + const e3 = window.ConversationController.get(E164_3); + assert.isTrue(e3?.isBlocked(), 'e3 should be blocked'); + const e3BlockItem = itemStorage.blocked.getBlockedNumbers().get(E164_3); + assert.isUndefined(e3BlockItem?.blockedAt, 'e3 should have no blockedAt'); + + const u1 = window.ConversationController.get(UUID_1); + assert.isTrue(u1?.isBlocked(), 'u1 should be blocked'); + const u1BlockItem = itemStorage.blocked + .getBlockedServiceIds() + .get(UUID_1); + assert.strictEqual( + u1BlockItem?.blockedAt, + now + 10 + 1, + 'u1 should keep its blockedAt' + ); + + const u2 = window.ConversationController.get(UUID_2); + assert.isFalse(u2?.isBlocked(), 'u2 should not be blocked'); + + const u3 = window.ConversationController.get(UUID_3); + assert.isTrue(u3?.isBlocked(), 'u3 should be blocked'); + const u3BlockItem = itemStorage.blocked + .getBlockedServiceIds() + .get(UUID_3); + assert.isUndefined(u3BlockItem?.blockedAt, 'u3 should have no blockedAt'); + + const g1 = window.ConversationController.get(GROUP_1); + assert.isTrue(g1?.isBlocked(), 'g1 should be blocked'); + const g1BlockItem = itemStorage.blocked.getBlockedGroups().get(GROUP_1); + assert.strictEqual( + g1BlockItem?.blockedAt, + now + 20 + 1, + 'g1 should keep its blockedAt' + ); + + const g2 = window.ConversationController.get(GROUP_2); + assert.isFalse(g2?.isBlocked(), 'g2 should not be blocked'); + + const g3 = window.ConversationController.get(GROUP_3); + assert.isTrue(g3?.isBlocked(), 'g3 should be blocked'); + const g3BlockItem = itemStorage.blocked.getBlockedGroups().get(GROUP_3); + assert.isUndefined(g3BlockItem?.blockedAt, 'g3 should have no blockedAt'); + }); + + it('handles legacy fields with acisBinary set', async () => { + const messageReceiver = new MessageReceiver({ + storage: itemStorage, + serverTrustRoots: [toBase64(fakeTrustRootPublicKey)], + }); + + const processedEnvelope: ProcessedEnvelope = { + id: generateUuid(), + receivedAtCounter: 1, + receivedAtDate: ReceivedTimestampMs.fromNumber(now - 1), + messageAgeSec: 1, + + type: Proto.Envelope.Type.DOUBLE_RATCHET, + source: ME_E164, + sourceServiceId: normalizeServiceId(ME_UUID, 'test1'), + sourceDevice: 1, + destinationServiceId: normalizeServiceId(ME_UUID, 'test2'), + updatedPni: normalizePni(generateUuid(), 'test3'), + timestamp: SentTimestampMs.fromNumber(now - 2), + content: Crypto.getRandomBytes(200), + serverGuid: generateUuid(), + serverTimestamp: ServerTimestampMs.fromNumber(now - 3), + groupId: undefined, + urgent: false, + story: false, + reportingToken: undefined, + }; + const blocked: Proto.SyncMessage.Blocked = { + numbers: [E164_1, E164_2], + acis: [], + groupIds: [ + Uint8Array.fromBase64(GROUP_1), + Uint8Array.fromBase64(GROUP_2), + ], + acisBinary: [ + Aci.parseFromServiceIdString(UUID_1).getRawUuidBytes(), + Aci.parseFromServiceIdString(UUID_3).getRawUuidBytes(), + ], + blockedE164s: [], + blockedAcis: [], + blockedGroups: [], + $unknown: [], + }; + + await messageReceiver._handleBlocked(processedEnvelope, blocked); + + const u1 = window.ConversationController.get(UUID_1); + assert.isTrue(u1?.isBlocked(), 'u1 should be blocked'); + const u2 = window.ConversationController.get(UUID_2); + assert.isFalse(u2?.isBlocked(), 'u2 should not be blocked'); + const u3 = window.ConversationController.get(UUID_3); + assert.isTrue(u3?.isBlocked(), 'u3 should be blocked'); + }); + }); }); diff --git a/ts/test-electron/backup/conversations_test.preload.ts b/ts/test-electron/backup/conversations_test.preload.ts index 7b106925f7..041d413dbc 100644 --- a/ts/test-electron/backup/conversations_test.preload.ts +++ b/ts/test-electron/backup/conversations_test.preload.ts @@ -108,7 +108,11 @@ describe('backup/conversations', () => { } ); - await itemStorage.blocked.addBlockedGroup(blockedGroupInfo.groupId); + const timestamp = Date.now(); + await itemStorage.blocked.addBlockedGroup( + blockedGroupInfo.groupId, + timestamp + ); await symmetricRoundtripHarness([]); @@ -116,6 +120,15 @@ describe('backup/conversations', () => { blockedGroupInfo.groupId ); assert.isTrue(blockedGroupAfter?.isBlocked()); + const blockedGroupItem = itemStorage.blocked + .getBlockedGroups() + .get(blockedGroupInfo.groupId); + assert.strictEqual( + blockedGroupItem?.blockedAt, + timestamp, + 'Timestamp on blocked group should be rountripped' + ); + const unblockedGroupAfter = window.ConversationController.get( unblockedGroupInfo.groupId ); diff --git a/ts/test-helpers/generateBackup.node.ts b/ts/test-helpers/generateBackup.node.ts index e5be15fea7..6b9ee978ea 100644 --- a/ts/test-helpers/generateBackup.node.ts +++ b/ts/test-helpers/generateBackup.node.ts @@ -202,6 +202,7 @@ function* createRecords({ contact: { aci: chatAci, blocked: false, + blockedAtTimestamp: null, visibility: Backups.Contact.Visibility.VISIBLE, registration: { registered: {}, diff --git a/ts/test-node/sql/migration_1770_test.node.ts b/ts/test-node/sql/migration_1770_test.node.ts new file mode 100644 index 0000000000..978d2f1fc3 --- /dev/null +++ b/ts/test-node/sql/migration_1770_test.node.ts @@ -0,0 +1,87 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as generateUuid } from 'uuid'; + +import type { WritableDB } from '../../sql/Interface.std.ts'; +import { + createDB, + updateToVersion, + insertData, + getTableData, +} from './helpers.node.ts'; +import { getRandomBytes } from '../../Crypto.node.ts'; +import { sortBy } from 'lodash'; + +describe('SQL/updateToSchemaVersion1770', () => { + let db: WritableDB; + + beforeEach(() => { + db = createDB(); + }); + + afterEach(() => { + db.close(); + }); + + const E164_1 = '+18005551111'; + const E164_2 = '+18005551112'; + const UUID_1 = generateUuid(); + const UUID_2 = generateUuid(); + const GROUP_1 = getRandomBytes(32).toBase64(); + const GROUP_2 = getRandomBytes(32).toBase64(); + + it('removes the cached attachment but preserves the author', () => { + updateToVersion(db, 1760); + insertData(db, 'items', [ + { + id: 'blocked', + json: { + id: 'blocked', + value: [E164_1, E164_2], + }, + }, + { + id: 'blocked-groups', + json: { + id: 'blocked-groups', + value: [GROUP_1, GROUP_2], + }, + }, + { + id: 'blocked-uuids', + json: { + id: 'blocked-uuids', + value: [UUID_1, UUID_2], + }, + }, + ]); + + updateToVersion(db, 1770); + + assert.deepStrictEqual(sortBy(getTableData(db, 'items'), 'id'), [ + { + id: 'blocked', + json: { + id: 'blocked', + value: [{ e164: E164_1 }, { e164: E164_2 }], + }, + }, + { + id: 'blocked-groups', + json: { + id: 'blocked-groups', + value: [{ groupId: GROUP_1 }, { groupId: GROUP_2 }], + }, + }, + { + id: 'blocked-uuids', + json: { + id: 'blocked-uuids', + value: [{ serviceId: UUID_1 }, { serviceId: UUID_2 }], + }, + }, + ]); + }); +}); diff --git a/ts/textsecure/MessageReceiver.preload.ts b/ts/textsecure/MessageReceiver.preload.ts index d85bc34f9a..f7f8131a50 100644 --- a/ts/textsecure/MessageReceiver.preload.ts +++ b/ts/textsecure/MessageReceiver.preload.ts @@ -88,7 +88,7 @@ import type { EventHandler } from './EventTarget.std.ts'; import EventTarget from './EventTarget.std.ts'; import type { IncomingWebSocketRequest } from './WebsocketResources.preload.ts'; import { ServerRequestType } from './WebsocketResources.preload.ts'; -import { type Storage } from './Storage.preload.ts'; +import { itemStorage, type Storage } from './Storage.preload.ts'; import { accountManager } from './AccountManager.preload.ts'; import { WarnOnlyError } from './Errors.std.ts'; import * as Bytes from '../Bytes.std.ts'; @@ -182,6 +182,7 @@ import { SentTimestampMs, ServerTimestampMs, } from '@signalapp/types'; +import type { ConversationAttributesTypeType } from '../model-types.d.ts'; const { isBoolean, isNumber, isString, noop } = lodash; @@ -3056,7 +3057,7 @@ export default class MessageReceiver return this.#handleContacts(envelope, syncMessage.content.contacts); } if (syncMessage.content?.blocked) { - return this.#handleBlocked(envelope, syncMessage.content.blocked); + return this._handleBlocked(envelope, syncMessage.content.blocked); } if (syncMessage.content?.request) { log.info('Got SyncMessage Request'); @@ -3947,7 +3948,8 @@ export default class MessageReceiver // This function calls applyMessageRequestResponse before setting storage so // proper before/after logic can be applied within that function. - async #handleBlocked( + // Exposed only for testing. + async _handleBlocked( envelope: ProcessedEnvelope, blocked: Proto.SyncMessage.Blocked ): Promise { @@ -3961,24 +3963,241 @@ export default class MessageReceiver receivedAtCounter: envelope.receivedAtCounter, receivedAtMs: envelope.receivedAtDate, timestamp: envelope.timestamp, + blockedAt: undefined, }; + const areModernFieldsUsed = + blocked.blockedE164s.length || + blocked.blockedAcis.length || + blocked.blockedGroups.length; + const areLegacyFieldsUsed = + blocked.numbers.length || blocked.acis.length || blocked.groupIds.length; + + if (areModernFieldsUsed || !areLegacyFieldsUsed) { + log.info(`${logId}: Using modern fields`); + + { + const previous = this.#storage.get('blocked', []); + const updatedBlocked = blocked.blockedE164s + .map(item => { + if (!item.e164) { + return; + } + + return { + e164: item.e164, + blockedAt: item.timestamp + ? TimestampMs.fromBigInt(item.timestamp) + : undefined, + }; + }) + .filter(isNotNil); + + const { added, removed } = diffArraysAsSets( + previous.map(item => item.e164), + updatedBlocked.map(item => item.e164) + ); + + if (removed.length) { + await Promise.all( + removed.map(getAndApply(messageRequestEnum.ACCEPT)) + ); + } + + await Promise.all( + updatedBlocked.map(async item => { + if (!item.e164 || !added.includes(item.e164)) { + return; + } + + const conversation = window.ConversationController.getOrCreate( + item.e164, + 'private' + ); + await conversation.applyMessageRequestResponse( + messageRequestEnum.BLOCK, + { + ...responseInfo, + blockedAt: item.blockedAt, + } + ); + }) + ); + + log.info(`${logId}: New e164 blocks:`, added); + log.info(`${logId}: New e164 unblocks:`, removed); + + await this.#storage.put('blocked', updatedBlocked); + itemStorage.blocked.setBlockedNumbers(); + } + + { + const updatedBlocked = blocked.blockedAcis + .map((item, index) => { + try { + const aci = fromAciUuidBytes(item.aciBinary); + if (!aci) { + return undefined; + } + + return { + serviceId: aci, + blockedAt: item.timestamp + ? TimestampMs.fromBigInt(item.timestamp) + : undefined, + }; + } catch (error) { + log.warn( + `${logId}: ACI ${index} was malformed`, + Errors.toLogFormat(error) + ); + return undefined; + } + }) + .filter(isNotNil); + + const previous = this.#storage.get('blocked-uuids', []); + const { added, removed } = diffArraysAsSets( + previous.map(item => item.serviceId), + updatedBlocked.map(item => item.serviceId) + ); + + if (removed.length) { + await Promise.all( + removed.map(getAndApply(messageRequestEnum.ACCEPT)) + ); + } + + await Promise.all( + updatedBlocked.map(async item => { + if (!added.includes(item.serviceId)) { + return; + } + + const conversation = window.ConversationController.getOrCreate( + item.serviceId, + 'private' + ); + await conversation.applyMessageRequestResponse( + messageRequestEnum.BLOCK, + { + ...responseInfo, + blockedAt: item.blockedAt, + } + ); + }) + ); + + log.info(`${logId}: New aci blocks:`, added); + log.info(`${logId}: New aci unblocks:`, removed); + + await this.#storage.put('blocked-uuids', updatedBlocked); + itemStorage.blocked.setBlockedServiceIds(); + } + + { + const updatedBlocked = blocked.blockedGroups + .map((item, index) => { + const { groupId, timestamp } = item; + + if (groupId?.byteLength !== GROUPV2_ID_LENGTH) { + log.error( + `${logId}: Received invalid groupId value at index ${index}` + ); + return undefined; + } + + return { + groupId: Bytes.toBase64(groupId), + blockedAt: timestamp + ? TimestampMs.fromBigInt(timestamp) + : undefined, + }; + }) + .filter(isNotNil); + + const previous = this.#storage.get('blocked-groups', []); + const { added, removed } = diffArraysAsSets( + previous.map(item => item.groupId), + updatedBlocked.map(item => item.groupId) + ); + + if (removed.length) { + await Promise.all( + removed.map(async item => { + const conversation = window.ConversationController.get(item); + if (!conversation) { + log.warn(`${logId}: Group groupv2(${item}) not found!`); + return; + } + await conversation.applyMessageRequestResponse( + messageRequestEnum.ACCEPT, + responseInfo + ); + }) + ); + } + + await Promise.all( + updatedBlocked.map(async item => { + if (!added.includes(item.groupId)) { + return; + } + + const conversation = window.ConversationController.get( + item.groupId + ); + if (!conversation) { + log.warn(`${logId}: Group groupv2(${item.groupId}) not found!`); + return; + } + await conversation.applyMessageRequestResponse( + messageRequestEnum.BLOCK, + { ...responseInfo, blockedAt: item.blockedAt } + ); + }) + ); + + log.info( + `${logId}: New groupId blocks:`, + added.map(groupId => `groupv2(${groupId})`) + ); + log.info( + `${logId}: New groupId unblocks:`, + removed.map(groupId => `groupv2(${groupId})`) + ); + + await this.#storage.put('blocked-groups', updatedBlocked); + itemStorage.blocked.setBlockedGroups(); + } + + return; + } + + log.info(`${logId}: Using legacy fields`); + function getAndApply( - type: Proto.SyncMessage.MessageRequestResponse.Type + type: Proto.SyncMessage.MessageRequestResponse.Type, + convoType: ConversationAttributesTypeType = 'private' ): (value: string) => Promise { return async item => { const conversation = window.ConversationController.getOrCreate( item, - 'private' + convoType ); await conversation.applyMessageRequestResponse(type, responseInfo); }; } + // If we get here, we need to be ready to keep all existing timestamps! + if (blocked.numbers) { const previous = this.#storage.get('blocked', []); - const { added, removed } = diffArraysAsSets(previous, blocked.numbers); + const { added, removed } = diffArraysAsSets( + previous.map(item => item.e164), + blocked.numbers + ); if (added.length) { await Promise.all(added.map(getAndApply(messageRequestEnum.BLOCK))); } @@ -3988,7 +4207,13 @@ export default class MessageReceiver log.info(`${logId}: New e164 blocks:`, added); log.info(`${logId}: New e164 unblocks:`, removed); - await this.#storage.put('blocked', blocked.numbers); + + const updatedBlocked = previous + .filter(item => !removed.includes(item.e164)) + .concat(added.map(e164 => ({ e164, blockedAt: undefined }))); + + await this.#storage.put('blocked', updatedBlocked); + itemStorage.blocked.setBlockedNumbers(); } if (blocked.acisBinary?.length || blocked.acis?.length) { const previous = this.#storage.get('blocked-uuids', []); @@ -4028,7 +4253,10 @@ export default class MessageReceiver // Older desktops might send the release note serviceId incorrectly acis = acis.filter(aci => !isSignalServiceId(aci)); - const { added, removed } = diffArraysAsSets(previous, acis); + const { added, removed } = diffArraysAsSets( + previous.map(item => item.serviceId), + acis + ); if (added.length) { await Promise.all(added.map(getAndApply(messageRequestEnum.BLOCK))); } @@ -4038,7 +4266,13 @@ export default class MessageReceiver log.info(`${logId}: New aci blocks:`, added); log.info(`${logId}: New aci unblocks:`, removed); - await this.#storage.put('blocked-uuids', acis); + + const updatedBlocked = previous + .filter(item => !removed.includes(item.serviceId)) + .concat(added.map(serviceId => ({ serviceId, blockedAt: undefined }))); + + await this.#storage.put('blocked-uuids', updatedBlocked); + itemStorage.blocked.setBlockedServiceIds(); } if (blocked.groupIds) { @@ -4053,7 +4287,10 @@ export default class MessageReceiver } }); - const { added, removed } = diffArraysAsSets(previous, groupIds); + const { added, removed } = diffArraysAsSets( + previous.map(item => item.groupId), + groupIds + ); if (added.length) { await Promise.all( added.map(async item => { @@ -4093,17 +4330,31 @@ export default class MessageReceiver `${logId}: New groupId unblocks:`, removed.map(groupId => `groupv2(${groupId})`) ); - await this.#storage.put('blocked-groups', groupIds); + + const updatedBlocked = previous + .filter(item => !removed.includes(item.groupId)) + .concat(added.map(groupId => ({ groupId, blockedAt: undefined }))); + + await this.#storage.put('blocked-groups', updatedBlocked); + itemStorage.blocked.setBlockedGroups(); } this.#removeFromCache(envelope); } #isBlocked(number: string): boolean { + const conversation = window.ConversationController.get(number); + if (conversation) { + return conversation.isBlocked(); + } return this.#storage.blocked.isBlocked(number); } #isServiceIdBlocked(serviceId: ServiceIdString): boolean { + const conversation = window.ConversationController.get(serviceId); + if (conversation) { + return conversation.isBlocked(); + } return this.#storage.blocked.isServiceIdBlocked(serviceId); } diff --git a/ts/textsecure/SendMessage.preload.ts b/ts/textsecure/SendMessage.preload.ts index 814c7e3450..abaa312391 100644 --- a/ts/textsecure/SendMessage.preload.ts +++ b/ts/textsecure/SendMessage.preload.ts @@ -103,6 +103,7 @@ import type { SendUnpinMessageType, } from '../types/PinnedMessage.std.ts'; import type { Emoji } from '../axo/emoji.std.ts'; +import type { BlockedNumber } from '../types/StorageKeys.std.ts'; const log = createLogger('SendMessage'); @@ -1202,8 +1203,12 @@ export class MessageSender { const blockedIdentifiers = new Set( concat( - itemStorage.blocked.getBlockedServiceIds(), - itemStorage.blocked.getBlockedNumbers() + Array.from(itemStorage.blocked.getBlockedServiceIds().values()).map( + item => item.serviceId + ), + Array.from(itemStorage.blocked.getBlockedNumbers().values()).map( + item => item.e164 + ) ) ); @@ -2242,25 +2247,44 @@ export class MessageSender { static getBlockSync( options: Readonly<{ - e164s: Array; - acis: Array; - groupIds: Array>; + e164s: ReadonlyArray; + acis: ReadonlyArray<{ + blockedAt: number | undefined; + aci: AciString; + }>; + groupIds: ReadonlyArray<{ + blockedAt: number | undefined; + groupId: Uint8Array; + }>; }> ): SingleProtoJobData { const myAci = itemStorage.user.getCheckedAci(); const blocked: Proto.SyncMessage.Blocked.Params = { - numbers: options.e164s, + numbers: options.e164s.map(item => item.e164), + blockedE164s: options.e164s.map(item => ({ + timestamp: item.blockedAt ? BigInt(item.blockedAt) : null, + e164: item.e164, + })), acisBinary: null, acis: null, - groupIds: options.groupIds, + blockedAcis: options.acis.map(item => ({ + timestamp: item.blockedAt ? BigInt(item.blockedAt) : null, + aci: item.aci, + aciBinary: toAciObject(item.aci).getRawUuidBytes(), + })), + groupIds: options.groupIds.map(item => item.groupId), + blockedGroups: options.groupIds.map(item => ({ + timestamp: item.blockedAt ? BigInt(item.blockedAt) : null, + groupId: item.groupId, + })), }; if (isProtoBinaryEncodingEnabled()) { - blocked.acisBinary = options.acis.map(aci => - toAciObject(aci).getRawUuidBytes() + blocked.acisBinary = options.acis.map(item => + toAciObject(item.aci).getRawUuidBytes() ); } else { - blocked.acis = options.acis; + blocked.acis = options.acis.map(item => item.aci); } const syncMessage = MessageSender.padSyncMessage({ diff --git a/ts/textsecure/Storage.preload.ts b/ts/textsecure/Storage.preload.ts index 64af6da508..cb5f5dbd8f 100644 --- a/ts/textsecure/Storage.preload.ts +++ b/ts/textsecure/Storage.preload.ts @@ -99,6 +99,7 @@ export class Storage implements StorageInterface { this.reset(); Object.assign(this.#items, await DataReader.getAllItems()); + this.blocked.load(); this.#ready = true; this.#callListeners(); @@ -107,6 +108,7 @@ export class Storage implements StorageInterface { public reset(): void { this.#ready = false; this.#items = Object.create(null); + this.blocked.reset(); } public getItemsState(): Partial { diff --git a/ts/textsecure/storage/Blocked.std.ts b/ts/textsecure/storage/Blocked.std.ts index 4ce1ffa67d..74a0f34305 100644 --- a/ts/textsecure/storage/Blocked.std.ts +++ b/ts/textsecure/storage/Blocked.std.ts @@ -1,16 +1,19 @@ // Copyright 2016 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import lodash from 'lodash'; - -import { createLogger } from '../../logging/log.std.ts'; import * as Bytes from '../../Bytes.std.ts'; +import { createLogger } from '../../logging/log.std.ts'; import { isAciString } from '../../util/isAciString.std.ts'; +import { isSignalServiceId } from '../../types/SignalConversation.std.ts'; +import { isNotNil } from '../../util/isNotNil.std.ts'; + import type { StorageInterface } from '../../types/Storage.d.ts'; import type { AciString, ServiceIdString } from '../../types/ServiceId.std.ts'; -import { isSignalServiceId } from '../../types/SignalConversation.std.ts'; - -const { without } = lodash; +import type { + BlockedGroup, + BlockedNumber, + BlockedServiceId, +} from '../../types/StorageKeys.std.ts'; const log = createLogger('Blocked'); @@ -18,63 +21,120 @@ const BLOCKED_NUMBERS_ID = 'blocked'; export const BLOCKED_UUIDS_ID = 'blocked-uuids'; const BLOCKED_GROUPS_ID = 'blocked-groups'; const RELEASE_NOTES_CHAT_BLOCKED_ID = 'releaseNotesChatBlocked'; +const RELEASE_NOTES_CHAT_BLOCKED_AT_ID = 'releaseNotesChatBlockedAt'; export class Blocked { readonly #storage: StorageInterface; + readonly #blockedNumbers: Map; + readonly #blockedServiceIds: Map; + readonly #blockedGroups: Map; + constructor(storage: StorageInterface) { this.#storage = storage; + + this.#blockedNumbers = new Map(); + this.#blockedServiceIds = new Map(); + this.#blockedGroups = new Map(); + + this.load(); } - public getBlockedNumbers(): Array { - return this.#storage.get(BLOCKED_NUMBERS_ID, new Array()); + public reset(): void { + this.#blockedNumbers.clear(); + this.#blockedServiceIds.clear(); + this.#blockedGroups.clear(); } - public isBlocked(number: string): boolean { - return this.getBlockedNumbers().includes(number); + public load(): void { + this.setBlockedNumbers(); + this.setBlockedServiceIds(); + this.setBlockedGroups(); } - public async addBlockedNumber(number: string): Promise { - const numbers = this.getBlockedNumbers(); - if (numbers.includes(number)) { + public setBlockedNumbers(): void { + const array = this.#storage.get(BLOCKED_NUMBERS_ID); + this.#blockedNumbers.clear(); + array?.forEach(item => { + this.#blockedNumbers.set(item.e164, item); + }); + } + public getBlockedNumbers(): ReadonlyMap { + return this.#blockedNumbers; + } + + public isBlocked(e164: string): boolean { + return Boolean(this.#blockedNumbers.get(e164)); + } + + public async addBlockedNumber( + e164: string, + blockedAt: number | undefined + ): Promise { + if (this.isBlocked(e164)) { return; } - log.info('adding', number, 'to blocked list'); - await this.#storage.put(BLOCKED_NUMBERS_ID, numbers.concat(number)); + log.info('adding', e164, 'to blocked list'); + + const data = { e164, blockedAt }; + this.#blockedNumbers.set(e164, data); + + const array = this.#storage.get(BLOCKED_NUMBERS_ID); + await this.#storage.put(BLOCKED_NUMBERS_ID, (array || []).concat(data)); } - public async removeBlockedNumber(number: string): Promise { - const numbers = this.getBlockedNumbers(); - if (!numbers.includes(number)) { + public async removeBlockedNumber(e164: string): Promise { + if (!this.isBlocked(e164)) { return; } - log.info('removing', number, 'from blocked list'); - await this.#storage.put(BLOCKED_NUMBERS_ID, without(numbers, number)); + log.info('removing', e164, 'from blocked list'); + + this.#blockedNumbers.delete(e164); + + const array = this.#storage.get(BLOCKED_NUMBERS_ID); + await this.#storage.put( + BLOCKED_NUMBERS_ID, + (array || []).filter(item => item.e164 !== e164) + ); } - public getBlockedServiceIds(): Array { - return this.#storage.get(BLOCKED_UUIDS_ID, new Array()); + public setBlockedServiceIds(): void { + const array = this.#storage.get(BLOCKED_UUIDS_ID); + this.#blockedServiceIds.clear(); + array?.forEach(item => { + this.#blockedServiceIds.set(item.serviceId, item); + }); + } + public getBlockedServiceIds(): ReadonlyMap { + return this.#blockedServiceIds; } public isServiceIdBlocked(serviceId: ServiceIdString): boolean { - return this.getBlockedServiceIds().includes(serviceId); + return Boolean(this.#blockedServiceIds.get(serviceId)); } - public async addBlockedServiceId(serviceId: ServiceIdString): Promise { + public async addBlockedServiceId( + serviceId: ServiceIdString, + blockedAt: number | undefined + ): Promise { if (isSignalServiceId(serviceId)) { log.error('Attempting to block release notes chat by serviceId'); return; } - const serviceIds = this.getBlockedServiceIds(); - if (serviceIds.includes(serviceId)) { + if (this.isServiceIdBlocked(serviceId)) { return; } log.info('adding', serviceId, 'to blocked list'); - await this.#storage.put(BLOCKED_UUIDS_ID, serviceIds.concat(serviceId)); + + const data = { serviceId, blockedAt }; + this.#blockedServiceIds.set(serviceId, data); + + const array = this.#storage.get(BLOCKED_UUIDS_ID); + await this.#storage.put(BLOCKED_UUIDS_ID, (array || []).concat(data)); } public async removeBlockedServiceId( @@ -85,61 +145,115 @@ export class Blocked { return; } - const numbers = this.getBlockedServiceIds(); - if (!numbers.includes(serviceId)) { + if (!this.isServiceIdBlocked(serviceId)) { return; } log.info('removing', serviceId, 'from blocked list'); - await this.#storage.put(BLOCKED_UUIDS_ID, without(numbers, serviceId)); + + this.#blockedServiceIds.delete(serviceId); + + const array = this.#storage.get(BLOCKED_UUIDS_ID); + await this.#storage.put( + BLOCKED_UUIDS_ID, + (array || []).filter(item => item.serviceId !== serviceId) + ); } public isReleaseNotesChatBlocked(): boolean { return this.#storage.get(RELEASE_NOTES_CHAT_BLOCKED_ID, false); } - - public async setReleaseNotesChatBlocked(blocked: boolean): Promise { - await this.#storage.put(RELEASE_NOTES_CHAT_BLOCKED_ID, blocked); + public whenWasReleaseNotesChatBlocked(): number | undefined { + return this.#storage.get(RELEASE_NOTES_CHAT_BLOCKED_AT_ID, undefined); } - public getBlockedGroups(): Array { - return this.#storage.get(BLOCKED_GROUPS_ID, new Array()); + public async setReleaseNotesChatBlocked( + blocked: boolean, + blockedAt: number | undefined + ): Promise { + await this.#storage.put(RELEASE_NOTES_CHAT_BLOCKED_ID, blocked); + await this.#storage.put( + RELEASE_NOTES_CHAT_BLOCKED_AT_ID, + blocked ? blockedAt : undefined + ); + } + + public setBlockedGroups(): void { + const array = this.#storage.get(BLOCKED_GROUPS_ID); + this.#blockedGroups.clear(); + array?.forEach(item => { + this.#blockedGroups.set(item.groupId, item); + }); + } + public getBlockedGroups(): ReadonlyMap { + return this.#blockedGroups; } public isGroupBlocked(groupId: string): boolean { - return this.getBlockedGroups().includes(groupId); + return Boolean(this.#blockedGroups.get(groupId)); } - public async addBlockedGroup(groupId: string): Promise { - const groupIds = this.getBlockedGroups(); - if (groupIds.includes(groupId)) { + public async addBlockedGroup( + groupId: string, + blockedAt: number | undefined + ): Promise { + if (this.isGroupBlocked(groupId)) { return; } log.info(`adding group(${groupId}) to blocked list`); - await this.#storage.put(BLOCKED_GROUPS_ID, groupIds.concat(groupId)); + + const data = { groupId, blockedAt }; + this.#blockedGroups.set(groupId, data); + + const array = this.#storage.get(BLOCKED_GROUPS_ID); + await this.#storage.put(BLOCKED_GROUPS_ID, (array || []).concat(data)); } public async removeBlockedGroup(groupId: string): Promise { - const groupIds = this.getBlockedGroups(); - if (!groupIds.includes(groupId)) { + if (!this.isGroupBlocked(groupId)) { return; } log.info(`removing group(${groupId} from blocked list`); - await this.#storage.put(BLOCKED_GROUPS_ID, without(groupIds, groupId)); + + this.#blockedGroups.delete(groupId); + + const array = this.#storage.get(BLOCKED_GROUPS_ID); + await this.#storage.put( + BLOCKED_GROUPS_ID, + (array || []).filter(item => item.groupId !== groupId) + ); } public getBlockedData(): { - e164s: Array; - acis: Array; - groupIds: Array>; + e164s: ReadonlyArray; + acis: ReadonlyArray<{ + blockedAt: number | undefined; + aci: AciString; + }>; + groupIds: ReadonlyArray<{ + blockedAt: number | undefined; + groupId: Uint8Array; + }>; } { - const e164s = this.getBlockedNumbers(); - const acis = this.getBlockedServiceIds().filter(item => isAciString(item)); - const groupIds = this.getBlockedGroups().map(item => - Bytes.fromBase64(item) - ); + const e164s = Array.from(this.getBlockedNumbers().values()); + const acis = Array.from(this.getBlockedServiceIds().values()) + .map(item => { + if (!isAciString(item.serviceId)) { + return undefined; + } + + return { + blockedAt: item.blockedAt, + aci: item.serviceId, + }; + }) + .filter(isNotNil); + const groupIds = Array.from(this.getBlockedGroups().values()).map(item => ({ + ...item, + groupId: Bytes.fromBase64(item.groupId), + })); return { e164s, diff --git a/ts/types/MessageRequestResponseEvent.std.ts b/ts/types/MessageRequestResponseEvent.std.ts index e6b73f7f0f..60a39590cb 100644 --- a/ts/types/MessageRequestResponseEvent.std.ts +++ b/ts/types/MessageRequestResponseEvent.std.ts @@ -17,11 +17,12 @@ export enum MessageRequestResponseSource { export type MessageRequestResponseInfo = | { source: MessageRequestResponseSource.LOCAL; - timestamp: number; + blockedAt: number; } | { source: MessageRequestResponseSource.STORAGE_SERVICE; learnedAtMs: number; + blockedAt: number | undefined; } | { source: @@ -30,4 +31,5 @@ export type MessageRequestResponseInfo = timestamp: number; receivedAtMs: number; receivedAtCounter: number; + blockedAt: number | undefined; }; diff --git a/ts/types/StorageKeys.std.ts b/ts/types/StorageKeys.std.ts index 55455f148e..b0cfff4ab7 100644 --- a/ts/types/StorageKeys.std.ts +++ b/ts/types/StorageKeys.std.ts @@ -59,6 +59,19 @@ export type IdentityKeyMap = Record< } >; +export type BlockedGroup = { + blockedAt: number | undefined; + groupId: string; +}; +export type BlockedServiceId = { + blockedAt: number | undefined; + serviceId: ServiceIdString; +}; +export type BlockedNumber = { + blockedAt: number | undefined; + e164: string; +}; + export type StorageAccessType = { 'always-relay-calls': boolean; 'audio-notification': boolean; @@ -66,8 +79,8 @@ export type StorageAccessType = { 'auto-download-attachment': AutoDownloadAttachmentType; autoConvertEmoji: boolean; 'badge-count-muted-conversations': boolean; - 'blocked-groups': ReadonlyArray; - 'blocked-uuids': ReadonlyArray; + 'blocked-groups': ReadonlyArray; + 'blocked-uuids': ReadonlyArray; 'call-ringtone-notification': boolean; 'call-system-notification': boolean; lastCallQualitySurveyTime: number; @@ -82,7 +95,7 @@ export type StorageAccessType = { audioMessage: boolean; attachmentMigration_isComplete: boolean; attachmentMigration_lastProcessedIndex: number; - blocked: ReadonlyArray; + blocked: ReadonlyArray; defaultConversationColor: DefaultConversationColorType; customColors: CustomColorsItemType; @@ -229,6 +242,7 @@ export type StorageAccessType = { releaseNotesVersionWatermark: string; releaseNotesPreviousManifestHash: string; releaseNotesChatBlocked: boolean; + releaseNotesChatBlockedAt: number | undefined; // If present - we are downloading backup backupDownloadPath: string; @@ -433,6 +447,7 @@ export const STORAGE_KEYS_TO_PRESERVE_WHEN_PRIMARY = [ 'read-receipt-setting', 'blocked', 'releaseNotesChatBlocked', + 'releaseNotesChatBlockedAt', 'device_name', 'seenPinMessageDisappearingMessagesWarningCount', 'usernameLastIntegrityCheck', diff --git a/ts/util/isBlocked.preload.ts b/ts/util/isBlocked.preload.ts index 1cbaf09e51..e630c41623 100644 --- a/ts/util/isBlocked.preload.ts +++ b/ts/util/isBlocked.preload.ts @@ -15,16 +15,19 @@ export function isBlocked( return itemStorage.blocked.isReleaseNotesChatBlocked(); } - if (isAciString(serviceId)) { - return itemStorage.blocked.isServiceIdBlocked(serviceId); + if ( + isAciString(serviceId) && + itemStorage.blocked.isServiceIdBlocked(serviceId) + ) { + return true; } - if (e164) { - return itemStorage.blocked.isBlocked(e164); + if (e164 && itemStorage.blocked.isBlocked(e164)) { + return true; } - if (groupId) { - return itemStorage.blocked.isGroupBlocked(groupId); + if (groupId && itemStorage.blocked.isGroupBlocked(groupId)) { + return true; } return false;