From 494572ad26098cdafa53e67b97e293f19b64d4bb Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Tue, 7 May 2024 11:59:33 -0500 Subject: [PATCH] Import distribution lists from backup Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> --- ts/services/backups/import.ts | 78 ++++++++++++++++++++++++++++ ts/test-mock/backups/backups_test.ts | 45 +++++++++++++++- 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index fd91514c8c..5e90615f39 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -13,6 +13,7 @@ import * as log from '../../logging/log'; import { StorySendMode } from '../../types/Stories'; import type { ServiceIdString } from '../../types/ServiceId'; import { fromAciObject, fromPniObject } from '../../types/ServiceId'; +import { isStoryDistributionId } from '../../types/StoryDistributionId'; import * as Errors from '../../types/errors'; import type { ConversationAttributesType, @@ -32,6 +33,8 @@ import { isAciString } from '../../util/isAciString'; import { createBatcher } from '../../util/batcher'; import { PhoneNumberDiscoverability } from '../../util/phoneNumberDiscoverability'; import { PhoneNumberSharingMode } from '../../util/phoneNumberSharingMode'; +import { bytesToUuid } from '../../util/uuidToBytes'; +import { missingCaseError } from '../../util/missingCaseError'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { SendStatus } from '../../messages/MessageSendState'; import type { SendStateByConversationId } from '../../messages/MessageSendState'; @@ -230,6 +233,11 @@ export class BackupImportStream extends Writable { convo = this.ourConversation; } else if (recipient.group) { convo = await this.fromGroup(recipient.group); + } else if (recipient.distributionList) { + await this.fromDistributionList(recipient.distributionList); + + // Not a conversation + return; } else { log.warn(`${this.logId}: unsupported recipient item`); return; @@ -497,6 +505,76 @@ export class BackupImportStream extends Writable { return attrs; } + private async fromDistributionList( + list: Backups.IDistributionList + ): Promise { + strictAssert( + Bytes.isNotEmpty(list.distributionId), + 'Missing distribution list id' + ); + + const id = bytesToUuid(list.distributionId); + strictAssert(isStoryDistributionId(id), 'Invalid distribution list id'); + + strictAssert( + list.privacyMode != null, + 'Missing distribution list privacy mode' + ); + + let isBlockList: boolean; + const { PrivacyMode } = Backups.DistributionList; + switch (list.privacyMode) { + case PrivacyMode.ALL: + strictAssert( + !list.memberRecipientIds?.length, + 'Distribution list with ALL privacy mode has members' + ); + isBlockList = true; + break; + case PrivacyMode.ALL_EXCEPT: + strictAssert( + list.memberRecipientIds?.length, + 'Distribution list with ALL_EXCEPT privacy mode has no members' + ); + isBlockList = true; + break; + case PrivacyMode.ONLY_WITH: + isBlockList = false; + break; + case PrivacyMode.UNKNOWN: + throw new Error('Invalid privacy mode for distribution list'); + default: + throw missingCaseError(list.privacyMode); + } + + const result = { + id, + name: list.name ?? '', + deletedAtTimestamp: + list.deletionTimestamp == null + ? undefined + : getTimestampFromLong(list.deletionTimestamp), + allowsReplies: list.allowReplies === true, + isBlockList, + members: (list.memberRecipientIds || []).map(recipientId => { + const convo = this.recipientIdToConvo.get(recipientId.toNumber()); + strictAssert(convo != null, 'Missing story distribution list member'); + strictAssert( + convo.serviceId, + 'Story distribution list member has no serviceId' + ); + + return convo.serviceId; + }), + + // Default values + senderKeyInfo: undefined, + storageNeedsSync: false, + }; + + await Data.createNewStoryDistribution(result); + } + private async fromChat(chat: Backups.IChat): Promise { strictAssert(chat.id != null, 'chat must have an id'); strictAssert(chat.recipientId != null, 'chat must have a recipientId'); diff --git a/ts/test-mock/backups/backups_test.ts b/ts/test-mock/backups/backups_test.ts index b5366eed98..5c2cb77b77 100644 --- a/ts/test-mock/backups/backups_test.ts +++ b/ts/test-mock/backups/backups_test.ts @@ -3,14 +3,21 @@ import createDebug from 'debug'; import Long from 'long'; -import { StorageState } from '@signalapp/mock-server'; +import { Proto, StorageState } from '@signalapp/mock-server'; +import { generateStoryDistributionId } from '../../types/StoryDistributionId'; +import { MY_STORY_ID } from '../../types/Stories'; +import { uuidToBytes } from '../../util/uuidToBytes'; import * as durations from '../../util/durations'; import type { App } from '../playwright'; import { Bootstrap } from '../bootstrap'; export const debug = createDebug('mock:test:backups'); +const IdentifierType = Proto.ManifestRecord.Identifier.Type; + +const DISTRIBUTION1 = generateStoryDistributionId(); + describe('backups', function (this: Mocha.Suite) { this.timeout(100 * durations.MINUTE); @@ -46,6 +53,33 @@ describe('backups', function (this: Mocha.Suite) { state = state.pin(pinned); + // Create empty My Story + state = state.addRecord({ + type: IdentifierType.STORY_DISTRIBUTION_LIST, + record: { + storyDistributionList: { + allowsReplies: true, + identifier: uuidToBytes(MY_STORY_ID), + isBlockList: true, + name: MY_STORY_ID, + recipientServiceIds: [pinned.device.aci], + }, + }, + }); + + state = state.addRecord({ + type: IdentifierType.STORY_DISTRIBUTION_LIST, + record: { + storyDistributionList: { + allowsReplies: true, + identifier: uuidToBytes(DISTRIBUTION1), + isBlockList: false, + name: 'friend', + recipientServiceIds: [friend.device.aci], + }, + }, + }); + await phone.setStorageState(state); app = await bootstrap.link(); @@ -138,6 +172,15 @@ describe('backups', function (this: Mocha.Suite) { .waitFor(); await snapshot('conversation'); + + debug('Switching to stories nav tab'); + await window.getByTestId('NavTabsItem--Stories').click(); + + debug('Opening story privacy'); + await window.locator('.StoriesTab__MoreActionsIcon').click(); + await window.getByRole('button', { name: 'Story Privacy' }).click(); + + await snapshot('story privacy'); }, this.test );