Persist timestamp along with blocked contacts and groups

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
automated-signal
2026-08-13 00:10:32 +00:00
committed by GitHub
co-authored by Scott Nonnenberg
parent 5d9a876db2
commit 58f0ac9e6b
29 changed files with 1417 additions and 181 deletions
+4
View File
@@ -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."
+9 -2
View File
@@ -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
+22 -3
View File
@@ -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 {
+4 -1
View File
@@ -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 {
+16
View File
@@ -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 (
+12 -4
View File
@@ -1454,7 +1454,9 @@ async function startApp(): Promise<void> {
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<void> {
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');
}
+30 -18
View File
@@ -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 },
};
+36 -12
View File
@@ -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<T = string | number> = (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<ConversationType>;
blockedGroups: ReadonlyArray<ConversationType>;
blockedContacts: ReadonlyArray<BlockedConversation>;
blockedGroups: ReadonlyArray<BlockedConversation>;
customColors: Record<string, CustomColorType>;
defaultConversationColor: DefaultConversationColorType;
deviceName?: string;
@@ -2252,20 +2258,29 @@ export function Preferences({
</SettingsRow>
{blockedContacts.length > 0 ? (
<SettingsRow title={i18n('icu:Preferences--blocked-users')}>
{blockedContacts.map(item => {
{blockedContacts.map(({ conversation, blockedAt }) => {
return (
<div className={tw('flex w-full items-center px-[14px]')}>
<div className={tw('p-2')}>
<Avatar
conversationType={item.type}
badge={getPreferredBadge(item.badges)}
conversationType={conversation.type}
badge={getPreferredBadge(conversation.badges)}
i18n={i18n}
size={AvatarSize.THIRTY_SIX}
theme={theme}
{...item}
{...conversation}
/>
</div>
<div>{item.title}</div>
<div className={tw('flex flex-col')}>
<div>{conversation.title}</div>
{isNumber(blockedAt) && blockedAt > 0 ? (
<div className={tw('type-body-small text-secondary')}>
{i18n('icu:Preferences--blocked--blocked-on', {
blockedAt: moment(blockedAt).format('ll'),
})}
</div>
) : undefined}
</div>
</div>
);
})}
@@ -2273,20 +2288,29 @@ export function Preferences({
) : undefined}
{blockedGroups.length > 0 ? (
<SettingsRow title={i18n('icu:Preferences--blocked-groups')}>
{blockedGroups.map(item => {
{blockedGroups.map(({ conversation, blockedAt }) => {
return (
<div className={tw('flex w-full items-center px-[14px]')}>
<div className={tw('p-2')}>
<Avatar
conversationType={item.type}
badge={getPreferredBadge(item.badges)}
conversationType={conversation.type}
badge={getPreferredBadge(conversation.badges)}
i18n={i18n}
size={AvatarSize.THIRTY_SIX}
theme={theme}
{...item}
{...conversation}
/>
</div>{' '}
<div className={tw('flex flex-col')}>
<div>{conversation.title}</div>
{isNumber(blockedAt) && blockedAt > 0 ? (
<div className={tw('type-body-small text-secondary')}>
{i18n('icu:Preferences--blocked--blocked-on', {
blockedAt: moment(blockedAt).format('ll'),
})}
</div>
) : undefined}{' '}
</div>
<div>{item.title}</div>
</div>
);
})}
+3 -3
View File
@@ -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
@@ -73,6 +73,7 @@ export async function onResponse(
receivedAtCounter,
receivedAtMs,
timestamp: sentAt,
blockedAt: sentAt,
})
);
+52 -10
View File
@@ -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) {
+16 -5
View File
@@ -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: {
+18 -3
View File
@@ -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;
+47 -6
View File
@@ -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<string>;
}>;
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<void> {
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
);
@@ -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<string>;
type OldBlockedServiceIdsList = ReadonlyArray<ServiceIdString>;
type OldBlockedGroupList = ReadonlyArray<string>;
type Item<T> = {
id: string;
value: T;
};
// New types:
type BlockedNumber = {
blockedAt: number | undefined;
e164: string;
};
type BlockedNumberList = ReadonlyArray<BlockedNumber>;
type BlockedServiceId = {
blockedAt: number | undefined;
serviceId: ServiceIdString;
};
type BlockedServiceIdList = ReadonlyArray<BlockedServiceId>;
type BlockedGroup = {
blockedAt: number | undefined;
groupId: string;
};
type BlockedGroupList = ReadonlyArray<BlockedGroup>;
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<BlockedNumberList> = {
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<OldBlockedServiceIdsList | undefined>;
if (blockedServiceIds?.value) {
const updatedServiceIds: BlockedServiceIdList =
blockedServiceIds.value.map(serviceId => ({
serviceId,
blockedAt: undefined,
}));
const item: Item<BlockedServiceIdList> = {
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<BlockedGroupList> = {
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)
);
}
}
+3
View File
@@ -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<SchemaUpdateType> = [
{ 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<SchemaUpdateType> = [
{ version: 1740, update: updateToSchemaVersion1740 },
{ version: 1750, update: updateToSchemaVersion1750 },
{ version: 1760, update: updateToSchemaVersion1760 },
{ version: 1770, update: updateToSchemaVersion1770 },
];
class DBVersionFromFutureError extends Error {
+3 -3
View File
@@ -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 }
);
+37 -23
View File
@@ -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<ConversationType> = useMemo(() => {
const result = new Set<ConversationType>();
const blockedContacts: Array<BlockedConversation> = useMemo(() => {
const result = new Map<string, BlockedConversation>();
(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<ConversationType> = useMemo(() => {
const result: Array<ConversationType> = [];
const blockedGroups: Array<BlockedConversation> = useMemo(() => {
const result: Array<BlockedConversation> = [];
(items['blocked-groups'] ?? []).forEach(item => {
result.push(conversationSelector(item));
result.push({
conversation: conversationSelector(item.groupId),
blockedAt: item.blockedAt,
});
});
return result;
}, [items, conversationSelector]);
@@ -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<ConversationAttributesType>
): 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');
});
});
});
@@ -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
);
+1
View File
@@ -202,6 +202,7 @@ function* createRecords({
contact: {
aci: chatAci,
blocked: false,
blockedAtTimestamp: null,
visibility: Backups.Contact.Visibility.VISIBLE,
registration: {
registered: {},
@@ -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 }],
},
},
]);
});
});
+262 -11
View File
@@ -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<void> {
@@ -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<void> {
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);
}
+34 -10
View File
@@ -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<string>;
acis: Array<AciString>;
groupIds: Array<Uint8Array<ArrayBuffer>>;
e164s: ReadonlyArray<BlockedNumber>;
acis: ReadonlyArray<{
blockedAt: number | undefined;
aci: AciString;
}>;
groupIds: ReadonlyArray<{
blockedAt: number | undefined;
groupId: Uint8Array<ArrayBuffer>;
}>;
}>
): 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({
+2
View File
@@ -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<Access> {
+165 -51
View File
@@ -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<string, BlockedNumber>;
readonly #blockedServiceIds: Map<string, BlockedServiceId>;
readonly #blockedGroups: Map<string, BlockedGroup>;
constructor(storage: StorageInterface) {
this.#storage = storage;
this.#blockedNumbers = new Map();
this.#blockedServiceIds = new Map();
this.#blockedGroups = new Map();
this.load();
}
public getBlockedNumbers(): Array<string> {
return this.#storage.get(BLOCKED_NUMBERS_ID, new Array<string>());
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<void> {
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<string, BlockedNumber> {
return this.#blockedNumbers;
}
public isBlocked(e164: string): boolean {
return Boolean(this.#blockedNumbers.get(e164));
}
public async addBlockedNumber(
e164: string,
blockedAt: number | undefined
): Promise<void> {
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<void> {
const numbers = this.getBlockedNumbers();
if (!numbers.includes(number)) {
public async removeBlockedNumber(e164: string): Promise<void> {
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<ServiceIdString> {
return this.#storage.get(BLOCKED_UUIDS_ID, new Array<ServiceIdString>());
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<string, BlockedServiceId> {
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<void> {
public async addBlockedServiceId(
serviceId: ServiceIdString,
blockedAt: number | undefined
): Promise<void> {
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<void> {
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<string> {
return this.#storage.get(BLOCKED_GROUPS_ID, new Array<string>());
public async setReleaseNotesChatBlocked(
blocked: boolean,
blockedAt: number | undefined
): Promise<void> {
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<string, BlockedGroup> {
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<void> {
const groupIds = this.getBlockedGroups();
if (groupIds.includes(groupId)) {
public async addBlockedGroup(
groupId: string,
blockedAt: number | undefined
): Promise<void> {
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<void> {
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<string>;
acis: Array<AciString>;
groupIds: Array<Uint8Array<ArrayBuffer>>;
e164s: ReadonlyArray<BlockedNumber>;
acis: ReadonlyArray<{
blockedAt: number | undefined;
aci: AciString;
}>;
groupIds: ReadonlyArray<{
blockedAt: number | undefined;
groupId: Uint8Array<ArrayBuffer>;
}>;
} {
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,
+3 -1
View File
@@ -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;
};
+18 -3
View File
@@ -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<string>;
'blocked-uuids': ReadonlyArray<ServiceIdString>;
'blocked-groups': ReadonlyArray<BlockedGroup>;
'blocked-uuids': ReadonlyArray<BlockedServiceId>;
'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<string>;
blocked: ReadonlyArray<BlockedNumber>;
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',
+9 -6
View File
@@ -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;