Refresh profiles on app start (at most every 12 hours)
This commit is contained in:
parent
86530c3dc9
commit
b725ed2ffb
14 changed files with 764 additions and 38 deletions
|
@ -552,6 +552,7 @@ describe('Backup', () => {
|
||||||
profileKey: 'BASE64KEY',
|
profileKey: 'BASE64KEY',
|
||||||
profileName: 'Someone! 🤔',
|
profileName: 'Someone! 🤔',
|
||||||
profileSharing: true,
|
profileSharing: true,
|
||||||
|
profileLastFetchedAt: 1524185933350,
|
||||||
timestamp: 1524185933350,
|
timestamp: 1524185933350,
|
||||||
type: 'private',
|
type: 'private',
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
|
|
|
@ -62,15 +62,18 @@ export function start(): void {
|
||||||
// we can reset the mute state on the model. If the mute has already expired
|
// we can reset the mute state on the model. If the mute has already expired
|
||||||
// then we reset the state right away.
|
// then we reset the state right away.
|
||||||
initMuteExpirationTimer(model: ConversationModel): void {
|
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(
|
window.Signal.Services.onTimeout(
|
||||||
model.get('muteExpiresAt'),
|
muteExpiresAt,
|
||||||
() => {
|
() => {
|
||||||
model.set({ muteExpiresAt: undefined });
|
model.set({ muteExpiresAt: undefined });
|
||||||
},
|
},
|
||||||
model.getMuteTimeoutId()
|
model.getMuteTimeoutId()
|
||||||
);
|
);
|
||||||
} else if (model.get('muteExpiresAt')) {
|
} else if (muteExpiresAt) {
|
||||||
model.set({ muteExpiresAt: undefined });
|
model.set({ muteExpiresAt: undefined });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -122,11 +125,11 @@ export function start(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConversationController {
|
export class ConversationController {
|
||||||
_initialFetchComplete: boolean | undefined;
|
private _initialFetchComplete: boolean | undefined;
|
||||||
|
|
||||||
_initialPromise: Promise<void> = Promise.resolve();
|
private _initialPromise: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
_conversations: ConversationModelCollectionType;
|
private _conversations: ConversationModelCollectionType;
|
||||||
|
|
||||||
constructor(conversations?: ConversationModelCollectionType) {
|
constructor(conversations?: ConversationModelCollectionType) {
|
||||||
if (!conversations) {
|
if (!conversations) {
|
||||||
|
@ -147,6 +150,10 @@ export class ConversationController {
|
||||||
return this._conversations.get(id as string);
|
return this._conversations.get(id as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAll(): Array<ConversationModel> {
|
||||||
|
return this._conversations.models;
|
||||||
|
}
|
||||||
|
|
||||||
dangerouslyCreateAndAdd(
|
dangerouslyCreateAndAdd(
|
||||||
attributes: Partial<ConversationModel>
|
attributes: Partial<ConversationModel>
|
||||||
): ConversationModel {
|
): ConversationModel {
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { WhatIsThis } from './window.d';
|
||||||
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
|
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
|
||||||
import { isWindowDragElement } from './util/isWindowDragElement';
|
import { isWindowDragElement } from './util/isWindowDragElement';
|
||||||
import { assert } from './util/assert';
|
import { assert } from './util/assert';
|
||||||
|
import { routineProfileRefresh } from './routineProfileRefresh';
|
||||||
|
|
||||||
export async function startApp(): Promise<void> {
|
export async function startApp(): Promise<void> {
|
||||||
window.startupProcessingQueue = new window.Signal.Util.StartupQueue();
|
window.startupProcessingQueue = new window.Signal.Util.StartupQueue();
|
||||||
|
@ -1972,6 +1973,22 @@ export async function startApp(): Promise<void> {
|
||||||
|
|
||||||
window.storage.onready(async () => {
|
window.storage.onready(async () => {
|
||||||
idleDetector.start();
|
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 {
|
} finally {
|
||||||
connecting = false;
|
connecting = false;
|
||||||
|
|
9
ts/model-types.d.ts
vendored
9
ts/model-types.d.ts
vendored
|
@ -168,13 +168,13 @@ export type ConversationAttributesType = {
|
||||||
lastMessageStatus: LastMessageStatus | null;
|
lastMessageStatus: LastMessageStatus | null;
|
||||||
markedUnread: boolean;
|
markedUnread: boolean;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
messageCountBeforeMessageRequests: number;
|
messageCountBeforeMessageRequests: number | null;
|
||||||
messageRequestResponseType: number;
|
messageRequestResponseType: number;
|
||||||
muteExpiresAt: number;
|
muteExpiresAt: number | undefined;
|
||||||
profileAvatar: WhatIsThis;
|
profileAvatar: WhatIsThis;
|
||||||
profileKeyCredential: string | null;
|
profileKeyCredential: string | null;
|
||||||
profileKeyVersion: string | null;
|
profileKeyVersion: string | null;
|
||||||
quotedMessageId: string;
|
quotedMessageId: string | null;
|
||||||
sealedSender: unknown;
|
sealedSender: unknown;
|
||||||
sentMessageCount: number;
|
sentMessageCount: number;
|
||||||
sharedGroupNames: Array<string>;
|
sharedGroupNames: Array<string>;
|
||||||
|
@ -193,7 +193,7 @@ export type ConversationAttributesType = {
|
||||||
needsVerification?: boolean;
|
needsVerification?: boolean;
|
||||||
profileSharing: boolean;
|
profileSharing: boolean;
|
||||||
storageID?: string;
|
storageID?: string;
|
||||||
storageUnknownFields: string;
|
storageUnknownFields?: string;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
version: number;
|
version: number;
|
||||||
|
|
||||||
|
@ -209,6 +209,7 @@ export type ConversationAttributesType = {
|
||||||
profileName?: string;
|
profileName?: string;
|
||||||
storageProfileKey?: string;
|
storageProfileKey?: string;
|
||||||
verified?: number;
|
verified?: number;
|
||||||
|
profileLastFetchedAt?: number;
|
||||||
|
|
||||||
// Group-only
|
// Group-only
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
|
|
@ -4271,13 +4271,15 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
>;
|
>;
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
window._.map(conversations, conversation => {
|
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<void> {
|
async getProfile(
|
||||||
|
providedUuid?: string,
|
||||||
|
providedE164?: string
|
||||||
|
): Promise<void> {
|
||||||
if (!window.textsecure.messaging) {
|
if (!window.textsecure.messaging) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Conversation.getProfile: window.textsecure.messaging not available'
|
'Conversation.getProfile: window.textsecure.messaging not available'
|
||||||
|
@ -4288,8 +4290,14 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
uuid: providedUuid,
|
uuid: providedUuid,
|
||||||
e164: providedE164,
|
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 {
|
const {
|
||||||
generateProfileKeyCredentialRequest,
|
generateProfileKeyCredentialRequest,
|
||||||
getClientZkProfileOperations,
|
getClientZkProfileOperations,
|
||||||
|
@ -4504,6 +4512,8 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.set('profileLastFetchedAt', Date.now());
|
||||||
|
|
||||||
window.Signal.Data.updateConversation(c.attributes);
|
window.Signal.Data.updateConversation(c.attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
167
ts/routineProfileRefresh.ts
Normal file
167
ts/routineProfileRefresh.ts
Normal file
|
@ -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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function routineProfileRefresh({
|
||||||
|
allConversations,
|
||||||
|
ourConversationId,
|
||||||
|
storage,
|
||||||
|
}: {
|
||||||
|
allConversations: Array<ConversationModel>;
|
||||||
|
ourConversationId: string;
|
||||||
|
storage: StorageType;
|
||||||
|
}): Promise<void> {
|
||||||
|
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<ConversationModel>,
|
||||||
|
ourConversationId: string
|
||||||
|
): Iterable<ConversationModel> {
|
||||||
|
const filteredConversations = getFilteredConversations(
|
||||||
|
conversations,
|
||||||
|
ourConversationId
|
||||||
|
);
|
||||||
|
return take(filteredConversations, MAX_CONVERSATIONS_TO_REFRESH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function* getFilteredConversations(
|
||||||
|
conversations: ReadonlyArray<ConversationModel>,
|
||||||
|
ourConversationId: string
|
||||||
|
): Iterable<ConversationModel> {
|
||||||
|
const sorted = sortBy(conversations, c => c.get('active_at'));
|
||||||
|
|
||||||
|
const conversationIdsSeen = new Set<string>([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<ConversationModel>
|
||||||
|
): boolean {
|
||||||
|
const activeAt = conversation.get('active_at');
|
||||||
|
return (
|
||||||
|
isNormalNumber(activeAt) &&
|
||||||
|
activeAt + MAX_AGE_TO_BE_CONSIDERED_ACTIVE > Date.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRefreshedProfileRecently(
|
||||||
|
conversation: Readonly<ConversationModel>
|
||||||
|
): boolean {
|
||||||
|
const profileLastFetchedAt = conversation.get('profileLastFetchedAt');
|
||||||
|
return (
|
||||||
|
isNormalNumber(profileLastFetchedAt) &&
|
||||||
|
profileLastFetchedAt + MAX_AGE_TO_BE_CONSIDERED_RECENTLY_REFRESHED >
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
}
|
|
@ -74,15 +74,14 @@ function applyUnknownFields(
|
||||||
record: RecordClass,
|
record: RecordClass,
|
||||||
conversation: ConversationModel
|
conversation: ConversationModel
|
||||||
): void {
|
): void {
|
||||||
if (conversation.get('storageUnknownFields')) {
|
const storageUnknownFields = conversation.get('storageUnknownFields');
|
||||||
|
if (storageUnknownFields) {
|
||||||
window.log.info(
|
window.log.info(
|
||||||
'storageService.applyUnknownFields: Applying unknown fields for',
|
'storageService.applyUnknownFields: Applying unknown fields for',
|
||||||
conversation.get('id')
|
conversation.get('id')
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
record.__unknownFields = base64ToArrayBuffer(
|
record.__unknownFields = base64ToArrayBuffer(storageUnknownFields);
|
||||||
conversation.get('storageUnknownFields')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
107
ts/sql/Server.ts
107
ts/sql/Server.ts
|
@ -21,6 +21,7 @@ import {
|
||||||
Dictionary,
|
Dictionary,
|
||||||
forEach,
|
forEach,
|
||||||
fromPairs,
|
fromPairs,
|
||||||
|
isNil,
|
||||||
isNumber,
|
isNumber,
|
||||||
isObject,
|
isObject,
|
||||||
isString,
|
isString,
|
||||||
|
@ -28,8 +29,11 @@ import {
|
||||||
last,
|
last,
|
||||||
map,
|
map,
|
||||||
pick,
|
pick,
|
||||||
|
omit,
|
||||||
} from 'lodash';
|
} from 'lodash';
|
||||||
|
|
||||||
|
import { assert } from '../util/assert';
|
||||||
|
import { isNormalNumber } from '../util/isNormalNumber';
|
||||||
import { combineNames } from '../util/combineNames';
|
import { combineNames } from '../util/combineNames';
|
||||||
|
|
||||||
import { GroupV2MemberType } from '../model-types.d';
|
import { GroupV2MemberType } from '../model-types.d';
|
||||||
|
@ -208,6 +212,30 @@ function objectToJSON(data: any) {
|
||||||
function jsonToObject(json: string): any {
|
function jsonToObject(json: string): any {
|
||||||
return JSON.parse(json);
|
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() {
|
function isRenderer() {
|
||||||
if (typeof process === 'undefined' || !process) {
|
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 = [
|
const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion1,
|
updateToSchemaVersion1,
|
||||||
updateToSchemaVersion2,
|
updateToSchemaVersion2,
|
||||||
|
@ -1679,6 +1733,7 @@ const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion21,
|
updateToSchemaVersion21,
|
||||||
updateToSchemaVersion22,
|
updateToSchemaVersion22,
|
||||||
updateToSchemaVersion23,
|
updateToSchemaVersion23,
|
||||||
|
updateToSchemaVersion24,
|
||||||
];
|
];
|
||||||
|
|
||||||
async function updateSchema(instance: PromisifiedSQLDatabase) {
|
async function updateSchema(instance: PromisifiedSQLDatabase) {
|
||||||
|
@ -2186,6 +2241,7 @@ async function saveConversation(
|
||||||
name,
|
name,
|
||||||
profileFamilyName,
|
profileFamilyName,
|
||||||
profileName,
|
profileName,
|
||||||
|
profileLastFetchedAt,
|
||||||
type,
|
type,
|
||||||
uuid,
|
uuid,
|
||||||
} = data;
|
} = data;
|
||||||
|
@ -2212,7 +2268,8 @@ async function saveConversation(
|
||||||
name,
|
name,
|
||||||
profileName,
|
profileName,
|
||||||
profileFamilyName,
|
profileFamilyName,
|
||||||
profileFullName
|
profileFullName,
|
||||||
|
profileLastFetchedAt
|
||||||
) values (
|
) values (
|
||||||
$id,
|
$id,
|
||||||
$json,
|
$json,
|
||||||
|
@ -2227,11 +2284,12 @@ async function saveConversation(
|
||||||
$name,
|
$name,
|
||||||
$profileName,
|
$profileName,
|
||||||
$profileFamilyName,
|
$profileFamilyName,
|
||||||
$profileFullName
|
$profileFullName,
|
||||||
|
$profileLastFetchedAt
|
||||||
);`,
|
);`,
|
||||||
{
|
{
|
||||||
$id: id,
|
$id: id,
|
||||||
$json: objectToJSON(data),
|
$json: objectToJSON(omit(data, ['profileLastFetchedAt'])),
|
||||||
|
|
||||||
$e164: e164,
|
$e164: e164,
|
||||||
$uuid: uuid,
|
$uuid: uuid,
|
||||||
|
@ -2244,6 +2302,7 @@ async function saveConversation(
|
||||||
$profileName: profileName,
|
$profileName: profileName,
|
||||||
$profileFamilyName: profileFamilyName,
|
$profileFamilyName: profileFamilyName,
|
||||||
$profileFullName: combineNames(profileName, profileFamilyName),
|
$profileFullName: combineNames(profileName, profileFamilyName),
|
||||||
|
$profileLastFetchedAt: profileLastFetchedAt,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2280,6 +2339,7 @@ async function updateConversation(data: ConversationType) {
|
||||||
name,
|
name,
|
||||||
profileName,
|
profileName,
|
||||||
profileFamilyName,
|
profileFamilyName,
|
||||||
|
profileLastFetchedAt,
|
||||||
e164,
|
e164,
|
||||||
uuid,
|
uuid,
|
||||||
} = data;
|
} = data;
|
||||||
|
@ -2304,11 +2364,12 @@ async function updateConversation(data: ConversationType) {
|
||||||
name = $name,
|
name = $name,
|
||||||
profileName = $profileName,
|
profileName = $profileName,
|
||||||
profileFamilyName = $profileFamilyName,
|
profileFamilyName = $profileFamilyName,
|
||||||
profileFullName = $profileFullName
|
profileFullName = $profileFullName,
|
||||||
|
profileLastFetchedAt = $profileLastFetchedAt
|
||||||
WHERE id = $id;`,
|
WHERE id = $id;`,
|
||||||
{
|
{
|
||||||
$id: id,
|
$id: id,
|
||||||
$json: objectToJSON(data),
|
$json: objectToJSON(omit(data, ['profileLastFetchedAt'])),
|
||||||
|
|
||||||
$e164: e164,
|
$e164: e164,
|
||||||
$uuid: uuid,
|
$uuid: uuid,
|
||||||
|
@ -2320,6 +2381,7 @@ async function updateConversation(data: ConversationType) {
|
||||||
$profileName: profileName,
|
$profileName: profileName,
|
||||||
$profileFamilyName: profileFamilyName,
|
$profileFamilyName: profileFamilyName,
|
||||||
$profileFullName: combineNames(profileName, profileFamilyName),
|
$profileFullName: combineNames(profileName, profileFamilyName),
|
||||||
|
$profileLastFetchedAt: profileLastFetchedAt,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2384,9 +2446,13 @@ async function eraseStorageServiceStateFromConversations() {
|
||||||
|
|
||||||
async function getAllConversations() {
|
async function getAllConversations() {
|
||||||
const db = getInstance();
|
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() {
|
async function getAllConversationIds() {
|
||||||
|
@ -2399,18 +2465,20 @@ async function getAllConversationIds() {
|
||||||
async function getAllPrivateConversations() {
|
async function getAllPrivateConversations() {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
const rows = await db.all(
|
const rows = await db.all(
|
||||||
`SELECT json FROM conversations WHERE
|
`SELECT json, profileLastFetchedAt
|
||||||
type = 'private'
|
FROM conversations
|
||||||
ORDER BY id ASC;`
|
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) {
|
async function getAllGroupsInvolvingId(id: string) {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
const rows = await db.all(
|
const rows = await db.all(
|
||||||
`SELECT json FROM conversations WHERE
|
`SELECT json, profileLastFetchedAt
|
||||||
|
FROM conversations WHERE
|
||||||
type = 'group' AND
|
type = 'group' AND
|
||||||
members LIKE $id
|
members LIKE $id
|
||||||
ORDER BY id ASC;`,
|
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(
|
async function searchConversations(
|
||||||
|
@ -2428,7 +2496,8 @@ async function searchConversations(
|
||||||
): Promise<Array<ConversationType>> {
|
): Promise<Array<ConversationType>> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
const rows = await db.all(
|
const rows = await db.all(
|
||||||
`SELECT json FROM conversations WHERE
|
`SELECT json, profileLastFetchedAt
|
||||||
|
FROM conversations WHERE
|
||||||
(
|
(
|
||||||
e164 LIKE $query OR
|
e164 LIKE $query OR
|
||||||
name 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(
|
async function searchMessages(
|
||||||
|
@ -4188,7 +4257,9 @@ function getExternalFilesForMessage(message: MessageType) {
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExternalFilesForConversation(conversation: ConversationType) {
|
function getExternalFilesForConversation(
|
||||||
|
conversation: Pick<ConversationType, 'avatar' | 'profileAvatar'>
|
||||||
|
) {
|
||||||
const { avatar, profileAvatar } = conversation;
|
const { avatar, profileAvatar } = conversation;
|
||||||
const files: Array<string> = [];
|
const files: Array<string> = [];
|
||||||
|
|
||||||
|
@ -4203,7 +4274,9 @@ function getExternalFilesForConversation(conversation: ConversationType) {
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExternalDraftFilesForConversation(conversation: ConversationType) {
|
function getExternalDraftFilesForConversation(
|
||||||
|
conversation: Pick<ConversationType, 'draftAttachments'>
|
||||||
|
) {
|
||||||
const draftAttachments = conversation.draftAttachments || [];
|
const draftAttachments = conversation.draftAttachments || [];
|
||||||
const files: Array<string> = [];
|
const files: Array<string> = [];
|
||||||
|
|
||||||
|
|
45
ts/test-both/util/isNormalNumber_test.ts
Normal file
45
ts/test-both/util/isNormalNumber_test.ts
Normal file
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
84
ts/test-both/util/iterables_test.ts
Normal file
84
ts/test-both/util/iterables_test.ts
Normal file
|
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
245
ts/test-electron/routineProfileRefresh_test.ts
Normal file
245
ts/test-electron/routineProfileRefresh_test.ts
Normal file
|
@ -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<ConversationAttributesType> = {}
|
||||||
|
): 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>
|
||||||
|
): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
8
ts/util/isNormalNumber.ts
Normal file
8
ts/util/isNormalNumber.ts
Normal file
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
68
ts/util/iterables.ts
Normal file
68
ts/util/iterables.ts
Normal file
|
@ -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<T, ResultT>(
|
||||||
|
iterable: Iterable<T>,
|
||||||
|
fn: (value: T) => ResultT
|
||||||
|
): Iterable<ResultT> {
|
||||||
|
return new MapIterable(iterable, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapIterable<T, ResultT> implements Iterable<ResultT> {
|
||||||
|
constructor(
|
||||||
|
private readonly iterable: Iterable<T>,
|
||||||
|
private readonly fn: (value: T) => ResultT
|
||||||
|
) {}
|
||||||
|
|
||||||
|
[Symbol.iterator](): Iterator<ResultT> {
|
||||||
|
return new MapIterator(this.iterable[Symbol.iterator](), this.fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapIterator<T, ResultT> implements Iterator<ResultT> {
|
||||||
|
constructor(
|
||||||
|
private readonly iterator: Iterator<T>,
|
||||||
|
private readonly fn: (value: T) => ResultT
|
||||||
|
) {}
|
||||||
|
|
||||||
|
next(): IteratorResult<ResultT> {
|
||||||
|
const nextIteration = this.iterator.next();
|
||||||
|
if (nextIteration.done) {
|
||||||
|
return nextIteration;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
done: false,
|
||||||
|
value: this.fn(nextIteration.value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function take<T>(iterable: Iterable<T>, amount: number): Iterable<T> {
|
||||||
|
return new TakeIterable(iterable, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TakeIterable<T> implements Iterable<T> {
|
||||||
|
constructor(
|
||||||
|
private readonly iterable: Iterable<T>,
|
||||||
|
private readonly amount: number
|
||||||
|
) {}
|
||||||
|
|
||||||
|
[Symbol.iterator](): Iterator<T> {
|
||||||
|
return new TakeIterator(this.iterable[Symbol.iterator](), this.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TakeIterator<T> implements Iterator<T> {
|
||||||
|
constructor(private readonly iterator: Iterator<T>, private amount: number) {}
|
||||||
|
|
||||||
|
next(): IteratorResult<T> {
|
||||||
|
const nextIteration = this.iterator.next();
|
||||||
|
if (nextIteration.done || this.amount === 0) {
|
||||||
|
return { done: true, value: undefined };
|
||||||
|
}
|
||||||
|
this.amount -= 1;
|
||||||
|
return nextIteration;
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import { GroupV2PendingMemberType } from '../model-types.d';
|
||||||
import { MediaItemType } from '../components/LightboxGallery';
|
import { MediaItemType } from '../components/LightboxGallery';
|
||||||
import { MessageType } from '../state/ducks/conversations';
|
import { MessageType } from '../state/ducks/conversations';
|
||||||
import { ConversationModel } from '../models/conversations';
|
import { ConversationModel } from '../models/conversations';
|
||||||
|
import { MessageModel } from '../models/messages';
|
||||||
|
|
||||||
type GetLinkPreviewImageResult = {
|
type GetLinkPreviewImageResult = {
|
||||||
data: ArrayBuffer;
|
data: ArrayBuffer;
|
||||||
|
@ -3313,8 +3314,8 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async setQuoteMessage(messageId: any) {
|
async setQuoteMessage(messageId: null | string) {
|
||||||
const model = messageId
|
const model: MessageModel | undefined = messageId
|
||||||
? await getMessageById(messageId, {
|
? await getMessageById(messageId, {
|
||||||
Message: Whisper.Message,
|
Message: Whisper.Message,
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue