diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5944b8856f..1f0bcc44e1 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1826,6 +1826,14 @@ "messageformat": "Choose chats that you want to appear in this folder", "description": "Preferences > Edit Chat Folder Page > Included Chats Section > Help" }, + "icu:Preferences__EditChatFolderPage__IncludedChatsSection__DirectChats": { + "messageformat": "1:1 chats", + "description": "Preferences > Edit Chat Folder Page > Included Chats Section > Direct Chats" + }, + "icu:Preferences__EditChatFolderPage__IncludedChatsSection__GroupChats": { + "messageformat": "Group chats", + "description": "Preferences > Edit Chat Folder Page > Included Chats Section > Group Chats" + }, "icu:Preferences__EditChatFolderPage__IncludedChatsSection__AddChatsButton": { "messageformat": "Add chats", "description": "Preferences > Edit Chat Folder Page > Included Chats Section > Add Chats Button" diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index f9b25686f3..1b2f37bb91 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -373,7 +373,7 @@ message ChatFolderRecord { FolderType folderType = 8; repeated Recipient includedRecipients = 9; repeated Recipient excludedRecipients = 10; - uint64 deletedAtTimestampMs = 11; // When non-zero, `position` should be set to -1 and `includedRecipients` should be empty + uint64 deletedAtTimestampMs = 11; // When non-zero, `position` should be set to 4294967295 (2^32-1) and `includedRecipients` should be empty } message NotificationProfile { diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 6f420e5aff..b486c7d9c3 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -39,6 +39,11 @@ import type { OneTimeDonationHumanAmounts, } from '../types/Donations'; import type { AnyToast } from '../types/Toast'; +import type { SmartPreferencesChatFoldersPageProps } from '../state/smart/PreferencesChatFoldersPage'; +import { PreferencesChatFoldersPage } from './preferences/chatFolders/PreferencesChatFoldersPage'; +import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/PreferencesEditChatFolderPage'; +import { PreferencesEditChatFolderPage } from './preferences/chatFolders/PreferencesEditChatFoldersPage'; +import { CHAT_FOLDER_DEFAULTS } from '../types/ChatFolder'; const { i18n } = window.SignalContext; @@ -254,15 +259,47 @@ function renderToastManager(): JSX.Element { return
; } +function renderPreferencesChatFoldersPage( + props: SmartPreferencesChatFoldersPageProps +): JSX.Element { + return ( + + ); +} + +function renderPreferencesEditChatFolderPage( + props: SmartPreferencesEditChatFolderPageProps +): JSX.Element { + return ( + undefined} + /> + ); +} + export default { title: 'Components/Preferences', component: Preferences, args: { i18n, - - conversations, - conversationSelector, - accountEntropyPool: 'uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t', autoDownloadAttachment: { @@ -398,8 +435,9 @@ export default { renderProfileEditor, renderToastManager, renderUpdateDialog, + renderPreferencesChatFoldersPage, + renderPreferencesEditChatFolderPage, getConversationsWithCustomColor: () => [], - getPreferredBadge: () => undefined, addCustomColor: action('addCustomColor'), doDeleteAllData: action('doDeleteAllData'), diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index 2a8af52722..8868d00808 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -36,7 +36,7 @@ import { focusableSelector } from '../util/focusableSelectors'; import { Modal } from './Modal'; import { SearchInput } from './SearchInput'; import { removeDiacritics } from '../util/removeDiacritics'; -import { assertDev, strictAssert } from '../util/assert'; +import { assertDev } from '../util/assert'; import { I18n } from './I18n'; import { FunSkinTonesList } from './fun/FunSkinTones'; import { emojiParentKeyConstant, type EmojiSkinTone } from './fun/data/emojis'; @@ -91,27 +91,15 @@ import type { PromptOSAuthResultType, } from '../util/os/promptOSAuthMain'; import type { DonationReceipt } from '../types/Donations'; -import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; -import { EditChatFoldersPage } from './preferences/EditChatFoldersPage'; -import { ChatFoldersPage } from './preferences/ChatFoldersPage'; -import type { - ChatFolderId, - ChatFolderParams, - ChatFolderRecord, -} from '../types/ChatFolder'; -import { - CHAT_FOLDER_DEFAULTS, - isChatFoldersEnabled, -} from '../types/ChatFolder'; -import type { GetConversationByIdType } from '../state/selectors/conversations'; +import type { ChatFolderId } from '../types/ChatFolder'; +import { isChatFoldersEnabled } from '../types/ChatFolder'; +import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/PreferencesEditChatFolderPage'; +import type { SmartPreferencesChatFoldersPageProps } from '../state/smart/PreferencesChatFoldersPage'; type CheckboxChangeHandlerType = (value: boolean) => unknown; type SelectChangeHandlerType = (value: T) => unknown; export type PropsDataType = { - conversations: ReadonlyArray; - conversationSelector: GetConversationByIdType; - // Settings accountEntropyPool: string | undefined; autoDownloadAttachment: AutoDownloadAttachmentType; @@ -223,6 +211,12 @@ type PropsFunctionType = { renderUpdateDialog: ( _: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }> ) => JSX.Element; + renderPreferencesChatFoldersPage: ( + props: SmartPreferencesChatFoldersPageProps + ) => JSX.Element; + renderPreferencesEditChatFolderPage: ( + props: SmartPreferencesEditChatFolderPageProps + ) => JSX.Element; // Other props addCustomColor: (color: CustomColorType) => unknown; @@ -236,7 +230,6 @@ type PropsFunctionType = { resumeBackupMediaDownload: () => void; pauseBackupMediaDownload: () => void; getConversationsWithCustomColor: (colorId: string) => Array; - getPreferredBadge: PreferredBadgeSelectorType; makeSyncRequest: () => unknown; onStartUpdate: () => unknown; pickLocalBackupFolder: () => Promise; @@ -362,8 +355,6 @@ const DEFAULT_ZOOM_FACTORS = [ ]; export function Preferences({ - conversations, - conversationSelector, accountEntropyPool, addCustomColor, autoDownloadAttachment, @@ -393,7 +384,6 @@ export function Preferences({ getConversationsWithCustomColor, getMessageCountBySchemaVersion, getMessageSampleForSchemaVersion, - getPreferredBadge, hasAudioNotifications, hasAutoConvertEmoji, hasAutoDownloadUpdate, @@ -490,6 +480,8 @@ export function Preferences({ renderProfileEditor, renderToastManager, renderUpdateDialog, + renderPreferencesChatFoldersPage, + renderPreferencesEditChatFolderPage, promptOSAuth, resetAllChatColors, resetDefaultChatColor, @@ -540,10 +532,6 @@ export function Preferences({ const [confirmPnpNotDiscoverable, setConfirmPnpNoDiscoverable] = useState(false); - const [chatFolders, setChatFolders] = useState< - ReadonlyArray - >([]); - const [editChatFolderPageId, setEditChatFolderPageId] = useState(null); @@ -560,34 +548,6 @@ export function Preferences({ setEditChatFolderPageId(null); }, [setPage]); - const handleCreateChatFolder = useCallback((params: ChatFolderParams) => { - setChatFolders(prev => { - return [...prev, { ...params, id: String(prev.length) as ChatFolderId }]; - }); - }, []); - - const handleUpdateChatFolder = useCallback( - (chatFolderId: ChatFolderId, chatFolderParams: ChatFolderParams) => { - setChatFolders(prev => { - return prev.map(chatFolder => { - if (chatFolder.id === chatFolderId) { - return { id: chatFolderId, ...chatFolderParams }; - } - return chatFolder; - }); - }); - }, - [] - ); - - const handleDeleteChatFolder = useCallback((chatFolderId: ChatFolderId) => { - setChatFolders(prev => { - return prev.filter(chatFolder => { - return chatFolder.id !== chatFolderId; - }); - }); - }, []); - function closeLanguageDialog() { setLanguageDialog(null); setSelectedLanguageLocale(localeOverride); @@ -1954,43 +1914,17 @@ export function Preferences({ /> ); } else if (page === SettingsPage.ChatFolders) { - content = ( - setPage(SettingsPage.Chats)} - onOpenEditChatFoldersPage={handleOpenEditChatFoldersPage} - chatFolders={chatFolders} - onCreateChatFolder={handleCreateChatFolder} - /> - ); + content = renderPreferencesChatFoldersPage({ + onBack: () => setPage(SettingsPage.Chats), + onOpenEditChatFoldersPage: handleOpenEditChatFoldersPage, + settingsPaneRef, + }); } else if (page === SettingsPage.EditChatFolder) { - let initChatFolderParam: ChatFolderParams; - if (editChatFolderPageId != null) { - const found = chatFolders.find(chatFolder => { - return chatFolder.id === editChatFolderPageId; - }); - strictAssert(found, 'Missing chat folder'); - initChatFolderParam = found; - } else { - initChatFolderParam = CHAT_FOLDER_DEFAULTS; - } - content = ( - - ); + content = renderPreferencesEditChatFolderPage({ + onBack: handleCloseEditChatFoldersPage, + settingsPaneRef, + existingChatFolderId: editChatFolderPageId, + }); } else if (page === SettingsPage.PNP) { let sharingDescription: string; diff --git a/ts/components/preferences/ChatFoldersPage.tsx b/ts/components/preferences/chatFolders/PreferencesChatFoldersPage.tsx similarity index 50% rename from ts/components/preferences/ChatFoldersPage.tsx rename to ts/components/preferences/chatFolders/PreferencesChatFoldersPage.tsx index 0fa25d3764..163e6a499b 100644 --- a/ts/components/preferences/ChatFoldersPage.tsx +++ b/ts/components/preferences/chatFolders/PreferencesChatFoldersPage.tsx @@ -3,32 +3,34 @@ import React, { useCallback, useMemo } from 'react'; import type { MutableRefObject } from 'react'; -import { ListBox, ListBoxItem } from 'react-aria-components'; -import type { LocalizerType } from '../../types/I18N'; -import { PreferencesContent } from '../Preferences'; -import { SettingsRow } from '../PreferencesUtil'; -import type { ChatFolderId } from '../../types/ChatFolder'; +import type { LocalizerType } from '../../../types/I18N'; +import { PreferencesContent } from '../../Preferences'; +import { SettingsRow } from '../../PreferencesUtil'; +import type { ChatFolderId } from '../../../types/ChatFolder'; import { CHAT_FOLDER_PRESETS, matchesChatFolderPreset, type ChatFolderParams, type ChatFolderPreset, - type ChatFolderRecord, -} from '../../types/ChatFolder'; -import { Button, ButtonVariant } from '../Button'; + type ChatFolder, + ChatFolderType, +} from '../../../types/ChatFolder'; +import { Button, ButtonVariant } from '../../Button'; // import { showToast } from '../../state/ducks/toast'; -export type ChatFoldersPageProps = Readonly<{ +export type PreferencesChatFoldersPageProps = Readonly<{ i18n: LocalizerType; onBack: () => void; onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void; - chatFolders: ReadonlyArray; + chatFolders: ReadonlyArray; onCreateChatFolder: (params: ChatFolderParams) => void; settingsPaneRef: MutableRefObject; }>; -export function ChatFoldersPage(props: ChatFoldersPageProps): JSX.Element { - const { i18n, onOpenEditChatFoldersPage } = props; +export function PreferencesChatFoldersPage( + props: PreferencesChatFoldersPageProps +): JSX.Element { + const { i18n, onOpenEditChatFoldersPage, chatFolders } = props; // showToast( // i18n("icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast") @@ -38,14 +40,57 @@ export function ChatFoldersPage(props: ChatFoldersPageProps): JSX.Element { onOpenEditChatFoldersPage(null); }, [onOpenEditChatFoldersPage]); + const presetItemsConfigs = useMemo(() => { + const initial: ReadonlyArray = [ + { + id: 'UnreadChats', + title: i18n( + 'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__UnreadFolder__Title' + ), + description: i18n( + 'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__UnreadFolder__Description' + ), + preset: CHAT_FOLDER_PRESETS.UNREAD_CHATS, + }, + { + id: 'DirectChats', + title: i18n( + 'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__DirectChatsFolder__Title' + ), + description: i18n( + 'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__DirectChatsFolder__Description' + ), + preset: CHAT_FOLDER_PRESETS.INDIVIDUAL_CHATS, + }, + { + id: 'GroupChats', + title: i18n( + 'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__GroupChatsFolder__Title' + ), + description: i18n( + 'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__GroupChatsFolder__Description' + ), + preset: CHAT_FOLDER_PRESETS.GROUP_CHATS, + }, + ]; + + const filtered = initial.filter(config => { + return !chatFolders.some(chatFolder => { + return matchesChatFolderPreset(chatFolder, config.preset); + }); + }); + + return filtered; + }, [i18n, chatFolders]); + return ( } contents={ @@ -58,86 +103,55 @@ export function ChatFoldersPage(props: ChatFoldersPageProps): JSX.Element { 'icu:Preferences__ChatFoldersPage__FoldersSection__Title' )} > - - - - - {i18n( - 'icu:Preferences__ChatFoldersPage__FoldersSection__CreateAFolderButton' - )} - - - - - - {i18n( - 'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title' - )} - - +
    +
  • + +
  • {props.chatFolders.map(chatFolder => { return ( ); })} - - - -
      - - - - -
    + {presetItemsConfigs.length > 0 && ( + +
      + {presetItemsConfigs.map(presetItemConfig => { + return ( + + ); + })} +
    +
    + )} } contentsRef={props.settingsPaneRef} @@ -146,42 +160,41 @@ export function ChatFoldersPage(props: ChatFoldersPageProps): JSX.Element { ); } -function ChatFolderPresetItem(props: { - i18n: LocalizerType; - icon: 'UnreadChats' | 'DirectChats' | 'GroupChats'; +type ChatFolderPresetItemConfig = Readonly<{ + id: 'UnreadChats' | 'DirectChats' | 'GroupChats'; title: string; description: string; preset: ChatFolderPreset; - chatFolders: ReadonlyArray; +}>; + +type ChatFolderPresetItemProps = Readonly<{ + i18n: LocalizerType; + config: ChatFolderPresetItemConfig; onCreateChatFolder: (params: ChatFolderParams) => void; -}) { - const { i18n, title, preset, chatFolders, onCreateChatFolder } = props; +}>; + +function ChatFolderPresetItem(props: ChatFolderPresetItemProps) { + const { i18n, config, onCreateChatFolder } = props; + const { title, preset } = config; const handleCreateChatFolder = useCallback(() => { onCreateChatFolder({ ...preset, name: title }); }, [onCreateChatFolder, title, preset]); - const hasPreset = useMemo(() => { - return chatFolders.some(chatFolder => { - return matchesChatFolderPreset(chatFolder, preset); - }); - }, [chatFolders, preset]); - - if (hasPreset) { - return null; - } - return ( -
  • +
  • - {props.title} + {props.config.title} - {props.description} + {props.config.description} +
  • ); } diff --git a/ts/components/preferences/EditChatFoldersPage.tsx b/ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx similarity index 87% rename from ts/components/preferences/EditChatFoldersPage.tsx rename to ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx index c4a7ee0e35..603f70b827 100644 --- a/ts/components/preferences/EditChatFoldersPage.tsx +++ b/ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx @@ -2,37 +2,38 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { MutableRefObject } from 'react'; import React, { useCallback, useMemo, useState } from 'react'; -import type { ConversationType } from '../../state/ducks/conversations'; -import type { PreferredBadgeSelectorType } from '../../state/selectors/badges'; -import type { LocalizerType } from '../../types/I18N'; -import type { ThemeType } from '../../types/Util'; -import { Input } from '../Input'; -import { Button, ButtonVariant } from '../Button'; -import { ConfirmationDialog } from '../ConfirmationDialog'; -import type { ChatFolderSelection } from './EditChatFoldersSelectChatsDialog'; -import { EditChatFoldersSelectChatsDialog } from './EditChatFoldersSelectChatsDialog'; -import { SettingsRow } from '../PreferencesUtil'; -import { Checkbox } from '../Checkbox'; -import { Avatar, AvatarSize } from '../Avatar'; -import { PreferencesContent } from '../Preferences'; -import type { ChatFolderId } from '../../types/ChatFolder'; +import type { ConversationType } from '../../../state/ducks/conversations'; +import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges'; +import type { LocalizerType } from '../../../types/I18N'; +import type { ThemeType } from '../../../types/Util'; +import { Input } from '../../Input'; +import { Button, ButtonVariant } from '../../Button'; +import { ConfirmationDialog } from '../../ConfirmationDialog'; +import type { ChatFolderSelection } from './PreferencesEditChatFoldersSelectChatsDialog'; +import { PreferencesEditChatFoldersSelectChatsDialog } from './PreferencesEditChatFoldersSelectChatsDialog'; +import { SettingsRow } from '../../PreferencesUtil'; +import { Checkbox } from '../../Checkbox'; +import { Avatar, AvatarSize } from '../../Avatar'; +import { PreferencesContent } from '../../Preferences'; +import type { ChatFolderId } from '../../../types/ChatFolder'; import { CHAT_FOLDER_NAME_MAX_CHAR_LENGTH, + ChatFolderParamsSchema, isSameChatFolderParams, - normalizeChatFolderParams, validateChatFolderParams, type ChatFolderParams, -} from '../../types/ChatFolder'; -import type { GetConversationByIdType } from '../../state/selectors/conversations'; -import { strictAssert } from '../../util/assert'; +} from '../../../types/ChatFolder'; +import type { GetConversationByIdType } from '../../../state/selectors/conversations'; +import { strictAssert } from '../../../util/assert'; +import { parseStrict } from '../../../util/schemas'; -export function EditChatFoldersPage(props: { +export type PreferencesEditChatFolderPageProps = Readonly<{ i18n: LocalizerType; existingChatFolderId: ChatFolderId | null; initChatFolderParams: ChatFolderParams; onBack: () => void; conversations: ReadonlyArray; - getPreferredBadge: PreferredBadgeSelectorType; + preferredBadgeSelector: PreferredBadgeSelectorType; theme: ThemeType; settingsPaneRef: MutableRefObject; conversationSelector: GetConversationByIdType; @@ -42,7 +43,11 @@ export function EditChatFoldersPage(props: { chatFolderId: ChatFolderId, chatFolderParams: ChatFolderParams ) => void; -}): JSX.Element { +}>; + +export function PreferencesEditChatFolderPage( + props: PreferencesEditChatFolderPageProps +): JSX.Element { const { i18n, initChatFolderParams, @@ -63,7 +68,7 @@ export function EditChatFoldersPage(props: { const [showSaveChangesDialog, setShowSaveChangesDialog] = useState(false); const normalizedChatFolderParams = useMemo(() => { - return normalizeChatFolderParams(chatFolderParams); + return parseStrict(ChatFolderParamsSchema, chatFolderParams); }, [chatFolderParams]); const isChanged = useMemo(() => { @@ -116,9 +121,9 @@ export function EditChatFoldersPage(props: { strictAssert(isValid, 'tried saving when invalid'); if (existingChatFolderId != null) { - onUpdateChatFolder(existingChatFolderId, chatFolderParams); + onUpdateChatFolder(existingChatFolderId, normalizedChatFolderParams); } else { - onCreateChatFolder(chatFolderParams); + onCreateChatFolder(normalizedChatFolderParams); } onBack(); }, [ @@ -126,7 +131,7 @@ export function EditChatFoldersPage(props: { existingChatFolderId, isChanged, isValid, - chatFolderParams, + normalizedChatFolderParams, onCreateChatFolder, onUpdateChatFolder, ]); @@ -197,7 +202,10 @@ export function EditChatFoldersPage(props: { 'icu:Preferences__EditChatFolderPage__FolderNameField__Label' )} > -
    +
    - 1:1 Chats + {i18n( + 'icu:Preferences__EditChatFolderPage__IncludedChatsSection__DirectChats' + )} )} @@ -240,7 +250,9 @@ export function EditChatFoldersPage(props: {
  • - Group Chats + {i18n( + 'icu:Preferences__EditChatFolderPage__IncludedChatsSection__GroupChats' + )}
  • )} @@ -359,14 +371,14 @@ export function EditChatFoldersPage(props: { )} {showInclusionsDialog && ( - )} {showExclusionsDialog && ( - ; @@ -30,17 +30,21 @@ export type ChatFolderSelection = Readonly<{ selectAllGroupChats: boolean; }>; -export function EditChatFoldersSelectChatsDialog(props: { +export type PreferencesEditChatFoldersSelectChatsDialogProps = Readonly<{ i18n: LocalizerType; title: string; conversations: ReadonlyArray; conversationSelector: GetConversationByIdType; onClose: (selection: ChatFolderSelection) => void; - getPreferredBadge: PreferredBadgeSelectorType; + preferredBadgeSelector: PreferredBadgeSelectorType; theme: ThemeType; initialSelection: ChatFolderSelection; showChatTypes: boolean; -}): JSX.Element { +}>; + +export function PreferencesEditChatFoldersSelectChatsDialog( + props: PreferencesEditChatFoldersSelectChatsDialogProps +): JSX.Element { const { i18n, conversations, @@ -242,7 +246,7 @@ export function EditChatFoldersSelectChatsDialog(props: { height: 404, }} i18n={i18n} - getPreferredBadge={props.getPreferredBadge} + getPreferredBadge={props.preferredBadgeSelector} getRow={index => rows[index]} onClickContactCheckbox={handleToggleSelectedConversation} rowCount={rows.length} diff --git a/ts/jobs/initializeAllJobQueues.ts b/ts/jobs/initializeAllJobQueues.ts index d5201b9e9c..1d170d045d 100644 --- a/ts/jobs/initializeAllJobQueues.ts +++ b/ts/jobs/initializeAllJobQueues.ts @@ -4,7 +4,7 @@ import type { WebAPIType } from '../textsecure/WebAPI'; import { drop } from '../util/drop'; import { CallLinkFinalizeDeleteManager } from './CallLinkFinalizeDeleteManager'; - +import { chatFolderCleanupService } from '../services/expiring/chatFolderCleanupService'; import { callLinkRefreshJobQueue } from './callLinkRefreshJobQueue'; import { conversationJobQueue } from './conversationJobQueue'; import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue'; @@ -46,6 +46,7 @@ export function initializeAllJobQueues({ drop(reportSpamJobQueue.streamJobs()); drop(callLinkRefreshJobQueue.streamJobs()); drop(CallLinkFinalizeDeleteManager.start()); + drop(chatFolderCleanupService.start('initializeAllJobQueues')); } export async function shutdownAllJobQueues(): Promise { @@ -60,5 +61,6 @@ export async function shutdownAllJobQueues(): Promise { removeStorageKeyJobQueue.shutdown(), reportSpamJobQueue.shutdown(), CallLinkFinalizeDeleteManager.stop(), + chatFolderCleanupService.stop('shutdownAllJobQueues'), ]); } diff --git a/ts/services/allLoaders.ts b/ts/services/allLoaders.ts index 6ffbf98631..e217100537 100644 --- a/ts/services/allLoaders.ts +++ b/ts/services/allLoaders.ts @@ -34,12 +34,14 @@ import { import { type ReduxInitData } from '../state/initializeRedux'; import { reinitializeRedux } from '../state/reinitializeRedux'; import { getGifsStateForRedux, loadGifsState } from './gifsLoader'; +import { getChatFoldersForRedux, loadChatFolders } from './chatFoldersLoader'; export async function loadAll(): Promise { await Promise.all([ loadBadges(), loadCallHistory(), loadCallLinks(), + loadChatFolders(), loadDistributionLists(), loadDonationReceipts(), loadGifsState(), @@ -64,6 +66,7 @@ export function getParametersForRedux(): ReduxInitData { callHistory: getCallsHistoryForRedux(), callHistoryUnreadCount: getCallsHistoryUnreadCountForRedux(), callLinks: getCallLinksForRedux(), + chatFolders: getChatFoldersForRedux(), donations: getDonationsForRedux(), gifs: getGifsStateForRedux(), mainWindowStats, diff --git a/ts/services/chatFoldersLoader.ts b/ts/services/chatFoldersLoader.ts new file mode 100644 index 0000000000..766077bbbc --- /dev/null +++ b/ts/services/chatFoldersLoader.ts @@ -0,0 +1,17 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { DataReader } from '../sql/Client'; +import type { ChatFolder } from '../types/ChatFolder'; +import { strictAssert } from '../util/assert'; + +let chatFolders: ReadonlyArray; + +export async function loadChatFolders(): Promise { + chatFolders = await DataReader.getCurrentChatFolders(); +} + +export function getChatFoldersForRedux(): ReadonlyArray { + strictAssert(chatFolders != null, 'chatFolders has not been loaded'); + return chatFolders; +} diff --git a/ts/services/expiring/chatFolderCleanupService.ts b/ts/services/expiring/chatFolderCleanupService.ts new file mode 100644 index 0000000000..afa2a1e451 --- /dev/null +++ b/ts/services/expiring/chatFolderCleanupService.ts @@ -0,0 +1,37 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { DataReader, DataWriter } from '../../sql/Client'; +import { getMessageQueueTime } from '../../util/getMessageQueueTime'; +import { createExpiringEntityCleanupService } from './createExpiringEntityCleanupService'; +import * as RemoteConfig from '../../RemoteConfig'; + +export const chatFolderCleanupService = createExpiringEntityCleanupService({ + logPrefix: 'ChatFolders', + getNextExpiringEntity: async () => { + const oldestDeletedChatFolder = + await DataReader.getOldestDeletedChatFolder(); + if (oldestDeletedChatFolder == null) { + return null; + } + const messageQueueTime = getMessageQueueTime(); + const expiresAtMs = + oldestDeletedChatFolder.deletedAtTimestampMs + messageQueueTime; + return { id: oldestDeletedChatFolder.id, expiresAtMs }; + }, + cleanupExpiredEntities: async () => { + const messageQueueTime = getMessageQueueTime(); + const deletedChatFolderIds = + await DataWriter.deleteExpiredChatFolders(messageQueueTime); + return deletedChatFolderIds; + }, + subscribeToTriggers: trigger => { + let prevMessageQueueTime = getMessageQueueTime(); + return RemoteConfig.onChange('global.messageQueueTimeInSeconds', () => { + const messageQueueTime = getMessageQueueTime(); + if (messageQueueTime !== prevMessageQueueTime) { + trigger('messageQueueTime changed'); + } + prevMessageQueueTime = getMessageQueueTime(); + }); + }, +}); diff --git a/ts/services/expiring/createExpiringEntityCleanupService.ts b/ts/services/expiring/createExpiringEntityCleanupService.ts new file mode 100644 index 0000000000..8aed43e437 --- /dev/null +++ b/ts/services/expiring/createExpiringEntityCleanupService.ts @@ -0,0 +1,273 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { getEnvironment, isTestEnvironment } from '../../environment'; +import { createLogger } from '../../logging/log'; +import * as Errors from '../../types/errors'; +import { strictAssert } from '../../util/assert'; +import { drop } from '../../util/drop'; +import { missingCaseError } from '../../util/missingCaseError'; +import { longTimeoutAsync } from '../../util/timeout'; + +const parentLog = createLogger('ExpiringEntityCleanupService'); + +export type ExpiringEntity = Readonly<{ + id: string; + expiresAtMs: number; +}>; + +export type Trigger = (reason: string) => void; +export type Unsubscribe = () => void; + +export type ExpiringEntityCleanupServiceOptions = Readonly<{ + logPrefix: string; + getNextExpiringEntity: () => Promise; + cleanupExpiredEntities: () => Promise>; + subscribeToTriggers: (trigger: Trigger) => Unsubscribe; + _mockGetCurrentTime?: () => number; + _mockScheduleLongTimeout?: (ms: number, signal: AbortSignal) => Promise; +}>; + +export type ExpiringEntityCleanupService = Readonly<{ + start: (reason: string) => Promise; + trigger: (reason: string) => Promise; + stop: (reason: string) => Promise; + stopImmediately: (reason: string) => void; +}>; + +enum ServiceState { + NEVER_STARTED, + STARTED, + STOPPED, +} + +export function createExpiringEntityCleanupService( + options: ExpiringEntityCleanupServiceOptions +): ExpiringEntityCleanupService { + const log = parentLog.child(options.logPrefix); + + let controller: AbortController | null = null; + let runningPromise: Promise | null = null; + + function getCurrentTime(): number { + if ( + options._mockGetCurrentTime != null && + isTestEnvironment(getEnvironment()) + ) { + return options._mockGetCurrentTime(); + } + return Date.now(); + } + + function scheduleLongTimeout(ms: number, signal: AbortSignal): Promise { + if ( + options._mockScheduleLongTimeout != null && + isTestEnvironment(getEnvironment()) + ) { + return options._mockScheduleLongTimeout(ms, signal); + } + return longTimeoutAsync(ms, signal); + } + + function cancelNextScheduledRun(reason: string) { + if (controller != null) { + log.warn(`cancel(${reason}) cancelling next scheduled run`); + controller.abort(reason); + controller = null; + } + } + + async function getNextExpiringEntity(): Promise { + try { + const result = await options.getNextExpiringEntity(); + if (result == null) { + log.info('no expiring entity found'); + } else { + log.info( + `next expiring entity is ${result.id} at ${result.expiresAtMs}` + ); + } + return result; + } catch (error) { + log.error( + 'failed to get next expiring entity', + Errors.toLogFormat(error) + ); + return null; + } + } + + async function cleanupExpiredEntities( + expectSomeDeletions: boolean + ): Promise { + try { + log.info('deleting expired entities'); + const deletedEntityIds = await options.cleanupExpiredEntities(); + // Runs that happen during + const logFn = + expectSomeDeletions && deletedEntityIds.length === 0 + ? log.warn + : log.info; + logFn( + `deleted ${deletedEntityIds.length} entities:`, + deletedEntityIds.join(', ') + ); + } catch (error) { + log.error('cleanupExpiredEntities errored', Errors.toLogFormat(error)); + } + } + + async function runOnceImmediately(expectSomeDeletions: boolean) { + // Don't start a new cleanup while one is running + runningPromise ??= cleanupExpiredEntities(expectSomeDeletions); + try { + await runningPromise; + } finally { + runningPromise = null; + } + } + + async function scheduleNextRun(): Promise { + strictAssert( + controller == null, + 'Cannot schedule next run until after previously scheduled run has fired' + ); + const nextExpiringEntity = await getNextExpiringEntity(); + if (nextExpiringEntity == null) { + return true; // something will have to call `trigger()` later + } + + const nextExpirationTime = nextExpiringEntity.expiresAtMs; + + const currentTime = getCurrentTime(); + if (nextExpirationTime <= currentTime) { + log.info('expiration time is in past, running immediately'); + await runOnceImmediately(true); + return false; + } + + const nextExpirationDelay = nextExpirationTime - currentTime; + log.info( + `scheduling next run for ${nextExpirationTime} in ${nextExpirationDelay}ms` + ); + try { + controller = new AbortController(); + await scheduleLongTimeout(nextExpirationDelay, controller.signal); + log.info('scheduled timer fired, running'); + } catch (error: unknown) { + log.warn( + 'scheduled timer was cancelled, not running', + Errors.toLogFormat(error) + ); + return true; + } finally { + controller = null; + } + + await runOnceImmediately(true); + return false; + } + + async function scheduleRunsUntilDrained() { + let shouldStop = false; + while (!shouldStop) { + // eslint-disable-next-line no-await-in-loop + shouldStop = await scheduleNextRun(); + } + } + + let unsubscribeCallback: Unsubscribe | null = null; + + function startSubscription() { + try { + unsubscribeCallback = options.subscribeToTriggers(trigger); + } catch (error) { + log.error('failed to subscribe', Errors.toLogFormat(error)); + } + } + + function cleanupSubscription() { + try { + unsubscribeCallback?.(); + } catch (error) { + log.error('failed to unsubscribe', Errors.toLogFormat(error)); + } + } + + // public api + + let serviceState = ServiceState.NEVER_STARTED; + + async function trigger(reason: string) { + if (serviceState === ServiceState.NEVER_STARTED) { + log.warn(`trigger(${reason}) service not started, doing nothing`); + return; + } + if (serviceState === ServiceState.STARTED) { + log.info(`trigger(${reason}) running`); + } + if (serviceState === ServiceState.STOPPED) { + log.warn(`trigger(${reason}) service stopped, doing nothing`); + return; + } + cancelNextScheduledRun(reason); + await runOnceImmediately(false); // wait for first run + drop(scheduleRunsUntilDrained()); + } + + async function start(reason: string) { + switch (serviceState) { + case ServiceState.NEVER_STARTED: + log.info(`start(${reason}) starting`); + break; + case ServiceState.STARTED: + log.warn(`start(${reason}) already started, doing nothing`); + return; + case ServiceState.STOPPED: + log.info(`start(${reason}) starting, previously stopped`); + break; + default: + throw missingCaseError(serviceState); + } + serviceState = ServiceState.STARTED; + await runOnceImmediately(false); // wait for first run + startSubscription(); + + drop(scheduleRunsUntilDrained()); + } + + function stopCleanup(reason: string) { + switch (serviceState) { + case ServiceState.NEVER_STARTED: + log.info(`stop(${reason}) never started, doing nothing`); + return; + case ServiceState.STARTED: + log.info(`stop(${reason}) stopping`); + break; + case ServiceState.STOPPED: + log.warn(`stop(${reason}) already stopped, doing nothing`); + return; + default: + throw missingCaseError(serviceState); + } + serviceState = ServiceState.STOPPED; + cleanupSubscription(); + cancelNextScheduledRun(reason); + } + + async function stop(reason: string) { + const wasRunning = serviceState === ServiceState.STARTED; + stopCleanup(reason); + if (wasRunning) { + await runOnceImmediately(false); + } + } + + function stopImmediately(reason: string) { + const wasRunning = serviceState === ServiceState.STARTED; + if (wasRunning) { + stopCleanup(reason); + } + } + + return { start, trigger, stop, stopImmediately }; +} diff --git a/ts/services/storage.ts b/ts/services/storage.ts index b6b2bb0c40..3d08245caa 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -32,6 +32,8 @@ import { toCallLinkRecord, mergeCallLinkRecord, toDefunctOrPendingCallLinkRecord, + toChatFolderRecord, + mergeChatFolderRecord, } from './storageRecordOps'; import type { MergeResultType } from './storageRecordOps'; import { MAX_READ_KEYS } from './storageConstants'; @@ -86,6 +88,7 @@ import { isDone as isRegistrationDone } from '../util/registration'; import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue'; import { isMockEnvironment } from '../environment'; import { validateConversation } from '../util/validateConversation'; +import type { ChatFolder } from '../types/ChatFolder'; const log = createLogger('storage'); @@ -102,15 +105,18 @@ const { const uploadBucket: Array = []; +const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type; + const validRecordTypes = new Set([ - 0, // UNKNOWN - 1, // CONTACT - 2, // GROUPV1 - 3, // GROUPV2 - 4, // ACCOUNT - 5, // STORY_DISTRIBUTION_LIST - 6, // STICKER_PACK - 7, // CALL_LINK + ITEM_TYPE.UNKNOWN, + ITEM_TYPE.CONTACT, + ITEM_TYPE.GROUPV1, + ITEM_TYPE.GROUPV2, + ITEM_TYPE.ACCOUNT, + ITEM_TYPE.STORY_DISTRIBUTION_LIST, + ITEM_TYPE.STICKER_PACK, + ITEM_TYPE.CALL_LINK, + ITEM_TYPE.CHAT_FOLDER, ]); const backOff = new BackOff([ @@ -181,8 +187,6 @@ async function generateManifest( await window.ConversationController.checkForConflicts(); - const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type; - const postUploadUpdateFunctions: Array<() => unknown> = []; const insertKeys = new Set(); const deleteKeys = new Set(); @@ -197,8 +201,8 @@ async function generateManifest( storageRecord, }: { conversation?: ConversationModel; - currentStorageID?: string; - currentStorageVersion?: number; + currentStorageID?: string | null; + currentStorageVersion?: number | null; identifierType: Proto.ManifestRecord.Identifier.Type; storageNeedsSync: boolean; storageRecord: Proto.IStorageRecord; @@ -353,6 +357,7 @@ async function generateManifest( storyDistributionLists, installedStickerPacks, uninstalledStickerPacks, + chatFolders, } = await getNonConversationRecords(); log.info( @@ -609,6 +614,33 @@ async function generateManifest( } }); + log.info(`upload(${version}): adding chatFolders=${chatFolders.length}`); + + chatFolders.forEach(chatFolder => { + const { isNewItem, storageID } = processStorageRecord({ + currentStorageID: chatFolder.storageID ?? undefined, + currentStorageVersion: chatFolder.storageVersion ?? undefined, + identifierType: ITEM_TYPE.CHAT_FOLDER, + storageNeedsSync: chatFolder.storageNeedsSync, + storageRecord: new Proto.StorageRecord({ + chatFolder: toChatFolderRecord(chatFolder), + }), + }); + + if (isNewItem) { + postUploadUpdateFunctions.push(() => { + drop( + DataWriter.updateChatFolder({ + ...chatFolder, + storageID, + storageVersion: version, + storageNeedsSync: false, + }) + ); + }); + } + }); + const unknownRecordsArray: ReadonlyArray = ( window.storage.get('storage-service-unknown-records') || [] ).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType)); @@ -1105,8 +1137,6 @@ async function mergeRecord( storageVersion, }); - const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type; - let mergeResult: MergeResultType = { details: [] }; let isUnsupported = false; let hasError = false; @@ -1164,6 +1194,12 @@ async function mergeRecord( storageVersion, storageRecord.callLink ); + } else if (itemType === ITEM_TYPE.CHAT_FOLDER && storageRecord.chatFolder) { + mergeResult = await mergeChatFolderRecord( + storageID, + storageVersion, + storageRecord.chatFolder + ); } else { isUnsupported = true; log.warn(`merge(${redactedStorageID}): unknown item type=${itemType}`); @@ -1220,6 +1256,7 @@ type NonConversationRecordsResultType = Readonly<{ installedStickerPacks: ReadonlyArray; uninstalledStickerPacks: ReadonlyArray; storyDistributionLists: ReadonlyArray; + chatFolders: ReadonlyArray; }>; // TODO: DESKTOP-3929 @@ -1231,6 +1268,7 @@ async function getNonConversationRecords(): Promise(); + const localVersions = new Map(); let localRecordCount = 0; const conversations = window.ConversationController.getAll(); @@ -1287,6 +1327,7 @@ async function processManifest( storyDistributionLists, installedStickerPacks, uninstalledStickerPacks, + chatFolders, } = await getNonConversationRecords(); const collectLocalKeysFromFields = ({ @@ -1317,6 +1358,9 @@ async function processManifest( installedStickerPacks.forEach(collectLocalKeysFromFields); localRecordCount += installedStickerPacks.length; + + chatFolders.forEach(collectLocalKeysFromFields); + localRecordCount += chatFolders.length; } const unknownRecordsArray: ReadonlyArray = @@ -1439,6 +1483,7 @@ async function processManifest( storyDistributionLists, installedStickerPacks, uninstalledStickerPacks, + chatFolders, } = await getNonConversationRecords(); uninstalledStickerPacks.forEach(stickerPack => { @@ -1592,6 +1637,25 @@ async function processManifest( } ); }); + + chatFolders.forEach(chatFolder => { + const { storageID, storageVersion } = chatFolder; + if (!storageID || remoteKeys.has(storageID)) { + return; + } + + const missingKey = redactStorageID(storageID, storageVersion); + log.info( + `process(${version}): localKey=${missingKey} was not ` + + 'in remote manifest' + ); + + void DataWriter.updateChatFolder({ + ...chatFolder, + storageID: null, + storageVersion: null, + }); + }); } log.info(`process(${version}): done`); @@ -1730,7 +1794,6 @@ async function processRemoteRecords( storageVersion: number, { decryptedItems, missingKeys }: FetchRemoteRecordsResultType ): Promise { - const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type; const droppedKeys = new Set(); // Drop all GV1 records for which we have GV2 record in the same manifest diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index df3023b31d..9a9fbc3128 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -4,6 +4,7 @@ import { isEqual } from 'lodash'; import Long from 'long'; +import { ServiceId } from '@signalapp/libsignal-client'; import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes'; import { deriveMasterKeyFromGroupV1 } from '../Crypto'; import * as Bytes from '../Bytes'; @@ -36,7 +37,11 @@ import { set as setUniversalExpireTimer, } from '../util/universalExpireTimer'; import { ourProfileKeyService } from './ourProfileKey'; -import { isGroupV1, isGroupV2 } from '../util/whatTypeOfConversation'; +import { + isDirectConversation, + isGroupV1, + isGroupV2, +} from '../util/whatTypeOfConversation'; import { DurationInSeconds } from '../util/durations'; import * as preferredReactionEmoji from '../reactions/preferredReactionEmoji'; import { SignalService as Proto } from '../protobuf'; @@ -99,6 +104,13 @@ import { getTypingIndicatorSetting, } from '../types/Util'; import { MessageRequestResponseSource } from '../types/MessageRequestResponseEvent'; +import type { ChatFolder, ChatFolderId } from '../types/ChatFolder'; +import { + CHAT_FOLDER_DELETED_POSITION, + ChatFolderType, +} from '../types/ChatFolder'; +import { deriveGroupID, deriveGroupSecretParams } from '../util/zkgroup'; +import { chatFolderCleanupService } from './expiring/chatFolderCleanupService'; const log = createLogger('storageRecordOps'); @@ -115,8 +127,8 @@ export type MergeResultType = Readonly<{ conversation?: ConversationModel; needsProfileFetch?: boolean; updatedConversations?: ReadonlyArray; - oldStorageID?: string; - oldStorageVersion?: number; + oldStorageID?: string | null; + oldStorageVersion?: number | null; details: ReadonlyArray; }>; @@ -753,6 +765,96 @@ export function toDefunctOrPendingCallLinkRecord( return callLinkRecord; } +function toRecipient(conversationId: string): Proto.Recipient { + const conversation = window.ConversationController.get(conversationId); + + if (conversation == null) { + throw new Error('toRecipient: Missing conversation'); + } + + const logPrefix = `toRecipient(${conversation.idForLogging()})`; + + if (isDirectConversation(conversation.attributes)) { + const serviceId = conversation.getServiceId(); + strictAssert( + serviceId, + `${logPrefix}: Missing serviceId on direct conversation` + ); + const serviceIdBinary = + ServiceId.parseFromServiceIdString(serviceId).getServiceIdBinary(); + return new Proto.Recipient({ + contact: new Proto.Recipient.Contact({ + serviceId, + e164: conversation.get('e164'), + serviceIdBinary, + }), + }); + } + + if (isGroupV2(conversation.attributes)) { + const masterKey = conversation.get('masterKey'); + strictAssert( + masterKey, + `${logPrefix}: Missing masterKey on groupV2 conversation` + ); + return new Proto.Recipient({ + groupMasterKey: Bytes.fromBase64(masterKey), + }); + } + + if (isGroupV1(conversation.attributes)) { + return new Proto.Recipient({ + legacyGroupId: conversation.getGroupIdBuffer(), + }); + } + + throw new Error(`${logPrefix}: Unexpected conversation type for recipient`); +} + +function toRecipients( + conversationIds: ReadonlyArray +): Array { + return conversationIds.map(conversationId => { + return toRecipient(conversationId); + }); +} + +function toChatFolderRecordFolderType( + folderType: ChatFolderType +): Proto.ChatFolderRecord.FolderType { + if (folderType === ChatFolderType.ALL) { + return Proto.ChatFolderRecord.FolderType.ALL; + } + if (folderType === ChatFolderType.CUSTOM) { + return Proto.ChatFolderRecord.FolderType.CUSTOM; + } + return Proto.ChatFolderRecord.FolderType.UNKNOWN; +} + +export function toChatFolderRecord( + chatFolder: ChatFolder +): Proto.ChatFolderRecord { + const chatFolderRecord = new Proto.ChatFolderRecord({ + id: uuidToBytes(chatFolder.id), + name: chatFolder.name, + position: chatFolder.position, + showOnlyUnread: chatFolder.showOnlyUnread, + showMutedChats: chatFolder.showMutedChats, + includeAllIndividualChats: chatFolder.includeAllIndividualChats, + includeAllGroupChats: chatFolder.includeAllGroupChats, + folderType: toChatFolderRecordFolderType(chatFolder.folderType), + includedRecipients: toRecipients(chatFolder.includedConversationIds), + excludedRecipients: toRecipients(chatFolder.excludedConversationIds), + deletedAtTimestampMs: Long.fromNumber(chatFolder.deletedAtTimestampMs), + }); + + if (chatFolder.storageUnknownFields != null) { + chatFolderRecord.$unknownFields = [chatFolder.storageUnknownFields]; + } + + return chatFolderRecord; +} + type MessageRequestCapableRecord = Proto.IContactRecord | Proto.IGroupV2Record; function applyMessageRequestState( @@ -2195,3 +2297,168 @@ export async function mergeCallLinkRecord( oldStorageVersion, }; } + +function protoToChatFolderType(folderType: Proto.ChatFolderRecord.FolderType) { + if (folderType === Proto.ChatFolderRecord.FolderType.ALL) { + return ChatFolderType.ALL; + } + if (folderType === Proto.ChatFolderRecord.FolderType.CUSTOM) { + return ChatFolderType.CUSTOM; + } + return ChatFolderType.UNKNOWN; +} + +function recipientToConversationId(recipient: Proto.Recipient): string { + let match: ConversationModel | undefined; + if (recipient.contact != null) { + match = window.ConversationController.get(recipient.contact.serviceId); + match ??= window.ConversationController.get(recipient.contact.e164); + } else if ( + recipient.groupMasterKey != null && + recipient.groupMasterKey.byteLength !== 0 + ) { + const secretParams = deriveGroupSecretParams(recipient.groupMasterKey); + const groupId = Bytes.toBase64(deriveGroupID(secretParams)); + match = window.ConversationController.get(groupId); + } else if ( + recipient.legacyGroupId != null && + recipient.legacyGroupId.byteLength !== 0 + ) { + const groupId = Bytes.toBinary(recipient.legacyGroupId); + match = window.ConversationController.get(groupId); + } else { + throw new Error('Unexpected type of recipient'); + } + strictAssert(match, 'Missing conversation for recipient'); + return match.id; +} + +function recipientsToConversationIds( + recipients: ReadonlyArray +): ReadonlyArray { + return recipients.map(recipient => { + return recipientToConversationId(recipient); + }); +} + +export async function mergeChatFolderRecord( + storageID: string, + storageVersion: number, + remoteChatFolderRecord: Proto.IChatFolderRecord +): Promise { + const redactedStorageID = redactExtendedStorageID({ + storageID, + storageVersion, + }); + + const logPrefix = `mergeChatFolderRecord(${redactedStorageID})`; + + if (remoteChatFolderRecord.id == null) { + return { shouldDrop: true, details: ['no id'] }; + } + + const remoteChatFolder: ChatFolder = { + id: bytesToUuid(remoteChatFolderRecord.id) as ChatFolderId, + folderType: protoToChatFolderType( + remoteChatFolderRecord.folderType ?? + Proto.ChatFolderRecord.FolderType.UNKNOWN + ), + name: remoteChatFolderRecord.name ?? '', + position: remoteChatFolderRecord.position ?? CHAT_FOLDER_DELETED_POSITION, + showOnlyUnread: remoteChatFolderRecord.showOnlyUnread ?? false, + showMutedChats: remoteChatFolderRecord.showMutedChats ?? false, + includeAllIndividualChats: + remoteChatFolderRecord.includeAllIndividualChats ?? false, + includeAllGroupChats: remoteChatFolderRecord.includeAllGroupChats ?? false, + includedConversationIds: recipientsToConversationIds( + remoteChatFolderRecord.includedRecipients ?? [] + ), + excludedConversationIds: recipientsToConversationIds( + remoteChatFolderRecord.excludedRecipients ?? [] + ), + deletedAtTimestampMs: + remoteChatFolderRecord.deletedAtTimestampMs?.toNumber() ?? 0, + storageID, + storageVersion, + storageUnknownFields: + remoteChatFolderRecord.$unknownFields != null + ? Bytes.concatenate(remoteChatFolderRecord.$unknownFields) + : null, + storageNeedsSync: false, + }; + + const localChatFolder = await DataReader.getChatFolder(remoteChatFolder.id); + + let deletedAtTimestampMs: number; + + const remoteDeletedAt = remoteChatFolder.deletedAtTimestampMs; + const localDeletedAt = localChatFolder?.deletedAtTimestampMs ?? 0; + + if (remoteDeletedAt > 0 && localDeletedAt > 0) { + if (remoteDeletedAt < localDeletedAt) { + deletedAtTimestampMs = remoteDeletedAt; + } else { + deletedAtTimestampMs = localDeletedAt; + } + } else if (remoteDeletedAt > 0) { + deletedAtTimestampMs = remoteDeletedAt; + } else if (localDeletedAt > 0) { + deletedAtTimestampMs = localDeletedAt; + } else { + deletedAtTimestampMs = remoteDeletedAt; + } + + if (deletedAtTimestampMs > 0) { + if (localChatFolder == null) { + log.info( + `${logPrefix}: skipping deleted chat folder, no local record found` + ); + } else if (localDeletedAt === deletedAtTimestampMs) { + log.info( + `${logPrefix}: skipping deleted chat folder, local record already deleted` + ); + } else if (localDeletedAt > 0) { + log.info(`${logPrefix}: updating deleted chat folder timestamp `); + await DataWriter.updateChatFolderDeletedAtTimestampMsFromSync( + remoteChatFolder.id, + deletedAtTimestampMs + ); + drop(chatFolderCleanupService.trigger('storage: updated timestamp')); + } else { + log.info(`${logPrefix}: deleting chat folder`); + await DataWriter.markChatFolderDeleted( + remoteChatFolder.id, + deletedAtTimestampMs, + false + ); + window.reduxActions.chatFolders.removeChatFolderRecord( + remoteChatFolder.id + ); + drop(chatFolderCleanupService.trigger('storage: deleted chat folder')); + } + } else if (localChatFolder == null) { + log.info(`${logPrefix}: creating new chat folder`); + await DataWriter.createChatFolder(remoteChatFolder); + window.reduxActions.chatFolders.addChatFolderRecord(remoteChatFolder); + } else { + log.info(`${logPrefix}: updating existing chat folder`); + await DataWriter.updateChatFolder(remoteChatFolder); + window.reduxActions.chatFolders.replaceChatFolderRecord(remoteChatFolder); + } + + const details = logRecordChanges( + localChatFolder != null ? toChatFolderRecord(localChatFolder) : undefined, + remoteChatFolderRecord + ); + + const shouldDrop = + remoteChatFolder.deletedAtTimestampMs > 0 && + isOlderThan(remoteChatFolder.deletedAtTimestampMs, getMessageQueueTime()); + + return { + details, + shouldDrop, + oldStorageID: localChatFolder?.storageID ?? undefined, + oldStorageVersion: localChatFolder?.storageVersion ?? undefined, + }; +} diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index ec6f4b089a..1c77bcf214 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -54,6 +54,7 @@ import type { GifType } from '../components/fun/panels/FunPanelGifs'; import type { NotificationProfileType } from '../types/NotificationProfile'; import type { DonationReceipt } from '../types/Donations'; import type { InsertOrUpdateCallLinkFromSyncResult } from './server/callLinks'; +import type { ChatFolderId, ChatFolder } from '../types/ChatFolder'; export type ReadableDB = Database & { __readable_db: never }; export type WritableDB = ReadableDB & { __writable_db: never }; @@ -346,8 +347,8 @@ export const StickerPackStatuses = [ export type StickerPackStatusType = (typeof StickerPackStatuses)[number]; export type StorageServiceFieldsType = Readonly<{ - storageID?: string; - storageVersion?: number; + storageID?: string | null; + storageVersion?: number | null; storageUnknownFields?: Uint8Array | null; storageNeedsSync: boolean; }>; @@ -872,6 +873,11 @@ type ReadableInterface = { getAllDonationReceipts(): Array; getDonationReceiptById(id: string): DonationReceipt | undefined; + getAllChatFolders: () => ReadonlyArray; + getCurrentChatFolders: () => ReadonlyArray; + getChatFolder: (id: ChatFolderId) => ChatFolder | null; + getOldestDeletedChatFolder: () => ChatFolder | null; + getMessagesNeedingUpgrade: ( limit: number, options: { maxVersion: number } @@ -1200,6 +1206,22 @@ type WritableInterface = { deleteDonationReceiptById(id: string): void; createDonationReceipt(profile: DonationReceipt): void; + createChatFolder: (chatFolder: ChatFolder) => void; + updateChatFolder: (chatFolder: ChatFolder) => void; + updateChatFolderPositions: (chatFolders: ReadonlyArray) => void; + updateChatFolderDeletedAtTimestampMsFromSync: ( + chatFolderId: ChatFolderId, + deletedAtTimestampMs: number + ) => void; + markChatFolderDeleted: ( + chatFolderId: ChatFolderId, + deletedAtTimestampMs: number, + storageNeedsSync: boolean + ) => void; + deleteExpiredChatFolders: ( + messageQueueTime: number + ) => ReadonlyArray; + removeAll: () => void; removeAllConfiguration: () => void; eraseStorageServiceState: () => void; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index d0cf38ce25..370202e180 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -239,6 +239,18 @@ import { getGroupSendMemberEndorsement, replaceAllEndorsementsForGroup, } from './server/groupSendEndorsements'; +import { + getAllChatFolders, + getCurrentChatFolders, + getChatFolder, + createChatFolder, + updateChatFolder, + markChatFolderDeleted, + getOldestDeletedChatFolder, + updateChatFolderPositions, + updateChatFolderDeletedAtTimestampMsFromSync, + deleteExpiredChatFolders, +} from './server/chatFolders'; import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer'; import type { GifType } from '../components/fun/panels/FunPanelGifs'; import type { NotificationProfileType } from '../types/NotificationProfile'; @@ -422,6 +434,11 @@ export const DataReader: ServerReadableInterface = { getAllDonationReceipts, getDonationReceiptById, + getAllChatFolders, + getCurrentChatFolders, + getChatFolder, + getOldestDeletedChatFolder, + callLinkExists, defunctCallLinkExists, getAllCallLinks, @@ -664,6 +681,13 @@ export const DataWriter: ServerWritableInterface = { deleteDonationReceiptById, createDonationReceipt, + createChatFolder, + updateChatFolder, + markChatFolderDeleted, + deleteExpiredChatFolders, + updateChatFolderPositions, + updateChatFolderDeletedAtTimestampMsFromSync, + removeAll, removeAllConfiguration, eraseStorageServiceState, @@ -7618,6 +7642,7 @@ function removeAll(db: WritableDB): void { DELETE FROM badges; DELETE FROM callLinks; DELETE FROM callsHistory; + DELETE FROM chatFolders; DELETE FROM conversations; DELETE FROM defunctCallLinks; DELETE FROM donationReceipts; @@ -7772,6 +7797,14 @@ function eraseStorageServiceState(db: WritableDB): void { storageVersion = null, storageUnknownFields = null, storageNeedsSync = 0; + + -- Chat Folders + UPDATE chatFolders + SET + storageID = null, + storageVersion = null, + storageUnknownFields = null, + storageNeedsSync = 0; `); } diff --git a/ts/sql/migrations/1440-chat-folders.ts b/ts/sql/migrations/1440-chat-folders.ts new file mode 100644 index 0000000000..c1e32cd48f --- /dev/null +++ b/ts/sql/migrations/1440-chat-folders.ts @@ -0,0 +1,30 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { WritableDB } from '../Interface'; +import { sql } from '../util'; + +export default function updateToSchemaVersion1440(db: WritableDB): void { + const [query] = sql` + CREATE TABLE chatFolders ( + id TEXT NOT NULL PRIMARY KEY, + folderType INTEGER NOT NULL, + name TEXT NOT NULL, + position INTEGER NOT NULL, + showOnlyUnread INTEGER NOT NULL, + showMutedChats INTEGER NOT NULL, + includeAllIndividualChats INTEGER NOT NULL, + includeAllGroupChats INTEGER NOT NULL, + includedConversationIds TEXT NOT NULL, + excludedConversationIds TEXT NOT NULL, + deletedAtTimestampMs INTEGER NOT NULL, + storageID TEXT, + storageVersion INTEGER, + storageUnknownFields BLOB, + storageNeedsSync INTEGER NOT NULL + ) STRICT; + + CREATE INDEX chatFolders_by_position on chatFolders (position); + `; + + db.exec(query); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 4765b93e74..96c7143a42 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -119,6 +119,7 @@ import updateToSchemaVersion1400 from './1400-simplify-receipts'; import updateToSchemaVersion1410 from './1410-remove-wallpaper'; import updateToSchemaVersion1420 from './1420-backup-downloads'; import updateToSchemaVersion1430 from './1430-call-links-epoch-id'; +import updateToSchemaVersion1440 from './1440-chat-folders'; import { DataWriter } from '../Server'; @@ -1593,6 +1594,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1410, update: updateToSchemaVersion1410 }, { version: 1420, update: updateToSchemaVersion1420 }, { version: 1430, update: updateToSchemaVersion1430 }, + { version: 1440, update: updateToSchemaVersion1440 }, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/sql/server/chatFolders.ts b/ts/sql/server/chatFolders.ts new file mode 100644 index 0000000000..00a8cde97e --- /dev/null +++ b/ts/sql/server/chatFolders.ts @@ -0,0 +1,275 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { + type ChatFolderId, + type ChatFolder, + CHAT_FOLDER_DELETED_POSITION, +} from '../../types/ChatFolder'; +import type { ReadableDB, WritableDB } from '../Interface'; +import { sql } from '../util'; + +export type ChatFolderRow = Readonly< + Omit< + ChatFolder, + | 'showOnlyUnread' + | 'showMutedChats' + | 'includeAllIndividualChats' + | 'includeAllGroupChats' + | 'includedConversationIds' + | 'excludedConversationIds' + | 'storageNeedsSync' + > & { + showOnlyUnread: 0 | 1; + showMutedChats: 0 | 1; + includeAllIndividualChats: 0 | 1; + includeAllGroupChats: 0 | 1; + includedConversationIds: string; + excludedConversationIds: string; + storageNeedsSync: 0 | 1; + } +>; + +function chatFolderToRow(chatFolder: ChatFolder): ChatFolderRow { + return { + ...chatFolder, + showOnlyUnread: chatFolder.showOnlyUnread ? 1 : 0, + showMutedChats: chatFolder.showMutedChats ? 1 : 0, + includeAllIndividualChats: chatFolder.includeAllIndividualChats ? 1 : 0, + includeAllGroupChats: chatFolder.includeAllGroupChats ? 1 : 0, + includedConversationIds: JSON.stringify(chatFolder.includedConversationIds), + excludedConversationIds: JSON.stringify(chatFolder.excludedConversationIds), + storageNeedsSync: chatFolder.storageNeedsSync ? 1 : 0, + }; +} + +function rowToChatFolder(chatFolderRow: ChatFolderRow): ChatFolder { + return { + ...chatFolderRow, + showOnlyUnread: chatFolderRow.showOnlyUnread === 1, + showMutedChats: chatFolderRow.showMutedChats === 1, + includeAllIndividualChats: chatFolderRow.includeAllIndividualChats === 1, + includeAllGroupChats: chatFolderRow.includeAllGroupChats === 1, + includedConversationIds: JSON.parse(chatFolderRow.includedConversationIds), + excludedConversationIds: JSON.parse(chatFolderRow.excludedConversationIds), + storageNeedsSync: chatFolderRow.storageNeedsSync === 1, + }; +} + +export function getAllChatFolders(db: ReadableDB): ReadonlyArray { + const [query, params] = sql` + SELECT * FROM chatFolders + `; + return db + .prepare(query) + .all(params) + .map(row => rowToChatFolder(row)); +} + +export function getCurrentChatFolders( + db: ReadableDB +): ReadonlyArray { + const [query, params] = sql` + SELECT * + FROM chatFolders + WHERE deletedAtTimestampMs IS 0 + ORDER BY position ASC + `; + return db + .prepare(query) + .all(params) + .map(row => rowToChatFolder(row)); +} + +export function getChatFolder( + db: ReadableDB, + id: ChatFolderId +): ChatFolder | null { + return db.transaction(() => { + const [query, params] = sql` + SELECT * FROM chatFolders + WHERE id = ${id}; + `; + const row = db.prepare(query).get(params); + if (row == null) { + return null; + } + return rowToChatFolder(row); + })(); +} + +export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void { + return db.transaction(() => { + const chatFolderRow = chatFolderToRow(chatFolder); + const [chatFolderQuery, chatFolderParams] = sql` + INSERT INTO chatFolders ( + id, + folderType, + name, + position, + showOnlyUnread, + showMutedChats, + includeAllIndividualChats, + includeAllGroupChats, + includedConversationIds, + excludedConversationIds, + deletedAtTimestampMs, + storageID, + storageVersion, + storageUnknownFields, + storageNeedsSync + ) VALUES ( + ${chatFolderRow.id}, + ${chatFolderRow.folderType}, + ${chatFolderRow.name}, + ${chatFolderRow.position}, + ${chatFolderRow.showOnlyUnread}, + ${chatFolderRow.showMutedChats}, + ${chatFolderRow.includeAllIndividualChats}, + ${chatFolderRow.includeAllGroupChats}, + ${chatFolderRow.includedConversationIds}, + ${chatFolderRow.excludedConversationIds}, + ${chatFolderRow.deletedAtTimestampMs}, + ${chatFolderRow.storageID}, + ${chatFolderRow.storageVersion}, + ${chatFolderRow.storageUnknownFields}, + ${chatFolderRow.storageNeedsSync} + ) + `; + db.prepare(chatFolderQuery).run(chatFolderParams); + })(); +} + +export function updateChatFolder(db: WritableDB, chatFolder: ChatFolder): void { + return db.transaction(() => { + const chatFolderRow = chatFolderToRow(chatFolder); + const [chatFolderQuery, chatFolderParams] = sql` + UPDATE chatFolders + SET + id = ${chatFolderRow.id}, + folderType = ${chatFolderRow.folderType}, + name = ${chatFolderRow.name}, + position = ${chatFolderRow.position}, + showOnlyUnread = ${chatFolderRow.showOnlyUnread}, + showMutedChats = ${chatFolderRow.showMutedChats}, + includeAllIndividualChats = ${chatFolderRow.includeAllIndividualChats}, + includeAllGroupChats = ${chatFolderRow.includeAllGroupChats}, + includedConversationIds = ${chatFolderRow.includedConversationIds}, + excludedConversationIds = ${chatFolderRow.excludedConversationIds}, + deletedAtTimestampMs = ${chatFolderRow.deletedAtTimestampMs}, + storageID = ${chatFolderRow.storageID}, + storageVersion = ${chatFolderRow.storageVersion}, + storageUnknownFields = ${chatFolderRow.storageUnknownFields}, + storageNeedsSync = ${chatFolderRow.storageNeedsSync} + WHERE + id = ${chatFolderRow.id} + `; + db.prepare(chatFolderQuery).run(chatFolderParams); + })(); +} + +const EMPTY_ARRAY = JSON.stringify([]); + +export function markChatFolderDeleted( + db: WritableDB, + id: ChatFolderId, + deletedAtTimestampMs: number, + storageNeedsSync: boolean +): void { + return db.transaction(() => { + const [query, params] = sql` + UPDATE chatFolders + SET + position = ${CHAT_FOLDER_DELETED_POSITION}, + deletedAtTimestampMs = ${deletedAtTimestampMs}, + storageNeedsSync = ${storageNeedsSync ? 1 : 0}, + includedConversationIds = ${EMPTY_ARRAY}, + excludedConversationIds = ${EMPTY_ARRAY} + WHERE id = ${id} + `; + db.prepare(query).run(params); + _resetAllChatFolderPositions(db); + })(); +} + +function _resetAllChatFolderPositions(db: WritableDB) { + const [query, params] = sql` + SELECT id FROM chatFolders + WHERE deletedAtTimestampMs IS 0 + ORDER BY position ASC + `; + + db.prepare(query, { pluck: true }) + .all(params) + .forEach((id, index) => { + const [update, updateParams] = sql` + UPDATE chatFolders + SET + position = ${index}, + storageNeedsSync = 1 + WHERE id = ${id} + `; + db.prepare(update).run(updateParams); + }); +} + +export function updateChatFolderPositions( + db: WritableDB, + chatFolders: ReadonlyArray +): void { + return db.transaction(() => { + for (const chatFolder of chatFolders) { + const [query, params] = sql` + UPDATE chatFolders + SET + position = ${chatFolder.position}, + storageNeedsSync = 1 + WHERE id = ${chatFolder.id} + `; + db.prepare(query).run(params); + } + })(); +} + +export function updateChatFolderDeletedAtTimestampMsFromSync( + db: WritableDB, + chatFolderId: ChatFolderId, + deletedAtTimestampMs: number +): void { + return db.transaction(() => { + const [update, updateParams] = sql` + UPDATE chatFolders + SET deletedAtTimestampMs = ${deletedAtTimestampMs} + WHERE id = ${chatFolderId} + `; + db.prepare(update).run(updateParams); + })(); +} + +export function getOldestDeletedChatFolder(db: ReadableDB): ChatFolder | null { + const [query, params] = sql` + SELECT * + FROM chatFolders + WHERE deletedAtTimestampMs > 0 + ORDER BY deletedAtTimestampMs ASC + LIMIT 1 + `; + const row = db.prepare(query).get(params); + if (row == null) { + return null; + } + return rowToChatFolder(row); +} + +export function deleteExpiredChatFolders( + db: WritableDB, + messageQueueTime: number +): ReadonlyArray { + const before = Date.now() - messageQueueTime; + const [query, params] = sql` + DELETE FROM chatFolders + WHERE deletedAtTimestampMs > 0 + AND deletedAtTimestampMs < ${before} + RETURNING id + `; + return db.prepare(query, { pluck: true }).all(params); +} diff --git a/ts/state/actions.ts b/ts/state/actions.ts index b247ac667d..b842c23aec 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -8,6 +8,7 @@ import { actions as audioRecorder } from './ducks/audioRecorder'; import { actions as badges } from './ducks/badges'; import { actions as callHistory } from './ducks/callHistory'; import { actions as calling } from './ducks/calling'; +import { actions as chatFolders } from './ducks/chatFolders'; import { actions as composer } from './ducks/composer'; import { actions as conversations } from './ducks/conversations'; import { actions as crashReports } from './ducks/crashReports'; @@ -44,6 +45,7 @@ export const actionCreators: ReduxActions = { badges, callHistory, calling, + chatFolders, composer, conversations, crashReports, diff --git a/ts/state/ducks/chatFolders.ts b/ts/state/ducks/chatFolders.ts new file mode 100644 index 0000000000..44f4580146 --- /dev/null +++ b/ts/state/ducks/chatFolders.ts @@ -0,0 +1,194 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { ReadonlyDeep } from 'type-fest'; +import { v4 as generateUuid } from 'uuid'; +import type { ThunkAction } from 'redux-thunk'; +import type { StateType as RootStateType } from '../reducer'; +import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; +import { useBoundActions } from '../../hooks/useBoundActions'; +import { + ChatFolderParamsSchema, + type ChatFolder, + type ChatFolderId, + type ChatFolderParams, +} from '../../types/ChatFolder'; +import { getCurrentChatFolders } from '../selectors/chatFolders'; +import { DataWriter } from '../../sql/Client'; +import { strictAssert } from '../../util/assert'; +import { storageServiceUploadJob } from '../../services/storage'; +import { parseStrict } from '../../util/schemas'; +import { chatFolderCleanupService } from '../../services/expiring/chatFolderCleanupService'; +import { drop } from '../../util/drop'; + +export type ChatFoldersState = ReadonlyDeep<{ + currentChatFolders: ReadonlyArray; +}>; + +const CHAT_FOLDER_RECORD_ADD = 'chatFolders/RECORD_ADD'; +const CHAT_FOLDER_RECORD_REPLACE = 'chatFolders/RECORD_REPLACE'; +const CHAT_FOLDER_RECORD_REMOVE = 'chatFolders/RECORD_REMOVE'; + +export type ChatFolderRecordAdd = ReadonlyDeep<{ + type: typeof CHAT_FOLDER_RECORD_ADD; + payload: ChatFolder; +}>; + +export type ChatFolderRecordReplace = ReadonlyDeep<{ + type: typeof CHAT_FOLDER_RECORD_REPLACE; + payload: ChatFolder; +}>; + +export type ChatFolderRecordRemove = ReadonlyDeep<{ + type: typeof CHAT_FOLDER_RECORD_REMOVE; + payload: ChatFolderId; +}>; + +export type ChatFolderAction = ReadonlyDeep< + ChatFolderRecordAdd | ChatFolderRecordReplace | ChatFolderRecordRemove +>; + +export function getEmptyState(): ChatFoldersState { + return { + currentChatFolders: [], + }; +} + +function addChatFolderRecord(chatFolder: ChatFolder): ChatFolderRecordAdd { + return { + type: CHAT_FOLDER_RECORD_ADD, + payload: chatFolder, + }; +} + +function replaceChatFolderRecord( + chatFolder: ChatFolder +): ChatFolderRecordReplace { + return { + type: CHAT_FOLDER_RECORD_REPLACE, + payload: chatFolder, + }; +} + +function removeChatFolderRecord( + chatFolderId: ChatFolderId +): ChatFolderRecordRemove { + return { + type: CHAT_FOLDER_RECORD_REMOVE, + payload: chatFolderId, + }; +} + +function createChatFolder( + chatFolderParams: ChatFolderParams +): ThunkAction { + return async (dispatch, getState) => { + const chatFolders = getCurrentChatFolders(getState()); + + const chatFolder: ChatFolder = { + ...chatFolderParams, + id: generateUuid() as ChatFolderId, + position: chatFolders.length, + deletedAtTimestampMs: 0, + storageID: null, + storageVersion: null, + storageUnknownFields: null, + storageNeedsSync: true, + }; + + await DataWriter.createChatFolder(chatFolder); + storageServiceUploadJob({ reason: 'createChatFolder' }); + dispatch(addChatFolderRecord(chatFolder)); + }; +} + +function updateChatFolder( + chatFolderId: ChatFolderId, + chatFolderParams: ChatFolderParams +): ThunkAction { + return async (dispatch, getState) => { + const chatFolders = getCurrentChatFolders(getState()); + + const prevChatFolder = chatFolders.find(chatFolder => { + return chatFolder.id === chatFolderId; + }); + strictAssert(prevChatFolder != null, 'Missing chat folder'); + + const nextChatFolder: ChatFolder = { + ...prevChatFolder, + ...parseStrict(ChatFolderParamsSchema, chatFolderParams), + storageNeedsSync: true, + }; + + await DataWriter.updateChatFolder(nextChatFolder); + storageServiceUploadJob({ reason: 'updateChatFolder' }); + dispatch(replaceChatFolderRecord(nextChatFolder)); + }; +} + +function deleteChatFolder( + chatFolderId: ChatFolderId +): ThunkAction { + return async dispatch => { + await DataWriter.markChatFolderDeleted(chatFolderId, Date.now(), true); + storageServiceUploadJob({ reason: 'deleteChatFolder' }); + dispatch(removeChatFolderRecord(chatFolderId)); + drop(chatFolderCleanupService.trigger('redux: deleted chat folder')); + }; +} + +export const actions = { + addChatFolderRecord, + replaceChatFolderRecord, + removeChatFolderRecord, + createChatFolder, + updateChatFolder, + deleteChatFolder, +}; + +export const useChatFolderActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); + +function toSortedChatFolders( + chatFolders: ReadonlyArray +): ReadonlyArray { + return chatFolders.toSorted((a, b) => a.position - b.position); +} + +export function reducer( + state: ChatFoldersState = getEmptyState(), + action: ChatFolderAction +): ChatFoldersState { + switch (action.type) { + case CHAT_FOLDER_RECORD_ADD: + return { + ...state, + currentChatFolders: toSortedChatFolders([ + ...state.currentChatFolders, + action.payload, + ]), + }; + case CHAT_FOLDER_RECORD_REPLACE: + return { + ...state, + currentChatFolders: toSortedChatFolders( + state.currentChatFolders.map(chatFolder => { + return chatFolder.id === action.payload.id + ? action.payload + : chatFolder; + }) + ), + }; + case CHAT_FOLDER_RECORD_REMOVE: + return { + ...state, + currentChatFolders: toSortedChatFolders( + state.currentChatFolders.filter(chatFolder => { + return chatFolder.id !== action.payload; + }) + ), + }; + default: + return state; + } +} diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index 5c49e511b7..04848efa88 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -8,6 +8,7 @@ import { getEmptyState as audioRecorderEmptyState } from './ducks/audioRecorder' import { getEmptyState as badgesEmptyState } from './ducks/badges'; import { getEmptyState as callHistoryEmptyState } from './ducks/callHistory'; import { getEmptyState as callingEmptyState } from './ducks/calling'; +import { getEmptyState as chatFoldersEmptyState } from './ducks/chatFolders'; import { getEmptyState as composerEmptyState } from './ducks/composer'; import { getEmptyState as conversationsEmptyState } from './ducks/conversations'; import { getEmptyState as crashReportsEmptyState } from './ducks/crashReports'; @@ -58,6 +59,7 @@ export function getInitialState( callLinks, callHistory: calls, callHistoryUnreadCount, + chatFolders, donations, gifs, mainWindowStats, @@ -87,6 +89,10 @@ export function getInitialState( ...callingEmptyState(), callLinks: makeLookup(callLinks, 'roomId'), }, + chatFolders: { + ...chatFoldersEmptyState(), + currentChatFolders: chatFolders, + }, donations, emojis: recentEmoji, gifs, @@ -140,6 +146,7 @@ function getEmptyState(): StateType { badges: badgesEmptyState(), callHistory: callHistoryEmptyState(), calling: callingEmptyState(), + chatFolders: chatFoldersEmptyState(), composer: composerEmptyState(), conversations: generateConversationsState(), crashReports: crashReportsEmptyState(), diff --git a/ts/state/initializeRedux.ts b/ts/state/initializeRedux.ts index 90bd713027..f0b54fb3c9 100644 --- a/ts/state/initializeRedux.ts +++ b/ts/state/initializeRedux.ts @@ -19,12 +19,14 @@ import type { RecentEmojiObjectType } from '../util/loadRecentEmojis'; import type { StickersStateType } from './ducks/stickers'; import type { GifsStateType } from './ducks/gifs'; import type { NotificationProfileType } from '../types/NotificationProfile'; +import type { ChatFolder } from '../types/ChatFolder'; export type ReduxInitData = { badgesState: BadgesStateType; callHistory: ReadonlyArray; callHistoryUnreadCount: number; callLinks: ReadonlyArray; + chatFolders: ReadonlyArray; donations: DonationsStateType; gifs: GifsStateType; mainWindowStats: MainWindowStatsType; @@ -57,6 +59,7 @@ export function initializeRedux(data: ReduxInitData): void { badges: bindActionCreators(actionCreators.badges, store.dispatch), callHistory: bindActionCreators(actionCreators.callHistory, store.dispatch), calling: bindActionCreators(actionCreators.calling, store.dispatch), + chatFolders: bindActionCreators(actionCreators.chatFolders, store.dispatch), composer: bindActionCreators(actionCreators.composer, store.dispatch), conversations: bindActionCreators( actionCreators.conversations, diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 8598804523..c6f8f4907a 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -10,6 +10,7 @@ import { reducer as audioRecorder } from './ducks/audioRecorder'; import { reducer as badges } from './ducks/badges'; import { reducer as calling } from './ducks/calling'; import { reducer as callHistory } from './ducks/callHistory'; +import { reducer as chatFolders } from './ducks/chatFolders'; import { reducer as composer } from './ducks/composer'; import { reducer as conversations } from './ducks/conversations'; import { reducer as crashReports } from './ducks/crashReports'; @@ -46,6 +47,7 @@ export const reducer = combineReducers({ badges, calling, callHistory, + chatFolders, composer, conversations, crashReports, diff --git a/ts/state/selectors/chatFolders.ts b/ts/state/selectors/chatFolders.ts new file mode 100644 index 0000000000..c1e9bb6d54 --- /dev/null +++ b/ts/state/selectors/chatFolders.ts @@ -0,0 +1,17 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { createSelector } from 'reselect'; +import type { StateType } from '../reducer'; +import type { ChatFoldersState } from '../ducks/chatFolders'; + +export function getChatFoldersState(state: StateType): ChatFoldersState { + return state.chatFolders; +} + +export const getCurrentChatFolders = createSelector( + getChatFoldersState, + state => { + return state.currentChatFolders; + } +); diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx index ec6fb0207c..85bff46660 100644 --- a/ts/state/smart/Preferences.tsx +++ b/ts/state/smart/Preferences.tsx @@ -10,8 +10,6 @@ import type { MutableRefObject } from 'react'; import { useItemsActions } from '../ducks/items'; import { useConversationsActions } from '../ducks/conversations'; import { - getAllComposableConversations, - getConversationSelector, getConversationsWithCustomColorSelector, getMe, } from '../selectors/conversations'; @@ -85,6 +83,10 @@ import { cancelBackupMediaDownload, } from '../../util/backupMediaDownload'; import { DonationsErrorBoundary } from '../../components/DonationsErrorBoundary'; +import type { SmartPreferencesChatFoldersPageProps } from './PreferencesChatFoldersPage'; +import { SmartPreferencesChatFoldersPage } from './PreferencesChatFoldersPage'; +import type { SmartPreferencesEditChatFolderPageProps } from './PreferencesEditChatFolderPage'; +import { SmartPreferencesEditChatFolderPage } from './PreferencesEditChatFolderPage'; const DEFAULT_NOTIFICATION_SETTING = 'message'; @@ -94,6 +96,18 @@ function renderUpdateDialog( return ; } +function renderPreferencesChatFoldersPage( + props: SmartPreferencesChatFoldersPageProps +): JSX.Element { + return ; +} + +function renderPreferencesEditChatFolderPage( + props: SmartPreferencesEditChatFolderPageProps +): JSX.Element { + return ; +} + function renderProfileEditor(options: { contentsRef: MutableRefObject; }): JSX.Element { @@ -180,8 +194,6 @@ export function SmartPreferences(): JSX.Element | null { getConversationsWithCustomColorSelector ); const i18n = useSelector(getIntl); - const conversations = useSelector(getAllComposableConversations); - const conversationSelector = useSelector(getConversationSelector); const items = useSelector(getItems); const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); const dialogType = useSelector(getUpdateDialogType); @@ -725,8 +737,6 @@ export function SmartPreferences(): JSX.Element | null { return ( void; + onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void; +}>; + +export function SmartPreferencesChatFoldersPage( + props: SmartPreferencesChatFoldersPageProps +): JSX.Element { + const i18n = useSelector(getIntl); + const chatFolders = useSelector(getCurrentChatFolders); + const { createChatFolder } = useChatFolderActions(); + return ( + + ); +} diff --git a/ts/state/smart/PreferencesEditChatFolderPage.tsx b/ts/state/smart/PreferencesEditChatFolderPage.tsx new file mode 100644 index 0000000000..b9ff02f31a --- /dev/null +++ b/ts/state/smart/PreferencesEditChatFolderPage.tsx @@ -0,0 +1,65 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import type { PreferencesEditChatFolderPageProps } from '../../components/preferences/chatFolders/PreferencesEditChatFoldersPage'; +import { PreferencesEditChatFolderPage } from '../../components/preferences/chatFolders/PreferencesEditChatFoldersPage'; +import { getIntl, getTheme } from '../selectors/user'; +import { CHAT_FOLDER_DEFAULTS } from '../../types/ChatFolder'; +import { + getAllComposableConversations, + getConversationSelector, +} from '../selectors/conversations'; +import { getPreferredBadgeSelector } from '../selectors/badges'; +import { useChatFolderActions } from '../ducks/chatFolders'; +import { getCurrentChatFolders } from '../selectors/chatFolders'; +import { strictAssert } from '../../util/assert'; + +export type SmartPreferencesEditChatFolderPageProps = Readonly<{ + onBack: () => void; + existingChatFolderId: PreferencesEditChatFolderPageProps['existingChatFolderId']; + settingsPaneRef: PreferencesEditChatFolderPageProps['settingsPaneRef']; +}>; + +export function SmartPreferencesEditChatFolderPage( + props: SmartPreferencesEditChatFolderPageProps +): JSX.Element { + const { existingChatFolderId } = props; + + const i18n = useSelector(getIntl); + const theme = useSelector(getTheme); + const conversations = useSelector(getAllComposableConversations); + const conversationSelector = useSelector(getConversationSelector); + const preferredBadgeSelector = useSelector(getPreferredBadgeSelector); + const chatFolders = useSelector(getCurrentChatFolders); + const { createChatFolder, updateChatFolder, deleteChatFolder } = + useChatFolderActions(); + + const initChatFolderParams = useMemo(() => { + if (existingChatFolderId == null) { + return CHAT_FOLDER_DEFAULTS; + } + const found = chatFolders.find(chatFolder => { + return chatFolder.id === existingChatFolderId; + }); + strictAssert(found, 'Unable to find chat folder'); + return found; + }, [chatFolders, existingChatFolderId]); + + return ( + + ); +} diff --git a/ts/state/types.ts b/ts/state/types.ts index 8229834fac..4bffd2c808 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -8,6 +8,7 @@ import type { actions as audioRecorder } from './ducks/audioRecorder'; import type { actions as badges } from './ducks/badges'; import type { actions as callHistory } from './ducks/callHistory'; import type { actions as calling } from './ducks/calling'; +import type { actions as chatFolders } from './ducks/chatFolders'; import type { actions as composer } from './ducks/composer'; import type { actions as conversations } from './ducks/conversations'; import type { actions as crashReports } from './ducks/crashReports'; @@ -43,6 +44,7 @@ export type ReduxActions = { badges: typeof badges; callHistory: typeof callHistory; calling: typeof calling; + chatFolders: typeof chatFolders; composer: typeof composer; conversations: typeof conversations; crashReports: typeof crashReports; diff --git a/ts/test-mock/storage/chat_folder_test.ts b/ts/test-mock/storage/chat_folder_test.ts new file mode 100644 index 0000000000..b0d7c6d820 --- /dev/null +++ b/ts/test-mock/storage/chat_folder_test.ts @@ -0,0 +1,301 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import Long from 'long'; +import { v4 as generateUuid } from 'uuid'; +import { Proto, StorageState } from '@signalapp/mock-server'; +import { expect } from 'playwright/test'; +import * as durations from '../../util/durations'; +import type { App } from './fixtures'; +import { Bootstrap, debug } from './fixtures'; +import { uuidToBytes } from '../../util/uuidToBytes'; +import { CHAT_FOLDER_DELETED_POSITION } from '../../types/ChatFolder'; + +const IdentifierType = Proto.ManifestRecord.Identifier.Type; + +describe('storage service/chat folders', function (this: Mocha.Suite) { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App; + + beforeEach(async () => { + bootstrap = new Bootstrap({ contactCount: 0 }); + await bootstrap.init(); + + const { phone } = bootstrap; + + let state = StorageState.getEmpty(); + + state = state.updateAccount({ + profileKey: phone.profileKey.serialize(), + givenName: phone.profileName, + }); + + await phone.setStorageState(state); + + app = await bootstrap.link(); + }); + + afterEach(async function (this: Mocha.Context) { + if (!bootstrap) { + return; + } + + await bootstrap.maybeSaveLogs(this.currentTest, app); + if (app) { + await app.close(); + } + await bootstrap.teardown(); + }); + + it('should update from storage service', async () => { + const { phone } = bootstrap; + const window = await app.getWindow(); + + const openSettingsBtn = window.locator( + '[data-testid="NavTabsItem--Settings"]' + ); + const openChatsSettingsBtn = window.locator('.Preferences__button--chats'); + const openChatFoldersSettingsBtn = window.locator( + '.Preferences__control:has-text("Add a chat folder")' + ); + + const ALL_CHATS_ID = generateUuid(); + const ALL_GROUPS_ID = generateUuid(); + const ALL_GROUPS_NAME = 'All Groups'; + const ALL_GROUPS_NAME_UPDATED = 'The Groups'; + + const allChatsListItem = window.getByTestId(`ChatFolder--${ALL_CHATS_ID}`); + const allGroupsListItem = window.getByTestId( + `ChatFolder--${ALL_GROUPS_ID}` + ); + + await openSettingsBtn.click(); + await openChatsSettingsBtn.click(); + await openChatFoldersSettingsBtn.click(); + + debug('adding ALL chat folder via storage service'); + { + let state = await phone.expectStorageState('initial state'); + + state = state.addRecord({ + type: IdentifierType.CHAT_FOLDER, + record: { + chatFolder: { + id: uuidToBytes(ALL_CHATS_ID), + folderType: Proto.ChatFolderRecord.FolderType.ALL, + name: null, + position: 0, + showOnlyUnread: false, + showMutedChats: false, + includeAllIndividualChats: true, + includeAllGroupChats: true, + includedRecipients: [], + excludedRecipients: [], + deletedAtTimestampMs: Long.fromNumber(0), + }, + }, + }); + + await phone.setStorageState(state); + await phone.sendFetchStorage({ timestamp: bootstrap.getTimestamp() }); + await app.waitForManifestVersion(state.version); + + await expect(allChatsListItem).toBeVisible(); + } + + debug('adding "All Groups" chat folder via storage service'); + { + let state = await phone.expectStorageState('adding all groups'); + + state = state.addRecord({ + type: IdentifierType.CHAT_FOLDER, + record: { + chatFolder: { + id: uuidToBytes(ALL_GROUPS_ID), + folderType: Proto.ChatFolderRecord.FolderType.CUSTOM, + name: ALL_GROUPS_NAME, + position: 1, + showOnlyUnread: false, + showMutedChats: false, + includeAllIndividualChats: false, + includeAllGroupChats: true, + includedRecipients: [], + excludedRecipients: [], + deletedAtTimestampMs: Long.fromNumber(0), + }, + }, + }); + + await phone.setStorageState(state); + await phone.sendFetchStorage({ timestamp: bootstrap.getTimestamp() }); + await app.waitForManifestVersion(state.version); + + await expect(allGroupsListItem).toBeVisible(); + await expect(allGroupsListItem).toHaveText(ALL_GROUPS_NAME); + } + + debug('updating "All Groups" chat folder via storage service'); + { + let state = await phone.expectStorageState('updating all groups'); + + state = state.updateRecord( + item => { + return item.record.chatFolder?.name === ALL_GROUPS_NAME; + }, + item => { + return { + ...item, + chatFolder: { + ...item.chatFolder, + name: ALL_GROUPS_NAME_UPDATED, + }, + }; + } + ); + + await phone.setStorageState(state); + await phone.sendFetchStorage({ timestamp: bootstrap.getTimestamp() }); + await app.waitForManifestVersion(state.version); + + await expect(allChatsListItem).toBeVisible(); + await expect(allGroupsListItem).toBeVisible(); + await expect(allGroupsListItem).toHaveText(ALL_GROUPS_NAME_UPDATED); + } + + debug('removing "All Groups" chat folder via storage service'); + { + let state = await phone.expectStorageState('removing all groups'); + + state = state.updateRecord( + item => { + return item.record.chatFolder?.name === ALL_GROUPS_NAME_UPDATED; + }, + item => { + return { + ...item, + chatFolder: { + ...item.chatFolder, + position: CHAT_FOLDER_DELETED_POSITION, + deletedAtTimestampMs: Long.fromNumber(Date.now()), + }, + }; + } + ); + + await phone.setStorageState(state); + await phone.sendFetchStorage({ timestamp: bootstrap.getTimestamp() }); + await app.waitForManifestVersion(state.version); + + await expect(allChatsListItem).toBeVisible(); + await expect(allGroupsListItem).not.toBeAttached(); + } + }); + + it('should upload to storage service', async () => { + const { phone } = bootstrap; + const window = await app.getWindow(); + + const openSettingsBtn = window.locator( + '[data-testid="NavTabsItem--Settings"]' + ); + const openChatsSettingsBtn = window.locator('.Preferences__button--chats'); + const openChatFoldersSettingsBtn = window.locator( + '.Preferences__control:has-text("Add a chat folder")' + ); + + const groupPresetBtn = window + .getByTestId('ChatFolderPreset--GroupChats') + .locator('button:has-text("Add")'); + + const groupsFolderBtn = window + .getByTestId('ChatFoldersList') + .locator('.Preferences__ChatFolders__ChatSelection__Item') + .getByText('Groups'); + + const editChatFolderNameInput = window + .getByTestId('EditChatFolderName') + .locator('input'); + const saveChatFolderBtn = window.locator( + '.Preferences__actions button:has-text("Save")' + ); + const deleteChatFolderBtn = window.locator( + '.Preferences__ChatFolders__ChatList__DeleteButton' + ); + + const confirmDeleteBtn = window + .getByTestId( + 'ConfirmationDialog.Preferences__EditChatFolderPage__DeleteChatFolderDialog' + ) + .locator('button:has-text("Delete")'); + + let state = await phone.expectStorageState('initial state'); + // wait for initial creation of story distribution list + state = await phone.waitForStorageState({ after: state }); + + debug('creating group'); + { + await openSettingsBtn.click(); + await openChatsSettingsBtn.click(); + await openChatFoldersSettingsBtn.click(); + + await groupPresetBtn.click(); + await expect(groupsFolderBtn).toBeVisible(); + + debug('waiting for storage sync'); + state = await phone.waitForStorageState({ after: state }); + + const found = state.hasRecord(item => { + return ( + item.type === IdentifierType.CHAT_FOLDER && + item.record.chatFolder?.name === 'Groups' + ); + }); + + expect(found).toBe(true); + } + + debug('updating group'); + { + await groupsFolderBtn.click(); + await editChatFolderNameInput.fill('My Groups'); + await saveChatFolderBtn.click(); + + debug('waiting for storage sync'); + state = await phone.waitForStorageState({ after: state }); + + const found = state.hasRecord(item => { + return ( + item.type === IdentifierType.CHAT_FOLDER && + item.record.chatFolder?.name === 'My Groups' + ); + }); + + expect(found).toBe(true); + } + + debug('deleting group'); + { + await groupsFolderBtn.click(); + await deleteChatFolderBtn.click(); + await confirmDeleteBtn.click(); + + debug('waiting for storage sync'); + state = await phone.waitForStorageState({ after: state }); + + const found = state.findRecord(item => { + return ( + item.type === IdentifierType.CHAT_FOLDER && + item.record.chatFolder?.name === 'My Groups' + ); + }); + + await expect(groupsFolderBtn).not.toBeAttached(); + await expect(groupPresetBtn).toBeVisible(); + + expect( + found?.record.chatFolder?.deletedAtTimestampMs?.toNumber() + ).toBeGreaterThan(0); + } + }); +}); diff --git a/ts/test-node/services/expiring/createExpiringEntityCleanupService_test.ts b/ts/test-node/services/expiring/createExpiringEntityCleanupService_test.ts new file mode 100644 index 0000000000..952f56857a --- /dev/null +++ b/ts/test-node/services/expiring/createExpiringEntityCleanupService_test.ts @@ -0,0 +1,310 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import assert from 'node:assert/strict'; +import type { + ExpiringEntity, + ExpiringEntityCleanupService, +} from '../../../services/expiring/createExpiringEntityCleanupService'; +import { createExpiringEntityCleanupService } from '../../../services/expiring/createExpiringEntityCleanupService'; + +function waitForMicrotasks() { + return new Promise(resolve => { + setTimeout(resolve, 1); + }); +} + +function createMockClock() { + type Callback = () => void; + + type Timeout = { + scheduledTime: number; + callback: Callback; + }; + + let currentTime = 0; + let uniqueTimerId = 0; + + const timeouts = new Map(); + + function getTimerId() { + uniqueTimerId += 1; + return uniqueTimerId; + } + + return { + getCurrentTime() { + return currentTime; + }, + async incrementCurrentTimeTo(expectedTime: number) { + currentTime += 1; + assert.equal(currentTime, expectedTime); + for (const [id, timeout] of timeouts) { + if (timeout.scheduledTime <= currentTime) { + timeouts.delete(id); + timeout.callback(); + } + } + await waitForMicrotasks(); + }, + setTimeout(callback: Callback, delayMs: number): number { + const id = getTimerId(); + const scheduledTime = currentTime + delayMs; + timeouts.set(id, { scheduledTime, callback }); + return id; + }, + clearTimeout(id: number): void { + timeouts.delete(id); + }, + expectTimeouts(message: string, expectedTimes: ReadonlyArray) { + const actualTimes = Array.from(timeouts.values()).map(timeout => { + return timeout.scheduledTime; + }); + assert.deepEqual(actualTimes, expectedTimes, message); + }, + reset() { + currentTime = 0; + timeouts.clear(); + }, + }; +} + +describe('createExpiringEntityCleanupService', () => { + let serviceInstance: ExpiringEntityCleanupService | null = null; + + const clock = createMockClock(); + + let mockExpiringEntities: Array = []; + + let calls: Array = []; + + function expectCalls(msg: string, expected: ReadonlyArray) { + assert.deepEqual(calls, expected, msg); + calls = []; + } + + function addExpiringEntity(id: string, expiresAtMs: number) { + mockExpiringEntities.push({ id, expiresAtMs }); + } + + function expectExpiringEntities( + message: string, + expectedIds: ReadonlyArray + ) { + const actualIds = mockExpiringEntities.map(entity => entity.id); + assert.deepEqual(actualIds, expectedIds, message); + } + + function getService() { + serviceInstance ??= createExpiringEntityCleanupService({ + logPrefix: 'test', + async getNextExpiringEntity() { + calls.push('getNextExpiringEntity'); + let result: ExpiringEntity | null = null; + for (const entity of mockExpiringEntities) { + if (result == null || entity.expiresAtMs <= result.expiresAtMs) { + result = entity; + } + } + return result; + }, + async cleanupExpiredEntities() { + calls.push('cleanupExpiredEntities'); + const deletedIds: Array = []; + const undeleted: Array = []; + for (const entity of mockExpiringEntities) { + if (entity.expiresAtMs <= clock.getCurrentTime()) { + deletedIds.push(entity.id); + } else { + undeleted.push(entity); + } + } + mockExpiringEntities = undeleted; + return deletedIds; + }, + subscribeToTriggers() { + calls.push('subscribeToTriggers'); + return () => { + calls.push('unsubscribeFromTriggers'); + }; + }, + _mockGetCurrentTime() { + return clock.getCurrentTime(); + }, + _mockScheduleLongTimeout(ms, signal) { + calls.push('_mockScheduleLongTimeout'); + return new Promise((resolve, reject) => { + const timer = clock.setTimeout(resolve, ms); + + signal.addEventListener('abort', () => { + clock.clearTimeout(timer); + reject(signal.reason); + }); + }); + }, + }); + + return serviceInstance; + } + + async function serviceStart(reason: string) { + await getService().start(reason); + await waitForMicrotasks(); + } + + async function serviceTrigger(reason: string) { + await getService().trigger(reason); + await waitForMicrotasks(); + } + + async function serviceStop(reason: string) { + await getService().stop(reason); + await waitForMicrotasks(); + } + + async function serviceStopImmediately(reason: string) { + getService().stopImmediately(reason); + await waitForMicrotasks(); + } + + afterEach(() => { + clock.reset(); + serviceInstance?.stopImmediately('afterEach'); + serviceInstance = null; + mockExpiringEntities = []; + }); + + it('should not call anything when triggered before start', async () => { + expectCalls('init', []); + await serviceTrigger('before start'); + expectCalls('triggering before start', []); + }); + + it('should startup correctly', async () => { + addExpiringEntity('a', 1); + addExpiringEntity('b', 2); + addExpiringEntity('c', 3); + addExpiringEntity('d', 4); + + // start will delete the first two + await clock.incrementCurrentTimeTo(1); + await clock.incrementCurrentTimeTo(2); + + await serviceStart('test start'); + expectCalls('after start', [ + 'cleanupExpiredEntities', + 'subscribeToTriggers', + 'getNextExpiringEntity', + '_mockScheduleLongTimeout', + ]); + expectExpiringEntities('after start', ['c', 'd']); + clock.expectTimeouts('after start', [3]); + + // calling start twice shouldn't do anything new + await serviceStart('test start when started'); + expectCalls('test start when started', []); + + // trigger first expiration + await clock.incrementCurrentTimeTo(3); + expectCalls('after expire c', [ + 'cleanupExpiredEntities', + 'getNextExpiringEntity', + '_mockScheduleLongTimeout', + ]); + expectExpiringEntities('after expire c', ['d']); + clock.expectTimeouts('after expire c', [4]); + + // trigger second expiration + await clock.incrementCurrentTimeTo(4); + expectCalls('after expire d', [ + 'cleanupExpiredEntities', + 'getNextExpiringEntity', + ]); + expectExpiringEntities('after expire d', []); + clock.expectTimeouts('after expire d', []); + + // adding past entity + addExpiringEntity('e', 1); + await serviceTrigger('added e'); + expectCalls('after trigger e', [ + 'cleanupExpiredEntities', + 'getNextExpiringEntity', + ]); + expectExpiringEntities('after first trigger', []); + clock.expectTimeouts('after first trigger', []); + + // adding future entity + addExpiringEntity('f', 5); + await serviceTrigger('added f'); + expectCalls('after trigger f', [ + 'cleanupExpiredEntities', + 'getNextExpiringEntity', + '_mockScheduleLongTimeout', + ]); + expectExpiringEntities('after trigger f', ['f']); + clock.expectTimeouts('after trigger f', [5]); + + // trigger future entity expiration + await clock.incrementCurrentTimeTo(5); + expectCalls('after expire f', [ + 'cleanupExpiredEntities', + 'getNextExpiringEntity', + ]); + expectExpiringEntities('after expire f', []); + clock.expectTimeouts('after expire f', []); + + // adding entity without triggering + addExpiringEntity('g', 6); + await clock.incrementCurrentTimeTo(6); + + // stopping service to check one last time + await serviceStop('test stop'); + expectCalls('after stop', [ + 'unsubscribeFromTriggers', + 'cleanupExpiredEntities', + ]); + expectExpiringEntities('after stop', []); + clock.expectTimeouts('after stop', []); + + // calling stop twice shouldn't do anything new + await serviceStop('test stop when stopped'); + expectCalls('test stop when stopped', []); + + // adding future entity after stopped + addExpiringEntity('h', 7); + await serviceTrigger('added h'); + expectCalls('added h after stop', []); + expectExpiringEntities('added h after stop', ['h']); + await clock.incrementCurrentTimeTo(7); + + // restarting + await serviceStart('restarting'); + expectCalls('after restarting', [ + 'cleanupExpiredEntities', + 'subscribeToTriggers', + 'getNextExpiringEntity', + ]); + expectExpiringEntities('removed h after restart', []); + clock.expectTimeouts('removed h after restart', []); + + // adding future entity + addExpiringEntity('i', 9); + await serviceTrigger('added i'); + expectCalls('added i', [ + 'cleanupExpiredEntities', + 'getNextExpiringEntity', + '_mockScheduleLongTimeout', + ]); + expectExpiringEntities('added i', ['i']); + clock.expectTimeouts('added i', [9]); + + await clock.incrementCurrentTimeTo(8); + expectCalls('not yet expired i', []); + expectExpiringEntities('not yet expired i', ['i']); + clock.expectTimeouts('not yet expired i', [9]); + + await serviceStopImmediately('test stop immediately'); + expectCalls('test stop immediately', ['unsubscribeFromTriggers']); + expectExpiringEntities('test stop immediately', ['i']); + clock.expectTimeouts('test stop immediately', []); + }); +}); diff --git a/ts/types/ChatFolder.ts b/ts/types/ChatFolder.ts index ff4ce2ebd4..fe3df86b6b 100644 --- a/ts/types/ChatFolder.ts +++ b/ts/types/ChatFolder.ts @@ -1,87 +1,105 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only - -import { Environment, getEnvironment } from '../environment'; +import { z } from 'zod'; +import { Environment, getEnvironment, isMockEnvironment } from '../environment'; import * as grapheme from '../util/grapheme'; import * as RemoteConfig from '../RemoteConfig'; import { isAlpha, isBeta, isProduction } from '../util/version'; export const CHAT_FOLDER_NAME_MAX_CHAR_LENGTH = 32; +export const CHAT_FOLDER_DELETED_POSITION = 4294967295; // 2^32-1 (max uint32) + export enum ChatFolderType { + UNKNOWN = 0, ALL = 1, CUSTOM = 2, } -export type ChatFolderId = string & { ChatFolderId: never }; +export type ChatFolderId = string & { ChatFolderId: never }; // uuid -export type ChatFolderRecord = Readonly<{ - id: ChatFolderId; - name: string; +export type ChatFolderPreset = Readonly<{ + folderType: ChatFolderType; showOnlyUnread: boolean; showMutedChats: boolean; includeAllIndividualChats: boolean; includeAllGroupChats: boolean; - folderType: ChatFolderType; includedConversationIds: ReadonlyArray; excludedConversationIds: ReadonlyArray; }>; -export type ChatFolderParams = Omit; -export type ChatFolderPreset = Omit; +export type ChatFolderParams = Readonly< + ChatFolderPreset & { + name: string; + } +>; + +export type ChatFolder = Readonly< + ChatFolderParams & { + id: ChatFolderId; + position: number; + deletedAtTimestampMs: number; + storageID: string | null; + storageVersion: number | null; + storageUnknownFields: Uint8Array | null; + storageNeedsSync: boolean; + } +>; + +export const ChatFolderPresetSchema = z.object({ + folderType: z.nativeEnum(ChatFolderType), + showOnlyUnread: z.boolean(), + showMutedChats: z.boolean(), + includeAllIndividualChats: z.boolean(), + includeAllGroupChats: z.boolean(), + includedConversationIds: z.array(z.string().uuid()).readonly(), + excludedConversationIds: z.array(z.string().uuid()).readonly(), +}) satisfies z.ZodType; + +export const ChatFolderParamsSchema = ChatFolderPresetSchema.extend({ + name: z.string().transform(input => input.normalize().trim()), +}) satisfies z.ZodType; + +export const ChatFolderSchema = ChatFolderParamsSchema.extend({ + id: z.intersection(z.string(), z.custom()), + position: z.number().int().gte(0), + deletedAtTimestampMs: z.number().int().positive(), + storageID: z.string().nullable(), + storageVersion: z.number().nullable(), + storageUnknownFields: z.instanceof(Uint8Array).nullable(), + storageNeedsSync: z.boolean(), +}) satisfies z.ZodType; export const CHAT_FOLDER_DEFAULTS: ChatFolderParams = { + folderType: ChatFolderType.CUSTOM, name: '', showOnlyUnread: false, - showMutedChats: false, + showMutedChats: true, includeAllIndividualChats: false, includeAllGroupChats: false, - folderType: ChatFolderType.CUSTOM, includedConversationIds: [], excludedConversationIds: [], }; export const CHAT_FOLDER_PRESETS = { UNREAD_CHATS: { + ...CHAT_FOLDER_DEFAULTS, showOnlyUnread: true, // only unread - showMutedChats: false, includeAllIndividualChats: true, // all 1:1's includeAllGroupChats: true, // all groups - folderType: ChatFolderType.CUSTOM, - includedConversationIds: [], - excludedConversationIds: [], }, INDIVIDUAL_CHATS: { - showOnlyUnread: false, - showMutedChats: false, + ...CHAT_FOLDER_DEFAULTS, includeAllIndividualChats: true, // all 1:1's - includeAllGroupChats: false, - folderType: ChatFolderType.CUSTOM, - includedConversationIds: [], - excludedConversationIds: [], }, GROUP_CHATS: { - showOnlyUnread: false, - showMutedChats: false, - includeAllIndividualChats: false, + ...CHAT_FOLDER_DEFAULTS, includeAllGroupChats: true, // all groups - folderType: ChatFolderType.CUSTOM, - includedConversationIds: [], - excludedConversationIds: [], }, } as const satisfies Record; export type ChatFolderPresetKey = keyof typeof CHAT_FOLDER_PRESETS; -export function normalizeChatFolderParams( - params: ChatFolderParams -): ChatFolderParams { - return { - ...params, - name: params.name.normalize().trim(), - }; -} - export function validateChatFolderParams(params: ChatFolderParams): boolean { return ( params.name !== '' && @@ -94,11 +112,11 @@ export function matchesChatFolderPreset( preset: ChatFolderPreset ): boolean { return ( + params.folderType === preset.folderType && params.showOnlyUnread === preset.showOnlyUnread && params.showMutedChats === preset.showMutedChats && params.includeAllIndividualChats === preset.includeAllIndividualChats && params.includeAllGroupChats === preset.includeAllGroupChats && - params.folderType === preset.folderType && isSameConversationIds( params.includedConversationIds, preset.includedConversationIds @@ -125,6 +143,16 @@ function isSameConversationIds( } export function isChatFoldersEnabled(): boolean { + const env = getEnvironment(); + + if ( + env === Environment.Development || + env === Environment.Test || + isMockEnvironment() + ) { + return true; + } + const version = window.getVersion?.(); if (version != null) { @@ -139,6 +167,5 @@ export function isChatFoldersEnabled(): boolean { } } - const env = getEnvironment(); - return env === Environment.Development || env === Environment.Test; + return false; } diff --git a/ts/util/privacy.ts b/ts/util/privacy.ts index 68d8b8304a..7e06be2449 100644 --- a/ts/util/privacy.ts +++ b/ts/util/privacy.ts @@ -30,7 +30,7 @@ export type RedactFunction = (value: string) => string; export function redactStorageID( storageID: string, - version?: number, + version?: number | null, conversation?: ConversationModel ): string { const convoId = conversation ? ` ${conversation?.idForLogging()}` : ''; diff --git a/ts/util/timeout.ts b/ts/util/timeout.ts index 7af877dd85..7b316b7f52 100644 --- a/ts/util/timeout.ts +++ b/ts/util/timeout.ts @@ -87,3 +87,16 @@ export class LongTimeout { this.#callback(); } } + +export function longTimeoutAsync( + ms: number, + signal: AbortSignal | null +): Promise { + return new Promise((resolve, reject) => { + const timeout = new LongTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + timeout.clear(); + reject(signal.reason); + }); + }); +} diff --git a/ts/windows/main/preload_test.ts b/ts/windows/main/preload_test.ts index 3a19195e6a..c120cac854 100644 --- a/ts/windows/main/preload_test.ts +++ b/ts/windows/main/preload_test.ts @@ -104,6 +104,7 @@ window.testUtilities = { callLinks: [], callHistory: [], callHistoryUnreadCount: 0, + chatFolders: [], gifs: { recentGifs: [], },