// Copyright 2016 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import lodash from 'lodash'; import { createLogger } from '../logging/log.std.js'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary.std.js'; import { isInPast, isMoreRecentThan } from '../util/timestamp.std.js'; import { getMessageQueueTime } from '../util/getMessageQueueTime.dom.js'; import { drop } from '../util/drop.std.js'; import { DataReader, DataWriter } from '../sql/Client.preload.js'; import { findNextProfileEvent, redactNotificationProfileId, } from '../types/NotificationProfile.std.js'; import { getActiveProfile, getCurrentState, getDeletedProfiles, getOverride, getProfiles, } from '../state/selectors/notificationProfiles.dom.js'; import { safeSetTimeout } from '../util/timeout.std.js'; import { ToastType } from '../types/Toast.dom.js'; import { toLogFormat } from '../types/errors.std.js'; import type { NextProfileEvent, NotificationProfileType, } from '../types/NotificationProfile.std.js'; const { debounce, isEqual, isNumber } = lodash; const log = createLogger('notificationProfilesService'); export class NotificationProfilesService { #timeout?: ReturnType | null; #debouncedRefreshNextEvent = debounce(this.#refreshNextEvent, 1000); update(): void { drop(this.#debouncedRefreshNextEvent()); } fastUpdate(): void { drop(this.#refreshNextEvent()); } async #refreshNextEvent(): Promise { log.info('notificationProfileService: starting'); const { updateCurrentState, updateOverride, profileWasRemoved } = window.reduxActions.notificationProfiles; const state = window.reduxStore.getState(); // This gets everything, even if it's not being shown to user const allProfilesIncludingRemoteOnly = state.notificationProfiles.profiles; // These fetches are limited to what user can see (local-only items, if sync=OFF) const profiles = getProfiles(state); const previousCurrentState = getCurrentState(state); const previousActiveProfile = getActiveProfile(state); const deletedProfiles = getDeletedProfiles(state); let override = getOverride(state); if (deletedProfiles.length) { log.info( `notificationProfileService: Checking ${deletedProfiles.length} profiles marked as deleted` ); } await Promise.all( deletedProfiles.map(async profile => { const { id, deletedAtTimestampMs } = profile; if (!deletedAtTimestampMs) { log.warn( `notificationProfileService: Deleted profile ${redactNotificationProfileId(id)} had no deletedAtTimestampMs` ); return; } if (isMoreRecentThan(deletedAtTimestampMs, getMessageQueueTime())) { return; } log.info( `notificationProfileService: Removing expired profile ${redactNotificationProfileId(id)}, deleted at ${new Date(deletedAtTimestampMs).toISOString()}` ); await DataWriter.deleteNotificationProfileById(id); profileWasRemoved(id); }) ); const time = Date.now(); if ( previousCurrentState.type === 'willDisable' && previousCurrentState.clearEnableOverride && isInPast(previousCurrentState.willDisableAt - 1) ) { if ( override?.enabled && override.enabled.profileId === previousCurrentState.activeProfile && (!override.enabled.endsAtMs || override.enabled.endsAtMs === previousCurrentState.willDisableAt) ) { log.info('notificationProfileService: Clearing manual enable override'); override = undefined; updateOverride(undefined, { fromStorageService: false }); } else { log.info( 'notificationProfileService: Tried to clear manual enable override, but it did not match previous override' ); } } else if ( previousCurrentState.type === 'willEnable' && previousCurrentState.clearDisableOverride && isInPast(previousCurrentState.willEnableAt - 1) ) { if ( override?.disabledAtMs && override.disabledAtMs < previousCurrentState.willEnableAt ) { log.info( 'notificationProfileService: Clearing manual disable override' ); override = undefined; updateOverride(undefined, { fromStorageService: false }); } else { log.info( 'notificationProfileService: Tried to clear manual disable override, but it did not match previous override' ); } } let currentState: NextProfileEvent; try { log.info('notificationProfileService: finding next profile event'); currentState = findNextProfileEvent({ override, profiles, time, }); } catch (error) { log.warn('notificationProfileService:', toLogFormat(error)); if (override) { log.warn( 'notificationProfileService: Clearing override because something went wrong' ); // This will kick off another profile update when it completes updateOverride(undefined, { fromStorageService: false }); } return; } const currentActiveProfileId = currentState.type === 'willDisable' || currentState.type === 'noChange' ? currentState.activeProfile : undefined; const currentActiveProfile = currentActiveProfileId ? allProfilesIncludingRemoteOnly.find( item => item.id === currentActiveProfileId ) : undefined; if ( !isEqual(previousCurrentState, currentState) || !isEqual(currentActiveProfile, previousActiveProfile) ) { const idForLogging = currentActiveProfile ? redactNotificationProfileId(currentActiveProfile.id) : 'NONE'; log.info( `notificationProfileService: next profile event has changed, updating redux. Active profile is ${idForLogging}` ); updateCurrentState(currentState, currentActiveProfile); // The active profile can influence the overall badge count window.Whisper.events.emit('updateUnreadCount'); } if (previousActiveProfile?.id === currentActiveProfileId) { // do nothing! // Something has changed, but it's still the same profile } else if ( previousActiveProfile && currentActiveProfile && previousActiveProfile.name === currentActiveProfile.name && // This off-by-one timestamp is created in prepareForDisabledNotificationProfileSync (previousActiveProfile.createdAtMs === currentActiveProfile.createdAtMs + 1 || previousActiveProfile.createdAtMs + 1 === currentActiveProfile.createdAtMs) ) { // do nothing! // We're switching to a different profile, but it's a remote/local copy. This will // happen whenever there's an override enabling a profile and notification profiles // sync is turned on/off. } else if (!currentActiveProfileId) { if (previousActiveProfile) { window.reduxActions.toast.showToast({ toastType: ToastType.NotificationProfileUpdate, parameters: { enabled: false, name: previousActiveProfile.name, }, }); } else { log.warn( 'refreshNextEvent: Unable to find just-disabled profile for toast' ); } } else if (currentActiveProfile) { window.reduxActions.toast.showToast({ toastType: ToastType.NotificationProfileUpdate, parameters: { enabled: true, name: currentActiveProfile.name, }, }); } else { log.warn( 'refreshNextEvent: Unable to find just-enabled profile for toast' ); } let nextCheck: number | undefined; if (currentState.type === 'willDisable') { nextCheck = currentState.willDisableAt; } else if (currentState.type === 'willEnable') { nextCheck = currentState.willEnableAt; } clearTimeoutIfNecessary(this.#timeout); this.#timeout = undefined; if (!isNumber(nextCheck)) { log.info( 'notificationProfileService: no future event found. setting no timeout' ); return; } const wait = nextCheck - Date.now(); log.info( `notificationProfileService: next check ${new Date(nextCheck).toISOString()};` + ` waiting ${wait}ms` ); this.#timeout = safeSetTimeout(this.#refreshNextEvent.bind(this), wait, { clampToMax: true, }); } } export function initialize(): void { if (instance) { log.warn('NotificationProfileService is already initialized!'); return; } instance = new NotificationProfilesService(); } export function update(): void { if (!instance) { throw new Error('update: NotificationProfileService not yet initialized!'); } instance.update(); } export function fastUpdate(): void { if (!instance) { throw new Error( 'fastUpdate: NotificationProfileService not yet initialized!' ); } instance.fastUpdate(); } let cachedProfiles: ReadonlyArray | undefined; export async function loadCachedProfiles(): Promise { cachedProfiles = await DataReader.getAllNotificationProfiles(); } export function getCachedProfiles(): ReadonlyArray { const profiles = cachedProfiles; if (profiles == null) { throw new Error('getCachedProfiles: Cache is empty!'); } cachedProfiles = undefined; return profiles; } let instance: NotificationProfilesService;