diff --git a/js/modules/data.d.ts b/js/modules/data.d.ts index 29e42df98..92441555f 100644 --- a/js/modules/data.d.ts +++ b/js/modules/data.d.ts @@ -1,3 +1,418 @@ +import { ConversationType } from '../../ts/state/ducks/conversations'; +import { Mesasge } from '../../ts/types/Message'; + +type IdentityKey = { + id: string; + publicKey: ArrayBuffer; + firstUse: boolean; + verified: number; + nonblockingApproval: boolean; +}; + +type PreKey = { + id: number; + publicKey: ArrayBuffer; + privateKey: ArrayBuffer; + recipient: string; +}; + +type SignedPreKey = { + id: number; + publicKey: ArrayBuffer; + privateKey: ArrayBuffer; + created_at: number; + confirmed: boolean; + signature: ArrayBuffer; +}; + +type ContactPreKey = { + id: number; + identityKeyString: string; + publicKey: ArrayBuffer; + keyId: number; +}; + +type ContactSignedPreKey = { + id: number; + identityKeyString: string; + publicKey: ArrayBuffer; + keyId: number; + signature: ArrayBuffer; + created_at: number; + confirmed: boolean; +}; + +type PairingAuthorisation = { + primaryDevicePubKey: string; + secondaryDevicePubKey: string; + requestSignature: ArrayBuffer; + grantSignature: ArrayBuffer | null; +}; + +type GuardNode = { + ed25519PubKey: string; +}; + +type SwarmNode = { + address: string; + ip: string; + port: string; + pubkey_ed25519: string; + pubkey_x25519: string; +}; + +type StorageItem = { + id: string; + value: any; +}; + +type SessionDataInfo = { + id: string; + number: string; + deviceId: number; + record: string; +}; + +type ServerToken = { + serverUrl: string; + token: string; +}; + +// Basic export function searchMessages(query: string): Promise>; export function searchConversations(query: string): Promise>; -export function getPrimaryDeviceFor(pubKey: string): Promise; +export function shutdown(): Promise; +export function close(): Promise; +export function removeDB(): Promise; +export function removeIndexedDBFiles(): Promise; +export function getPasswordHash(): Promise; + +// Identity Keys +export function createOrUpdateIdentityKey(data: IdentityKey): Promise; +export function getIdentityKeyById(id: string): Promise; +export function bulkAddIdentityKeys(array: Array): Promise; +export function removeIdentityKeyById(id: string): Promise; +export function removeAllIdentityKeys(): Promise; + +// Pre Keys +export function createOrUpdatePreKey(data: PreKey): Promise; +export function getPreKeyById(id: number): Promise; +export function getPreKeyByRecipient(recipient: string): Promise; +export function bulkAddPreKeys(data: Array): Promise; +export function removePreKeyById(id: number): Promise; +export function getAllPreKeys(): Promise>; + +// Signed Pre Keys +export function createOrUpdateSignedPreKey(data: SignedPreKey): Promise; +export function getSignedPreKeyById(id: number): Promise; +export function getAllSignedPreKeys(): Promise; +export function bulkAddSignedPreKeys(array: Array): Promise; +export function removeSignedPreKeyById(id: number): Promise; +export function removeAllSignedPreKeys(): Promise; + +// Contact Pre Key +export function createOrUpdateContactPreKey(data: ContactPreKey): Promise; +export function getContactPreKeyById(id: number): Promise; +export function getContactPreKeyByIdentityKey( + key: string +): Promise; +export function getContactPreKeys( + keyId: number, + identityKeyString: string +): Promise>; +export function getAllContactPreKeys(): Promise>; +export function bulkAddContactPreKeys( + array: Array +): Promise; +export function removeContactPreKeyByIdentityKey(id: number): Promise; +export function removeAllContactPreKeys(): Promise; + +// Contact Signed Pre Key +export function createOrUpdateContactSignedPreKey( + data: ContactSignedPreKey +): Promise; +export function getContactSignedPreKeyById( + id: number +): Promise; +export function getContactSignedPreKeyByIdentityKey( + key: string +): Promise; +export function getContactSignedPreKeys( + keyId: number, + identityKeyString: string +): Promise>; +export function bulkAddContactSignedPreKeys( + array: Array +): Promise; +export function removeContactSignedPreKeyByIdentityKey( + id: string +): Promise; +export function removeAllContactSignedPreKeys(): Promise; + +// Authorisations & Linking +export function createOrUpdatePairingAuthorisation( + data: PairingAuthorisation +): Promise; +export function removePairingAuthorisationForSecondaryPubKey( + pubKey: string +): Promise; +export function getGrantAuthorisationsForPrimaryPubKey( + pubKey: string +): Promise>; +export function getGrantAuthorisationForSecondaryPubKey( + pubKey: string +): Promise; +export function getAuthorisationForSecondaryPubKey( + pubKey: string +): Promise; +export function getSecondaryDevicesFor( + primaryDevicePubKey: string +): Promise>; +export function getPrimaryDeviceFor( + secondaryDevicePubKey: string +): Promise; +export function getPairedDevicesFor(pubKey: string): Promise>; + +// Guard Nodes +export function getGuardNodes(): Promise; +export function updateGuardNodes(nodes: Array): Promise; + +// Storage Items +export function createOrUpdateItem(data: StorageItem): Promise; +export function getItemById(id: string): Promise; +export function getAlItems(): Promise>; +export function bulkAddItems(array: Array): Promise; +export function removeItemById(id: string): Promise; +export function removeAllItems(): Promise; + +// Sessions +export function createOrUpdateSession(data: SessionDataInfo): Promise; +export function getAllSessions(): Promise>; +export function getSessionById(id: string): Promise; +export function getSessionsByNumber(number: string): Promise; +export function bulkAddSessions(array: Array): Promise; +export function removeSessionById(id: string): Promise; +export function removeSessionsByNumber(number: string): Promise; +export function removeAllSessions(): Promise; + +// Conversations +export function getConversationCount(): Promise; +export function saveConversation(data: ConversationType): Promise; +export function saveConversations(data: Array): Promise; +export function updateConversation(data: ConversationType): Promise; +export function removeConversation(id: string): Promise; + +export function getAllConversations({ + ConversationCollection, +}: { + ConversationCollection: any; +}): Promise>; + +export function getAllConversationIds(): Promise>; +export function getAllPrivateConversations(): Promise>; +export function getAllPublicConversations(): Promise>; +export function getPublicConversationsByServer( + server: string, + { ConversationCollection }: { ConversationCollection: any } +): Promise; +export function getPubkeysInPublicConversation( + id: string +): Promise>; +export function savePublicServerToken(data: ServerToken): Promise; +export function getPublicServerTokenByServerUrl( + serverUrl: string +): Promise; +export function getAllGroupsInvolvingId( + id: string, + { ConversationCollection }: { ConversationCollection: any } +): Promise>; + +// Returns conversation row +// TODO: Make strict return types for search +export function searchConversations(query: string): Promise; +export function searchMessages(query: string): Promise; +export function searchMessagesInConversation( + query: string, + conversationId: string, + { limit }?: { limit: any } +): Promise; +export function getMessageCount(): Promise; +export function saveMessage( + data: Mesasge, + { forceSave, Message }?: { forceSave: any; Message: any } +): Promise; +export function cleanSeenMessages(): Promise; +export function cleanLastHashes(): Promise; +export function saveSeenMessageHash(data: { + expiresAt: number; + hash: string; +}): Promise; + +// TODO: Strictly type the following +export function updateLastHash(data: any): Promise; +export function saveSeenMessageHashes(data: any): Promise; +export function saveLegacyMessage(data: any): Promise; +export function saveMessages( + arrayOfMessages: any, + { forceSave }?: any +): Promise; +export function removeMessage(id: string, { Message }?: any): Promise; +export function getUnreadByConversation( + conversationId: string, + { MessageCollection }?: any +): Promise; +export function removeAllMessagesInConversation( + conversationId: string, + { MessageCollection }?: any +): Promise; + +export function getMessageBySender( + { + source, + sourceDevice, + sent_at, + }: { source: any; sourceDevice: any; sent_at: any }, + { Message }: { Message: any } +): Promise; +export function getMessageIdsFromServerIds( + serverIds: any, + conversationId: any +): Promise; +export function getMessageById( + id: string, + { Message }: { Message: any } +): Promise; +export function getAllMessages({ + MessageCollection, +}: { + MessageCollection: any; +}): Promise; +export function getAllUnsentMessages({ + MessageCollection, +}: { + MessageCollection: any; +}): Promise; +export function getAllMessageIds(): Promise; +export function getMessagesBySentAt( + sentAt: any, + { MessageCollection }: { MessageCollection: any } +): Promise; +export function getExpiredMessages({ + MessageCollection, +}: { + MessageCollection: any; +}): Promise; +export function getOutgoingWithoutExpiresAt({ + MessageCollection, +}: any): Promise; +export function getNextExpiringMessage({ + MessageCollection, +}: { + MessageCollection: any; +}): Promise; +export function getNextExpiringMessage({ + MessageCollection, +}: { + MessageCollection: any; +}): Promise; +export function getMessagesByConversation( + conversationId: any, + { + limit, + receivedAt, + MessageCollection, + type, + }: { + limit?: number; + receivedAt?: number; + MessageCollection: any; + type?: string; + } +): Promise; +export function getSeenMessagesByHashList(hashes: any): Promise; +export function getLastHashBySnode(convoId: any, snode: any): Promise; + +// Unprocessed +export function getUnprocessedCount(): Promise; +export function getAllUnprocessed(): Promise; +export function getUnprocessedById(id: any): Promise; +export function saveUnprocessed( + data: any, + { + forceSave, + }?: { + forceSave: any; + } +): Promise; +export function saveUnprocesseds( + arrayOfUnprocessed: any, + { + forceSave, + }?: { + forceSave: any; + } +): Promise; +export function updateUnprocessedAttempts( + id: any, + attempts: any +): Promise; +export function updateUnprocessedWithData(id: any, data: any): Promise; +export function removeUnprocessed(id: any): Promise; +export function removeAllUnprocessed(): Promise; + +// Attachment Downloads +export function getNextAttachmentDownloadJobs(limit: any): Promise; +export function saveAttachmentDownloadJob(job: any): Promise; +export function setAttachmentDownloadJobPending( + id: any, + pending: any +): Promise; +export function resetAttachmentDownloadPending(): Promise; +export function removeAttachmentDownloadJob(id: any): Promise; +export function removeAllAttachmentDownloadJobs(): Promise; + +// Other +export function removeAll(): Promise; +export function removeAllConfiguration(): Promise; +export function removeAllConversations(): Promise; +export function removeAllPrivateConversations(): Promise; +export function removeOtherData(): Promise; +export function cleanupOrphanedAttachments(): Promise; + +// Getters +export function getMessagesNeedingUpgrade( + limit: any, + { + maxVersion, + }: { + maxVersion?: number; + } +): Promise; +export function getLegacyMessagesNeedingUpgrade( + limit: any, + { + maxVersion, + }: { + maxVersion?: number; + } +): Promise; +export function getMessagesWithVisualMediaAttachments( + conversationId: any, + { + limit, + }: { + limit: any; + } +): Promise; +export function getMessagesWithFileAttachments( + conversationId: any, + { + limit, + }: { + limit: any; + } +): Promise; + +// Sender Keys +export function getSenderKeys(groupId: any, senderIdentity: any): Promise; +export function createOrUpdateSenderKeys(data: any): Promise; diff --git a/package.json b/package.json index f6031755b..8dda1a941 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ }, "devDependencies": { "@types/chai": "4.1.2", + "@types/chai-as-promised": "^7.1.2", "@types/classnames": "2.2.3", "@types/color": "^3.0.0", "@types/config": "0.0.34", diff --git a/ts/global.d.ts b/ts/global.d.ts index 34564325a..39e55c39f 100644 --- a/ts/global.d.ts +++ b/ts/global.d.ts @@ -1,3 +1,4 @@ +// TODO: Delete this and depend on window.ts instead interface Window { CONSTANTS: any; versionInfo: any; diff --git a/ts/session/crypto/MessageEncrypter.ts b/ts/session/crypto/MessageEncrypter.ts new file mode 100644 index 000000000..22ce64ccc --- /dev/null +++ b/ts/session/crypto/MessageEncrypter.ts @@ -0,0 +1,40 @@ +import { EncryptionType } from '../types/EncryptionType'; +import { SignalService } from '../../protobuf'; + +function padPlainTextBuffer(messageBuffer: Uint8Array): Uint8Array { + const plaintext = new Uint8Array( + getPaddedMessageLength(messageBuffer.byteLength + 1) - 1 + ); + plaintext.set(new Uint8Array(messageBuffer)); + plaintext[messageBuffer.byteLength] = 0x80; + + return plaintext; +} + +function getPaddedMessageLength(originalLength: number): number { + const messageLengthWithTerminator = originalLength + 1; + let messagePartCount = Math.floor(messageLengthWithTerminator / 160); + + if (messageLengthWithTerminator % 160 !== 0) { + messagePartCount += 1; + } + + return messagePartCount * 160; +} + +export function encrypt( + device: string, + plainTextBuffer: Uint8Array, + encryptionType: EncryptionType +): { + envelopeType: SignalService.Envelope.Type; + cipherText: Uint8Array; +} { + const plainText = padPlainTextBuffer(plainTextBuffer); + // TODO: Do encryption here? + + return { + envelopeType: SignalService.Envelope.Type.CIPHERTEXT, + cipherText: new Uint8Array(), + }; +} diff --git a/ts/session/crypto/index.ts b/ts/session/crypto/index.ts new file mode 100644 index 000000000..02d1b8904 --- /dev/null +++ b/ts/session/crypto/index.ts @@ -0,0 +1,3 @@ +import * as MessageEncrypter from './MessageEncrypter'; + +export { MessageEncrypter }; diff --git a/ts/session/index.ts b/ts/session/index.ts new file mode 100644 index 000000000..4aeab7901 --- /dev/null +++ b/ts/session/index.ts @@ -0,0 +1,8 @@ +import * as Messages from './messages'; +import * as Protocols from './protocols'; + +// TODO: Do we export class instances here? +// E.g +// export const messageQueue = new MessageQueue() + +export { Messages, Protocols }; diff --git a/ts/session/protocols/MultiDeviceProtocol.ts b/ts/session/protocols/MultiDeviceProtocol.ts new file mode 100644 index 000000000..b144c20cf --- /dev/null +++ b/ts/session/protocols/MultiDeviceProtocol.ts @@ -0,0 +1,6 @@ +// TODO: Populate this with multi device specific code, e.g getting linked devices for a user etc... +// We need to deprecate the multi device code we have in js and slowly transition to this file + +export function implementStuffHere() { + throw new Error("Don't call me :("); +} diff --git a/ts/session/protocols/SessionProtocol.ts b/ts/session/protocols/SessionProtocol.ts new file mode 100644 index 000000000..ebeac9f37 --- /dev/null +++ b/ts/session/protocols/SessionProtocol.ts @@ -0,0 +1,57 @@ +// TODO: Need to flesh out these functions +// Structure of this can be changed for example sticking this all in a class +// The reason i haven't done it is to avoid having instances of the protocol, rather you should be able to call the functions directly + +import { OutgoingContentMessage } from '../messages/outgoing'; + +export function hasSession(device: string): boolean { + return false; // TODO: Implement +} + +export function hasSentSessionRequest(device: string): boolean { + // TODO: need a way to keep track of if we've sent a session request + // My idea was to use the timestamp of when it was sent but there might be another better approach + return false; +} + +export async function sendSessionRequestIfNeeded( + device: string +): Promise { + if (hasSession(device) || hasSentSessionRequest(device)) { + return Promise.resolve(); + } + + // TODO: Call sendSessionRequest with SessionReset + return Promise.reject(new Error('Need to implement this function')); +} + +// TODO: Replace OutgoingContentMessage with SessionReset +export async function sendSessionRequest( + message: OutgoingContentMessage +): Promise { + // TODO: Optimistically store timestamp of when session request was sent + // TODO: Send out the request via MessageSender + // TODO: On failure, unset the timestamp + return Promise.resolve(); +} + +export function sessionEstablished(device: string) { + // TODO: this is called when we receive an encrypted message from the other user + // Maybe it should be renamed to something else + // TODO: This should make `hasSentSessionRequest` return `false` +} + +export function shouldProcessSessionRequest( + device: string, + messageTimestamp: number +): boolean { + // TODO: Need to do the following here + // messageTimestamp > session request sent timestamp && messageTimestamp > session request processed timestamp + return false; +} + +export function sessionRequestProcessed(device: string) { + // TODO: this is called when we process the session request + // This should store the processed timestamp + // Again naming is crap so maybe some other name is better +} diff --git a/ts/session/protocols/index.ts b/ts/session/protocols/index.ts new file mode 100644 index 000000000..e0cfeb680 --- /dev/null +++ b/ts/session/protocols/index.ts @@ -0,0 +1,4 @@ +import * as SessionProtocol from './SessionProtocol'; +import * as MultiDeviceProtocol from './MultiDeviceProtocol'; + +export { SessionProtocol, MultiDeviceProtocol }; diff --git a/ts/session/sending/MessageQueue.ts b/ts/session/sending/MessageQueue.ts new file mode 100644 index 000000000..274640ac4 --- /dev/null +++ b/ts/session/sending/MessageQueue.ts @@ -0,0 +1,60 @@ +import { EventEmitter } from 'events'; +import { + MessageQueueInterface, + MessageQueueInterfaceEvents, +} from './MessageQueueInterface'; +import { OpenGroupMessage, OutgoingContentMessage } from '../messages/outgoing'; +import { PendingMessageCache } from './PendingMessageCache'; +import { JobQueue, TypedEventEmitter } from '../utils'; + +export class MessageQueue implements MessageQueueInterface { + public readonly events: TypedEventEmitter; + private readonly jobQueues: Map = new Map(); + private readonly cache: PendingMessageCache; + + constructor() { + this.events = new EventEmitter(); + this.cache = new PendingMessageCache(); + this.processAllPending(); + } + + public sendUsingMultiDevice(user: string, message: OutgoingContentMessage) { + throw new Error('Method not implemented.'); + } + public send(device: string, message: OutgoingContentMessage) { + throw new Error('Method not implemented.'); + } + public sendToGroup(message: OutgoingContentMessage | OpenGroupMessage) { + throw new Error('Method not implemented.'); + } + public sendSyncMessage(message: OutgoingContentMessage) { + throw new Error('Method not implemented.'); + } + + public processPending(device: string) { + // TODO: implement + } + + private processAllPending() { + // TODO: Get all devices which are pending here + } + + private queue(device: string, message: OutgoingContentMessage) { + // TODO: implement + } + + private queueOpenGroupMessage(message: OpenGroupMessage) { + // TODO: Do we need to queue open group messages? + // If so we can get open group job queue and add the send job here + } + + private getJobQueue(device: string): JobQueue { + let queue = this.jobQueues.get(device); + if (!queue) { + queue = new JobQueue(); + this.jobQueues.set(device, queue); + } + + return queue; + } +} diff --git a/ts/session/sending/MessageQueueInterface.ts b/ts/session/sending/MessageQueueInterface.ts new file mode 100644 index 000000000..553b25ed0 --- /dev/null +++ b/ts/session/sending/MessageQueueInterface.ts @@ -0,0 +1,19 @@ +import { OpenGroupMessage, OutgoingContentMessage } from '../messages/outgoing'; +import { RawMessage } from '../types/RawMessage'; +import { TypedEventEmitter } from '../utils'; + +// TODO: add all group messages here, replace OutgoingContentMessage with them +type GroupMessageType = OpenGroupMessage | OutgoingContentMessage; + +export interface MessageQueueInterfaceEvents { + success: (message: RawMessage) => void; + fail: (message: RawMessage, error: Error) => void; +} + +export interface MessageQueueInterface { + events: TypedEventEmitter; + sendUsingMultiDevice(user: string, message: OutgoingContentMessage): void; + send(device: string, message: OutgoingContentMessage): void; + sendToGroup(message: GroupMessageType): void; + sendSyncMessage(message: OutgoingContentMessage): void; +} diff --git a/ts/session/sending/MessageSender.ts b/ts/session/sending/MessageSender.ts new file mode 100644 index 000000000..852157ca9 --- /dev/null +++ b/ts/session/sending/MessageSender.ts @@ -0,0 +1,14 @@ +// REMOVE COMMENT AFTER: This can just export pure functions as it doesn't need state + +import { RawMessage } from '../types/RawMessage'; +import { OpenGroupMessage } from '../messages/outgoing'; + +export async function send(message: RawMessage): Promise { + return Promise.resolve(); +} + +export async function sendToOpenGroup( + message: OpenGroupMessage +): Promise { + return Promise.resolve(); +} diff --git a/ts/session/sending/PendingMessageCache.ts b/ts/session/sending/PendingMessageCache.ts new file mode 100644 index 000000000..2f10e58a6 --- /dev/null +++ b/ts/session/sending/PendingMessageCache.ts @@ -0,0 +1,36 @@ +import { RawMessage } from '../types/RawMessage'; +import { OutgoingContentMessage } from '../messages/outgoing'; + +// TODO: We should be able to import functions straight from the db here without going through the window object + +export class PendingMessageCache { + private readonly cachedMessages: Array = []; + + constructor() { + // TODO: We should load pending messages from db here + } + + public addPendingMessage( + device: string, + message: OutgoingContentMessage + ): RawMessage { + // TODO: Maybe have a util for converting OutgoingContentMessage to RawMessage? + // TODO: Raw message has uuid, how are we going to set that? maybe use a different identifier? + // One could be device + timestamp would make a unique identifier + // TODO: Return previous pending message if it exists + return {} as RawMessage; + } + + public removePendingMessage(message: RawMessage) { + // TODO: implement + } + + public getPendingDevices(): Array { + // TODO: this should return all devices which have pending messages + return []; + } + + public getPendingMessages(device: string): Array { + return []; + } +} diff --git a/ts/session/sending/index.ts b/ts/session/sending/index.ts new file mode 100644 index 000000000..f6cf299b6 --- /dev/null +++ b/ts/session/sending/index.ts @@ -0,0 +1,6 @@ +// TS 3.8 supports export * as X from 'Y' +import * as MessageSender from './MessageSender'; +export { MessageSender }; + +export * from './MessageQueue'; +export * from './MessageQueueInterface'; diff --git a/ts/session/types/EncryptionType.ts b/ts/session/types/EncryptionType.ts new file mode 100644 index 000000000..ed27e1023 --- /dev/null +++ b/ts/session/types/EncryptionType.ts @@ -0,0 +1,5 @@ +export enum EncryptionType { + Signal, + SessionReset, + MediumGroup, +} diff --git a/ts/session/types/RawMessage.ts b/ts/session/types/RawMessage.ts new file mode 100644 index 000000000..30d2e0d9b --- /dev/null +++ b/ts/session/types/RawMessage.ts @@ -0,0 +1,12 @@ +import { EncryptionType } from './EncryptionType'; + +// TODO: Should we store failure count on raw messages?? +// Might be better to have a seperate interface which takes in a raw message aswell as a failure count +export interface RawMessage { + identifier: string; + plainTextBuffer: Uint8Array; + timestamp: number; + device: string; + ttl: number; + encryption: EncryptionType; +} diff --git a/ts/session/utils/JobQueue.ts b/ts/session/utils/JobQueue.ts new file mode 100644 index 000000000..fa5082836 --- /dev/null +++ b/ts/session/utils/JobQueue.ts @@ -0,0 +1,47 @@ +import { v4 as uuid } from 'uuid'; + +type Job = (() => PromiseLike) | (() => ResultType); + +// TODO: This needs to replace js/modules/job_queue.js +export class JobQueue { + private pending: Promise = Promise.resolve(); + private readonly jobs: Map> = new Map(); + + public has(id: string): boolean { + return this.jobs.has(id); + } + + public async add(job: Job): Promise { + const id = uuid(); + + return this.addWithId(id, job); + } + + public async addWithId( + id: string, + job: Job + ): Promise { + if (this.jobs.has(id)) { + return this.jobs.get(id) as Promise; + } + + const previous = this.pending || Promise.resolve(); + this.pending = previous.then(job, job); + + const current = this.pending; + void current + .catch(() => { + // This is done to avoid UnhandledPromiseError + }) + .finally(() => { + if (this.pending === current) { + delete this.pending; + } + this.jobs.delete(id); + }); + + this.jobs.set(id, current); + + return current; + } +} diff --git a/ts/session/utils/TypedEmitter.ts b/ts/session/utils/TypedEmitter.ts new file mode 100644 index 000000000..a6a27f200 --- /dev/null +++ b/ts/session/utils/TypedEmitter.ts @@ -0,0 +1,53 @@ +// Code from https://github.com/andywer/typed-emitter + +type Arguments = [T] extends [(...args: infer U) => any] + ? U + : [T] extends [void] ? [] : [T]; + +/** + * Type-safe event emitter. + * + * Use it like this: + * + * interface MyEvents { + * error: (error: Error) => void + * message: (from: string, content: string) => void + * } + * + * const myEmitter = new EventEmitter() as TypedEmitter + * + * myEmitter.on("message", (from, content) => { + * // ... + * }) + * + * myEmitter.emit("error", "x") // <- Will catch this type error + * + * or + * + * class MyEmitter extends EventEmitter implements TypedEventEmitter + */ +export interface TypedEventEmitter { + addListener(event: E, listener: Events[E]): this; + on(event: E, listener: Events[E]): this; + once(event: E, listener: Events[E]): this; + prependListener(event: E, listener: Events[E]): this; + prependOnceListener( + event: E, + listener: Events[E] + ): this; + + off(event: E, listener: Events[E]): this; + removeAllListeners(event?: E): this; + removeListener(event: E, listener: Events[E]): this; + + emit( + event: E, + ...args: Arguments + ): boolean; + eventNames(): Array; + listeners(event: E): Array; + listenerCount(event: E): number; + + getMaxListeners(): number; + setMaxListeners(maxListeners: number): this; +} diff --git a/ts/session/utils/index.ts b/ts/session/utils/index.ts new file mode 100644 index 000000000..a33d528ba --- /dev/null +++ b/ts/session/utils/index.ts @@ -0,0 +1,2 @@ +export * from './TypedEmitter'; +export * from './JobQueue'; diff --git a/ts/test/session/utils/JobQueue_test.ts b/ts/test/session/utils/JobQueue_test.ts new file mode 100644 index 000000000..864c69247 --- /dev/null +++ b/ts/test/session/utils/JobQueue_test.ts @@ -0,0 +1,113 @@ +import chai from 'chai'; +import { v4 as uuid } from 'uuid'; +import { JobQueue } from '../../../session/utils/JobQueue'; +import { timeout } from '../../utils/timeout'; + +// tslint:disable-next-line: no-require-imports no-var-requires +const chaiAsPromised = require('chai-as-promised'); +chai.use(chaiAsPromised); + +const { assert } = chai; + +describe('JobQueue', () => { + describe('has', () => { + it('should return the correct value', async () => { + const queue = new JobQueue(); + const id = 'jobId'; + + assert.isFalse(queue.has(id)); + const promise = queue.addWithId(id, async () => timeout(100)); + assert.isTrue(queue.has(id)); + await promise; + assert.isFalse(queue.has(id)); + }); + }); + + describe('addWithId', () => { + it('should run the jobs concurrently', async () => { + const input = [[10, 300], [20, 200], [30, 100]]; + const queue = new JobQueue(); + const mapper = async ([value, ms]: Array): Promise => + queue.addWithId(uuid(), async () => { + await timeout(ms); + + return value; + }); + + const start = Date.now(); + await assert.eventually.deepEqual(Promise.all(input.map(mapper)), [ + 10, + 20, + 30, + ]); + const timeTaken = Date.now() - start; + assert.closeTo(timeTaken, 600, 50, 'Queue was delayed'); + }); + + it('should return the result of the job', async () => { + const queue = new JobQueue(); + const success = queue.addWithId(uuid(), async () => { + await timeout(100); + + return 'success'; + }); + const failure = queue.addWithId(uuid(), async () => { + await timeout(100); + throw new Error('failed'); + }); + + await assert.eventually.equal(success, 'success'); + await assert.isRejected(failure, /failed/); + }); + + it('should handle sync and async tasks', async () => { + const queue = new JobQueue(); + const first = queue.addWithId(uuid(), () => 'first'); + const second = queue.addWithId(uuid(), async () => { + await timeout(100); + + return 'second'; + }); + const third = queue.addWithId(uuid(), () => 'third'); + + await assert.eventually.deepEqual(Promise.all([first, second, third]), [ + 'first', + 'second', + 'third', + ]); + }); + + it('should return the previous job if same id was passed', async () => { + const queue = new JobQueue(); + const id = uuid(); + const job = async () => { + await timeout(100); + + return 'job1'; + }; + + const promise = queue.addWithId(id, job); + const otherPromise = queue.addWithId(id, () => 'job2'); + await assert.eventually.equal(promise, 'job1'); + await assert.eventually.equal(otherPromise, 'job1'); + }); + + it('should remove completed jobs', async () => { + const queue = new JobQueue(); + const id = uuid(); + + const successfullJob = queue.addWithId(id, async () => timeout(100)); + assert.isTrue(queue.has(id)); + await successfullJob; + assert.isFalse(queue.has(id)); + + const failJob = queue.addWithId(id, async () => { + await timeout(100); + throw new Error('failed'); + }); + assert.isTrue(queue.has(id)); + await assert.isRejected(failJob, /failed/); + assert.isFalse(queue.has(id)); + }); + }); +}); diff --git a/ts/test/tslint.json b/ts/test/tslint.json index 4645335d0..9d1d3e71b 100644 --- a/ts/test/tslint.json +++ b/ts/test/tslint.json @@ -6,6 +6,11 @@ "no-implicit-dependencies": false, // All tests use arrow functions, and they can be long - "max-func-body-length": false + "max-func-body-length": false, + + "no-unused-expression": false, + + "await-promise": [true, "PromiseLike"], + "no-floating-promises": [true, "PromiseLike"] } } diff --git a/ts/test/utils/timeout.ts b/ts/test/utils/timeout.ts new file mode 100644 index 000000000..cafd9cf55 --- /dev/null +++ b/ts/test/utils/timeout.ts @@ -0,0 +1,4 @@ +export async function timeout(ms: number): Promise { + // tslint:disable-next-line no-string-based-set-timeout + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/ts/window.ts b/ts/window.ts new file mode 100644 index 000000000..aeb19ec5d --- /dev/null +++ b/ts/window.ts @@ -0,0 +1,120 @@ +import { LocalizerType } from './types/Util'; + +interface Window { + seedNodeList: any; + + WebAPI: any; + LokiSnodeAPI: any; + SenderKeyAPI: any; + LokiMessageAPI: any; + StubMessageAPI: any; + StubAppDotNetApi: any; + LokiPublicChatAPI: any; + LokiAppDotNetServerAPI: any; + LokiFileServerAPI: any; + LokiRssAPI: any; + + CONSTANTS: any; + versionInfo: any; + + Events: any; + Lodash: any; + clearLocalData: any; + getAccountManager: any; + getConversations: any; + getFriendsFromContacts: any; + mnemonic: any; + clipboard: any; + attemptConnection: any; + + passwordUtil: any; + userConfig: any; + shortenPubkey: any; + + dcodeIO: any; + libsignal: any; + libloki: any; + displayNameRegex: any; + + Signal: any; + Whisper: any; + ConversationController: any; + + onLogin: any; + setPassword: any; + textsecure: any; + Session: any; + log: any; + i18n: LocalizerType; + friends: any; + generateID: any; + storage: any; + pushToast: any; + + confirmationDialog: any; + showQRDialog: any; + showSeedDialog: any; + showPasswordDialog: any; + showEditProfileDialog: any; + + deleteAccount: any; + + toggleTheme: any; + toggleMenuBar: any; + toggleSpellCheck: any; + toggleLinkPreview: any; + toggleMediaPermissions: any; + + getSettingValue: any; + setSettingValue: any; + lokiFeatureFlags: any; + + resetDatabase: any; +} + +declare const window: Window; + +// Utilities +export const WebAPI = window.WebAPI; +export const Events = window.Events; +export const Signal = window.Signal; +export const Whisper = window.Whisper; +export const ConversationController = window.ConversationController; +export const passwordUtil = window.passwordUtil; + +// Values +export const CONSTANTS = window.CONSTANTS; +export const versionInfo = window.versionInfo; +export const mnemonic = window.mnemonic; +export const lokiFeatureFlags = window.lokiFeatureFlags; + +// Getters +export const getAccountManager = window.getAccountManager; +export const getConversations = window.getConversations; +export const getFriendsFromContacts = window.getFriendsFromContacts; +export const getSettingValue = window.getSettingValue; + +// Setters +export const setPassword = window.setPassword; +export const setSettingValue = window.setSettingValue; + +// UI Events +export const pushToast = window.pushToast; +export const confirmationDialog = window.confirmationDialog; + +export const showQRDialog = window.showQRDialog; +export const showSeedDialog = window.showSeedDialog; +export const showPasswordDialog = window.showPasswordDialog; +export const showEditProfileDialog = window.showEditProfileDialog; + +export const toggleTheme = window.toggleTheme; +export const toggleMenuBar = window.toggleMenuBar; +export const toggleSpellCheck = window.toggleSpellCheck; +export const toggleLinkPreview = window.toggleLinkPreview; +export const toggleMediaPermissions = window.toggleMediaPermissions; + +// Actions +export const clearLocalData = window.clearLocalData; +export const deleteAccount = window.deleteAccount; +export const resetDatabase = window.resetDatabase; +export const attemptConnection = window.attemptConnection; diff --git a/yarn.lock b/yarn.lock index cb8e9ef0e..bf5553f64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -156,6 +156,18 @@ dependencies: defer-to-connect "^1.0.1" +"@types/chai-as-promised@^7.1.2": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@types/chai-as-promised/-/chai-as-promised-7.1.2.tgz#2f564420e81eaf8650169e5a3a6b93e096e5068b" + integrity sha512-PO2gcfR3Oxa+u0QvECLe1xKXOqYTzCmWf0FhLhjREoW3fPAVamjihL7v1MOVLJLsnAMdLcjkfrs01yvDMwVK4Q== + dependencies: + "@types/chai" "*" + +"@types/chai@*": + version "4.2.11" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.11.tgz#d3614d6c5f500142358e6ed24e1bf16657536c50" + integrity sha512-t7uW6eFafjO+qJ3BIV2gGUyZs27egcNRkUdalkud+Qa3+kg//f129iuOFivHDXQ+vnU3fDXuwgv0cqMCbcE8sw== + "@types/chai@4.1.2": version "4.1.2" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.1.2.tgz#f1af664769cfb50af805431c407425ed619daa21"