// 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; 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; 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 | 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; 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 { 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 ): ReadonlyArray { return orderBy(profiles, ['createdAtMs'], ['desc']); } export function redactNotificationProfileId(id: string): string { return `[REDACTED]${id.slice(-3)}`; } export function fromDayOfWeekArray( scheduleDaysEnabled: Array | 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 | undefined { if (!scheduleDaysEnabled) { return undefined; } const scheduleDaysEnabledArray: Array = []; 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; }