diff --git a/js/models/conversations.d.ts b/js/models/conversations.d.ts index 02c78688a..137ae18ab 100644 --- a/js/models/conversations.d.ts +++ b/js/models/conversations.d.ts @@ -3,6 +3,7 @@ interface ConversationAttributes { left: boolean; expireTimer: number; profileSharing: boolean; + secondaryStatus: boolean; mentionedUs: boolean; unreadCount: number; isArchived: boolean; diff --git a/js/models/conversations.js b/js/models/conversations.js index 92f2ac674..ba3c21834 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1317,8 +1317,7 @@ }); if (this.isMe()) { - await message.markMessageSyncOnly(); - // sending is done in the 'private' case below + return message.sendSyncMessageOnly(chatMessage); } const options = {}; @@ -1648,8 +1647,10 @@ }; if (this.isMe()) { - await message.markMessageSyncOnly(); - // sending of the message is handled in the 'private' case below + const expirationTimerMessage = new libsession.Messages.Outgoing.ExpirationTimerUpdateMessage( + expireUpdate + ); + return message.sendSyncMessageOnly(expirationTimerMessage); } if (this.get('type') === 'private') { @@ -1856,14 +1857,12 @@ const groupUpdateMessage = new libsession.Messages.Outgoing.ClosedGroupUpdateMessage( updateParams ); - libsession - .getMessageQueue() - .sendToGroup(groupUpdateMessage) - .catch(log.error); + await this.sendClosedGroupMessageWithSync(groupUpdateMessage); }, sendGroupInfo(recipient) { - if (this.isClosedGroup()) { + // Only send group info if we're a closed group and we haven't left + if (this.isClosedGroup() && !this.get('left')) { const updateParams = { timestamp: Date.now(), groupId: this.id, @@ -1927,12 +1926,46 @@ quitGroup ); - await libsession.getMessageQueue().sendToGroup(quitGroupMessage); + await this.sendClosedGroupMessageWithSync(quitGroupMessage); this.updateTextInputState(); } }, + async sendClosedGroupMessageWithSync(message) { + const { + ClosedGroupMessage, + ClosedGroupChatMessage, + } = libsession.Messages.Outgoing; + if (!(message instanceof ClosedGroupMessage)) { + throw new Error('Invalid closed group message.'); + } + + // Sync messages for Chat Messages need to be constructed after confirming send was successful. + if (message instanceof ClosedGroupChatMessage) { + throw new Error( + 'ClosedGroupChatMessage should be constructed manually and sent' + ); + } + + try { + await libsession.getMessageQueue().sendToGroup(message); + + const syncMessage = libsession.Utils.SyncMessageUtils.getSentSyncMessage( + { + destination: message.groupId, + message, + } + ); + + if (syncMessage) { + await libsession.getMessageQueue().sendSyncMessage(syncMessage); + } + } catch (e) { + window.log.error(e); + } + }, + async markRead(newestUnreadDate, providedOptions) { const options = providedOptions || {}; _.defaults(options, { sendReadReceipts: true }); diff --git a/js/models/messages.js b/js/models/messages.js index 71380fc1f..7d94c7089 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1079,8 +1079,7 @@ if (recipients.length === 1 && recipients[0] === this.OUR_NUMBER) { this.trigger('pending'); // FIXME audric add back profileKey - await this.markMessageSyncOnly(); - // sending is done in the private case below + return this.sendSyncMessageOnly(chatMessage); } if (conversation.isPrivate()) { @@ -1155,9 +1154,9 @@ // Special-case the self-send case - we send only a sync message if (number === this.OUR_NUMBER) { - await this.markMessageSyncOnly(); - // sending is done in the private case below + return this.sendSyncMessageOnly(chatMessage); } + const conversation = this.getConversation(); const recipientPubKey = new libsession.Types.PubKey(number); @@ -1322,6 +1321,44 @@ Message: Whisper.Message, }); }, + + async sendSyncMessageOnly(dataMessage) { + this.set({ + sent_to: [this.OUR_NUMBER], + sent: true, + expirationStartTimestamp: Date.now(), + }); + + await window.Signal.Data.saveMessage(this.attributes, { + Message: Whisper.Message, + }); + + const data = + dataMessage instanceof libsession.Messages.Outgoing.DataMessage + ? dataMessage.dataProto() + : dataMessage; + await this.sendSyncMessage(data); + }, + + async sendSyncMessage(dataMessage) { + // TODO: Return here if we've already sent a sync message + if (this.get('synced')) { + return; + } + + const syncMessage = new libsession.Messages.Outgoing.SentSyncMessage({ + timestamp: this.get('sent_at'), + identifier: this.id, + dataMessage, + destination: this.get('destination'), + expirationStartTimestamp: this.get('expirationStartTimestamp'), + sent_to: this.get('sent_to'), + unidentifiedDeliveries: this.get('unidentifiedDeliveries'), + }); + + await libsession.getMessageQueue().sendSyncMessage(syncMessage); + }, + send(promise) { this.trigger('pending'); return promise diff --git a/js/modules/data.d.ts b/js/modules/data.d.ts index 32b8d28c2..9f190a5a8 100644 --- a/js/modules/data.d.ts +++ b/js/modules/data.d.ts @@ -1,7 +1,7 @@ import { ConversationType } from '../../ts/state/ducks/conversations'; -import { Mesasge } from '../../ts/types/Message'; +import { Message } from '../../ts/types/Message'; -type IdentityKey = { +export type IdentityKey = { id: string; publicKey: ArrayBuffer; firstUse: boolean; @@ -9,14 +9,14 @@ type IdentityKey = { nonblockingApproval: boolean; }; -type PreKey = { +export type PreKey = { id: number; publicKey: ArrayBuffer; privateKey: ArrayBuffer; recipient: string; }; -type SignedPreKey = { +export type SignedPreKey = { id: number; publicKey: ArrayBuffer; privateKey: ArrayBuffer; @@ -25,14 +25,14 @@ type SignedPreKey = { signature: ArrayBuffer; }; -type ContactPreKey = { +export type ContactPreKey = { id: number; identityKeyString: string; publicKey: ArrayBuffer; keyId: number; }; -type ContactSignedPreKey = { +export type ContactSignedPreKey = { id: number; identityKeyString: string; publicKey: ArrayBuffer; @@ -42,18 +42,18 @@ type ContactSignedPreKey = { confirmed: boolean; }; -type PairingAuthorisation = { +export type PairingAuthorisation = { primaryDevicePubKey: string; secondaryDevicePubKey: string; requestSignature: ArrayBuffer; grantSignature?: ArrayBuffer; }; -type GuardNode = { +export type GuardNode = { ed25519PubKey: string; }; -type SwarmNode = { +export type SwarmNode = { address: string; ip: string; port: string; @@ -61,19 +61,19 @@ type SwarmNode = { pubkey_x25519: string; }; -type StorageItem = { +export type StorageItem = { id: string; value: any; }; -type SessionDataInfo = { +export type SessionDataInfo = { id: string; number: string; deviceId: number; record: string; }; -type ServerToken = { +export type ServerToken = { serverUrl: string; token: string; }; diff --git a/ts/receiver/groups.ts b/ts/receiver/groups.ts index 6325a7f97..bac23f4f0 100644 --- a/ts/receiver/groups.ts +++ b/ts/receiver/groups.ts @@ -71,11 +71,14 @@ export async function preprocessGroupMessage( return true; } } - if (group.type === GROUP_TYPES.REQUEST_INFO && !newGroup) { - window.libloki.api.debug.logGroupRequestInfo( - `Received GROUP_TYPES.REQUEST_INFO from source: ${source}, primarySource: ${primarySource}, sending back group info.` - ); - conversation.sendGroupInfo(source); + if (group.type === GROUP_TYPES.REQUEST_INFO) { + // We can only send the request info back if we have the information + if (!newGroup) { + window.libloki.api.debug.logGroupRequestInfo( + `Received GROUP_TYPES.REQUEST_INFO from source: ${source}, primarySource: ${primarySource}, sending back group info.` + ); + conversation.sendGroupInfo(source); + } return true; } diff --git a/ts/session/instance.ts b/ts/session/instance.ts index 385ead7c8..15f35652b 100644 --- a/ts/session/instance.ts +++ b/ts/session/instance.ts @@ -1,8 +1,8 @@ -import { MessageQueue } from './sending/'; +import { MessageQueue, MessageQueueInterface } from './sending/'; let messageQueue: MessageQueue; -function getMessageQueue() { +function getMessageQueue(): MessageQueueInterface { if (!messageQueue) { messageQueue = new MessageQueue(); } diff --git a/ts/session/messages/outgoing/content/data/DataMessage.ts b/ts/session/messages/outgoing/content/data/DataMessage.ts index 869a3b736..e65f35763 100644 --- a/ts/session/messages/outgoing/content/data/DataMessage.ts +++ b/ts/session/messages/outgoing/content/data/DataMessage.ts @@ -2,11 +2,11 @@ import { ContentMessage } from '../ContentMessage'; import { SignalService } from '../../../../../protobuf'; export abstract class DataMessage extends ContentMessage { + public abstract dataProto(): SignalService.DataMessage; + protected contentProto(): SignalService.Content { return new SignalService.Content({ dataMessage: this.dataProto(), }); } - - protected abstract dataProto(): SignalService.DataMessage; } diff --git a/ts/session/messages/outgoing/content/data/DeviceUnlinkMessage.ts b/ts/session/messages/outgoing/content/data/DeviceUnlinkMessage.ts index b08764e07..3b6bd99e2 100644 --- a/ts/session/messages/outgoing/content/data/DeviceUnlinkMessage.ts +++ b/ts/session/messages/outgoing/content/data/DeviceUnlinkMessage.ts @@ -6,7 +6,7 @@ export class DeviceUnlinkMessage extends DataMessage { return 4 * 24 * 60 * 60 * 1000; // 4 days for device unlinking } - protected dataProto(): SignalService.DataMessage { + public dataProto(): SignalService.DataMessage { const flags = SignalService.DataMessage.Flags.UNPAIRING_REQUEST; return new SignalService.DataMessage({ diff --git a/ts/session/messages/outgoing/content/data/ExpirationTimerUpdateMessage.ts b/ts/session/messages/outgoing/content/data/ExpirationTimerUpdateMessage.ts index 6329ec32b..f13d0717e 100644 --- a/ts/session/messages/outgoing/content/data/ExpirationTimerUpdateMessage.ts +++ b/ts/session/messages/outgoing/content/data/ExpirationTimerUpdateMessage.ts @@ -29,7 +29,7 @@ export class ExpirationTimerUpdateMessage extends DataMessage { return this.getDefaultTTL(); } - protected dataProto(): SignalService.DataMessage { + public dataProto(): SignalService.DataMessage { const data = new SignalService.DataMessage(); const groupMessage = new SignalService.GroupContext(); diff --git a/ts/session/messages/outgoing/content/data/GroupInvitationMessage.ts b/ts/session/messages/outgoing/content/data/GroupInvitationMessage.ts index 69c019230..c80ce06e5 100644 --- a/ts/session/messages/outgoing/content/data/GroupInvitationMessage.ts +++ b/ts/session/messages/outgoing/content/data/GroupInvitationMessage.ts @@ -24,7 +24,7 @@ export class GroupInvitationMessage extends DataMessage { return this.getDefaultTTL(); } - protected dataProto(): SignalService.DataMessage { + public dataProto(): SignalService.DataMessage { const groupInvitation = new SignalService.DataMessage.GroupInvitation({ serverAddress: this.serverAddress, channelId: this.channelId, diff --git a/ts/session/messages/outgoing/content/data/group/ClosedGroupChatMessage.ts b/ts/session/messages/outgoing/content/data/group/ClosedGroupChatMessage.ts index 4bdc144e8..68ab5188f 100644 --- a/ts/session/messages/outgoing/content/data/group/ClosedGroupChatMessage.ts +++ b/ts/session/messages/outgoing/content/data/group/ClosedGroupChatMessage.ts @@ -25,6 +25,13 @@ export class ClosedGroupChatMessage extends ClosedGroupMessage { return this.getDefaultTTL(); } + public dataProto(): SignalService.DataMessage { + const messageProto = this.chatMessage.dataProto(); + messageProto.group = this.groupContext(); + + return messageProto; + } + protected groupContext(): SignalService.GroupContext { // use the parent method to fill id correctly const groupContext = super.groupContext(); @@ -32,11 +39,4 @@ export class ClosedGroupChatMessage extends ClosedGroupMessage { return groupContext; } - - protected dataProto(): SignalService.DataMessage { - const messageProto = this.chatMessage.dataProto(); - messageProto.group = this.groupContext(); - - return messageProto; - } } diff --git a/ts/session/messages/outgoing/content/data/group/ClosedGroupMessage.ts b/ts/session/messages/outgoing/content/data/group/ClosedGroupMessage.ts index 7586cc3ab..701634d4f 100644 --- a/ts/session/messages/outgoing/content/data/group/ClosedGroupMessage.ts +++ b/ts/session/messages/outgoing/content/data/group/ClosedGroupMessage.ts @@ -23,16 +23,16 @@ export abstract class ClosedGroupMessage extends DataMessage { return this.getDefaultTTL(); } - protected groupContext(): SignalService.GroupContext { - const id = new Uint8Array(StringUtils.encode(this.groupId.key, 'utf8')); - - return new SignalService.GroupContext({ id }); - } - - protected dataProto(): SignalService.DataMessage { + public dataProto(): SignalService.DataMessage { const dataMessage = new SignalService.DataMessage(); dataMessage.group = this.groupContext(); return dataMessage; } + + protected groupContext(): SignalService.GroupContext { + const id = new Uint8Array(StringUtils.encode(this.groupId.key, 'utf8')); + + return new SignalService.GroupContext({ id }); + } } diff --git a/ts/session/messages/outgoing/content/data/mediumgroup/MediumGroupChatMessage.ts b/ts/session/messages/outgoing/content/data/mediumgroup/MediumGroupChatMessage.ts index 59d557ab7..035558975 100644 --- a/ts/session/messages/outgoing/content/data/mediumgroup/MediumGroupChatMessage.ts +++ b/ts/session/messages/outgoing/content/data/mediumgroup/MediumGroupChatMessage.ts @@ -21,7 +21,7 @@ export class MediumGroupChatMessage extends MediumGroupMessage { this.chatMessage = params.chatMessage; } - protected dataProto(): SignalService.DataMessage { + public dataProto(): SignalService.DataMessage { const messageProto = this.chatMessage.dataProto(); messageProto.mediumGroupUpdate = super.dataProto().mediumGroupUpdate; diff --git a/ts/session/messages/outgoing/content/data/mediumgroup/MediumGroupMessage.ts b/ts/session/messages/outgoing/content/data/mediumgroup/MediumGroupMessage.ts index 6f5f7514b..2f39a0b50 100644 --- a/ts/session/messages/outgoing/content/data/mediumgroup/MediumGroupMessage.ts +++ b/ts/session/messages/outgoing/content/data/mediumgroup/MediumGroupMessage.ts @@ -23,14 +23,14 @@ export abstract class MediumGroupMessage extends DataMessage { return this.getDefaultTTL(); } - protected mediumGroupContext(): SignalService.MediumGroupUpdate { - return new SignalService.MediumGroupUpdate({ groupId: this.groupId.key }); - } - - protected dataProto(): SignalService.DataMessage { + public dataProto(): SignalService.DataMessage { const dataMessage = new SignalService.DataMessage(); dataMessage.mediumGroupUpdate = this.mediumGroupContext(); return dataMessage; } + + protected mediumGroupContext(): SignalService.MediumGroupUpdate { + return new SignalService.MediumGroupUpdate({ groupId: this.groupId.key }); + } } diff --git a/ts/session/messages/outgoing/content/receipt/ReceiptMessage.ts b/ts/session/messages/outgoing/content/receipt/ReceiptMessage.ts index e6f7b34ab..ccef56c38 100644 --- a/ts/session/messages/outgoing/content/receipt/ReceiptMessage.ts +++ b/ts/session/messages/outgoing/content/receipt/ReceiptMessage.ts @@ -6,7 +6,7 @@ interface ReceiptMessageParams extends MessageParams { timestamps: Array; } export abstract class ReceiptMessage extends ContentMessage { - private readonly timestamps: Array; + public readonly timestamps: Array; constructor({ timestamp, identifier, timestamps }: ReceiptMessageParams) { super({ timestamp, identifier }); diff --git a/ts/session/messages/outgoing/content/sync/SentSyncMessage.ts b/ts/session/messages/outgoing/content/sync/SentSyncMessage.ts index 3c7ad0685..328fd9958 100644 --- a/ts/session/messages/outgoing/content/sync/SentSyncMessage.ts +++ b/ts/session/messages/outgoing/content/sync/SentSyncMessage.ts @@ -4,15 +4,15 @@ import { MessageParams } from '../../Message'; import { PubKey } from '../../../../types'; interface SentSyncMessageParams extends MessageParams { - dataMessage: SignalService.DataMessage; + dataMessage: SignalService.IDataMessage; expirationStartTimestamp?: number; sentTo?: Array; unidentifiedDeliveries?: Array; - destination?: PubKey; + destination?: PubKey | string; } -export abstract class SentSyncMessage extends SyncMessage { - public readonly dataMessage: SignalService.DataMessage; +export class SentSyncMessage extends SyncMessage { + public readonly dataMessage: SignalService.IDataMessage; public readonly expirationStartTimestamp?: number; public readonly sentTo?: Array; public readonly unidentifiedDeliveries?: Array; @@ -25,7 +25,9 @@ export abstract class SentSyncMessage extends SyncMessage { this.expirationStartTimestamp = params.expirationStartTimestamp; this.sentTo = params.sentTo; this.unidentifiedDeliveries = params.unidentifiedDeliveries; - this.destination = params.destination; + + const { destination } = params; + this.destination = destination ? PubKey.cast(destination) : undefined; } protected syncProto(): SignalService.SyncMessage { diff --git a/ts/session/messages/outgoing/content/sync/SyncReadMessage.ts b/ts/session/messages/outgoing/content/sync/SyncReadMessage.ts index 9ef669188..062b306aa 100644 --- a/ts/session/messages/outgoing/content/sync/SyncReadMessage.ts +++ b/ts/session/messages/outgoing/content/sync/SyncReadMessage.ts @@ -1,13 +1,14 @@ +import _ from 'lodash'; import { SyncMessage } from './SyncMessage'; import { SignalService } from '../../../../../protobuf'; import { MessageParams } from '../../Message'; interface SyncReadMessageParams extends MessageParams { - readMessages: any; + readMessages: Array<{ sender: string; timestamp: number }>; } -export abstract class SyncReadMessage extends SyncMessage { - public readonly readMessages: any; +export class SyncReadMessage extends SyncMessage { + public readonly readMessages: Array<{ sender: string; timestamp: number }>; constructor(params: SyncReadMessageParams) { super({ timestamp: params.timestamp, identifier: params.identifier }); @@ -19,7 +20,7 @@ export abstract class SyncReadMessage extends SyncMessage { syncMessage.read = []; for (const read of this.readMessages) { const readMessage = new SignalService.SyncMessage.Read(); - read.timestamp = readMessage.timestamp; + read.timestamp = _.toNumber(readMessage.timestamp); read.sender = readMessage.sender; syncMessage.read.push(readMessage); } diff --git a/ts/session/sending/MessageQueue.ts b/ts/session/sending/MessageQueue.ts index cbb2e817e..f8c089d14 100644 --- a/ts/session/sending/MessageQueue.ts +++ b/ts/session/sending/MessageQueue.ts @@ -13,12 +13,7 @@ import { TypingMessage, } from '../messages/outgoing'; import { PendingMessageCache } from './PendingMessageCache'; -import { - GroupUtils, - JobQueue, - SyncMessageUtils, - TypedEventEmitter, -} from '../utils'; +import { GroupUtils, JobQueue, TypedEventEmitter } from '../utils'; import { PubKey } from '../types'; import { MessageSender } from '.'; import { MultiDeviceProtocol, SessionProtocol } from '../protocols'; @@ -39,44 +34,20 @@ export class MessageQueue implements MessageQueueInterface { user: PubKey, message: ContentMessage ): Promise { - const userDevices = await MultiDeviceProtocol.getAllDevices(user.key); + if (message instanceof SyncMessage) { + return this.sendSyncMessage(message); + } + const userDevices = await MultiDeviceProtocol.getAllDevices(user.key); await this.sendMessageToDevices(userDevices, message); } public async send(device: PubKey, message: ContentMessage): Promise { - await this.sendMessageToDevices([device], message); - } - - public async sendMessageToDevices( - devices: Array, - message: ContentMessage - ) { - let currentDevices = [...devices]; - - // Sync to our devices if syncable - if (SyncMessageUtils.canSync(message)) { - const syncMessage = SyncMessageUtils.from(message); - if (!syncMessage) { - throw new Error( - 'MessageQueue internal error occured: failed to make sync message' - ); - } - - await this.sendSyncMessage(syncMessage); - - const ourDevices = await MultiDeviceProtocol.getOurDevices(); - // Remove our devices from currentDevices - currentDevices = currentDevices.filter( - device => !ourDevices.some(d => device.isEqual(d)) - ); + if (message instanceof SyncMessage) { + return this.sendSyncMessage(message); } - const promises = currentDevices.map(async device => { - await this.process(device, message); - }); - - return Promise.all(promises); + await this.sendMessageToDevices([device], message); } public async sendToGroup( @@ -120,7 +91,16 @@ export class MessageQueue implements MessageQueueInterface { } // Get devices in group - const recipients = await GroupUtils.getGroupMembers(groupId); + let recipients = await GroupUtils.getGroupMembers(groupId); + + // Don't send to our own device as they'll likely be synced across. + const ourKey = await UserUtil.getCurrentDevicePubKey(); + if (!ourKey) { + throw new Error('Cannot get current user public key'); + } + const ourPrimary = await MultiDeviceProtocol.getPrimaryDevice(ourKey); + recipients = recipients.filter(member => !ourPrimary.isEqual(member)); + if (recipients.length === 0) { return; } @@ -133,16 +113,15 @@ export class MessageQueue implements MessageQueueInterface { ); } - public async sendSyncMessage(message: SyncMessage | undefined): Promise { + public async sendSyncMessage( + message: SyncMessage | undefined + ): Promise { if (!message) { return; } const ourDevices = await MultiDeviceProtocol.getOurDevices(); - const promises = ourDevices.map(async device => - this.process(device, message) - ); - return Promise.all(promises); + await this.sendMessageToDevices(ourDevices, message); } public async processPending(device: PubKey) { @@ -179,6 +158,17 @@ export class MessageQueue implements MessageQueueInterface { }); } + public async sendMessageToDevices( + devices: Array, + message: ContentMessage + ) { + const promises = devices.map(async device => { + await this.process(device, message); + }); + + return Promise.all(promises); + } + private async processAllPending() { const devices = await this.pendingMessageCache.getDevices(); const promises = devices.map(async device => this.processPending(device)); diff --git a/ts/session/sending/MessageQueueInterface.ts b/ts/session/sending/MessageQueueInterface.ts index 2cd58354e..3a66616c5 100644 --- a/ts/session/sending/MessageQueueInterface.ts +++ b/ts/session/sending/MessageQueueInterface.ts @@ -20,5 +20,5 @@ export interface MessageQueueInterface { sendUsingMultiDevice(user: PubKey, message: ContentMessage): Promise; send(device: PubKey, message: ContentMessage): Promise; sendToGroup(message: GroupMessageType): Promise; - sendSyncMessage(message: SyncMessage | undefined): Promise; + sendSyncMessage(message: SyncMessage | undefined): Promise; } diff --git a/ts/session/utils/Promise.ts b/ts/session/utils/Promise.ts index b106f6032..2ce92a534 100644 --- a/ts/session/utils/Promise.ts +++ b/ts/session/utils/Promise.ts @@ -34,7 +34,7 @@ export async function waitForTask( return Promise.race([timeoutPromise, taskPromise]) as Promise; } -interface PollOptions { +export interface PollOptions { timeout: number; interval: number; } @@ -43,7 +43,7 @@ interface PollOptions { * Creates a promise which calls the `task` every `interval` until `done` is called or until `timeout` period is reached. * If `timeout` is reached then this will throw an Error. * - * @param check The check which runs every `interval` ms. + * @param task The task which runs every `interval` ms. * @param options The polling options. */ export async function poll( diff --git a/ts/session/utils/String.ts b/ts/session/utils/String.ts index 0c2b16c37..820021757 100644 --- a/ts/session/utils/String.ts +++ b/ts/session/utils/String.ts @@ -1,7 +1,7 @@ import ByteBuffer from 'bytebuffer'; -type Encoding = 'base64' | 'hex' | 'binary' | 'utf8'; -type BufferType = ByteBuffer | Buffer | ArrayBuffer | Uint8Array; +export type Encoding = 'base64' | 'hex' | 'binary' | 'utf8'; +export type BufferType = ByteBuffer | Buffer | ArrayBuffer | Uint8Array; /** * Take a string value with the given encoding and converts it to an `ArrayBuffer`. diff --git a/ts/session/utils/SyncMessageUtils.ts b/ts/session/utils/SyncMessage.ts similarity index 74% rename from ts/session/utils/SyncMessageUtils.ts rename to ts/session/utils/SyncMessage.ts index 1aa0a2c28..ffa5b8bdb 100644 --- a/ts/session/utils/SyncMessageUtils.ts +++ b/ts/session/utils/SyncMessage.ts @@ -1,25 +1,34 @@ import * as _ from 'lodash'; -import { UserUtil } from '../../util/'; +import { UserUtil } from '../../util'; import { getAllConversations } from '../../../js/modules/data'; -import { ContentMessage, SyncMessage } from '../messages/outgoing'; import { MultiDeviceProtocol } from '../protocols'; import ByteBuffer from 'bytebuffer'; - -export function from(message: ContentMessage): SyncMessage | undefined { - if (message instanceof SyncMessage) { - return message; +import { + ContentMessage, + DataMessage, + SentSyncMessage, +} from '../messages/outgoing'; +import { PubKey } from '../types'; + +export function getSentSyncMessage(params: { + message: ContentMessage; + expirationStartTimestamp?: number; + sentTo?: Array; + destination: PubKey | string; +}): SentSyncMessage | undefined { + if (!(params.message instanceof DataMessage)) { + return undefined; } - // Stubbed for now - return undefined; -} - -export function canSync(message: ContentMessage): boolean { - // This function should be agnostic to the device; it shouldn't need - // to know about the recipient - - // Stubbed for now - return Boolean(from(message)); + const pubKey = PubKey.cast(params.destination); + return new SentSyncMessage({ + timestamp: Date.now(), + identifier: params.message.identifier, + destination: pubKey, + dataMessage: params.message.dataProto(), + expirationStartTimestamp: params.expirationStartTimestamp, + sentTo: params.sentTo, + }); } export async function getSyncContacts(): Promise | undefined> { @@ -64,10 +73,7 @@ export async function getSyncContacts(): Promise | undefined> { .filter(c => c.id !== primaryDevice.key); // Return unique contacts - return _.uniqBy( - [...primaryContacts, ...secondaryContacts], - device => !!device - ); + return _.uniqBy([...primaryContacts, ...secondaryContacts], 'id'); } export async function filterOpenGroupsConvos( diff --git a/ts/session/utils/index.ts b/ts/session/utils/index.ts index 4089c585c..73a11d4de 100644 --- a/ts/session/utils/index.ts +++ b/ts/session/utils/index.ts @@ -1,6 +1,6 @@ import * as MessageUtils from './Messages'; import * as GroupUtils from './Groups'; -import * as SyncMessageUtils from './SyncMessageUtils'; +import * as SyncMessageUtils from './SyncMessage'; import * as StringUtils from './String'; import * as PromiseUtils from './Promise'; diff --git a/ts/test/session/sending/MessageQueue_test.ts b/ts/test/session/sending/MessageQueue_test.ts index 3f1a4cb72..7c7241f9f 100644 --- a/ts/test/session/sending/MessageQueue_test.ts +++ b/ts/test/session/sending/MessageQueue_test.ts @@ -231,6 +231,18 @@ describe('MessageQueue', () => { expect(args[0]).to.have.same.members(devices); expect(args[1]).to.equal(message); }); + + it('should send sync message if it was passed in', async () => { + const devices = TestUtils.generateFakePubKeys(3); + sandbox.stub(MultiDeviceProtocol, 'getAllDevices').resolves(devices); + const stub = sandbox.stub(messageQueueStub, 'sendSyncMessage').resolves(); + + const message = new TestSyncMessage({ timestamp: Date.now() }); + await messageQueueStub.sendUsingMultiDevice(devices[0], message); + + const args = stub.lastCall.args as [ContentMessage]; + expect(args[0]).to.equal(message); + }); }); describe('sendMessageToDevices', () => { @@ -243,51 +255,6 @@ describe('MessageQueue', () => { await messageQueueStub.sendMessageToDevices(devices, message); expect(pendingMessageCache.getCache()).to.have.length(devices.length); }); - - it('should send sync message if possible', async () => { - hasSessionStub.returns(false); - - sandbox.stub(SyncMessageUtils, 'canSync').returns(true); - - sandbox - .stub(SyncMessageUtils, 'from') - .returns(new TestSyncMessage({ timestamp: Date.now() })); - - // This stub ensures that the message won't process - const sendSyncMessageStub = sandbox - .stub(messageQueueStub, 'sendSyncMessage') - .resolves(); - - const ourDevices = [ourDevice, ...TestUtils.generateFakePubKeys(2)]; - sandbox - .stub(MultiDeviceProtocol, 'getAllDevices') - .callsFake(async user => { - if (ourDevice.isEqual(user)) { - return ourDevices; - } - - return []; - }); - - const devices = [...ourDevices, ...TestUtils.generateFakePubKeys(3)]; - const message = TestUtils.generateChatMessage(); - - await messageQueueStub.sendMessageToDevices(devices, message); - expect(sendSyncMessageStub.called).to.equal( - true, - 'sendSyncMessage was not called.' - ); - expect( - pendingMessageCache.getCache().map(c => c.device) - ).to.not.have.members( - ourDevices.map(d => d.key), - 'Sending regular messages to our own device is not allowed.' - ); - expect(pendingMessageCache.getCache()).to.have.length( - devices.length - ourDevices.length, - 'Messages should not be sent to our devices.' - ); - }); }); describe('sendSyncMessage', () => { @@ -320,6 +287,12 @@ describe('MessageQueue', () => { }); describe('closed groups', async () => { + beforeEach(() => { + sandbox + .stub(MultiDeviceProtocol, 'getPrimaryDevice') + .resolves(new PrimaryPubKey(ourNumber)); + }); + it('can send to closed group', async () => { const members = TestUtils.generateFakePubKeys(4).map( p => new PrimaryPubKey(p.key) @@ -351,6 +324,19 @@ describe('MessageQueue', () => { await messageQueueStub.sendToGroup(message); expect(sendUsingMultiDeviceStub.callCount).to.equal(0); }); + + it('wont send message to our device', async () => { + sandbox + .stub(GroupUtils, 'getGroupMembers') + .resolves([new PrimaryPubKey(ourNumber)]); + const sendUsingMultiDeviceStub = sandbox + .stub(messageQueueStub, 'sendUsingMultiDevice') + .resolves(); + + const message = TestUtils.generateClosedGroupMessage(); + await messageQueueStub.sendToGroup(message); + expect(sendUsingMultiDeviceStub.callCount).to.equal(0); + }); }); describe('open groups', async () => { diff --git a/ts/test/session/utils/Messages_test.ts b/ts/test/session/utils/Messages_test.ts new file mode 100644 index 000000000..a3775abfb --- /dev/null +++ b/ts/test/session/utils/Messages_test.ts @@ -0,0 +1,63 @@ +import chai from 'chai'; +import { TestUtils } from '../../test-utils/'; +import { MessageUtils } from '../../../session/utils/'; +import { PubKey } from '../../../session/types/'; + +// tslint:disable-next-line: no-require-imports no-var-requires +const chaiAsPromised = require('chai-as-promised'); +chai.use(chaiAsPromised); + +const { expect } = chai; + +describe('Message Utils', () => { + describe('toRawMessage', () => { + it('can convert to raw message', async () => { + const device = TestUtils.generateFakePubKey(); + const message = TestUtils.generateChatMessage(); + + const rawMessage = MessageUtils.toRawMessage(device, message); + + expect(Object.keys(rawMessage)).to.have.length(6); + expect(rawMessage.identifier).to.exist; + expect(rawMessage.device).to.exist; + expect(rawMessage.encryption).to.exist; + expect(rawMessage.plainTextBuffer).to.exist; + expect(rawMessage.timestamp).to.exist; + expect(rawMessage.ttl).to.exist; + }); + + it('should generate valid plainTextBuffer', async () => { + const device = TestUtils.generateFakePubKey(); + const message = TestUtils.generateChatMessage(); + + const rawMessage = MessageUtils.toRawMessage(device, message); + + const rawBuffer = rawMessage.plainTextBuffer; + const rawBufferJSON = JSON.stringify(rawBuffer); + const messageBufferJSON = JSON.stringify(message.plainTextBuffer()); + + expect(rawBuffer instanceof Uint8Array).to.equal( + true, + 'raw message did not contain a plainTextBuffer' + ); + expect(rawBufferJSON).to.equal( + messageBufferJSON, + 'plainTextBuffer was not converted correctly' + ); + }); + + it('should maintain pubkey', async () => { + const device = TestUtils.generateFakePubKey(); + const message = TestUtils.generateChatMessage(); + + const rawMessage = MessageUtils.toRawMessage(device, message); + const derivedPubKey = PubKey.from(rawMessage.device); + + expect(derivedPubKey).to.exist; + expect(derivedPubKey?.isEqual(device)).to.equal( + true, + 'pubkey of message was not converted correctly' + ); + }); + }); +}); diff --git a/ts/test/session/utils/Promise_test.ts b/ts/test/session/utils/Promise_test.ts new file mode 100644 index 000000000..c4ca24057 --- /dev/null +++ b/ts/test/session/utils/Promise_test.ts @@ -0,0 +1,141 @@ +import chai from 'chai'; +import * as sinon from 'sinon'; + +import { PromiseUtils } from '../../../session/utils/'; + +// tslint:disable-next-line: no-require-imports no-var-requires +const chaiAsPromised = require('chai-as-promised'); +chai.use(chaiAsPromised); + +const { expect } = chai; + +describe('Promise Utils', () => { + const sandbox = sinon.createSandbox(); + let pollSpy: sinon.SinonSpy< + [ + (done: (arg: any) => void) => Promise | void, + (Partial | undefined)? + ], + Promise + >; + let waitForTaskSpy: sinon.SinonSpy< + [(done: (arg: any) => void) => Promise | void, (number | undefined)?], + Promise + >; + let waitUntilSpy: sinon.SinonSpy< + [() => Promise | boolean, (number | undefined)?], + Promise + >; + + beforeEach(() => { + pollSpy = sandbox.spy(PromiseUtils, 'poll'); + waitForTaskSpy = sandbox.spy(PromiseUtils, 'waitForTask'); + waitUntilSpy = sandbox.spy(PromiseUtils, 'waitUntil'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('poll', () => { + it('will call done on finished', async () => { + // completionSpy will be called on done + const completionSpy = sandbox.spy(); + + // tslint:disable-next-line: mocha-unneeded-done + const task = (done: any) => { + completionSpy(); + done(); + }; + + const promise = PromiseUtils.poll(task, {}); + + await expect(promise).to.be.fulfilled; + expect(pollSpy.callCount).to.equal(1); + expect(completionSpy.callCount).to.equal(1); + }); + + it('can timeout a task', async () => { + // completionSpy will be called on done + const completionSpy = sandbox.spy(); + const task = (_done: any) => undefined; + + const promise = PromiseUtils.poll(task, { timeout: 1 }); + + await expect(promise).to.be.rejectedWith('Periodic check timeout'); + expect(pollSpy.callCount).to.equal(1); + expect(completionSpy.callCount).to.equal(0); + }); + + it('will recur according to interval option', async () => { + const expectedRecurrences = 4; + const timeout = 1000; + const interval = timeout / expectedRecurrences; + + const recurrenceSpy = sandbox.spy(); + const task = (done: any) => { + recurrenceSpy(); + + // Done after we've been called `expectedRecurrences` times + if (recurrenceSpy.callCount === expectedRecurrences) { + done(); + } + }; + + const promise = PromiseUtils.poll(task, { timeout, interval }); + + await expect(promise).to.be.fulfilled; + expect(pollSpy.callCount).to.equal(1); + expect(recurrenceSpy.callCount).to.equal(expectedRecurrences); + }); + }); + + describe('waitForTask', () => { + it('can wait for a task', async () => { + // completionSpy will be called on done + const completionSpy = sandbox.spy(); + + // tslint:disable-next-line: mocha-unneeded-done + const task = (done: any) => { + completionSpy(); + done(); + }; + + const promise = PromiseUtils.waitForTask(task); + + await expect(promise).to.be.fulfilled; + expect(waitForTaskSpy.callCount).to.equal(1); + expect(completionSpy.callCount).to.equal(1); + }); + + it('can timeout a task', async () => { + // completionSpy will be called on done + const completionSpy = sandbox.spy(); + const task = async (_done: any) => undefined; + + const promise = PromiseUtils.waitForTask(task, 1); + + await expect(promise).to.be.rejectedWith('Task timed out.'); + expect(waitForTaskSpy.callCount).to.equal(1); + expect(completionSpy.callCount).to.equal(0); + }); + }); + + describe('waitUntil', () => { + it('can wait for check', async () => { + const check = () => true; + const promise = PromiseUtils.waitUntil(check); + + await expect(promise).to.be.fulfilled; + expect(waitUntilSpy.callCount).to.equal(1); + }); + + it('can timeout a check', async () => { + const check = () => false; + const promise = PromiseUtils.waitUntil(check, 1); + + await expect(promise).to.be.rejectedWith('Periodic check timeout'); + expect(waitUntilSpy.callCount).to.equal(1); + }); + }); +}); diff --git a/ts/test/session/utils/String_test.ts b/ts/test/session/utils/String_test.ts new file mode 100644 index 000000000..f9b3be86d --- /dev/null +++ b/ts/test/session/utils/String_test.ts @@ -0,0 +1,227 @@ +import chai from 'chai'; +import ByteBuffer from 'bytebuffer'; + +// Can't import type as StringUtils.Encoding +import { Encoding } from '../../../session/utils/String'; +import { StringUtils } from '../../../session/utils/'; + +// tslint:disable-next-line: no-require-imports no-var-requires +const chaiAsPromised = require('chai-as-promised'); +chai.use(chaiAsPromised); + +const { expect } = chai; + +describe('String Utils', () => { + describe('encode', () => { + it('can encode to base64', async () => { + const testString = 'AAAAAAAAAA'; + const encoded = StringUtils.encode(testString, 'base64'); + + expect(encoded instanceof ArrayBuffer).to.equal( + true, + 'a buffer was not returned from `encode`' + ); + expect(encoded.byteLength).to.be.greaterThan(0); + }); + + it('can encode to hex', async () => { + const testString = 'AAAAAAAAAA'; + const encoded = StringUtils.encode(testString, 'hex'); + + expect(encoded instanceof ArrayBuffer).to.equal( + true, + 'a buffer was not returned from `encode`' + ); + expect(encoded.byteLength).to.be.greaterThan(0); + }); + + it('wont encode invalid hex', async () => { + const testString = 'ZZZZZZZZZZ'; + const encoded = StringUtils.encode(testString, 'hex'); + + expect(encoded.byteLength).to.equal(0); + }); + + it('can encode to binary', async () => { + const testString = 'AAAAAAAAAA'; + const encoded = StringUtils.encode(testString, 'binary'); + + expect(encoded instanceof ArrayBuffer).to.equal( + true, + 'a buffer was not returned from `encode`' + ); + expect(encoded.byteLength).to.be.greaterThan(0); + }); + + it('can encode to utf8', async () => { + const testString = 'AAAAAAAAAA'; + const encoded = StringUtils.encode(testString, 'binary'); + + expect(encoded instanceof ArrayBuffer).to.equal( + true, + 'a buffer was not returned from `encode`' + ); + expect(encoded.byteLength).to.be.greaterThan(0); + }); + + it('can encode empty string', async () => { + const testString = ''; + expect(testString).to.have.length(0); + + const allEncodedings = (['base64', 'hex', 'binary', 'utf8'] as Array< + Encoding + >).map(e => StringUtils.encode(testString, e)); + + allEncodedings.forEach(encoded => { + expect(encoded instanceof ArrayBuffer).to.equal( + true, + 'a buffer was not returned from `encode`' + ); + expect(encoded.byteLength).to.equal(0); + }); + }); + + it('can encode huge string', async () => { + const stringSize = Math.pow(2, 16); + const testString = Array(stringSize) + .fill('0') + .join(''); + + const allEncodedings = (['base64', 'hex', 'binary', 'utf8'] as Array< + Encoding + >).map(e => StringUtils.encode(testString, e)); + + allEncodedings.forEach(encoded => { + expect(encoded instanceof ArrayBuffer).to.equal( + true, + 'a buffer was not returned from `encode`' + ); + expect(encoded.byteLength).to.be.greaterThan(0); + }); + }); + + it("won't encode illegal string length in hex", async () => { + const testString = 'A'; + const encode = () => StringUtils.encode(testString, 'hex'); + + // Ensure string is odd length + expect(testString.length % 2).to.equal(1); + expect(encode).to.throw('Illegal str: Length not a multiple of 2'); + }); + + it('can encode obscure string', async () => { + const testString = + '↓←¶ᶑᵶ⅑⏕→⅓‎ᵹ⅙ᵰᶎ⅔⅗↔‌ᶈ⅞⸜ᶊᵴᶉ↉¥ᶖᶋᶃᶓ⏦ᵾᶂᶆ↕⸝ᶔᶐ⏔£⏙⅐⅒ᶌ⁁ᶘᶄᶒᶸ⅘‏⅚⅛ᶙᶇᶕᶀ↑ᵿ⏠ᶍᵯ⏖⏗⅜ᶚᶏ⁊‍ᶁᶗᵽᵼ⅝⏘⅖⅕⏡'; + + // Not valid hex format; try test the others + const encodings = ['base64', 'binary', 'utf8'] as Array; + + encodings.forEach(encoding => { + const encoded = StringUtils.encode(testString, encoding); + expect(encoded instanceof ArrayBuffer).to.equal( + true, + `a buffer was not returned using encoding: '${encoding}'` + ); + expect(encoded.byteLength).to.be.greaterThan(0); + }); + }); + }); + + describe('decode', () => { + it('can decode empty buffer', async () => { + const buffer = new ByteBuffer(0); + + const encodings = ['base64', 'hex', 'binary', 'utf8'] as Array; + + // Each encoding should be valid + encodings.forEach(encoding => { + const decoded = StringUtils.decode(buffer, encoding); + + expect(decoded).to.exist; + expect(typeof decoded === String.name.toLowerCase()); + expect(decoded).to.have.length(0); + }); + }); + + it('can decode huge buffer', async () => { + const bytes = Math.pow(2, 16); + const bufferString = Array(bytes) + .fill('A') + .join(''); + const buffer = ByteBuffer.fromUTF8(bufferString); + + const encodings = ['base64', 'hex', 'binary', 'utf8'] as Array; + + // Each encoding should be valid + encodings.forEach(encoding => { + const decoded = StringUtils.decode(buffer, encoding); + + expect(decoded).to.exist; + expect(typeof decoded === String.name.toLowerCase()); + expect(decoded).to.have.length.greaterThan(0); + }); + }); + + it('can decode from ByteBuffer', async () => { + const buffer = ByteBuffer.fromUTF8('AAAAAAAAAA'); + + const encodings = ['base64', 'hex', 'binary', 'utf8'] as Array; + + // Each encoding should be valid + encodings.forEach(encoding => { + const decoded = StringUtils.decode(buffer, encoding); + + expect(decoded).to.exist; + expect(typeof decoded === String.name.toLowerCase()); + expect(decoded).to.have.length.greaterThan(0); + }); + }); + + it('can decode from Buffer', async () => { + const arrayBuffer = new ArrayBuffer(10); + const buffer = Buffer.from(arrayBuffer); + buffer.writeUInt8(0, 0); + + const encodings = ['base64', 'hex', 'binary', 'utf8'] as Array; + + // Each encoding should be valid + encodings.forEach(encoding => { + const decoded = StringUtils.decode(buffer, encoding); + + expect(decoded).to.exist; + expect(typeof decoded === String.name.toLowerCase()); + expect(decoded).to.have.length.greaterThan(0); + }); + }); + + it('can decode from ArrayBuffer', async () => { + const buffer = new ArrayBuffer(10); + + const encodings = ['base64', 'hex', 'binary', 'utf8'] as Array; + + // Each encoding should be valid + encodings.forEach(encoding => { + const decoded = StringUtils.decode(buffer, encoding); + + expect(decoded).to.exist; + expect(typeof decoded === String.name.toLowerCase()); + expect(decoded).to.have.length.greaterThan(0); + }); + }); + + it('can decode from Uint8Array', async () => { + const buffer = new Uint8Array(10); + + const encodings = ['base64', 'hex', 'binary', 'utf8'] as Array; + + // Each encoding should be valid + encodings.forEach(encoding => { + const decoded = StringUtils.decode(buffer, encoding); + + expect(decoded).to.exist; + expect(typeof decoded === String.name.toLowerCase()); + expect(decoded).to.have.length.greaterThan(0); + }); + }); + }); +}); diff --git a/ts/test/session/utils/SyncMessage_test.ts b/ts/test/session/utils/SyncMessage_test.ts new file mode 100644 index 000000000..df14f6627 --- /dev/null +++ b/ts/test/session/utils/SyncMessage_test.ts @@ -0,0 +1,117 @@ +import chai from 'chai'; +import * as sinon from 'sinon'; + +import { SyncMessageUtils } from '../../../session/utils/'; +import { TestUtils } from '../../test-utils'; +import { UserUtil } from '../../../util'; +import { MultiDeviceProtocol } from '../../../session/protocols'; +import { SyncMessage } from '../../../session/messages/outgoing'; + +// tslint:disable-next-line: no-require-imports no-var-requires +const chaiAsPromised = require('chai-as-promised'); +chai.use(chaiAsPromised); + +const { expect } = chai; + +describe('Sync Message Utils', () => { + describe('getSyncContacts', () => { + let getAllConversationsStub: sinon.SinonStub; + let getOrCreateAndWaitStub: sinon.SinonStub; + let getOrCreatAndWaitItem: any; + + // Fill half with secondaries, half with primaries + const numConversations = 20; + const primaryConversations = new Array(numConversations / 2) + .fill({}) + .map( + () => + new TestUtils.MockConversation({ + type: TestUtils.MockConversationType.Primary, + }) + ); + const secondaryConversations = new Array(numConversations / 2) + .fill({}) + .map( + () => + new TestUtils.MockConversation({ + type: TestUtils.MockConversationType.Secondary, + }) + ); + const conversations = [...primaryConversations, ...secondaryConversations]; + + const sandbox = sinon.createSandbox(); + const ourDevice = TestUtils.generateFakePubKey(); + const ourNumber = ourDevice.key; + + const ourPrimaryDevice = TestUtils.generateFakePubKey(); + + beforeEach(async () => { + // Util Stubs + TestUtils.stubWindow('Whisper', { + ConversationCollection: sandbox.stub(), + }); + + getAllConversationsStub = TestUtils.stubData( + 'getAllConversations' + ).resolves(conversations); + + // Scale result in sync with secondaryConversations on callCount + getOrCreateAndWaitStub = sandbox.stub().callsFake(() => { + const item = + secondaryConversations[getOrCreateAndWaitStub.callCount - 1]; + + // Make the item a primary device to match the call in SyncMessage under secondaryContactsPromise + getOrCreatAndWaitItem = { + ...item, + getPrimaryDevicePubKey: () => item.id, + attributes: { + secondaryStatus: false, + }, + }; + + return getOrCreatAndWaitItem; + }); + + TestUtils.stubWindow('ConversationController', { + getOrCreateAndWait: getOrCreateAndWaitStub, + }); + + // Stubs + sandbox.stub(UserUtil, 'getCurrentDevicePubKey').resolves(ourNumber); + sandbox + .stub(MultiDeviceProtocol, 'getPrimaryDevice') + .resolves(ourPrimaryDevice); + }); + + afterEach(() => { + sandbox.restore(); + TestUtils.restoreStubs(); + }); + + it('can get sync contacts with only primary contacts', async () => { + getAllConversationsStub.resolves(primaryConversations); + + const contacts = await SyncMessageUtils.getSyncContacts(); + expect(getAllConversationsStub.callCount).to.equal(1); + + // Each contact should be a primary device + expect(contacts).to.have.length(numConversations / 2); + expect(contacts?.find(c => c.attributes.secondaryStatus)).to.not.exist; + }); + + it('can get sync contacts of assorted primaries and secondaries', async () => { + // Map secondary contacts to stub resolution + const contacts = await SyncMessageUtils.getSyncContacts(); + expect(getAllConversationsStub.callCount).to.equal(1); + + // We should have numConversations unique contacts + expect(contacts).to.have.length(numConversations); + + // All contacts should be primary; half of which some from secondaries in secondaryContactsPromise + expect(contacts?.find(c => c.attributes.secondaryStatus)).to.not.exist; + expect(contacts?.filter(c => c.isPrimary)).to.have.length( + numConversations / 2 + ); + }); + }); +}); diff --git a/ts/test/test-utils/utils/message.ts b/ts/test/test-utils/utils/message.ts index e2e71504a..4aef39cd0 100644 --- a/ts/test/test-utils/utils/message.ts +++ b/ts/test/test-utils/utils/message.ts @@ -5,7 +5,8 @@ import { } from '../../../session/messages/outgoing'; import { v4 as uuid } from 'uuid'; import { OpenGroup } from '../../../session/types'; -import { generateFakePubKey } from './pubkey'; +import { generateFakePubKey, generateFakePubKeys } from './pubkey'; +import { ConversationAttributes } from '../../../../js/models/conversations'; export function generateChatMessage(identifier?: string): ChatMessage { return new ChatMessage({ @@ -46,3 +47,68 @@ export function generateClosedGroupMessage( chatMessage: generateChatMessage(), }); } + +interface MockConversationParams { + id?: string; + type: MockConversationType; + members?: Array; +} + +export enum MockConversationType { + Primary = 'primary', + Secondary = 'secondary', + Group = 'group', +} + +export class MockConversation { + public id: string; + public type: MockConversationType; + public attributes: ConversationAttributes; + public isPrimary?: boolean; + + constructor(params: MockConversationParams) { + const dayInSeconds = 86400; + + this.type = params.type; + this.id = params.id ?? generateFakePubKey().key; + this.isPrimary = this.type === MockConversationType.Primary; + + const members = + this.type === MockConversationType.Group + ? params.members ?? generateFakePubKeys(10).map(m => m.key) + : []; + + this.attributes = { + members, + left: false, + expireTimer: dayInSeconds, + profileSharing: true, + mentionedUs: false, + unreadCount: 99, + isArchived: false, + active_at: Date.now(), + timestamp: Date.now(), + secondaryStatus: !this.isPrimary, + }; + } + + public isPrivate() { + return true; + } + + public isOurLocalDevice() { + return false; + } + + public isBlocked() { + return false; + } + + public getPrimaryDevicePubKey() { + if (this.type === MockConversationType.Group) { + return undefined; + } + + return this.isPrimary ? this.id : generateFakePubKey().key; + } +}