Signal-Desktop/ts/types/NotificationProfile.ts

550 lines
14 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber, orderBy } from 'lodash';
import { DAY, HOUR, MINUTE } from '../util/durations';
import { strictAssert } from '../util/assert';
import type { StorageServiceFieldsType } from '../sql/Interface';
// Note: this must match the Backup and Storage Service protos for NotificationProfile
// This variable is separate so we aren't forced to add it to ScheduleDays object below
export const DayOfWeekUnknown = 0;
export enum DayOfWeek {
MONDAY = 1,
TUESDAY = 2,
WEDNESDAY = 3,
THURSDAY = 4,
FRIDAY = 5,
SATURDAY = 6,
SUNDAY = 7,
}
export type ScheduleDays = { [key in DayOfWeek]: boolean };
export type NotificationProfileIdString = string & {
__notification_profile_id: never;
};
export type NotificationProfileType = Readonly<{
id: NotificationProfileIdString;
name: string;
emoji: string | undefined;
/* A numeric representation of a color, like 0xAARRGGBB */
color: number;
createdAtMs: number;
allowAllCalls: boolean;
allowAllMentions: boolean;
// conversationIds
allowedMembers: ReadonlySet<string>;
scheduleEnabled: boolean;
// These two are 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345) */
scheduleStartTime: number | undefined;
scheduleEndTime: number | undefined;
scheduleDaysEnabled: ScheduleDays | undefined;
deletedAtTimestampMs: number | undefined;
}> &
StorageServiceFieldsType;
export type NotificationProfileOverride =
| {
disabledAtMs: number;
enabled: undefined;
}
| {
disabledAtMs: undefined;
enabled: {
profileId: string;
endsAtMs?: number;
};
};
export type CurrentNotificationProfileState = {
enabledProfileId: string;
nextChangeAt: number | undefined;
};
export type NextProfileEvent =
| {
type: 'noChange';
activeProfile: string | undefined;
}
| {
type: 'willDisable';
willDisableAt: number;
activeProfile: string;
clearEnableOverride?: boolean;
}
| {
type: 'willEnable';
willEnableAt: number;
toEnable: string;
clearDisableOverride?: boolean;
};
// This is A100 background color
export const DEFAULT_PROFILE_COLOR = 0xffe3e3fe;
export const NOTIFICATION_PROFILE_ID_LENGTH = 16;
export function shouldNotify({
isCall,
isMention,
conversationId,
activeProfile,
}: {
isCall: boolean;
isMention: boolean;
conversationId: string;
activeProfile: NotificationProfileType | undefined;
}): boolean {
if (!activeProfile) {
return true;
}
if (isCall && activeProfile.allowAllCalls) {
return true;
}
if (isMention && activeProfile.allowAllMentions) {
return true;
}
if (activeProfile.allowedMembers.has(conversationId)) {
return true;
}
return false;
}
export function findNextProfileEvent({
override,
profiles,
time,
}: {
override: NotificationProfileOverride | undefined;
profiles: ReadonlyArray<NotificationProfileType>;
time: number;
}): NextProfileEvent {
if (override?.enabled?.endsAtMs) {
const profile = getProfileById(override.enabled.profileId, profiles);
// Note: we may go immediately from this to the same profile enabled, if its schedule
// dictates that it should be on. But we return this timestamp to clear the override.
return {
type: 'willDisable',
willDisableAt: override.enabled.endsAtMs,
activeProfile: profile.id,
clearEnableOverride: true,
};
}
if (override?.enabled) {
const profile = getProfileById(override.enabled.profileId, profiles);
const isEnabled = isProfileEnabledBySchedule({ time, profile });
if (isEnabled) {
const willDisableAt = findNextScheduledDisable({ time, profile });
strictAssert(
willDisableAt,
'findNextProfileEvent: override enabled - profile is also enabled by schedule, it should disable!'
);
return {
type: 'willDisable',
willDisableAt,
activeProfile: profile.id,
clearEnableOverride: true,
};
}
const nextEnableTime = findNextScheduledEnable({ time, profile });
if (!nextEnableTime) {
return {
type: 'noChange',
activeProfile: profile.id,
};
}
const nextDisableTime = findNextScheduledDisable({
time: nextEnableTime + 1,
profile,
});
strictAssert(
nextDisableTime,
'findNextProfileEvent: override enabled - profile will enable by schedule, it should disable!'
);
return {
type: 'willDisable',
activeProfile: profile.id,
willDisableAt: nextDisableTime,
clearEnableOverride: true,
};
}
if (override?.disabledAtMs) {
const rightAfterDisable = override.disabledAtMs + 1;
const nextScheduledEnable = findNextScheduledEnableForAll({
profiles,
time: rightAfterDisable,
});
if (nextScheduledEnable) {
return {
type: 'willEnable',
willEnableAt: nextScheduledEnable.time,
toEnable: nextScheduledEnable.profile.id,
clearDisableOverride: true,
};
}
return {
type: 'noChange',
activeProfile: undefined,
};
}
const activeProfileBySchedule = areAnyProfilesEnabledBySchedule({
profiles,
time,
});
if (activeProfileBySchedule) {
const disabledAt = findNextScheduledDisable({
profile: activeProfileBySchedule,
time,
});
// A newer profile will preempt this active profile if its schedule overlaps.
const newerProfiles = profiles.filter(
item => item.createdAtMs > activeProfileBySchedule.createdAtMs
);
const preemptResult = findNextScheduledEnableForAll({
profiles: newerProfiles,
time: time + 1,
});
strictAssert(
disabledAt,
`Schedule ${activeProfileBySchedule.id} is enabled by schedule right now, it should disable soon!`
);
return {
type: 'willDisable',
activeProfile: activeProfileBySchedule.id,
willDisableAt: preemptResult
? Math.min(preemptResult.time, disabledAt)
: disabledAt,
};
}
const nextProfileToEnable = findNextScheduledEnableForAll({ profiles, time });
if (nextProfileToEnable) {
return {
type: 'willEnable',
willEnableAt: nextProfileToEnable.time,
toEnable: nextProfileToEnable.profile.id,
};
}
return {
type: 'noChange',
activeProfile: undefined,
};
}
// Should this profile be active right now, based on its schedule?
export function isProfileEnabledBySchedule({
time,
profile,
}: {
time: number;
profile: NotificationProfileType;
}): boolean {
const day = getDayOfWeek(time);
const midnight = getMidnight(time);
const {
scheduleEnabled,
scheduleDaysEnabled,
scheduleEndTime,
scheduleStartTime,
} = profile;
if (
!scheduleEnabled ||
!scheduleDaysEnabled?.[day] ||
!isNumber(scheduleEndTime) ||
!isNumber(scheduleStartTime)
) {
return false;
}
const scheduleStart = scheduleToTime(midnight, scheduleStartTime);
const scheduleEnd = scheduleToTime(midnight, scheduleEndTime);
if (time >= scheduleStart && time <= scheduleEnd) {
return true;
}
return false;
}
// Find the profile that should be active right, based on schedules
export function areAnyProfilesEnabledBySchedule({
time,
profiles,
}: {
time: number;
profiles: ReadonlyArray<NotificationProfileType>;
}): NotificationProfileType | undefined {
// We find the first match, assuming the array is sorted, newest to oldest
for (const profile of profiles) {
const result = isProfileEnabledBySchedule({ time, profile });
if (result) {
return profile;
}
}
return undefined;
}
// Find the next time this profile's schedule will tell it to disable
export function findNextScheduledDisable({
profile,
time,
}: {
profile: NotificationProfileType;
time: number;
}): number | undefined {
const startingDay = getDayOfWeek(time);
let result;
const { scheduleEnabled } = profile;
if (!scheduleEnabled) {
return undefined;
}
loopThroughWeek({
time,
startingDay,
check: ({ startOfDay, day }) => {
const { scheduleDaysEnabled, scheduleEndTime } = profile;
if (!scheduleDaysEnabled?.[day] || !isNumber(scheduleEndTime)) {
return false;
}
const scheduleEnd = scheduleToTime(startOfDay, scheduleEndTime);
if (time < scheduleEnd) {
result = scheduleEnd;
return true;
}
return false;
},
});
return result;
}
// Find the next time this profile's schedule will tell it to enable
export function findNextScheduledEnable({
profile,
time,
}: {
profile: NotificationProfileType;
time: number;
}): number | undefined {
const startingDay = getDayOfWeek(time);
let result: number | undefined;
const { scheduleEnabled } = profile;
if (!scheduleEnabled) {
return undefined;
}
loopThroughWeek({
time,
startingDay,
check: ({ startOfDay, day }) => {
const { scheduleDaysEnabled, scheduleStartTime } = profile;
if (!scheduleDaysEnabled?.[day] || !isNumber(scheduleStartTime)) {
return false;
}
const scheduleStart = scheduleToTime(startOfDay, scheduleStartTime);
if (time < scheduleStart) {
result = scheduleStart;
return true;
}
return false;
},
});
return result;
}
// This is specifically about finding schedule that will enable later. It will not return
// a schedule enabled right now unless it also has the next scheduled start.
export function findNextScheduledEnableForAll({
profiles,
time,
}: {
profiles: ReadonlyArray<NotificationProfileType>;
time: number;
}): { profile: NotificationProfileType; time: number } | undefined {
let earliestResult:
| { profile: NotificationProfileType; time: number }
| undefined;
for (const profile of profiles) {
const result = findNextScheduledEnable({ time, profile });
if (isNumber(result) && (!earliestResult || result < earliestResult.time)) {
earliestResult = {
profile,
time: result,
};
}
}
return earliestResult;
}
export function getDayOfWeek(time: number): DayOfWeek {
const date = new Date(time);
const day = date.getDay();
if (day === 0) {
return DayOfWeek.SUNDAY;
}
if (day < DayOfWeek.MONDAY || day > DayOfWeek.SUNDAY) {
throw new Error(`getDayOfWeek: Got day that was out of range: ${day}`);
}
return day;
}
// scheduleTime is of the format 2200 for 10:00pm.
export function scheduleToTime(midnight: number, scheduleTime: number): number {
const hours = Math.floor(scheduleTime / 100);
const minutes = scheduleTime % 100;
return midnight + hours * HOUR + minutes * MINUTE;
}
export function getMidnight(time: number): number {
const now = new Date(time);
const midnight = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
0
);
return midnight.getTime();
}
export function loopThroughWeek({
time,
startingDay,
check,
}: {
time: number;
startingDay: DayOfWeek;
check: (options: { startOfDay: number; day: DayOfWeek }) => boolean;
}): void {
const todayAtMidnight = getMidnight(time);
let index = 0;
while (index < DayOfWeek.SUNDAY) {
let indexDay = startingDay + index;
if (indexDay > DayOfWeek.SUNDAY) {
indexDay -= DayOfWeek.SUNDAY;
}
const startOfDay = todayAtMidnight + DAY * index;
const result = check({ startOfDay, day: indexDay });
if (result) {
return;
}
index += 1;
}
}
export function getProfileById(
id: string,
profiles: ReadonlyArray<NotificationProfileType>
): NotificationProfileType {
const profile = profiles.find(value => value.id === id);
if (!profile) {
throw new Error(
`getProfileById: Unable to find profile with id ${redactNotificationProfileId(id)}`
);
}
return profile;
}
// We want the most recently-created at the beginning; they take precedence in conflicts
export function sortProfiles(
profiles: ReadonlyArray<NotificationProfileType>
): ReadonlyArray<NotificationProfileType> {
return orderBy(profiles, ['createdAtMs'], ['desc']);
}
export function redactNotificationProfileId(id: string): string {
return `[REDACTED]${id.slice(-3)}`;
}
export function fromDayOfWeekArray(
scheduleDaysEnabled: Array<number> | null | undefined
): ScheduleDays | undefined {
if (!scheduleDaysEnabled) {
return undefined;
}
return {
[DayOfWeek.MONDAY]:
scheduleDaysEnabled.includes(DayOfWeek.MONDAY) ||
scheduleDaysEnabled.includes(DayOfWeekUnknown),
[DayOfWeek.TUESDAY]: scheduleDaysEnabled.includes(DayOfWeek.TUESDAY),
[DayOfWeek.WEDNESDAY]: scheduleDaysEnabled.includes(DayOfWeek.WEDNESDAY),
[DayOfWeek.THURSDAY]: scheduleDaysEnabled.includes(DayOfWeek.THURSDAY),
[DayOfWeek.FRIDAY]: scheduleDaysEnabled.includes(DayOfWeek.FRIDAY),
[DayOfWeek.SATURDAY]: scheduleDaysEnabled.includes(DayOfWeek.SATURDAY),
[DayOfWeek.SUNDAY]: scheduleDaysEnabled.includes(DayOfWeek.SUNDAY),
};
}
export function toDayOfWeekArray(
scheduleDaysEnabled: ScheduleDays | undefined
): Array<number> | undefined {
if (!scheduleDaysEnabled) {
return undefined;
}
const scheduleDaysEnabledArray: Array<number> = [];
if (scheduleDaysEnabled[DayOfWeek.MONDAY]) {
scheduleDaysEnabledArray.push(DayOfWeek.MONDAY);
}
if (scheduleDaysEnabled[DayOfWeek.TUESDAY]) {
scheduleDaysEnabledArray.push(DayOfWeek.TUESDAY);
}
if (scheduleDaysEnabled[DayOfWeek.WEDNESDAY]) {
scheduleDaysEnabledArray.push(DayOfWeek.WEDNESDAY);
}
if (scheduleDaysEnabled[DayOfWeek.THURSDAY]) {
scheduleDaysEnabledArray.push(DayOfWeek.THURSDAY);
}
if (scheduleDaysEnabled[DayOfWeek.FRIDAY]) {
scheduleDaysEnabledArray.push(DayOfWeek.FRIDAY);
}
if (scheduleDaysEnabled[DayOfWeek.SATURDAY]) {
scheduleDaysEnabledArray.push(DayOfWeek.SATURDAY);
}
if (scheduleDaysEnabled[DayOfWeek.SUNDAY]) {
scheduleDaysEnabledArray.push(DayOfWeek.SUNDAY);
}
return scheduleDaysEnabledArray;
}