diff --git a/protos/Backups.proto b/protos/Backups.proto index faed8eed54..01057d3630 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -1292,6 +1292,7 @@ message NotificationProfile { uint32 scheduleStartTime = 9; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345) uint32 scheduleEndTime = 10; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345) repeated DayOfWeek scheduleDaysEnabled = 11; + bytes id = 12; // should be 16 bytes } message ChatFolder { @@ -1312,4 +1313,5 @@ message ChatFolder { FolderType folderType = 6; repeated uint64 includedRecipientIds = 7; // generated recipient id of groups, contacts, and/or note to self repeated uint64 excludedRecipientIds = 8; // generated recipient id of groups, contacts, and/or note to self -} \ No newline at end of file + bytes id = 9; // should be 16 bytes +} diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 1455a86f5f..f19c3fea4f 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -54,6 +54,7 @@ message ManifestRecord { STICKER_PACK = 6; CALL_LINK = 7; CHAT_FOLDER = 8; + NOTIFICATION_PROFILE = 9; } bytes raw = 1; @@ -77,6 +78,7 @@ message StorageRecord { StickerPackRecord stickerPack = 6; CallLinkRecord callLink = 7; ChatFolderRecord chatFolder = 8; + NotificationProfile notificationProfile = 9; } } @@ -228,6 +230,20 @@ message AccountRecord { optional uint64 endedAtTimestamp = 2; } + message NotificationProfileManualOverride { + message ManuallyEnabled { + bytes id = 1; + + // This will be unset if no timespan was chosen in the UI. + uint64 endAtTimestampMs = 3; + } + + oneof override { + uint64 disabledAtTimestampMs = 1; + ManuallyEnabled enabled = 2; + } + } + bytes profileKey = 1; string givenName = 2; string familyName = 3; @@ -275,6 +291,7 @@ message AccountRecord { optional uint64 backupTier = 40; IAPSubscriberData backupSubscriberData = 41; optional AvatarColor avatarColor = 42; + NotificationProfileManualOverride notificationProfileManualOverride = 44; } message StoryDistributionListRecord { @@ -314,20 +331,20 @@ message CallLinkRecord { // should be cleared } -message ChatFolderRecord { - message Recipient { - message Contact { - string serviceId = 1; - string e164 = 2; - } - - oneof identifier { - Contact contact = 1; - bytes legacyGroupId = 2; - bytes groupMasterKey = 3; - } +message Recipient { + message Contact { + string serviceId = 1; + string e164 = 2; } + oneof identifier { + Contact contact = 1; + bytes legacyGroupId = 2; + bytes groupMasterKey = 3; + } +} + +message ChatFolderRecord { // Represents the default "All chats" folder record vs all other custom folders enum FolderType { UNKNOWN = 0; @@ -347,3 +364,30 @@ message ChatFolderRecord { repeated Recipient excludedRecipients = 10; uint64 deletedAtTimestampMs = 11; // When non-zero, `position` should be set to -1 and `includedRecipients` should be empty } + +message NotificationProfile { + enum DayOfWeek { + UNKNOWN = 0; // Interpret as "Monday" + MONDAY = 1; + TUESDAY = 2; + WEDNESDAY = 3; + THURSDAY = 4; + FRIDAY = 5; + SATURDAY = 6; + SUNDAY = 7; + } + + bytes id = 1; + string name = 2; + optional string emoji = 3; + fixed32 color = 4; // 0xAARRGGBB + uint64 createdAtMs = 5; + bool allowAllCalls = 6; + bool allowAllMentions = 7; + repeated Recipient allowedMembers = 8; + bool scheduleEnabled = 9; + uint32 scheduleStartTime = 10; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345) + uint32 scheduleEndTime = 11; // 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345) + repeated DayOfWeek scheduleDaysEnabled = 12; + uint64 deletedAtTimestampMs = 13; +} diff --git a/ts/background.ts b/ts/background.ts index b022d75be9..4958161fe9 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -47,6 +47,10 @@ import { initialize as initializeExpiringMessageService, update as updateExpiringMessagesService, } from './services/expiringMessagesDeletion'; +import { + initialize as initializeNotificationProfilesService, + update as updateNotificationProfileService, +} from './services/notificationProfilesService'; import { tapToViewMessagesDeletionService } from './services/tapToViewMessagesDeletionService'; import { senderCertificateService } from './services/senderCertificate'; import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher'; @@ -1455,6 +1459,7 @@ export async function startApp(): Promise { void badgeImageFileDownloader.checkForFilesToDownload(); initializeExpiringMessageService(); + initializeNotificationProfilesService(); log.info('Blocked uuids cleanup: starting...'); const blockedUuids = window.storage.get(BLOCKED_UUIDS_ID, []); @@ -1526,10 +1531,12 @@ export async function startApp(): Promise { window.Whisper.events.trigger('timetravel'); }); - void updateExpiringMessagesService(); + updateExpiringMessagesService(); + updateNotificationProfileService(); tapToViewMessagesDeletionService.update(); window.Whisper.events.on('timetravel', () => { - void updateExpiringMessagesService(); + updateExpiringMessagesService(); + updateNotificationProfileService(); tapToViewMessagesDeletionService.update(); }); diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index caa448fd50..94c167dfde 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -88,6 +88,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ ...storyProps, availableCameras: [], acceptCall: action('accept-call'), + activeNotificationProfile: undefined, approveUser: action('approve-user'), batchUserAction: action('batch-user-action'), bounceAppIconStart: action('bounce-app-icon-start'), diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 5f59fc3d70..ed47936ef8 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -54,6 +54,11 @@ import { CallingAdhocCallInfo } from './CallingAdhocCallInfo'; import { callLinkRootKeyToUrl } from '../util/callLinkRootKeyToUrl'; import { usePrevious } from '../hooks/usePrevious'; import { copyCallLink } from '../util/copyLinksWithToast'; +import { + redactNotificationProfileId, + shouldNotify, +} from '../types/NotificationProfile'; +import type { NotificationProfileType } from '../types/NotificationProfile'; const GROUP_CALL_RING_DURATION = 60 * 1000; @@ -79,6 +84,7 @@ export type CallingImageDataCache = Map; export type PropsType = { activeCall?: ActiveCallType; + activeNotificationProfile: NotificationProfileType | undefined; availableCameras: Array; callLink: CallLinkType | undefined; cancelCall: (_: CancelCallType) => void; @@ -150,6 +156,7 @@ type ActiveCallManagerPropsType = { } & Omit< PropsType, | 'acceptCall' + | 'activeNotificationProfile' | 'bounceAppIconStart' | 'bounceAppIconStop' | 'declineCall' @@ -536,6 +543,7 @@ function ActiveCallManager({ export function CallManager({ acceptCall, activeCall, + activeNotificationProfile, approveUser, availableCameras, batchUserAction, @@ -598,6 +606,23 @@ export function CallManager({ const ringingCallId = ringingCall?.conversation.id; useEffect(() => { if (hasInitialLoadCompleted && ringingCallId) { + if ( + !shouldNotify({ + activeProfile: activeNotificationProfile, + conversationId: ringingCallId, + isCall: true, + isMention: false, + }) + ) { + const redactedId = redactNotificationProfileId( + activeNotificationProfile?.id ?? '' + ); + log.info( + `CallManager: Would play ringtone, but notification profile ${redactedId} prevented it` + ); + return; + } + log.info('CallManager: Playing ringtone'); playRingtone(); @@ -609,7 +634,13 @@ export function CallManager({ stopRingtone(); return noop; - }, [hasInitialLoadCompleted, playRingtone, ringingCallId, stopRingtone]); + }, [ + activeNotificationProfile, + hasInitialLoadCompleted, + playRingtone, + ringingCallId, + stopRingtone, + ]); const mightBeRingingOutgoingGroupCall = isGroupOrAdhocActiveCall(activeCall) && @@ -691,6 +722,23 @@ export function CallManager({ // In the future, we may want to show the incoming call bar when a call is active. if (ringingCall) { + if ( + !shouldNotify({ + isCall: true, + isMention: false, + conversationId: ringingCall.conversation.id, + activeProfile: activeNotificationProfile, + }) + ) { + const redactedId = redactNotificationProfileId( + activeNotificationProfile?.id ?? '' + ); + log.info( + `CallManager: Would show incoming call bar, but notification profile ${redactedId} prevented it` + ); + return null; + } + return ( { loadCallLinks(), loadDistributionLists(), loadGifsState(), + loadNotificationProfiles(), loadRecentEmojis(), loadStickers(), loadStories(), @@ -60,6 +65,7 @@ export function getParametersForRedux(): ReduxInitData { gifs: getGifsStateForRedux(), mainWindowStats, menuOptions, + notificationProfiles: getNotificationProfiles(), recentEmoji: getEmojiReducerState(), stickers: getStickersReduxState(), stories: getStoriesForRedux(), diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index bd28547860..1f938e9473 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -145,6 +145,7 @@ import { trimBody } from '../../util/longAttachment'; import { generateBackupsSubscriberData } from '../../util/backupSubscriptionData'; import { getEnvironment, isTestEnvironment } from '../../environment'; import { calculateLightness } from '../../util/getHSL'; +import { toDayOfWeekArray } from '../../types/NotificationProfile'; const MAX_CONCURRENCY = 10; @@ -211,6 +212,7 @@ export type StatsType = { chats: number; distributionLists: number; messages: number; + notificationProfiles: number; skippedMessages: number; stickerPacks: number; fixedDirectMessages: number; @@ -232,6 +234,7 @@ export class BackupExportStream extends Readable { chats: 0, distributionLists: 0, messages: 0, + notificationProfiles: 0, skippedMessages: 0, stickerPacks: 0, fixedDirectMessages: 0, @@ -578,6 +581,60 @@ export class BackupExportStream extends Readable { this.#stats.adHocCalls += 1; } + const allNotificationProfiles = + await DataReader.getAllNotificationProfiles(); + + for (const profile of allNotificationProfiles) { + const { + id, + name, + emoji, + color, + createdAtMs, + allowAllCalls, + allowAllMentions, + allowedMembers, + scheduleEnabled, + scheduleStartTime, + scheduleEndTime, + scheduleDaysEnabled, + } = profile; + + const allowedRecipients = Array.from(allowedMembers) + .map(conversationId => { + const conversation = + window.ConversationController.get(conversationId); + if (!conversation) { + return undefined; + } + + const { attributes } = conversation; + return this.#getRecipientId(attributes); + }) + .filter(isNotNil); + + this.#pushFrame({ + notificationProfile: { + id: Bytes.fromHex(id), + name, + emoji, + color, + createdAtMs: getSafeLongFromTimestamp(createdAtMs), + allowAllCalls, + allowAllMentions, + allowedMembers: allowedRecipients, + scheduleEnabled, + scheduleStartTime, + scheduleEndTime, + scheduleDaysEnabled: toDayOfWeekArray(scheduleDaysEnabled), + }, + }); + + // eslint-disable-next-line no-await-in-loop + await this.#flush(); + this.#stats.notificationProfiles += 1; + } + let cursor: PageMessagesCursorType | undefined; const callHistory = await DataReader.getAllCallHistory(); diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index f029268f72..50a85f3588 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -132,6 +132,12 @@ import { saveBackupsSubscriberData } from '../../util/backupSubscriptionData'; import { postSaveUpdates } from '../../util/cleanup'; import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import { MessageModel } from '../../models/messages'; +import { + DEFAULT_PROFILE_COLOR, + fromDayOfWeekArray, + type NotificationProfileType, +} from '../../types/NotificationProfile'; +import { normalizeNotificationProfileId } from '../../types/NotificationProfile-node'; const MAX_CONCURRENCY = 10; @@ -503,9 +509,7 @@ export class BackupImportStream extends Writable { } else if (frame.adHocCall) { await this.#fromAdHocCall(frame.adHocCall); } else if (frame.notificationProfile) { - log.warn( - `${this.#logId}: Received currently unsupported feature: notification profile. Dropping.` - ); + await this.#fromNotificationProfile(frame.notificationProfile); } else if (frame.chatFolder) { log.warn( `${this.#logId}: Received currently unsupported feature: chat folder. Dropping.` @@ -3427,6 +3431,65 @@ export class BackupImportStream extends Writable { } } + async #fromNotificationProfile( + incomingProfile: Backups.INotificationProfile + ) { + const { + id, + name, + emoji, + color, + createdAtMs, + allowAllCalls, + allowAllMentions, + allowedMembers, + scheduleEnabled, + scheduleStartTime, + scheduleEndTime, + scheduleDaysEnabled, + } = incomingProfile; + strictAssert(name, 'notification profile must have a valid name'); + if (!id || !id.length) { + log.warn('Dropping notification profile; it was missing an id'); + return; + } + + const allowedMemberConversationIds: ReadonlyArray | undefined = + allowedMembers + ?.map(recipientIdLong => { + const recipientId = recipientIdLong.toNumber(); + const attributes = this.#recipientIdToConvo.get(recipientId); + if (!attributes) { + return undefined; + } + + return attributes.id; + }) + .filter(isNotNil); + + const profile: NotificationProfileType = { + id: normalizeNotificationProfileId(Bytes.toHex(id), 'import', log), + name, + emoji: dropNull(emoji), + color: dropNull(color) ?? DEFAULT_PROFILE_COLOR, + createdAtMs: getCheckedTimestampOrUndefinedFromLong(createdAtMs) ?? 0, + allowAllCalls: Boolean(allowAllCalls), + allowAllMentions: Boolean(allowAllMentions), + allowedMembers: new Set(allowedMemberConversationIds ?? []), + scheduleEnabled: Boolean(scheduleEnabled), + scheduleStartTime: dropNull(scheduleStartTime), + scheduleEndTime: dropNull(scheduleEndTime), + scheduleDaysEnabled: fromDayOfWeekArray(scheduleDaysEnabled), + deletedAtTimestampMs: undefined, + storageNeedsSync: false, + storageID: undefined, + storageUnknownFields: undefined, + storageVersion: undefined, + }; + + await DataWriter.createNotificationProfile(profile); + } + async #fromCustomChatColors( customChatColors: | ReadonlyArray diff --git a/ts/services/expiringMessagesDeletion.ts b/ts/services/expiringMessagesDeletion.ts index ff805e7965..ba5882b8cc 100644 --- a/ts/services/expiringMessagesDeletion.ts +++ b/ts/services/expiringMessagesDeletion.ts @@ -12,14 +12,14 @@ import { sleep } from '../util/sleep'; import { SECOND } from '../util/durations'; import { MessageModel } from '../models/messages'; import { cleanupMessages } from '../util/cleanup'; +import { drop } from '../util/drop'; class ExpiringMessagesDeletionService { - public update: () => void; - #timeout?: ReturnType; + #debouncedCheckExpiringMessages = debounce(this.#checkExpiringMessages, 1000); - constructor() { - this.update = debounce(this.#checkExpiringMessages, 1000); + update() { + drop(this.#debouncedCheckExpiringMessages()); } async #destroyExpiredMessages() { @@ -118,11 +118,11 @@ export function initialize(): void { instance = new ExpiringMessagesDeletionService(); } -export async function update(): Promise { +export function update(): void { if (!instance) { throw new Error('Expiring Messages Deletion service not yet initialized!'); } - await instance.update(); + instance.update(); } let instance: ExpiringMessagesDeletionService; diff --git a/ts/services/notificationProfilesService.ts b/ts/services/notificationProfilesService.ts new file mode 100644 index 0000000000..7ed2dbe101 --- /dev/null +++ b/ts/services/notificationProfilesService.ts @@ -0,0 +1,188 @@ +// Copyright 2016 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { debounce, isEqual, isNumber } from 'lodash'; + +import * as log from '../logging/log'; + +import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; +import { isInPast, isMoreRecentThan } from '../util/timestamp'; +import { getMessageQueueTime } from '../util/getMessageQueueTime'; +import { drop } from '../util/drop'; +import { DataReader, DataWriter } from '../sql/Client'; +import { + findNextProfileEvent, + redactNotificationProfileId, + type NotificationProfileType, +} from '../types/NotificationProfile'; +import { + getCurrentState, + getDeletedProfiles, + getOverride, + getProfiles, +} from '../state/selectors/notificationProfiles'; +import { safeSetTimeout } from '../util/timeout'; + +export class NotificationProfilesService { + #timeout?: ReturnType | null; + #debouncedRefreshNextEvent = debounce(this.#refreshNextEvent, 1000); + + update(): void { + drop(this.#debouncedRefreshNextEvent()); + } + + async #refreshNextEvent() { + log.info('notificationProfileService: starting'); + + const { updateCurrentState, updateOverride, profileWasRemoved } = + window.reduxActions.notificationProfiles; + + const state = window.reduxStore.getState(); + const profiles = getProfiles(state); + const previousCurrentState = getCurrentState(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); + } 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); + } else { + log.info( + 'notificationProfileService: Tried to clear manual disable override, but it did not match previous override' + ); + } + } + + log.info('notificationProfileService: finding next profile event'); + const currentState = findNextProfileEvent({ + override, + profiles, + time, + }); + + if (!isEqual(previousCurrentState, currentState)) { + log.info( + 'notificationProfileService: next profile event has changed, updating redux' + ); + updateCurrentState(currentState); + } + + 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 = Date.now() - nextCheck; + 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('NotificationProfileService not yet initialized!'); + // } + // instance.update(); +} + +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; diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index ce960f8e33..32d7a55801 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -45,6 +45,7 @@ import type { import type { SyncTaskType } from '../util/syncTasks'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup'; import type { GifType } from '../components/fun/panels/FunPanelGifs'; +import type { NotificationProfileType } from '../types/NotificationProfile'; export type ReadableDB = Database & { __readable_db: never }; export type WritableDB = ReadableDB & { __writable_db: never }; @@ -726,6 +727,9 @@ type ReadableInterface = { }): Array; countStoryReadsByConversation(conversationId: string): number; + getAllNotificationProfiles(): Array; + getNotificationProfileById(id: string): NotificationProfileType | undefined; + getMessagesNeedingUpgrade: ( limit: number, options: { maxVersion: number } @@ -1020,6 +1024,12 @@ type WritableInterface = { _deleteAllStoryReads(): void; addNewStoryRead(read: StoryReadType): void; + _deleteAllNotificationProfiles(): void; + deleteNotificationProfileById(id: string): void; + markNotificationProfileDeleted(id: string): number | undefined; + createNotificationProfile(profile: NotificationProfileType): void; + updateNotificationProfile(profile: NotificationProfileType): void; + removeAll: () => void; removeAllConfiguration: () => void; eraseStorageServiceState: () => void; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index dada536382..b979d6c3a8 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -211,8 +211,9 @@ import { getGroupSendMemberEndorsement, replaceAllEndorsementsForGroup, } from './server/groupSendEndorsements'; -import type { GifType } from '../components/fun/panels/FunPanelGifs'; import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer'; +import type { GifType } from '../components/fun/panels/FunPanelGifs'; +import type { NotificationProfileType } from '../types/NotificationProfile'; type ConversationRow = Readonly<{ json: string; @@ -363,6 +364,9 @@ export const DataReader: ServerReadableInterface = { getCallHistoryGroups, hasGroupCallHistoryMessage, + getAllNotificationProfiles, + getNotificationProfileById, + callLinkExists, defunctCallLinkExists, getAllCallLinks, @@ -586,6 +590,12 @@ export const DataWriter: ServerWritableInterface = { _deleteAllStoryReads, addNewStoryRead, + _deleteAllNotificationProfiles, + createNotificationProfile, + deleteNotificationProfileById, + markNotificationProfileDeleted, + updateNotificationProfile, + removeAll, removeAllConfiguration, eraseStorageServiceState, @@ -6859,6 +6869,224 @@ function countStoryReadsByConversation( ); } +type NotificationProfileForDatabase = Readonly< + { + emoji: string | null; + allowAllCalls: 0 | 1; + allowAllMentions: 0 | 1; + scheduleEnabled: 0 | 1; + allowedMembersJson: string | null; + scheduleStartTime: number | null; + scheduleEndTime: number | null; + scheduleDaysEnabledJson: string | null; + deletedAtTimestampMs: number | null; + storageID: string | null; + storageVersion: number | null; + storageNeedsSync: 0 | 1; + } & Omit< + NotificationProfileType, + | 'emoji' + | 'allowAllCalls' + | 'allowAllMentions' + | 'scheduleEnabled' + | 'allowedMembers' + | 'scheduleStartTime' + | 'scheduleEndTime' + | 'scheduleDaysEnabled' + | 'deletedAtTimestampMs' + | 'storageID' + | 'storageVersion' + | 'storageNeedsSync' + > +>; + +function hydrateNotificationProfile( + profile: NotificationProfileForDatabase +): NotificationProfileType { + return { + ...omit(profile, ['allowedMembersJson', 'scheduleDaysEnabledJson']), + emoji: profile.emoji || undefined, + allowAllCalls: Boolean(profile.allowAllCalls), + allowAllMentions: Boolean(profile.allowAllMentions), + scheduleEnabled: Boolean(profile.scheduleEnabled), + allowedMembers: profile.allowedMembersJson + ? new Set(JSON.parse(profile.allowedMembersJson)) + : new Set(), + scheduleStartTime: profile.scheduleStartTime || undefined, + scheduleEndTime: profile.scheduleEndTime || undefined, + scheduleDaysEnabled: profile.scheduleDaysEnabledJson + ? JSON.parse(profile.scheduleDaysEnabledJson) + : undefined, + deletedAtTimestampMs: profile.deletedAtTimestampMs || undefined, + storageID: profile.storageID || undefined, + storageVersion: profile.storageVersion || undefined, + storageNeedsSync: Boolean(profile.storageNeedsSync), + storageUnknownFields: profile.storageUnknownFields || undefined, + }; +} +function freezeNotificationProfile( + profile: NotificationProfileType +): NotificationProfileForDatabase { + return { + ...omit(profile, ['allowedMembers', 'scheduleDaysEnabled']), + emoji: profile.emoji || null, + allowAllCalls: profile.allowAllCalls ? 1 : 0, + allowAllMentions: profile.allowAllMentions ? 1 : 0, + scheduleEnabled: profile.scheduleEnabled ? 1 : 0, + allowedMembersJson: profile.allowedMembers + ? JSON.stringify(Array.from(profile.allowedMembers)) + : null, + scheduleStartTime: profile.scheduleStartTime || null, + scheduleEndTime: profile.scheduleEndTime || null, + scheduleDaysEnabledJson: profile.scheduleDaysEnabled + ? JSON.stringify(profile.scheduleDaysEnabled) + : null, + deletedAtTimestampMs: profile.deletedAtTimestampMs || null, + storageID: profile.storageID || null, + storageVersion: profile.storageVersion || null, + storageNeedsSync: profile.storageNeedsSync ? 1 : 0, + storageUnknownFields: profile.storageUnknownFields || null, + }; +} + +function getAllNotificationProfiles( + db: ReadableDB +): Array { + const notificationProfiles = db + .prepare('SELECT * FROM notificationProfiles ORDER BY createdAtMs DESC;') + .all(); + + return notificationProfiles.map(hydrateNotificationProfile); +} +function getNotificationProfileById( + db: ReadableDB, + id: string +): NotificationProfileType | undefined { + const [query, parameters] = + sql`SELECT * FROM notificationProfiles WHERE id = ${id}`; + const fromDatabase = db + .prepare(query) + .get(parameters); + + if (fromDatabase) { + return hydrateNotificationProfile(fromDatabase); + } + + return undefined; +} +function _deleteAllNotificationProfiles(db: WritableDB): void { + db.prepare('DELETE FROM notificationProfiles;').run(); +} +function deleteNotificationProfileById(db: WritableDB, id: string): void { + const [query, parameters] = + sql`DELETE FROM notificationProfiles WHERE id = ${id}`; + db.prepare(query).run(parameters); +} +function markNotificationProfileDeleted( + db: WritableDB, + id: string +): number | undefined { + const now = new Date().getTime(); + + const [query, parameters] = sql` + UPDATE notificationProfiles + SET deletedAtTimestampMs = ${now} + WHERE + id = ${id} AND + deletedAtTimestampMs IS NULL + RETURNING deletedAtTimestampMs`; + + const record = db + .prepare(query) + .get<{ deletedAtTimestampMs: number }>(parameters); + + return record?.deletedAtTimestampMs; +} +function createNotificationProfile( + db: WritableDB, + profile: NotificationProfileType +): void { + strictAssert(profile.name, 'Notification profile does not have a valid name'); + + const forDatabase = freezeNotificationProfile(profile); + + db.prepare( + ` + INSERT INTO notificationProfiles( + id, + name, + emoji, + color, + createdAtMs, + allowAllCalls, + allowAllMentions, + allowedMembersJson, + scheduleEnabled, + scheduleStartTime, + scheduleEndTime, + scheduleDaysEnabledJson, + deletedAtTimestampMs, + storageID, + storageVersion, + storageUnknownFields, + storageNeedsSync + ) VALUES ( + $id, + $name, + $emoji, + $color, + $createdAtMs, + $allowAllCalls, + $allowAllMentions, + $allowedMembersJson, + $scheduleEnabled, + $scheduleStartTime, + $scheduleEndTime, + $scheduleDaysEnabledJson, + $deletedAtTimestampMs, + $storageID, + $storageVersion, + $storageUnknownFields, + $storageNeedsSync + ); + ` + ).run(forDatabase); +} +function updateNotificationProfile( + db: WritableDB, + profile: NotificationProfileType +): void { + strictAssert(profile.name, 'Notification profile does not have a valid name'); + + db.transaction(() => { + const forDatabase = freezeNotificationProfile(profile); + + db.prepare( + ` + UPDATE notificationProfiles SET + name = $name, + emoji = $emoji, + color = $color, + createdAtMs = $createdAtMs, + allowAllCalls = $allowAllCalls, + allowAllMentions = $allowAllMentions, + allowedMembersJson = $allowedMembersJson, + scheduleEnabled = $scheduleEnabled, + scheduleStartTime = $scheduleStartTime, + scheduleEndTime = $scheduleEndTime, + scheduleDaysEnabledJson = $scheduleDaysEnabledJson, + deletedAtTimestampMs = $deletedAtTimestampMs, + storageID = $storageID, + storageVersion = $storageVersion, + storageUnknownFields = $storageUnknownFields, + storageNeedsSync = $storageNeedsSync + WHERE + id = $id; + ` + ).run(forDatabase); + })(); +} + // All data in database function removeAll(db: WritableDB): void { db.transaction(() => { @@ -6885,6 +7113,7 @@ function removeAll(db: WritableDB): void { DELETE FROM kyberPreKeys; DELETE FROM messages_fts; DELETE FROM messages; + DELETE FROM notificationProfiles; DELETE FROM preKeys; DELETE FROM reactions; DELETE FROM senderKeys; diff --git a/ts/sql/migrations/1350-notification-profiles.ts b/ts/sql/migrations/1350-notification-profiles.ts new file mode 100644 index 0000000000..a36e56f461 --- /dev/null +++ b/ts/sql/migrations/1350-notification-profiles.ts @@ -0,0 +1,58 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { Database } from '@signalapp/sqlcipher'; +import type { LoggerType } from '../../types/Logging'; +import { sql } from '../util'; + +export const version = 1350; + +export function updateToSchemaVersion1350( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 1350) { + return; + } + + db.transaction(() => { + const [query] = sql` + CREATE TABLE notificationProfiles( + id TEXT PRIMARY KEY NOT NULL, + + name TEXT NOT NULL, + emoji TEXT, + /* A numeric representation of a color, like 0xAARRGGBB */ + color INTEGER NOT NULL, + + createdAtMs INTEGER NOT NULL, + + allowAllCalls INTEGER NOT NULL, + allowAllMentions INTEGER NOT NULL, + + /* A JSON array of conversationId strings */ + allowedMembersJson TEXT NOT NULL, + scheduleEnabled INTEGER NOT NULL, + + /* 24-hour clock int, 0000-2359 (e.g., 15, 900, 1130, 2345) */ + scheduleStartTime INTEGER, + scheduleEndTime INTEGER, + + /* A JSON object with true/false for each of the numbers in the Protobuf enum */ + scheduleDaysEnabledJson TEXT, + deletedAtTimestampMs INTEGER, + + storageID TEXT, + storageVersion INTEGER, + storageUnknownFields BLOB, + storageNeedsSync INTEGER NOT NULL DEFAULT 0 + ) STRICT; + `; + + db.exec(query); + + db.pragma('user_version = 1350'); + })(); + + logger.info('updateToSchemaVersion1350: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 5d86cb6632..0ea10b41e2 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -109,10 +109,11 @@ import { updateToSchemaVersion1300 } from './1300-sticker-pack-refs'; import { updateToSchemaVersion1310 } from './1310-muted-fixup'; import { updateToSchemaVersion1320 } from './1320-unprocessed-received-at-date'; import { updateToSchemaVersion1330 } from './1330-sync-tasks-type-index'; +import { updateToSchemaVersion1340 } from './1340-recent-gifs'; import { - updateToSchemaVersion1340, + updateToSchemaVersion1350, version as MAX_VERSION, -} from './1340-recent-gifs'; +} from './1350-notification-profiles'; import { DataWriter } from '../Server'; @@ -2100,6 +2101,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion1320, updateToSchemaVersion1330, updateToSchemaVersion1340, + updateToSchemaVersion1350, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/state/actions.ts b/ts/state/actions.ts index fa14fde171..00c009734e 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -22,6 +22,7 @@ import { actions as lightbox } from './ducks/lightbox'; import { actions as linkPreviews } from './ducks/linkPreviews'; import { actions as mediaGallery } from './ducks/mediaGallery'; import { actions as network } from './ducks/network'; +import { actions as notificationProfiles } from './ducks/notificationProfiles'; import { actions as safetyNumber } from './ducks/safetyNumber'; import { actions as search } from './ducks/search'; import { actions as stickers } from './ducks/stickers'; @@ -55,6 +56,7 @@ export const actionCreators: ReduxActions = { linkPreviews, mediaGallery, network, + notificationProfiles, safetyNumber, search, stickers, diff --git a/ts/state/ducks/notificationProfiles.ts b/ts/state/ducks/notificationProfiles.ts new file mode 100644 index 0000000000..e46f342379 --- /dev/null +++ b/ts/state/ducks/notificationProfiles.ts @@ -0,0 +1,277 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ReadonlyDeep } from 'type-fest'; +import type { ThunkAction } from 'redux-thunk'; + +import { update as updateProfileService } from '../../services/notificationProfilesService'; +import { strictAssert } from '../../util/assert'; +import { + type BoundActionCreatorsMapObject, + useBoundActions, +} from '../../hooks/useBoundActions'; +import { DataWriter } from '../../sql/Client'; +import { sortProfiles } from '../../types/NotificationProfile'; + +import type { + NextProfileEvent, + NotificationProfileOverride, + NotificationProfileType, +} from '../../types/NotificationProfile'; + +const { + updateNotificationProfile, + createNotificationProfile, + markNotificationProfileDeleted, +} = DataWriter; + +// State + +export type NotificationProfilesStateType = ReadonlyDeep<{ + currentState: NextProfileEvent; + override: NotificationProfileOverride | undefined; + profiles: ReadonlyArray; +}>; + +// Actions + +const CREATE_PROFILE = 'NotificationProfiles/CREATE_PROFILE'; +const MARK_PROFILE_DELETED = 'NotificationProfiles/MARK_PROFILE_DELETED'; +const REMOVE_PROFILE = 'NotificationProfiles/REMOVE_PROFILE'; +const UPDATE_CURRENT_STATE = 'NotificationProfiles/UPDATE_CURRENT_STATE'; +const UPDATE_OVERRIDE = 'NotificationProfiles/UPDATE_OVERRIDE'; +const UPDATE_PROFILE = 'NotificationProfiles/UPDATE_PROFILE'; + +export type CreateProfile = ReadonlyDeep<{ + type: typeof CREATE_PROFILE; + payload: NotificationProfileType; +}>; + +export type RemoveProfile = ReadonlyDeep<{ + type: typeof REMOVE_PROFILE; + payload: string; +}>; + +export type MarkProfileDeleted = ReadonlyDeep<{ + type: typeof MARK_PROFILE_DELETED; + payload: { + id: string; + deletedAtTimestampMs: number; + }; +}>; + +export type UpdateCurrentState = ReadonlyDeep<{ + type: typeof UPDATE_CURRENT_STATE; + payload: NextProfileEvent; +}>; + +export type UpdateOverride = ReadonlyDeep<{ + type: typeof UPDATE_OVERRIDE; + payload: NotificationProfileOverride | undefined; +}>; + +export type UpdateProfile = ReadonlyDeep<{ + type: typeof UPDATE_PROFILE; + payload: NotificationProfileType; +}>; + +type NotificationProfilesActionType = ReadonlyDeep< + | CreateProfile + | MarkProfileDeleted + | RemoveProfile + | UpdateCurrentState + | UpdateOverride + | UpdateProfile +>; + +// Action Creators + +export const actions = { + createProfile, + profileWasCreated, + profileWasRemoved, + profileWasUpdated, + markProfileDeleted, + updateCurrentState, + updateOverride, + updateProfile, +}; + +export const useNotificationProfilesActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); + +function createProfile( + payload: NotificationProfileType +): ThunkAction { + return async dispatch => { + await createNotificationProfile(payload); + dispatch({ + type: CREATE_PROFILE, + payload, + }); + updateProfileService(); + }; +} + +function markProfileDeleted( + id: string +): ThunkAction { + return async dispatch => { + // Here we just set deletedAtTimestampMs, which removes it from the UI but keeps + // it around for agreement between devices in storage service. + const deletedAtTimestampMs = await markNotificationProfileDeleted(id); + strictAssert( + deletedAtTimestampMs, + 'removeProfile: expected when marking profile deleted' + ); + dispatch({ + type: MARK_PROFILE_DELETED, + payload: { + id, + deletedAtTimestampMs, + }, + }); + updateProfileService(); + }; +} + +function updateCurrentState(payload: NextProfileEvent): UpdateCurrentState { + // No need for a thunk - redux is the source of truth, and it's only kept in memory + return { + type: UPDATE_CURRENT_STATE, + payload, + }; +} + +function updateOverride( + payload: NotificationProfileOverride | undefined +): ThunkAction { + return async dispatch => { + await window.storage.put('notificationProfileOverride', payload); + dispatch({ + type: UPDATE_OVERRIDE, + payload, + }); + updateProfileService(); + }; +} + +function updateProfile( + payload: NotificationProfileType +): ThunkAction { + return async dispatch => { + await updateNotificationProfile(payload); + dispatch({ + type: UPDATE_PROFILE, + payload, + }); + updateProfileService(); + }; +} + +// Used for storage service, where the updates go directly to the database +function profileWasCreated(payload: NotificationProfileType): CreateProfile { + updateProfileService(); + return { + type: CREATE_PROFILE, + payload, + }; +} + +// Used for storage service, where the updates go directly to the database +function profileWasUpdated(payload: NotificationProfileType): UpdateProfile { + updateProfileService(); + return { + type: UPDATE_PROFILE, + payload, + }; +} + +// Used for when cleaning out profiles that were marked deleted long ago +function profileWasRemoved(payload: string): RemoveProfile { + updateProfileService(); + return { + type: REMOVE_PROFILE, + payload, + }; +} + +// Reducer + +export function getEmptyState(): NotificationProfilesStateType { + return { + currentState: { type: 'noChange', activeProfile: undefined }, + override: undefined, + profiles: [], + }; +} + +export function reducer( + state: NotificationProfilesStateType = getEmptyState(), + action: NotificationProfilesActionType +): NotificationProfilesStateType { + if (action.type === CREATE_PROFILE) { + const { payload } = action; + return { + ...state, + profiles: sortProfiles([payload, ...state.profiles]), + }; + } + + if (action.type === MARK_PROFILE_DELETED) { + const { payload } = action; + const { id, deletedAtTimestampMs } = payload; + + return { + ...state, + profiles: state.profiles.map(item => { + if (item.id === id) { + return { ...item, deletedAtTimestampMs }; + } + return item; + }), + }; + } + + if (action.type === REMOVE_PROFILE) { + const { payload } = action; + + return { + ...state, + profiles: state.profiles.filter(item => item.id !== payload), + }; + } + + if (action.type === UPDATE_CURRENT_STATE) { + const { payload } = action; + return { + ...state, + currentState: payload, + }; + } + + if (action.type === UPDATE_OVERRIDE) { + const { payload } = action; + return { + ...state, + override: payload, + }; + } + + if (action.type === UPDATE_PROFILE) { + const { payload } = action; + return { + ...state, + profiles: state.profiles.map(item => { + if (item.id === payload.id) { + return payload; + } + + return item; + }), + }; + } + + return state; +} diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index 3a87390d5b..e37d18dd10 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -23,6 +23,7 @@ import { getEmptyState as linkPreviewsEmptyState } from './ducks/linkPreviews'; import { getEmptyState as mediaGalleryEmptyState } from './ducks/mediaGallery'; import { getEmptyState as navEmptyState } from './ducks/nav'; import { getEmptyState as networkEmptyState } from './ducks/network'; +import { getEmptyState as notificationProfilesEmptyState } from './ducks/notificationProfiles'; import { getEmptyState as preferredReactionsEmptyState } from './ducks/preferredReactions'; import { getEmptyState as safetyNumberEmptyState } from './ducks/safetyNumber'; import { getEmptyState as searchEmptyState } from './ducks/search'; @@ -59,6 +60,7 @@ export function getInitialState( gifs, mainWindowStats, menuOptions, + notificationProfiles, recentEmoji, stickers, stories, @@ -86,6 +88,11 @@ export function getInitialState( emojis: recentEmoji, gifs, items, + notificationProfiles: { + ...notificationProfilesEmptyState(), + override: items.notificationProfileOverride, + profiles: notificationProfiles, + }, stickers, stories: { ...storiesEmptyState(), @@ -145,6 +152,7 @@ function getEmptyState(): StateType { mediaGallery: mediaGalleryEmptyState(), nav: navEmptyState(), network: networkEmptyState(), + notificationProfiles: notificationProfilesEmptyState(), preferredReactions: preferredReactionsEmptyState(), safetyNumber: safetyNumberEmptyState(), search: searchEmptyState(), diff --git a/ts/state/initializeRedux.ts b/ts/state/initializeRedux.ts index 4ec61558e5..fc72dcdccc 100644 --- a/ts/state/initializeRedux.ts +++ b/ts/state/initializeRedux.ts @@ -17,6 +17,7 @@ import type { CallLinkType } from '../types/CallLink'; import type { RecentEmojiObjectType } from '../util/loadRecentEmojis'; import type { StickersStateType } from './ducks/stickers'; import type { GifsStateType } from './ducks/gifs'; +import type { NotificationProfileType } from '../types/NotificationProfile'; export type ReduxInitData = { badgesState: BadgesStateType; @@ -26,6 +27,7 @@ export type ReduxInitData = { gifs: GifsStateType; mainWindowStats: MainWindowStatsType; menuOptions: MenuOptionsType; + notificationProfiles: ReadonlyArray; recentEmoji: RecentEmojiObjectType; stickers: StickersStateType; stories: Array; @@ -81,6 +83,10 @@ export function initializeRedux(data: ReduxInitData): void { store.dispatch ), network: bindActionCreators(actionCreators.network, store.dispatch), + notificationProfiles: bindActionCreators( + actionCreators.notificationProfiles, + store.dispatch + ), safetyNumber: bindActionCreators( actionCreators.safetyNumber, store.dispatch diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 98cde5c9a7..bced6947b2 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -25,6 +25,7 @@ import { reducer as linkPreviews } from './ducks/linkPreviews'; import { reducer as mediaGallery } from './ducks/mediaGallery'; import { reducer as nav } from './ducks/nav'; import { reducer as network } from './ducks/network'; +import { reducer as notificationProfiles } from './ducks/notificationProfiles'; import { reducer as preferredReactions } from './ducks/preferredReactions'; import { reducer as safetyNumber } from './ducks/safetyNumber'; import { reducer as search } from './ducks/search'; @@ -59,6 +60,7 @@ export const reducer = combineReducers({ mediaGallery, nav, network, + notificationProfiles, preferredReactions, safetyNumber, search, diff --git a/ts/state/selectors/notificationProfiles.ts b/ts/state/selectors/notificationProfiles.ts new file mode 100644 index 0000000000..0910b84016 --- /dev/null +++ b/ts/state/selectors/notificationProfiles.ts @@ -0,0 +1,89 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// SPDX-License-Identifier: AGPL-3.0-only + +import { createSelector } from 'reselect'; + +import * as log from '../../logging/log'; + +import type { StateType } from '../reducer'; +import type { NotificationProfilesStateType } from '../ducks/notificationProfiles'; +import { + redactNotificationProfileId, + type NextProfileEvent, + type NotificationProfileOverride, + type NotificationProfileType, +} from '../../types/NotificationProfile'; + +export const getNotificationProfileData = ( + state: StateType +): NotificationProfilesStateType => { + return state.notificationProfiles; +}; + +export const getProfiles = createSelector( + getNotificationProfileData, + ( + state: NotificationProfilesStateType + ): ReadonlyArray => { + return state.profiles.filter( + profile => profile.deletedAtTimestampMs == null + ); + } +); + +export const getDeletedProfiles = createSelector( + getNotificationProfileData, + ( + state: NotificationProfilesStateType + ): ReadonlyArray => { + return state.profiles.filter( + profile => profile.deletedAtTimestampMs != null + ); + } +); + +export const getOverride = createSelector( + getNotificationProfileData, + ( + state: NotificationProfilesStateType + ): NotificationProfileOverride | undefined => { + return state.override; + } +); + +export const getCurrentState = createSelector( + getNotificationProfileData, + (state: NotificationProfilesStateType): NextProfileEvent => { + return state.currentState; + } +); + +export const getActiveProfile = createSelector( + getCurrentState, + getProfiles, + ( + state: NextProfileEvent, + profiles: ReadonlyArray + ): NotificationProfileType | undefined => { + let profileId: string; + + if (state.type === 'noChange' && state.activeProfile) { + profileId = state.activeProfile; + } else if (state.type === 'willDisable') { + profileId = state.activeProfile; + } else { + return undefined; + } + + const profile = profiles.find(item => item.id === profileId); + if (!profile) { + log.warn( + `getActiveProfile: currentState referred to profileId ${redactNotificationProfileId(profileId)} not in the list` + ); + } + + return profile; + } +); diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index c5f2222bd4..e55fbfa8a7 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -58,6 +58,7 @@ import { renderReactionPicker } from './renderReactionPicker'; import { isSharingPhoneNumberWithEverybody as getIsSharingPhoneNumberWithEverybody } from '../../util/phoneNumberSharingMode'; import { useGlobalModalActions } from '../ducks/globalModals'; import { isLonelyGroup } from '../ducks/callingHelpers'; +import { getActiveProfile } from '../selectors/notificationProfiles'; function renderDeviceSelection(): JSX.Element { return ; @@ -439,6 +440,7 @@ export const SmartCallManager = memo(function SmartCallManager() { const availableCameras = useSelector(getAvailableCameras); const hasInitialLoadCompleted = useSelector(getHasInitialLoadCompleted); const me = useSelector(getMe); + const activeNotificationProfile = useSelector(getActiveProfile); const { approveUser, @@ -485,6 +487,7 @@ export const SmartCallManager = memo(function SmartCallManager() { { if ( expectedString.includes('ReleaseChannelDonationRequest') || // TODO (DESKTOP-8025) roundtrip these frames - fullPath.includes('chat_folder') || - fullPath.includes('notification_profile') + fullPath.includes('chat_folder') ) { // Skip the unsupported tests return; diff --git a/ts/test-electron/sql/notificationProfiles_test.ts b/ts/test-electron/sql/notificationProfiles_test.ts new file mode 100644 index 0000000000..e4c802e225 --- /dev/null +++ b/ts/test-electron/sql/notificationProfiles_test.ts @@ -0,0 +1,247 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { DataReader, DataWriter } from '../../sql/Client'; + +import { DayOfWeek } from '../../types/NotificationProfile'; +import { generateNotificationProfileId } from '../../types/NotificationProfile-node'; + +import type { NotificationProfileType } from '../../types/NotificationProfile'; + +const { getAllNotificationProfiles } = DataReader; +const { + _deleteAllNotificationProfiles, + createNotificationProfile, + deleteNotificationProfileById, + markNotificationProfileDeleted, + updateNotificationProfile, +} = DataWriter; + +describe('sql/notificationProfiles', () => { + beforeEach(async () => { + await _deleteAllNotificationProfiles(); + }); + after(async () => { + await _deleteAllNotificationProfiles(); + }); + + it('should roundtrip', async () => { + const now = Date.now(); + const profile1: NotificationProfileType = { + id: generateNotificationProfileId(), + name: 'After Hours', + emoji: '💤 ', + color: 0xff111111, + + createdAtMs: now, + + allowAllCalls: true, + allowAllMentions: true, + + allowedMembers: new Set(['conversation-1', 'conversation-2']), + scheduleEnabled: true, + + scheduleStartTime: 1800, + scheduleEndTime: 2400, + + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: true, + [DayOfWeek.WEDNESDAY]: true, + [DayOfWeek.THURSDAY]: true, + [DayOfWeek.FRIDAY]: true, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + deletedAtTimestampMs: undefined, + storageID: 'storageId-1', + storageVersion: 56, + storageUnknownFields: new Uint8Array([1, 2, 3, 4]), + storageNeedsSync: false, + }; + const profile2: NotificationProfileType = { + id: generateNotificationProfileId(), + name: 'Holiday', + emoji: undefined, + color: 0xff222222, + + createdAtMs: now + 1, + + allowAllCalls: false, + allowAllMentions: false, + + allowedMembers: new Set(), + scheduleEnabled: false, + + scheduleStartTime: undefined, + scheduleEndTime: undefined, + scheduleDaysEnabled: undefined, + + deletedAtTimestampMs: undefined, + storageID: undefined, + storageVersion: undefined, + storageUnknownFields: undefined, + storageNeedsSync: true, + }; + + await createNotificationProfile(profile1); + const oneProfile = await getAllNotificationProfiles(); + assert.lengthOf(oneProfile, 1); + assert.deepEqual(oneProfile[0], profile1); + + await createNotificationProfile(profile2); + const twoProfiles = await getAllNotificationProfiles(); + assert.lengthOf(twoProfiles, 2); + assert.deepEqual(twoProfiles[0], profile2); + assert.deepEqual(twoProfiles[1], profile1); + + await deleteNotificationProfileById(profile1.id); + const backToOneProfile = await getAllNotificationProfiles(); + assert.lengthOf(backToOneProfile, 1); + assert.deepEqual(backToOneProfile[0], profile2); + }); + + it('can mark a profile as deleted', async () => { + const now = Date.now(); + const profile1: NotificationProfileType = { + id: generateNotificationProfileId(), + name: 'After Hours', + emoji: '💤 ', + color: 0xff111111, + + createdAtMs: now, + + allowAllCalls: true, + allowAllMentions: true, + + allowedMembers: new Set(['conversation-1', 'conversation-2']), + scheduleEnabled: true, + + scheduleStartTime: 1800, + scheduleEndTime: 2400, + + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: true, + [DayOfWeek.WEDNESDAY]: true, + [DayOfWeek.THURSDAY]: true, + [DayOfWeek.FRIDAY]: true, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + deletedAtTimestampMs: undefined, + storageID: 'storageId-1', + storageVersion: 56, + storageUnknownFields: new Uint8Array([1, 2, 3, 4]), + storageNeedsSync: false, + }; + const profile2: NotificationProfileType = { + id: generateNotificationProfileId(), + name: 'Holiday', + emoji: undefined, + color: 0xff222222, + + createdAtMs: now + 1, + + allowAllCalls: false, + allowAllMentions: false, + + allowedMembers: new Set(), + scheduleEnabled: false, + + scheduleStartTime: undefined, + scheduleEndTime: undefined, + scheduleDaysEnabled: undefined, + + deletedAtTimestampMs: undefined, + storageID: undefined, + storageVersion: undefined, + storageUnknownFields: undefined, + storageNeedsSync: true, + }; + + await createNotificationProfile(profile1); + await createNotificationProfile(profile2); + + const timestamp = await markNotificationProfileDeleted(profile1.id); + assert.isDefined(timestamp); + + const twoProfiles = await getAllNotificationProfiles(); + assert.lengthOf(twoProfiles, 2); + assert.strictEqual(twoProfiles[1].deletedAtTimestampMs, timestamp); + }); + + it('can update a profile', async () => { + const now = Date.now(); + const id = generateNotificationProfileId(); + + const profile: NotificationProfileType = { + id, + name: 'After Hours', + emoji: '💤 ', + color: 0xff111111, + + createdAtMs: now, + + allowAllCalls: true, + allowAllMentions: true, + + allowedMembers: new Set(['conversation-1', 'conversation-2']), + scheduleEnabled: true, + + scheduleStartTime: 1800, + scheduleEndTime: 2400, + + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: true, + [DayOfWeek.WEDNESDAY]: true, + [DayOfWeek.THURSDAY]: true, + [DayOfWeek.FRIDAY]: true, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + deletedAtTimestampMs: undefined, + storageID: 'storageId-1', + storageVersion: 56, + storageUnknownFields: new Uint8Array([1, 2, 3, 4]), + storageNeedsSync: false, + }; + const update: NotificationProfileType = { + id, + name: 'Holiday', + emoji: '📆 ', + color: 0xff222222, + + createdAtMs: now + 1, + + allowAllCalls: false, + allowAllMentions: false, + + allowedMembers: new Set(), + scheduleEnabled: false, + + scheduleStartTime: undefined, + scheduleEndTime: undefined, + scheduleDaysEnabled: undefined, + + deletedAtTimestampMs: undefined, + storageID: undefined, + storageVersion: undefined, + storageUnknownFields: undefined, + storageNeedsSync: true, + }; + + await createNotificationProfile(profile); + const oneProfile = await getAllNotificationProfiles(); + assert.lengthOf(oneProfile, 1); + assert.deepEqual(oneProfile[0], profile); + + await updateNotificationProfile(update); + const stillOneProfile = await getAllNotificationProfiles(); + assert.lengthOf(stillOneProfile, 1); + assert.deepEqual(stillOneProfile[0], update); + }); +}); diff --git a/ts/test-electron/state/ducks/stories_test.ts b/ts/test-electron/state/ducks/stories_test.ts index 3c6fd85d90..0d176f17b6 100644 --- a/ts/test-electron/state/ducks/stories_test.ts +++ b/ts/test-electron/state/ducks/stories_test.ts @@ -29,6 +29,13 @@ import { dropNull } from '../../../util/dropNull'; import { MessageModel } from '../../../models/messages'; describe('both/state/ducks/stories', () => { + const ourAci = generateAci(); + const deviceId = 2; + + before(async () => { + await window.textsecure.storage.put('uuid_id', `${ourAci}.${deviceId}`); + }); + const getEmptyRootState = () => ({ ...rootReducer(undefined, noopAction()), stories: getEmptyState(), diff --git a/ts/test-node/sql/migration_1350_test.ts b/ts/test-node/sql/migration_1350_test.ts new file mode 100644 index 0000000000..f1f6d83f58 --- /dev/null +++ b/ts/test-node/sql/migration_1350_test.ts @@ -0,0 +1,25 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { sql } from '../../sql/util'; +import type { WritableDB } from '../../sql/Interface'; +import { updateToVersion, createDB } from './helpers'; + +describe('SQL/updateToSchemaVersion1350', () => { + let db: WritableDB; + beforeEach(() => { + db = createDB(); + updateToVersion(db, 1350); + }); + + afterEach(() => { + db.close(); + }); + + it('creates new notificationProfiles table', () => { + const [query] = sql`SELECT * FROM notificationProfiles`; + db.prepare(query).run(); + }); + + // See test-electron/sql/notificationProfiles_test.ts +}); diff --git a/ts/test-node/types/NotificationProfile_test.ts b/ts/test-node/types/NotificationProfile_test.ts new file mode 100644 index 0000000000..07d44f68c2 --- /dev/null +++ b/ts/test-node/types/NotificationProfile_test.ts @@ -0,0 +1,859 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { DAY, HOUR } from '../../util/durations'; + +import { + DayOfWeek, + findNextProfileEvent, + getDayOfWeek, + getMidnight, + loopThroughWeek, + sortProfiles, +} from '../../types/NotificationProfile'; +import { generateNotificationProfileId } from '../../types/NotificationProfile-node'; + +import type { + NextProfileEvent, + NotificationProfileType, +} from '../../types/NotificationProfile'; + +describe('NotificationProfile', () => { + const startingTime = Date.now(); + const startingTimeDay = getDayOfWeek(startingTime); + const delta = startingTimeDay - 1; + + const mondayOfThisWeek = startingTime - DAY * delta; + const midnight = getMidnight(mondayOfThisWeek); + + // 10am on Monday of this week, local time + const now = midnight + 10 * HOUR; + + const CONVERSATION1 = 'conversation-1'; + const CONVERSATION2 = 'conversation-2'; + + function createBasicProfile( + partial?: Partial + ): NotificationProfileType { + return { + id: generateNotificationProfileId(), + name: 'After Hours', + emoji: '💤 ', + color: 0xff111111, + + createdAtMs: now, + + allowAllCalls: true, + allowAllMentions: false, + + allowedMembers: new Set([CONVERSATION1, CONVERSATION2]), + scheduleEnabled: false, + + scheduleStartTime: undefined, + scheduleEndTime: undefined, + + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + deletedAtTimestampMs: undefined, + storageID: 'storageId-1', + storageVersion: 56, + storageUnknownFields: undefined, + storageNeedsSync: false, + + ...partial, + }; + } + + describe('findNextProfileEvent', () => { + it('should return noChange with no profiles', () => { + const expected: NextProfileEvent = { + type: 'noChange', + activeProfile: undefined, + }; + const profiles: ReadonlyArray = []; + + const actual = findNextProfileEvent({ + override: undefined, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return noChange with no profiles with schedules', () => { + const expected: NextProfileEvent = { + type: 'noChange', + activeProfile: undefined, + }; + const profiles: ReadonlyArray = [ + createBasicProfile(), + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: undefined, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return noChange if manual enable override w/o end time and profile has no schedule', () => { + const defaultProfile = createBasicProfile(); + const expected: NextProfileEvent = { + type: 'noChange', + activeProfile: defaultProfile.id, + }; + const profiles: ReadonlyArray = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: { + disabledAtMs: undefined, + enabled: { + profileId: defaultProfile.id, + }, + }, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return willDisable if manual enable override w/ end time and profile has no schedule', () => { + const defaultProfile = createBasicProfile(); + const disableAt = now + HOUR; + + const expected: NextProfileEvent = { + type: 'willDisable', + activeProfile: defaultProfile.id, + willDisableAt: disableAt, + clearEnableOverride: true, + }; + const profiles: ReadonlyArray = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: { + disabledAtMs: undefined, + enabled: { + profileId: defaultProfile.id, + endsAtMs: disableAt, + }, + }, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return willDisable if manual enable override w/ end time overlaps with scheduled time', () => { + const defaultProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 900, + scheduleEndTime: 1200, + }); + + const expected: NextProfileEvent = { + type: 'willDisable', + activeProfile: defaultProfile.id, + willDisableAt: now + HOUR, + clearEnableOverride: true, + }; + const profiles: ReadonlyArray = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: { + disabledAtMs: undefined, + enabled: { + profileId: defaultProfile.id, + endsAtMs: now + HOUR, + }, + }, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + it('should return willDisable if manual enable override w/ end time if different profile enables at end time', () => { + const newProfile = createBasicProfile({ + name: 'new', + createdAtMs: now, + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 900, + scheduleEndTime: 1200, + }); + const oldProfile = createBasicProfile({ + name: 'old', + createdAtMs: now - 10, + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 900, + scheduleEndTime: 1200, + }); + const profiles = sortProfiles([oldProfile, newProfile]); + + const expected: NextProfileEvent = { + type: 'willDisable', + activeProfile: oldProfile.id, + willDisableAt: now + HOUR, + clearEnableOverride: true, + }; + + const actual = findNextProfileEvent({ + override: { + disabledAtMs: undefined, + enabled: { + profileId: oldProfile.id, + endsAtMs: now + HOUR, + }, + }, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return willDisable if manual enable override w/o end time refers to profile with schedule, enabled now', () => { + const defaultProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 900, + scheduleEndTime: 1200, + }); + + const expected: NextProfileEvent = { + type: 'willDisable', + activeProfile: defaultProfile.id, + willDisableAt: now + 2 * HOUR, + clearEnableOverride: true, + }; + const profiles: ReadonlyArray = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: { + disabledAtMs: undefined, + enabled: { + profileId: defaultProfile.id, + }, + }, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + it('should return willDisable if manual enable override w/o end time refers to profile with schedule, not enabled now', () => { + const defaultProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: true, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 900, + scheduleEndTime: 1200, + }); + + const expected: NextProfileEvent = { + type: 'willDisable', + activeProfile: defaultProfile.id, + willDisableAt: now + 2 * DAY + 2 * HOUR, + clearEnableOverride: true, + }; + const profiles: ReadonlyArray = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: { + disabledAtMs: undefined, + enabled: { + profileId: defaultProfile.id, + }, + }, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return willDisable if profile should be active right now', () => { + const defaultProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 900, + scheduleEndTime: 1200, + }); + + const expected: NextProfileEvent = { + type: 'willDisable', + activeProfile: defaultProfile.id, + willDisableAt: now + 2 * HOUR, + }; + const profiles: ReadonlyArray = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: undefined, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + it('should return willDisable with earlier start if two profiles should be active right now', () => { + const oldProfile = createBasicProfile({ + createdAtMs: now - 10, + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 800, + scheduleEndTime: 1200, + }); + const newProfile = createBasicProfile({ + createdAtMs: now, + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 900, + scheduleEndTime: 1200, + }); + + const expected: NextProfileEvent = { + type: 'willDisable', + activeProfile: oldProfile.id, + willDisableAt: now + 2 * HOUR, + }; + const profiles: ReadonlyArray = sortProfiles([ + oldProfile, + newProfile, + ]); + + const actual = findNextProfileEvent({ + override: undefined, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + it('should return willDisable with newer profile with same start if two profiles should be active right now', () => { + const oldProfile = createBasicProfile({ + createdAtMs: now - 10, + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 900, + scheduleEndTime: 1200, + }); + const newProfile = createBasicProfile({ + createdAtMs: now, + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 900, + scheduleEndTime: 1200, + }); + + const expected: NextProfileEvent = { + type: 'willDisable', + activeProfile: newProfile.id, + willDisableAt: now + 2 * HOUR, + }; + const profiles: ReadonlyArray = sortProfiles([ + oldProfile, + newProfile, + ]); + + const actual = findNextProfileEvent({ + override: undefined, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return willEnable if profile is scheduled to start soon', () => { + const defaultProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 1100, + scheduleEndTime: 1400, + }); + + const expected: NextProfileEvent = { + type: 'willEnable', + toEnable: defaultProfile.id, + willEnableAt: now + HOUR, + }; + const profiles: ReadonlyArray = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: undefined, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return willEnable w/ manual disable override if profile will start soon', () => { + const defaultProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 1100, + scheduleEndTime: 1400, + }); + + const expected: NextProfileEvent = { + type: 'willEnable', + toEnable: defaultProfile.id, + willEnableAt: now + HOUR, + clearDisableOverride: true, + }; + const profiles: ReadonlyArray = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: { + disabledAtMs: now, + enabled: undefined, + }, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return willEnable w/ manual disable override if profile should be active now, another starts tomorrow', () => { + const activeProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 900, + scheduleEndTime: 1100, + }); + const willStartProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: true, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 1100, + scheduleEndTime: 1400, + }); + + const expected: NextProfileEvent = { + type: 'willEnable', + toEnable: willStartProfile.id, + willEnableAt: now + DAY + HOUR, + clearDisableOverride: true, + }; + const profiles: ReadonlyArray = [ + activeProfile, + willStartProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: { + disabledAtMs: now, + enabled: undefined, + }, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return willEnable if profile schedule starts in six days', () => { + const defaultProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: true, + }, + scheduleStartTime: 1100, + scheduleEndTime: 1400, + }); + + const expected: NextProfileEvent = { + type: 'willEnable', + toEnable: defaultProfile.id, + willEnableAt: now + HOUR + 6 * DAY, + }; + const profiles: ReadonlyArray = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: undefined, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return willEnable for newer profile if there is a conflict', () => { + const newProfile = createBasicProfile({ + name: 'new-profile', + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 1100, + scheduleEndTime: 1400, + }); + const oldProfile = createBasicProfile({ + name: 'old-profile', + createdAtMs: now - HOUR, + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 1100, + scheduleEndTime: 1400, + }); + + const expected: NextProfileEvent = { + type: 'willEnable', + toEnable: newProfile.id, + willEnableAt: now + HOUR, + }; + const profiles: ReadonlyArray = sortProfiles([ + oldProfile, + newProfile, + ]); + + const actual = findNextProfileEvent({ + override: undefined, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + + it('should return willEnable for older profile if it will activate first', () => { + const newProfile = createBasicProfile({ + name: 'new-profile', + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: true, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 1100, + scheduleEndTime: 1400, + }); + const oldProfile = createBasicProfile({ + name: 'old-profile', + createdAtMs: now - HOUR, + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 1100, + scheduleEndTime: 1400, + }); + + const expected: NextProfileEvent = { + type: 'willEnable', + toEnable: oldProfile.id, + willEnableAt: now + HOUR, + }; + const profiles: ReadonlyArray = sortProfiles([ + oldProfile, + newProfile, + ]); + + const actual = findNextProfileEvent({ + override: undefined, + profiles, + time: now, + }); + assert.deepEqual(expected, actual); + }); + }); + + describe('loopThroughWeek', () => { + it('finds result on first day checked', () => { + let count = 0; + const startingDay = getDayOfWeek(now); + + loopThroughWeek({ + time: now, + startingDay, + check: _options => { + count += 1; + return true; + }, + }); + + assert.strictEqual(count, 1); + }); + it('finds result on second-to-last day checked', () => { + let count = 0; + const startingDay = getDayOfWeek(now); + + loopThroughWeek({ + time: now, + startingDay, + check: ({ day }) => { + count += 1; + if (day === DayOfWeek.SATURDAY) { + return true; + } + return false; + }, + }); + + assert.strictEqual(count, 6); + }); + it('loops through entire week if check returns false', () => { + let count = 0; + const startingDay = getDayOfWeek(now); + + loopThroughWeek({ + time: now, + startingDay, + check: _options => { + count += 1; + return false; + }, + }); + + assert.strictEqual(count, 7); + }); + it('loops from monday to sunday', () => { + let count = 0; + const startingDay = getDayOfWeek(now); + + loopThroughWeek({ + time: now, + startingDay, + check: ({ day }) => { + count += 1; + if (day === DayOfWeek.SUNDAY) { + return true; + } + return false; + }, + }); + + assert.strictEqual(count, 7); + }); + it('loops from sunday to saturday', () => { + const sundayAt10 = now + 6 * DAY; + let count = 0; + const startingDay = getDayOfWeek(sundayAt10); + + loopThroughWeek({ + time: sundayAt10, + startingDay, + check: ({ day }) => { + count += 1; + if (day === DayOfWeek.SATURDAY) { + return true; + } + return false; + }, + }); + + assert.strictEqual(count, 7); + }); + }); + + describe('sortProfiles', () => { + it('sorts profiles in descending by create date', () => { + const old = createBasicProfile({ name: 'old', createdAtMs: now - 10 }); + const middle = createBasicProfile({ name: 'middle', createdAtMs: now }); + const newest = createBasicProfile({ + name: 'newest', + createdAtMs: now + 10, + }); + + const starting = [middle, old, newest]; + const actual = sortProfiles(starting); + + assert.strictEqual(actual[0].name, 'newest'); + assert.strictEqual(actual[1].name, 'middle'); + assert.strictEqual(actual[2].name, 'old'); + }); + }); +}); diff --git a/ts/types/NotificationProfile-node.ts b/ts/types/NotificationProfile-node.ts new file mode 100644 index 0000000000..01062ee72a --- /dev/null +++ b/ts/types/NotificationProfile-node.ts @@ -0,0 +1,49 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// Note: this is a dangerous import; it will break storybook +import { getRandomBytes } from '../Crypto'; + +import * as Bytes from '../Bytes'; +import * as log from '../logging/log'; + +import { NOTIFICATION_PROFILE_ID_LENGTH } from './NotificationProfile'; + +import type { NotificationProfileIdString } from './NotificationProfile'; +import type { LoggerType } from './Logging'; + +export function generateNotificationProfileId(): NotificationProfileIdString { + return Bytes.toHex( + getRandomBytes(NOTIFICATION_PROFILE_ID_LENGTH) + ) as NotificationProfileIdString; +} + +export function isNotificationProfileId( + value?: string +): value is NotificationProfileIdString { + if (!value) { + return false; + } + + const bytes = Bytes.fromHex(value); + return bytes.byteLength === NOTIFICATION_PROFILE_ID_LENGTH; +} + +export function normalizeNotificationProfileId( + id: string, + context: string, + logger: Pick = log +): NotificationProfileIdString { + const result = id.toUpperCase(); + + if (!isNotificationProfileId(result)) { + logger.warn( + 'Normalizing invalid notification profile id: ' + + `${id} to ${result} in context "${context}"` + ); + + return result as unknown as NotificationProfileIdString; + } + + return result; +} diff --git a/ts/types/NotificationProfile.ts b/ts/types/NotificationProfile.ts new file mode 100644 index 0000000000..d9d1feffac --- /dev/null +++ b/ts/types/NotificationProfile.ts @@ -0,0 +1,546 @@ +// 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, + }); + strictAssert( + disabledAt, + `Schedule ${activeProfileBySchedule.id} is enabled by schedule right now, it should disable soon!` + ); + return { + type: 'willDisable', + activeProfile: activeProfileBySchedule.id, + willDisableAt: 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 { + const candidates: Array = []; + + for (const profile of profiles) { + const result = isProfileEnabledBySchedule({ time, profile }); + if (result) { + candidates.push(profile); + } + } + + // In the case of conflicts, we want the one that started earlier. Or the one that was + // created more recently in the case of a tie. + const sortedCandidates = orderBy( + candidates, + ['scheduleStartTime', 'createdAtMs'], + ['asc', 'desc'] + ); + + return sortedCandidates[0]; +} + +// 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; +} diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index f060fa43e0..aebaa79981 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -23,9 +23,9 @@ import type { BackupStatusType, } from './backups'; import type { ServiceIdString } from './ServiceId'; - import type { RegisteredChallengeType } from '../challenge'; import type { ServerAlertsType } from '../util/handleServerAlerts'; +import type { NotificationProfileOverride } from './NotificationProfile'; export type AutoDownloadAttachmentType = { photos: boolean; @@ -201,6 +201,7 @@ export type StorageAccessType = { }; serverAlerts: ServerAlertsType; needOrphanedAttachmentCheck: boolean; + notificationProfileOverride: NotificationProfileOverride | undefined; observedCapabilities: { deleteSync?: true; ssre2?: true; diff --git a/ts/util/lint/license_comments.ts b/ts/util/lint/license_comments.ts index 2497d99ab7..2324cbfd6b 100644 --- a/ts/util/lint/license_comments.ts +++ b/ts/util/lint/license_comments.ts @@ -239,7 +239,7 @@ async function main() { ); } - if (!secondLine.includes('SPDX-License-Identifier: AGPL-3.0-only')) { + if (!secondLine?.includes('SPDX-License-Identifier: AGPL-3.0-only')) { warnings.push( chalk.red('Missing/incorrect license line'), indent( diff --git a/ts/util/timeout.ts b/ts/util/timeout.ts index a481354190..c173287237 100644 --- a/ts/util/timeout.ts +++ b/ts/util/timeout.ts @@ -9,13 +9,27 @@ const MAX_SAFE_TIMEOUT_DELAY = 2147483647; // max 32-bit signed integer // timeout if the delay is safe, otherwise does not set a timeout. export function safeSetTimeout( callback: VoidFunction, - delayMs: number + providedDelayMs: number, + options?: { + clampToMax: boolean; + } ): NodeJS.Timeout | null { + let delayMs = providedDelayMs; + + if (delayMs < 0) { + logging.warn('safeSetTimeout: timeout is less than zero'); + delayMs = 0; + } + if (delayMs > MAX_SAFE_TIMEOUT_DELAY) { - logging.warn( - 'safeSetTimeout: timeout is larger than maximum setTimeout delay' - ); - return null; + if (options?.clampToMax) { + delayMs = MAX_SAFE_TIMEOUT_DELAY; + } else { + logging.warn( + 'safeSetTimeout: timeout is larger than maximum setTimeout delay' + ); + return null; + } } return setTimeout(callback, delayMs); diff --git a/ts/windows/main/preload_test.ts b/ts/windows/main/preload_test.ts index b3e4127743..ea93fb8e46 100644 --- a/ts/windows/main/preload_test.ts +++ b/ts/windows/main/preload_test.ts @@ -118,6 +118,7 @@ window.testUtilities = { isProduction: false, platform: 'test', }, + notificationProfiles: [], recentEmoji: { recents: [], },