signal-desktop/ts/routineProfileRefresh.ts

169 lines
4.9 KiB
TypeScript
Raw Normal View History

// 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 { isOlderThan } from './util/timestamp';
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;
const MIN_ELAPSED_DURATION_TO_REFRESH_AGAIN = 12 * 3600 * 1000;
// 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)) {
return isOlderThan(storedValue, MIN_ELAPSED_DURATION_TO_REFRESH_AGAIN);
}
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()
);
}