// Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import createDebug from 'debug'; import Long from 'long'; import { Proto, StorageState } from '@signalapp/mock-server'; import type { Group } from '@signalapp/mock-server'; import * as durations from '../../util/durations'; import { uuidToBytes } from '../../util/uuidToBytes'; import { MY_STORY_ID } from '../../types/Stories'; import { generateStoryDistributionId } from '../../types/StoryDistributionId'; import type { App } from '../playwright'; import { Bootstrap } from '../bootstrap'; export const debug = createDebug('mock:test:stories'); const IdentifierType = Proto.ManifestRecord.Identifier.Type; const DISTRIBUTION1 = generateStoryDistributionId(); const DISTRIBUTION2 = generateStoryDistributionId(); describe('story/messaging', function (this: Mocha.Suite) { this.timeout(durations.MINUTE); let bootstrap: Bootstrap; let app: App; let group: Group; beforeEach(async () => { bootstrap = new Bootstrap(); await bootstrap.init(); const { phone, contacts } = bootstrap; const [first, second] = contacts; let state = StorageState.getEmpty(); state = state.updateAccount({ profileKey: phone.profileKey.serialize(), e164: phone.device.number, givenName: phone.profileName, hasSetMyStoriesPrivacy: true, }); // Create empty My Story state = state.addRecord({ type: IdentifierType.STORY_DISTRIBUTION_LIST, record: { storyDistributionList: { allowsReplies: true, identifier: uuidToBytes(MY_STORY_ID), isBlockList: false, name: MY_STORY_ID, recipientServiceIds: [], }, }, }); // Create two distribution lists corresponding to two contacts state = state.addRecord({ type: IdentifierType.STORY_DISTRIBUTION_LIST, record: { storyDistributionList: { allowsReplies: true, identifier: uuidToBytes(DISTRIBUTION1), isBlockList: false, name: 'first', recipientServiceIds: [first.device.aci], }, }, }); state = state.addRecord({ type: IdentifierType.STORY_DISTRIBUTION_LIST, record: { storyDistributionList: { allowsReplies: true, identifier: uuidToBytes(DISTRIBUTION2), isBlockList: false, name: 'second', recipientServiceIds: [second.device.aci], }, }, }); // Add a group for story send const members = [...contacts].slice(0, 10); group = await phone.createGroup({ title: 'Mock Group', members: [phone, ...members], }); state = state .addGroup(group, { whitelisted: true, storySendMode: Proto.GroupV2Record.StorySendMode.ENABLED, }) .pinGroup(group); // Finally whitelist and pin contacts for (const contact of [first, second]) { state = state.addContact(contact, { whitelisted: true, serviceE164: contact.device.number, identityKey: contact.publicKey.serialize(), profileKey: contact.profileKey.serialize(), givenName: contact.profileName, }); state = state.pin(contact); } await phone.setStorageState(state); app = await bootstrap.link(); }); afterEach(async function (this: Mocha.Context) { if (!bootstrap) { return; } await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); it('allows replies on multiple distribution lists', async () => { const { phone, desktop, contacts } = bootstrap; const [first, second] = contacts; const window = await app.getWindow(); const sentAt = Date.now(); debug('waiting for storage service sync to complete'); await app.waitForStorageService(); debug('sending story sync message'); await phone.sendRaw( desktop, { syncMessage: { sent: { timestamp: Long.fromNumber(sentAt), expirationStartTimestamp: Long.fromNumber(sentAt), storyMessage: { textAttachment: { text: 'hello', }, allowsReplies: true, }, storyMessageRecipients: [ { destinationServiceId: first.device.aci, distributionListIds: [DISTRIBUTION1], isAllowedToReply: true, }, { destinationServiceId: second.device.aci, distributionListIds: [DISTRIBUTION2], isAllowedToReply: true, }, ], }, }, }, { timestamp: sentAt } ); debug('sending story replies'); await first.sendRaw( desktop, { dataMessage: { body: 'first reply', storyContext: { authorAci: phone.device.aci, sentTimestamp: Long.fromNumber(sentAt), }, timestamp: Long.fromNumber(sentAt + 1), }, }, { timestamp: sentAt + 1 } ); await second.sendRaw( desktop, { dataMessage: { body: 'second reply', storyContext: { authorAci: phone.device.aci, sentTimestamp: Long.fromNumber(sentAt), }, timestamp: Long.fromNumber(sentAt + 2), }, }, { timestamp: sentAt + 2 } ); const leftPane = window.locator('#LeftPane'); debug('Finding both replies'); await leftPane .locator(`[data-testid="${first.device.aci}"] >> "first reply"`) .waitFor(); await leftPane .locator(`[data-testid="${second.device.aci}"] >> "second reply"`) .waitFor(); }); it('allows replies to groups', async () => { const { desktop, contacts } = bootstrap; const window = await app.getWindow(); debug('waiting for storage service sync to complete'); await app.waitForStorageService(); await window.getByTestId('NavTabsItem--Stories').click(); debug('Create and send a story to the group'); await window.getByRole('button', { name: 'Add a story' }).first().click(); await window.getByRole('button', { name: 'Text story' }).click(); // Note: For some reason `.click()` doesn't work here anymore. await window.locator('.TextAttachment').dispatchEvent('click'); await window.getByRole('textbox', { name: 'Add text' }).fill('hello'); await window.getByRole('button', { name: 'Next' }).click(); await window .locator('.Checkbox__container') .getByText('Mock Group') .click(); await window.getByRole('button', { name: 'Send story' }).click(); // Grab the time the story was sent at const time = await window.locator('time').nth(1).getAttribute('datetime'); if (!time) { throw new Error('Cannot locate story time'); } const sentAt = new Date(time).valueOf(); debug('Contact sends reply to group story', { story: sentAt, reply: sentAt + 1, }); await contacts[0].sendRaw( desktop, { dataMessage: { body: 'first reply', storyContext: { authorAci: desktop.aci, sentTimestamp: Long.fromNumber(sentAt), }, groupV2: { masterKey: group.masterKey, }, timestamp: Long.fromNumber(sentAt + 1), }, }, { timestamp: sentAt + 1 } ); debug('Ensure sender sees the reply'); await window .locator('.StoryListItem__button') .getByText('Mock Group') .click(); // For some reason we need to click the story & exit before the reply shows up await window.getByRole('button', { name: 'Close' }).click(); await window .locator('.StoryListItem__button') .getByText('Mock Group') .click(); await window.getByText('1 reply').click(); await window.getByText('first reply').waitFor(); }); });