Files
Desktop/ts/services/notificationProfilesService.preload.ts

297 lines
9.3 KiB
TypeScript

// 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<typeof setTimeout> | null;
#debouncedRefreshNextEvent = debounce(this.#refreshNextEvent, 1000);
update(): void {
drop(this.#debouncedRefreshNextEvent());
}
fastUpdate(): void {
drop(this.#refreshNextEvent());
}
async #refreshNextEvent(): Promise<void> {
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<NotificationProfileType> | undefined;
export async function loadCachedProfiles(): Promise<void> {
cachedProfiles = await DataReader.getAllNotificationProfiles();
}
export function getCachedProfiles(): ReadonlyArray<NotificationProfileType> {
const profiles = cachedProfiles;
if (profiles == null) {
throw new Error('getCachedProfiles: Cache is empty!');
}
cachedProfiles = undefined;
return profiles;
}
let instance: NotificationProfilesService;