diff --git a/test/backup_test.js b/test/backup_test.js index e712f790e..87a02ffe4 100644 --- a/test/backup_test.js +++ b/test/backup_test.js @@ -552,6 +552,7 @@ describe('Backup', () => { profileKey: 'BASE64KEY', profileName: 'Someone! 🤔', profileSharing: true, + profileLastFetchedAt: 1524185933350, timestamp: 1524185933350, type: 'private', unreadCount: 0, diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 6c1e14039..34e5eefa0 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -62,15 +62,18 @@ export function start(): void { // we can reset the mute state on the model. If the mute has already expired // then we reset the state right away. initMuteExpirationTimer(model: ConversationModel): void { - if (model.isMuted()) { + const muteExpiresAt = model.get('muteExpiresAt'); + // This check for `muteExpiresAt` is likely redundant, but is needed to appease + // TypeScript. + if (model.isMuted() && muteExpiresAt) { window.Signal.Services.onTimeout( - model.get('muteExpiresAt'), + muteExpiresAt, () => { model.set({ muteExpiresAt: undefined }); }, model.getMuteTimeoutId() ); - } else if (model.get('muteExpiresAt')) { + } else if (muteExpiresAt) { model.set({ muteExpiresAt: undefined }); } }, @@ -122,11 +125,11 @@ export function start(): void { } export class ConversationController { - _initialFetchComplete: boolean | undefined; + private _initialFetchComplete: boolean | undefined; - _initialPromise: Promise = Promise.resolve(); + private _initialPromise: Promise = Promise.resolve(); - _conversations: ConversationModelCollectionType; + private _conversations: ConversationModelCollectionType; constructor(conversations?: ConversationModelCollectionType) { if (!conversations) { @@ -147,6 +150,10 @@ export class ConversationController { return this._conversations.get(id as string); } + getAll(): Array { + return this._conversations.models; + } + dangerouslyCreateAndAdd( attributes: Partial ): ConversationModel { diff --git a/ts/background.ts b/ts/background.ts index 936c70372..b7bbf8f78 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -7,6 +7,7 @@ import { WhatIsThis } from './window.d'; import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings'; import { isWindowDragElement } from './util/isWindowDragElement'; import { assert } from './util/assert'; +import { routineProfileRefresh } from './routineProfileRefresh'; export async function startApp(): Promise { window.startupProcessingQueue = new window.Signal.Util.StartupQueue(); @@ -1972,6 +1973,22 @@ export async function startApp(): Promise { window.storage.onready(async () => { idleDetector.start(); + + // Kick off a profile refresh if necessary, but don't wait for it, as failure is + // tolerable. + const ourConversationId = window.ConversationController.getOurConversationId(); + if (ourConversationId) { + routineProfileRefresh({ + allConversations: window.ConversationController.getAll(), + ourConversationId, + storage: window.storage, + }); + } else { + assert( + false, + 'Failed to fetch our conversation ID. Skipping routine profile refresh' + ); + } }); } finally { connecting = false; diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 541b9b164..b34ec5f45 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -168,13 +168,13 @@ export type ConversationAttributesType = { lastMessageStatus: LastMessageStatus | null; markedUnread: boolean; messageCount: number; - messageCountBeforeMessageRequests: number; + messageCountBeforeMessageRequests: number | null; messageRequestResponseType: number; - muteExpiresAt: number; + muteExpiresAt: number | undefined; profileAvatar: WhatIsThis; profileKeyCredential: string | null; profileKeyVersion: string | null; - quotedMessageId: string; + quotedMessageId: string | null; sealedSender: unknown; sentMessageCount: number; sharedGroupNames: Array; @@ -193,7 +193,7 @@ export type ConversationAttributesType = { needsVerification?: boolean; profileSharing: boolean; storageID?: string; - storageUnknownFields: string; + storageUnknownFields?: string; unreadCount?: number; version: number; @@ -209,6 +209,7 @@ export type ConversationAttributesType = { profileName?: string; storageProfileKey?: string; verified?: number; + profileLastFetchedAt?: number; // Group-only groupId?: string; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index b201f267f..851bae22e 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -4271,13 +4271,15 @@ export class ConversationModel extends window.Backbone.Model< >; return Promise.all( window._.map(conversations, conversation => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.getProfile(conversation.get('uuid')!, conversation.get('e164')!); + this.getProfile(conversation.get('uuid'), conversation.get('e164')); }) ); } - async getProfile(providedUuid: string, providedE164: string): Promise { + async getProfile( + providedUuid?: string, + providedE164?: string + ): Promise { if (!window.textsecure.messaging) { throw new Error( 'Conversation.getProfile: window.textsecure.messaging not available' @@ -4288,8 +4290,14 @@ export class ConversationModel extends window.Backbone.Model< uuid: providedUuid, e164: providedE164, }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const c = window.ConversationController.get(id)!; + const c = window.ConversationController.get(id); + if (!c) { + window.log.error( + 'getProfile: failed to find conversation; doing nothing' + ); + return; + } + const { generateProfileKeyCredentialRequest, getClientZkProfileOperations, @@ -4504,6 +4512,8 @@ export class ConversationModel extends window.Backbone.Model< } } + c.set('profileLastFetchedAt', Date.now()); + window.Signal.Data.updateConversation(c.attributes); } diff --git a/ts/routineProfileRefresh.ts b/ts/routineProfileRefresh.ts new file mode 100644 index 000000000..dea6cea20 --- /dev/null +++ b/ts/routineProfileRefresh.ts @@ -0,0 +1,167 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isNil, sortBy } from 'lodash'; + +import * as log from './logging/log'; +import { assert } from './util/assert'; +import { missingCaseError } from './util/missingCaseError'; +import { isNormalNumber } from './util/isNormalNumber'; +import { map, take } from './util/iterables'; +import { ConversationModel } from './models/conversations'; + +const STORAGE_KEY = 'lastAttemptedToRefreshProfilesAt'; +const MAX_AGE_TO_BE_CONSIDERED_ACTIVE = 30 * 24 * 60 * 60 * 1000; +const MAX_AGE_TO_BE_CONSIDERED_RECENTLY_REFRESHED = 1 * 24 * 60 * 60 * 1000; +const MAX_CONVERSATIONS_TO_REFRESH = 50; + +// This type is a little stricter than what's on `window.storage`, and only requires what +// we need for easier testing. +type StorageType = { + get: (key: string) => unknown; + put: (key: string, value: unknown) => Promise; +}; + +export async function routineProfileRefresh({ + allConversations, + ourConversationId, + storage, +}: { + allConversations: Array; + ourConversationId: string; + storage: StorageType; +}): Promise { + log.info('routineProfileRefresh: starting'); + + if (!hasEnoughTimeElapsedSinceLastRefresh(storage)) { + log.info('routineProfileRefresh: too soon to refresh. Doing nothing'); + return; + } + + log.info('routineProfileRefresh: updating last refresh time'); + await storage.put(STORAGE_KEY, Date.now()); + + const conversationsToRefresh = getConversationsToRefresh( + allConversations, + ourConversationId + ); + + log.info('routineProfileRefresh: starting to refresh conversations'); + + let totalCount = 0; + let successCount = 0; + await Promise.all( + map(conversationsToRefresh, async (conversation: ConversationModel) => { + totalCount += 1; + try { + await conversation.getProfile( + conversation.get('uuid'), + conversation.get('e164') + ); + successCount += 1; + } catch (err) { + window.log.error( + 'routineProfileRefresh: failed to fetch a profile', + err?.stack || err + ); + } + }) + ); + + log.info( + `routineProfileRefresh: successfully refreshed ${successCount} out of ${totalCount} conversation(s)` + ); +} + +function hasEnoughTimeElapsedSinceLastRefresh(storage: StorageType): boolean { + const storedValue = storage.get(STORAGE_KEY); + + if (isNil(storedValue)) { + return true; + } + + if (isNormalNumber(storedValue)) { + const twelveHoursAgo = Date.now() - 43200000; + return storedValue < twelveHoursAgo; + } + + assert( + false, + `An invalid value was stored in ${STORAGE_KEY}; treating it as nil` + ); + return true; +} + +function getConversationsToRefresh( + conversations: ReadonlyArray, + ourConversationId: string +): Iterable { + const filteredConversations = getFilteredConversations( + conversations, + ourConversationId + ); + return take(filteredConversations, MAX_CONVERSATIONS_TO_REFRESH); +} + +function* getFilteredConversations( + conversations: ReadonlyArray, + ourConversationId: string +): Iterable { + const sorted = sortBy(conversations, c => c.get('active_at')); + + const conversationIdsSeen = new Set([ourConversationId]); + + // We use a `for` loop (instead of something like `forEach`) because we want to be able + // to yield. We use `for ... of` for readability. + // eslint-disable-next-line no-restricted-syntax + for (const conversation of sorted) { + const type = conversation.get('type'); + switch (type) { + case 'private': + if ( + !conversationIdsSeen.has(conversation.id) && + isConversationActive(conversation) && + !hasRefreshedProfileRecently(conversation) + ) { + conversationIdsSeen.add(conversation.id); + yield conversation; + } + break; + case 'group': + // eslint-disable-next-line no-restricted-syntax + for (const member of conversation.getMembers()) { + if ( + !conversationIdsSeen.has(member.id) && + !hasRefreshedProfileRecently(member) + ) { + conversationIdsSeen.add(member.id); + yield member; + } + } + break; + default: + throw missingCaseError(type); + } + } +} + +function isConversationActive( + conversation: Readonly +): boolean { + const activeAt = conversation.get('active_at'); + return ( + isNormalNumber(activeAt) && + activeAt + MAX_AGE_TO_BE_CONSIDERED_ACTIVE > Date.now() + ); +} + +function hasRefreshedProfileRecently( + conversation: Readonly +): boolean { + const profileLastFetchedAt = conversation.get('profileLastFetchedAt'); + return ( + isNormalNumber(profileLastFetchedAt) && + profileLastFetchedAt + MAX_AGE_TO_BE_CONSIDERED_RECENTLY_REFRESHED > + Date.now() + ); +} diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index dfd43eb92..032627e97 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -74,15 +74,14 @@ function applyUnknownFields( record: RecordClass, conversation: ConversationModel ): void { - if (conversation.get('storageUnknownFields')) { + const storageUnknownFields = conversation.get('storageUnknownFields'); + if (storageUnknownFields) { window.log.info( 'storageService.applyUnknownFields: Applying unknown fields for', conversation.get('id') ); // eslint-disable-next-line no-param-reassign - record.__unknownFields = base64ToArrayBuffer( - conversation.get('storageUnknownFields') - ); + record.__unknownFields = base64ToArrayBuffer(storageUnknownFields); } } diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 165fdd735..6378a7007 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -21,6 +21,7 @@ import { Dictionary, forEach, fromPairs, + isNil, isNumber, isObject, isString, @@ -28,8 +29,11 @@ import { last, map, pick, + omit, } from 'lodash'; +import { assert } from '../util/assert'; +import { isNormalNumber } from '../util/isNormalNumber'; import { combineNames } from '../util/combineNames'; import { GroupV2MemberType } from '../model-types.d'; @@ -208,6 +212,30 @@ function objectToJSON(data: any) { function jsonToObject(json: string): any { return JSON.parse(json); } +function rowToConversation( + row: Readonly<{ + json: string; + profileLastFetchedAt: null | number; + }> +): ConversationType { + const parsedJson = JSON.parse(row.json); + + let profileLastFetchedAt: undefined | number; + if (isNormalNumber(row.profileLastFetchedAt)) { + profileLastFetchedAt = row.profileLastFetchedAt; + } else { + assert( + isNil(row.profileLastFetchedAt), + 'profileLastFetchedAt contained invalid data; defaulting to undefined' + ); + profileLastFetchedAt = undefined; + } + + return { + ...parsedJson, + profileLastFetchedAt, + }; +} function isRenderer() { if (typeof process === 'undefined' || !process) { @@ -1655,6 +1683,32 @@ async function updateToSchemaVersion23( } } +async function updateToSchemaVersion24( + currentVersion: number, + instance: PromisifiedSQLDatabase +) { + if (currentVersion >= 24) { + return; + } + + await instance.run('BEGIN TRANSACTION;'); + + try { + await instance.run(` + ALTER TABLE conversations + ADD COLUMN profileLastFetchedAt INTEGER; + `); + + await instance.run('PRAGMA user_version = 24;'); + await instance.run('COMMIT TRANSACTION;'); + + console.log('updateToSchemaVersion24: success!'); + } catch (error) { + await instance.run('ROLLBACK;'); + throw error; + } +} + const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -1679,6 +1733,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion21, updateToSchemaVersion22, updateToSchemaVersion23, + updateToSchemaVersion24, ]; async function updateSchema(instance: PromisifiedSQLDatabase) { @@ -2186,6 +2241,7 @@ async function saveConversation( name, profileFamilyName, profileName, + profileLastFetchedAt, type, uuid, } = data; @@ -2212,7 +2268,8 @@ async function saveConversation( name, profileName, profileFamilyName, - profileFullName + profileFullName, + profileLastFetchedAt ) values ( $id, $json, @@ -2227,11 +2284,12 @@ async function saveConversation( $name, $profileName, $profileFamilyName, - $profileFullName + $profileFullName, + $profileLastFetchedAt );`, { $id: id, - $json: objectToJSON(data), + $json: objectToJSON(omit(data, ['profileLastFetchedAt'])), $e164: e164, $uuid: uuid, @@ -2244,6 +2302,7 @@ async function saveConversation( $profileName: profileName, $profileFamilyName: profileFamilyName, $profileFullName: combineNames(profileName, profileFamilyName), + $profileLastFetchedAt: profileLastFetchedAt, } ); } @@ -2280,6 +2339,7 @@ async function updateConversation(data: ConversationType) { name, profileName, profileFamilyName, + profileLastFetchedAt, e164, uuid, } = data; @@ -2304,11 +2364,12 @@ async function updateConversation(data: ConversationType) { name = $name, profileName = $profileName, profileFamilyName = $profileFamilyName, - profileFullName = $profileFullName + profileFullName = $profileFullName, + profileLastFetchedAt = $profileLastFetchedAt WHERE id = $id;`, { $id: id, - $json: objectToJSON(data), + $json: objectToJSON(omit(data, ['profileLastFetchedAt'])), $e164: e164, $uuid: uuid, @@ -2320,6 +2381,7 @@ async function updateConversation(data: ConversationType) { $profileName: profileName, $profileFamilyName: profileFamilyName, $profileFullName: combineNames(profileName, profileFamilyName), + $profileLastFetchedAt: profileLastFetchedAt, } ); } @@ -2384,9 +2446,13 @@ async function eraseStorageServiceStateFromConversations() { async function getAllConversations() { const db = getInstance(); - const rows = await db.all('SELECT json FROM conversations ORDER BY id ASC;'); + const rows = await db.all(` + SELECT json, profileLastFetchedAt + FROM conversations + ORDER BY id ASC; + `); - return map(rows, row => jsonToObject(row.json)); + return map(rows, row => rowToConversation(row)); } async function getAllConversationIds() { @@ -2399,18 +2465,20 @@ async function getAllConversationIds() { async function getAllPrivateConversations() { const db = getInstance(); const rows = await db.all( - `SELECT json FROM conversations WHERE - type = 'private' - ORDER BY id ASC;` + `SELECT json, profileLastFetchedAt + FROM conversations + WHERE type = 'private' + ORDER BY id ASC;` ); - return map(rows, row => jsonToObject(row.json)); + return map(rows, row => rowToConversation(row)); } async function getAllGroupsInvolvingId(id: string) { const db = getInstance(); const rows = await db.all( - `SELECT json FROM conversations WHERE + `SELECT json, profileLastFetchedAt + FROM conversations WHERE type = 'group' AND members LIKE $id ORDER BY id ASC;`, @@ -2419,7 +2487,7 @@ async function getAllGroupsInvolvingId(id: string) { } ); - return map(rows, row => jsonToObject(row.json)); + return map(rows, row => rowToConversation(row)); } async function searchConversations( @@ -2428,7 +2496,8 @@ async function searchConversations( ): Promise> { const db = getInstance(); const rows = await db.all( - `SELECT json FROM conversations WHERE + `SELECT json, profileLastFetchedAt + FROM conversations WHERE ( e164 LIKE $query OR name LIKE $query OR @@ -2442,7 +2511,7 @@ async function searchConversations( } ); - return map(rows, row => jsonToObject(row.json)); + return map(rows, row => rowToConversation(row)); } async function searchMessages( @@ -4188,7 +4257,9 @@ function getExternalFilesForMessage(message: MessageType) { return files; } -function getExternalFilesForConversation(conversation: ConversationType) { +function getExternalFilesForConversation( + conversation: Pick +) { const { avatar, profileAvatar } = conversation; const files: Array = []; @@ -4203,7 +4274,9 @@ function getExternalFilesForConversation(conversation: ConversationType) { return files; } -function getExternalDraftFilesForConversation(conversation: ConversationType) { +function getExternalDraftFilesForConversation( + conversation: Pick +) { const draftAttachments = conversation.draftAttachments || []; const files: Array = []; diff --git a/ts/test-both/util/isNormalNumber_test.ts b/ts/test-both/util/isNormalNumber_test.ts new file mode 100644 index 000000000..bb8a72ac0 --- /dev/null +++ b/ts/test-both/util/isNormalNumber_test.ts @@ -0,0 +1,45 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { isNormalNumber } from '../../util/isNormalNumber'; + +describe('isNormalNumber', () => { + it('returns false for non-numbers', () => { + assert.isFalse(isNormalNumber(undefined)); + assert.isFalse(isNormalNumber(null)); + assert.isFalse(isNormalNumber('123')); + assert.isFalse(isNormalNumber(BigInt(123))); + }); + + it('returns false for Number objects, which should never be used', () => { + // eslint-disable-next-line no-new-wrappers + assert.isFalse(isNormalNumber(new Number(123))); + }); + + it('returns false for values that can be converted to numbers', () => { + const obj = { + [Symbol.toPrimitive]() { + return 123; + }, + }; + assert.isFalse(isNormalNumber(obj)); + }); + + it('returns false for NaN', () => { + assert.isFalse(isNormalNumber(NaN)); + }); + + it('returns false for Infinity', () => { + assert.isFalse(isNormalNumber(Infinity)); + assert.isFalse(isNormalNumber(-Infinity)); + }); + + it('returns true for other numbers', () => { + assert.isTrue(isNormalNumber(123)); + assert.isTrue(isNormalNumber(0)); + assert.isTrue(isNormalNumber(-1)); + assert.isTrue(isNormalNumber(0.12)); + }); +}); diff --git a/ts/test-both/util/iterables_test.ts b/ts/test-both/util/iterables_test.ts new file mode 100644 index 000000000..de47d0fab --- /dev/null +++ b/ts/test-both/util/iterables_test.ts @@ -0,0 +1,84 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; + +import { map, take } from '../../util/iterables'; + +describe('iterable utilities', () => { + describe('map', () => { + it('returns an empty iterable when passed an empty iterable', () => { + const fn = sinon.fake(); + + assert.deepEqual([...map([], fn)], []); + assert.deepEqual([...map(new Set(), fn)], []); + assert.deepEqual([...map(new Map(), fn)], []); + + sinon.assert.notCalled(fn); + }); + + it('returns a new iterator with values mapped', () => { + const fn = sinon.fake((n: number) => n * n); + const result = map([1, 2, 3], fn); + + sinon.assert.notCalled(fn); + + assert.deepEqual([...result], [1, 4, 9]); + assert.notInstanceOf(result, Array); + + sinon.assert.calledThrice(fn); + }); + + it('iterating doesn\'t "spend" the iterable', () => { + const result = map([1, 2, 3], n => n * n); + + assert.deepEqual([...result], [1, 4, 9]); + assert.deepEqual([...result], [1, 4, 9]); + assert.deepEqual([...result], [1, 4, 9]); + }); + + it('can map over an infinite iterable', () => { + const everyNumber = { + *[Symbol.iterator]() { + for (let i = 0; true; i += 1) { + yield i; + } + }, + }; + + const fn = sinon.fake((n: number) => n * n); + const result = map(everyNumber, fn); + const iterator = result[Symbol.iterator](); + + assert.deepEqual(iterator.next(), { value: 0, done: false }); + assert.deepEqual(iterator.next(), { value: 1, done: false }); + assert.deepEqual(iterator.next(), { value: 4, done: false }); + assert.deepEqual(iterator.next(), { value: 9, done: false }); + }); + }); + + describe('take', () => { + it('returns the first n elements from an iterable', () => { + const everyNumber = { + *[Symbol.iterator]() { + for (let i = 0; true; i += 1) { + yield i; + } + }, + }; + + assert.deepEqual([...take(everyNumber, 0)], []); + assert.deepEqual([...take(everyNumber, 1)], [0]); + assert.deepEqual([...take(everyNumber, 7)], [0, 1, 2, 3, 4, 5, 6]); + }); + + it('stops after the iterable has been exhausted', () => { + const set = new Set([1, 2, 3]); + + assert.deepEqual([...take(set, 3)], [1, 2, 3]); + assert.deepEqual([...take(set, 4)], [1, 2, 3]); + assert.deepEqual([...take(set, 10000)], [1, 2, 3]); + }); + }); +}); diff --git a/ts/test-electron/routineProfileRefresh_test.ts b/ts/test-electron/routineProfileRefresh_test.ts new file mode 100644 index 000000000..3c9db7cc4 --- /dev/null +++ b/ts/test-electron/routineProfileRefresh_test.ts @@ -0,0 +1,245 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as sinon from 'sinon'; +import { v4 as uuid } from 'uuid'; +import { times } from 'lodash'; +import { ConversationModel } from '../models/conversations'; +import { ConversationAttributesType } from '../model-types.d'; + +import { routineProfileRefresh } from '../routineProfileRefresh'; + +describe('routineProfileRefresh', () => { + let sinonSandbox: sinon.SinonSandbox; + + beforeEach(() => { + sinonSandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sinonSandbox.restore(); + }); + + function makeConversation( + overrideAttributes: Partial = {} + ): ConversationModel { + const result = new ConversationModel({ + profileSharing: true, + left: false, + accessKey: uuid(), + draftAttachments: [], + draftBodyRanges: [], + draftTimestamp: null, + inbox_position: 0, + isPinned: false, + lastMessageDeletedForEveryone: false, + lastMessageStatus: 'sent', + markedUnread: false, + messageCount: 2, + messageCountBeforeMessageRequests: 0, + messageRequestResponseType: 0, + muteExpiresAt: 0, + profileAvatar: undefined, + profileKeyCredential: uuid(), + profileKeyVersion: '', + quotedMessageId: null, + sealedSender: 1, + sentMessageCount: 1, + sharedGroupNames: [], + id: uuid(), + type: 'private', + timestamp: Date.now(), + active_at: Date.now(), + version: 2, + ...overrideAttributes, + }); + sinonSandbox.stub(result, 'getProfile').resolves(undefined); + return result; + } + + function makeGroup( + groupMembers: Array + ): ConversationModel { + const result = makeConversation({ type: 'group' }); + // This is easier than setting up all of the scaffolding for `getMembers`. + sinonSandbox.stub(result, 'getMembers').returns(groupMembers); + return result; + } + + function makeStorage(lastAttemptAt: undefined | number = undefined) { + return { + get: sinonSandbox + .stub() + .withArgs('lastAttemptedToRefreshProfilesAt') + .returns(lastAttemptAt), + put: sinonSandbox.stub().resolves(undefined), + }; + } + + it('does nothing when the last refresh time is less than 12 hours ago', async () => { + const conversation1 = makeConversation(); + const conversation2 = makeConversation(); + const storage = makeStorage(Date.now() - 1234); + + await routineProfileRefresh({ + allConversations: [conversation1, conversation2], + ourConversationId: uuid(), + storage, + }); + + sinon.assert.notCalled(conversation1.getProfile as sinon.SinonStub); + sinon.assert.notCalled(conversation2.getProfile as sinon.SinonStub); + sinon.assert.notCalled(storage.put); + }); + + it('asks conversations to get their profiles', async () => { + const conversation1 = makeConversation(); + const conversation2 = makeConversation(); + + await routineProfileRefresh({ + allConversations: [conversation1, conversation2], + ourConversationId: uuid(), + storage: makeStorage(), + }); + + sinon.assert.calledOnce(conversation1.getProfile as sinon.SinonStub); + sinon.assert.calledOnce(conversation2.getProfile as sinon.SinonStub); + }); + + it("skips conversations that haven't been active in 30 days", async () => { + const recentlyActive = makeConversation(); + const inactive = makeConversation({ + active_at: Date.now() - 31 * 24 * 60 * 60 * 1000, + }); + const neverActive = makeConversation({ active_at: undefined }); + + await routineProfileRefresh({ + allConversations: [recentlyActive, inactive, neverActive], + ourConversationId: uuid(), + storage: makeStorage(), + }); + + sinon.assert.calledOnce(recentlyActive.getProfile as sinon.SinonStub); + sinon.assert.notCalled(inactive.getProfile as sinon.SinonStub); + sinon.assert.notCalled(neverActive.getProfile as sinon.SinonStub); + }); + + it('skips your own conversation', async () => { + const notMe = makeConversation(); + const me = makeConversation(); + + await routineProfileRefresh({ + allConversations: [notMe, me], + ourConversationId: me.id, + storage: makeStorage(), + }); + + sinon.assert.notCalled(me.getProfile as sinon.SinonStub); + }); + + it('skips conversations that were refreshed in the last hour', async () => { + const neverRefreshed = makeConversation(); + const recentlyFetched = makeConversation({ + profileLastFetchedAt: Date.now() - 59 * 60 * 1000, + }); + + await routineProfileRefresh({ + allConversations: [neverRefreshed, recentlyFetched], + ourConversationId: uuid(), + storage: makeStorage(), + }); + + sinon.assert.calledOnce(neverRefreshed.getProfile as sinon.SinonStub); + sinon.assert.notCalled(recentlyFetched.getProfile as sinon.SinonStub); + }); + + it('"digs into" the members of an active group', async () => { + const privateConversation = makeConversation(); + + const recentlyActiveGroupMember = makeConversation(); + const inactiveGroupMember = makeConversation({ + active_at: Date.now() - 31 * 24 * 60 * 60 * 1000, + }); + const memberWhoHasRecentlyRefreshed = makeConversation({ + profileLastFetchedAt: Date.now() - 59 * 60 * 1000, + }); + + const groupConversation = makeGroup([ + recentlyActiveGroupMember, + inactiveGroupMember, + memberWhoHasRecentlyRefreshed, + ]); + + await routineProfileRefresh({ + allConversations: [ + privateConversation, + recentlyActiveGroupMember, + inactiveGroupMember, + memberWhoHasRecentlyRefreshed, + groupConversation, + ], + ourConversationId: uuid(), + storage: makeStorage(), + }); + + sinon.assert.calledOnce(privateConversation.getProfile as sinon.SinonStub); + sinon.assert.calledOnce( + recentlyActiveGroupMember.getProfile as sinon.SinonStub + ); + sinon.assert.calledOnce(inactiveGroupMember.getProfile as sinon.SinonStub); + sinon.assert.notCalled( + memberWhoHasRecentlyRefreshed.getProfile as sinon.SinonStub + ); + sinon.assert.notCalled(groupConversation.getProfile as sinon.SinonStub); + }); + + it('only refreshes profiles for the 50 most recently active direct conversations', async () => { + const me = makeConversation(); + + const activeConversations = times(40, () => makeConversation()); + + const inactiveGroupMembers = times(10, () => + makeConversation({ + active_at: Date.now() - 999 * 24 * 60 * 60 * 1000, + }) + ); + const recentlyActiveGroup = makeGroup(inactiveGroupMembers); + + const shouldNotBeIncluded = [ + // Recently-active groups with no other members + makeGroup([]), + makeGroup([me]), + // Old direct conversations + ...times(3, () => + makeConversation({ + active_at: Date.now() - 365 * 24 * 60 * 60 * 1000, + }) + ), + // Old groups + ...times(3, () => makeGroup(inactiveGroupMembers)), + ]; + + await routineProfileRefresh({ + allConversations: [ + me, + + ...activeConversations, + + recentlyActiveGroup, + ...inactiveGroupMembers, + + ...shouldNotBeIncluded, + ], + ourConversationId: me.id, + storage: makeStorage(), + }); + + [...activeConversations, ...inactiveGroupMembers].forEach(conversation => { + sinon.assert.calledOnce(conversation.getProfile as sinon.SinonStub); + }); + + [me, ...shouldNotBeIncluded].forEach(conversation => { + sinon.assert.notCalled(conversation.getProfile as sinon.SinonStub); + }); + }); +}); diff --git a/ts/util/isNormalNumber.ts b/ts/util/isNormalNumber.ts new file mode 100644 index 000000000..b9e2fd186 --- /dev/null +++ b/ts/util/isNormalNumber.ts @@ -0,0 +1,8 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function isNormalNumber(value: unknown): value is number { + return ( + typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value) + ); +} diff --git a/ts/util/iterables.ts b/ts/util/iterables.ts new file mode 100644 index 000000000..379103d27 --- /dev/null +++ b/ts/util/iterables.ts @@ -0,0 +1,68 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable max-classes-per-file */ + +export function map( + iterable: Iterable, + fn: (value: T) => ResultT +): Iterable { + return new MapIterable(iterable, fn); +} + +class MapIterable implements Iterable { + constructor( + private readonly iterable: Iterable, + private readonly fn: (value: T) => ResultT + ) {} + + [Symbol.iterator](): Iterator { + return new MapIterator(this.iterable[Symbol.iterator](), this.fn); + } +} + +class MapIterator implements Iterator { + constructor( + private readonly iterator: Iterator, + private readonly fn: (value: T) => ResultT + ) {} + + next(): IteratorResult { + const nextIteration = this.iterator.next(); + if (nextIteration.done) { + return nextIteration; + } + return { + done: false, + value: this.fn(nextIteration.value), + }; + } +} + +export function take(iterable: Iterable, amount: number): Iterable { + return new TakeIterable(iterable, amount); +} + +class TakeIterable implements Iterable { + constructor( + private readonly iterable: Iterable, + private readonly amount: number + ) {} + + [Symbol.iterator](): Iterator { + return new TakeIterator(this.iterable[Symbol.iterator](), this.amount); + } +} + +class TakeIterator implements Iterator { + constructor(private readonly iterator: Iterator, private amount: number) {} + + next(): IteratorResult { + const nextIteration = this.iterator.next(); + if (nextIteration.done || this.amount === 0) { + return { done: true, value: undefined }; + } + this.amount -= 1; + return nextIteration; + } +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index d0480d71a..054bffd20 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -8,6 +8,7 @@ import { GroupV2PendingMemberType } from '../model-types.d'; import { MediaItemType } from '../components/LightboxGallery'; import { MessageType } from '../state/ducks/conversations'; import { ConversationModel } from '../models/conversations'; +import { MessageModel } from '../models/messages'; type GetLinkPreviewImageResult = { data: ArrayBuffer; @@ -3313,8 +3314,8 @@ Whisper.ConversationView = Whisper.View.extend({ return null; }, - async setQuoteMessage(messageId: any) { - const model = messageId + async setQuoteMessage(messageId: null | string) { + const model: MessageModel | undefined = messageId ? await getMessageById(messageId, { Message: Whisper.Message, })