diff --git a/package.json b/package.json index 8f8efbfd8e..d0a94a781b 100644 --- a/package.json +++ b/package.json @@ -237,7 +237,7 @@ "@indutny/parallel-prettier": "3.0.0", "@indutny/rezip-electron": "2.0.1", "@napi-rs/canvas": "0.1.61", - "@signalapp/mock-server": "14.1.1", + "@signalapp/mock-server": "15.1.0", "@storybook/addon-a11y": "8.4.4", "@storybook/addon-actions": "8.4.4", "@storybook/addon-controls": "8.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bb41a2253..d96dad40fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,8 +439,8 @@ importers: specifier: 0.1.61 version: 0.1.61 '@signalapp/mock-server': - specifier: 14.1.1 - version: 14.1.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + specifier: 15.1.0 + version: 15.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@storybook/addon-a11y': specifier: 8.4.4 version: 8.4.4(storybook@8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10)) @@ -3471,8 +3471,8 @@ packages: '@signalapp/minimask@1.0.1': resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==} - '@signalapp/mock-server@14.1.1': - resolution: {integrity: sha512-NmzW4fsbKtHqb/aOtrxCw43ir0Yhpxy776ifHWYL0Fd+4fz3J7EVbNTTgDmYc4iOncM+z/GcrSOzMnPp8RMoWQ==} + '@signalapp/mock-server@15.1.0': + resolution: {integrity: sha512-7tZuwkCNNB0GUEmNwGpuGVNIwRV3SgvLX60LQ44cZbW2kd9XgYDfB7ZcaIBFmPjh+2bknMzbaugNR2ilQS6NJw==} '@signalapp/parchment-cjs@3.0.1': resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==} @@ -14243,7 +14243,7 @@ snapshots: '@signalapp/minimask@1.0.1': {} - '@signalapp/mock-server@14.1.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + '@signalapp/mock-server@15.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)': dependencies: '@indutny/parallel-prettier': 3.0.0(prettier@3.3.3) '@signalapp/libsignal-client': 0.76.7 diff --git a/ts/services/storage.ts b/ts/services/storage.ts index cf67ab0540..85a427b641 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -2351,14 +2351,16 @@ export function storageServiceUploadJobAfterEnabled({ if (storageServiceEnabled) { return storageServiceUploadJob({ reason }); } - log.info(`storageServiceNeedsUploadAfterEnabled: ${reason}`); + log.info( + `storageServiceNeedsUploadAfterEnabled(${reason}): waiting until enabled` + ); storageServiceNeedsUploadAfterEnabled = true; } export const storageServiceUploadJob = debounce( ({ reason }: { reason: string }) => { if (!storageServiceEnabled) { - log.info('storageServiceUploadJob: called before enabled'); + log.info(`storageServiceUploadJob(${reason}): called before enabled `); return; } diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 797fac7dde..2290d97c16 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -2410,7 +2410,10 @@ export async function mergeChatFolderRecord( deletedAtTimestampMs = remoteDeletedAt; } - if (deletedAtTimestampMs > 0) { + if (remoteChatFolder.folderType === ChatFolderType.ALL) { + log.info(`${logPrefix}: Updating or inserting all chats folder`); + await DataWriter.upsertAllChatsChatFolderFromSync(remoteChatFolder); + } else if (deletedAtTimestampMs > 0) { if (localChatFolder == null) { log.info( `${logPrefix}: skipping deleted chat folder, no local record found` @@ -2420,9 +2423,11 @@ export async function mergeChatFolderRecord( `${logPrefix}: skipping deleted chat folder, local record already deleted` ); } else if (localDeletedAt > 0) { - log.info(`${logPrefix}: updating deleted chat folder timestamp `); + log.info(`${logPrefix}: updating deleted chat folder timestamp`); + await DataWriter.updateChatFolderDeletedAtTimestampMsFromSync( remoteChatFolder.id, + // `deletedAtTimestampMs` should already be the earlier delete timestamp deletedAtTimestampMs ); drop(chatFolderCleanupService.trigger('storage: updated timestamp')); @@ -2433,21 +2438,18 @@ export async function mergeChatFolderRecord( 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); } + window.reduxActions.chatFolders.refetchChatFolders(); + const details = logRecordChanges( localChatFolder != null ? toChatFolderRecord(localChatFolder) : undefined, remoteChatFolderRecord diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 93dd3170f2..7b8e6e271c 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -1263,6 +1263,7 @@ type WritableInterface = { createChatFolder: (chatFolder: ChatFolder) => void; createAllChatsChatFolder: () => ChatFolder; + upsertAllChatsChatFolderFromSync: (chatFolder: ChatFolder) => void; updateChatFolder: (chatFolder: ChatFolder) => void; updateChatFolderPositions: (chatFolders: ReadonlyArray) => void; updateChatFolderDeletedAtTimestampMsFromSync: ( diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 8aeb0309bc..7c47b480cd 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -244,6 +244,7 @@ import { createChatFolder, hasAllChatsChatFolder, createAllChatsChatFolder, + upsertAllChatsChatFolderFromSync, updateChatFolder, markChatFolderDeleted, getOldestDeletedChatFolder, @@ -707,6 +708,7 @@ export const DataWriter: ServerWritableInterface = { createChatFolder, createAllChatsChatFolder, + upsertAllChatsChatFolderFromSync, updateChatFolder, markChatFolderDeleted, deleteExpiredChatFolders, @@ -5779,9 +5781,9 @@ function _getAttachmentDownloadJob( function removeAllBackupAttachmentDownloadJobs(db: WritableDB): void { const [query, params] = sql` DELETE FROM attachment_downloads - WHERE - source = ${AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA} - OR + WHERE + source = ${AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA} + OR source = ${AttachmentDownloadSource.BACKUP_IMPORT_NO_MEDIA};`; db.prepare(query).run(params); } diff --git a/ts/sql/migrations/1480-chat-folders-remove-duplicates.ts b/ts/sql/migrations/1480-chat-folders-remove-duplicates.ts new file mode 100644 index 0000000000..ef2e8319f4 --- /dev/null +++ b/ts/sql/migrations/1480-chat-folders-remove-duplicates.ts @@ -0,0 +1,24 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { LoggerType } from '../../types/Logging.js'; +import type { WritableDB } from '../Interface.js'; +import { sql } from '../util.js'; + +export default function updateToSchemaVersion1480( + db: WritableDB, + logger: LoggerType +): void { + const [query, params] = sql` + DELETE FROM chatFolders + WHERE folderType IS 1 + AND id NOT IN ( + SELECT id FROM chatFolders + WHERE folderType IS 1 + ORDER BY storageVersion DESC + LIMIT 1 + ) + `; + const result = db.prepare(query).run(params); + logger.info(`Removed ${result.changes} duplicate all chats chat folders`); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index a19e20f0fb..9231dddb97 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -123,6 +123,7 @@ import updateToSchemaVersion1440 from './1440-chat-folders.js'; import updateToSchemaVersion1450 from './1450-all-media.js'; import updateToSchemaVersion1460 from './1460-attachment-duration.js'; import updateToSchemaVersion1470 from './1470-kyber-triple.js'; +import updateToSchemaVersion1480 from './1480-chat-folders-remove-duplicates.js'; import { DataWriter } from '../Server.js'; @@ -1603,6 +1604,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1450, update: updateToSchemaVersion1450 }, { version: 1460, update: updateToSchemaVersion1460 }, { version: 1470, update: updateToSchemaVersion1470 }, + { version: 1480, update: updateToSchemaVersion1480 }, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/sql/server/chatFolders.ts b/ts/sql/server/chatFolders.ts index c939384fcf..40662521b9 100644 --- a/ts/sql/server/chatFolders.ts +++ b/ts/sql/server/chatFolders.ts @@ -10,6 +10,7 @@ import { } from '../../types/ChatFolder.js'; import type { ReadableDB, WritableDB } from '../Interface.js'; import { sql } from '../util.js'; +import { strictAssert } from '../../util/assert.js'; export type ChatFolderRow = Readonly< Omit< @@ -181,6 +182,37 @@ export function createAllChatsChatFolder(db: WritableDB): ChatFolder { })(); } +export function upsertAllChatsChatFolderFromSync( + db: WritableDB, + chatFolder: ChatFolder +): void { + return db.transaction(() => { + strictAssert( + chatFolder.folderType === ChatFolderType.ALL, + 'Chat folder must have folderType=ALL' + ); + + if (hasAllChatsChatFolder(db)) { + const chatFolderRow = chatFolderToRow(chatFolder); + const [query, params] = sql` + UPDATE chatFolders + SET + id = ${chatFolderRow.id}, + position = ${chatFolderRow.position}, + storageID = ${chatFolderRow.storageID}, + storageVersion = ${chatFolderRow.storageVersion}, + storageUnknownFields = ${chatFolderRow.storageUnknownFields}, + storageNeedsSync = ${chatFolderRow.storageNeedsSync} + WHERE + folderType = ${ChatFolderType.ALL} + `; + db.prepare(query).run(params); + } else { + _insertChatFolder(db, chatFolder); + } + })(); +} + export function updateChatFolder(db: WritableDB, chatFolder: ChatFolder): void { return db.transaction(() => { const chatFolderRow = chatFolderToRow(chatFolder); diff --git a/ts/state/ducks/chatFolders.ts b/ts/state/ducks/chatFolders.ts index ed1152f78e..34f366f337 100644 --- a/ts/state/ducks/chatFolders.ts +++ b/ts/state/ducks/chatFolders.ts @@ -3,6 +3,7 @@ import type { ReadonlyDeep } from 'type-fest'; import { v4 as generateUuid } from 'uuid'; import type { ThunkAction } from 'redux-thunk'; +import { throttle } from 'lodash'; import type { StateType as RootStateType } from '../reducer.js'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.js'; import { useBoundActions } from '../../hooks/useBoundActions.js'; @@ -10,7 +11,6 @@ import { ChatFolderParamsSchema, lookupCurrentChatFolder, toCurrentChatFolders, - getSortedCurrentChatFolders, type ChatFolder, type ChatFolderId, type ChatFolderParams, @@ -35,9 +35,6 @@ export type ChatFoldersState = ReadonlyDeep<{ const CHAT_FOLDER_RECORD_REPLACE_ALL = 'chatFolders/CHAT_FOLDER_RECORD_REPLACE_ALL'; -const CHAT_FOLDER_RECORD_ADD = 'chatFolders/RECORD_ADD'; -const CHAT_FOLDER_RECORD_REPLACE = 'chatFolders/RECORD_REPLACE'; -const CHAT_FOLDER_RECORD_REMOVE = 'chatFolders/RECORD_REMOVE'; const CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID = 'chatFolders/CHANGE_SELECTED_CHAT_FOLDER_ID'; @@ -46,32 +43,13 @@ export type ChatFolderRecordReplaceAll = ReadonlyDeep<{ payload: CurrentChatFolders; }>; -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 ChatFolderChangeSelectedChatFolderId = ReadonlyDeep<{ type: typeof CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID; payload: ChatFolderId | null; }>; export type ChatFolderAction = ReadonlyDeep< - | ChatFolderRecordReplaceAll - | ChatFolderRecordAdd - | ChatFolderRecordReplace - | ChatFolderRecordRemove - | ChatFolderChangeSelectedChatFolderId + ChatFolderRecordReplaceAll | ChatFolderChangeSelectedChatFolderId >; export function getEmptyState(): ChatFoldersState { @@ -94,34 +72,26 @@ function replaceAllChatFolderRecords( }; } -function addChatFolderRecord(chatFolder: ChatFolder): ChatFolderRecordAdd { - return { - type: CHAT_FOLDER_RECORD_ADD, - payload: chatFolder, +function _refetchChatFolders(): ThunkAction< + void, + RootStateType, + unknown, + ChatFolderRecordReplaceAll +> { + return async dispatch => { + const chatFolders = await DataReader.getCurrentChatFolders(); + const currentChatFolders = toCurrentChatFolders(chatFolders); + dispatch(replaceAllChatFolderRecords(currentChatFolders)); }; } -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, - }; -} +// Note: 100ms is just the max amount of time before +// users start to perceive a delay +const refetchChatFolders = throttle(_refetchChatFolders, 100); function createChatFolder( chatFolderParams: ChatFolderParams -): ThunkAction { +): ThunkAction { return async (dispatch, getState) => { const chatFolders = getCurrentChatFolders(getState()); @@ -138,7 +108,7 @@ function createChatFolder( await DataWriter.createChatFolder(chatFolder); storageServiceUploadJob({ reason: 'createChatFolder' }); - dispatch(addChatFolderRecord(chatFolder)); + dispatch(_refetchChatFolders()); }; } @@ -151,15 +121,14 @@ function createAllChatsChatFolder(): ThunkAction< return async dispatch => { await DataWriter.createAllChatsChatFolder(); storageServiceUploadJob({ reason: 'createAllChatsChatFolder' }); - const chatFolders = await DataReader.getCurrentChatFolders(); - dispatch(replaceAllChatFolderRecords(toCurrentChatFolders(chatFolders))); + dispatch(_refetchChatFolders()); }; } function updateChatFolder( chatFolderId: ChatFolderId, chatFolderParams: ChatFolderParams -): ThunkAction { +): ThunkAction { return async (dispatch, getState) => { const currentChatFolders = getCurrentChatFolders(getState()); @@ -176,24 +145,24 @@ function updateChatFolder( await DataWriter.updateChatFolder(nextChatFolder); storageServiceUploadJob({ reason: 'updateChatFolder' }); - dispatch(replaceChatFolderRecord(nextChatFolder)); + dispatch(_refetchChatFolders()); }; } function deleteChatFolder( chatFolderId: ChatFolderId -): ThunkAction { +): ThunkAction { return async dispatch => { await DataWriter.markChatFolderDeleted(chatFolderId, Date.now(), true); storageServiceUploadJob({ reason: 'deleteChatFolder' }); - dispatch(removeChatFolderRecord(chatFolderId)); + dispatch(_refetchChatFolders()); drop(chatFolderCleanupService.trigger('redux: deleted chat folder')); }; } function updateChatFoldersPositions( chatFolderIds: ReadonlyArray -): ThunkAction { +): ThunkAction { return async (dispatch, getState) => { const currentChatFolders = getCurrentChatFolders(getState()); const chatFolders = chatFolderIds.map((chatFolderId, index) => { @@ -205,7 +174,7 @@ function updateChatFoldersPositions( }); await DataWriter.updateChatFolderPositions(chatFolders); storageServiceUploadJob({ reason: 'updateChatFoldersPositions' }); - dispatch(replaceAllChatFolderRecords(toCurrentChatFolders(chatFolders))); + dispatch(_refetchChatFolders()); }; } @@ -219,10 +188,7 @@ function updateSelectedChangeFolderId( } export const actions = { - replaceAllChatFolderRecords, - addChatFolderRecord, - replaceChatFolderRecord, - removeChatFolderRecord, + refetchChatFolders, createChatFolder, createAllChatsChatFolder, updateChatFolder, @@ -245,37 +211,6 @@ export function reducer( ...state, currentChatFolders: action.payload, }; - case CHAT_FOLDER_RECORD_ADD: - return { - ...state, - currentChatFolders: toCurrentChatFolders([ - ...getSortedCurrentChatFolders(state.currentChatFolders), - action.payload, - ]), - }; - case CHAT_FOLDER_RECORD_REPLACE: - return { - ...state, - currentChatFolders: toCurrentChatFolders([ - ...getSortedCurrentChatFolders(state.currentChatFolders).filter( - chatFolder => { - return chatFolder.id !== action.payload.id; - } - ), - action.payload, - ]), - }; - case CHAT_FOLDER_RECORD_REMOVE: - return { - ...state, - currentChatFolders: toCurrentChatFolders( - getSortedCurrentChatFolders(state.currentChatFolders).filter( - chatFolder => { - return chatFolder.id !== action.payload; - } - ) - ), - }; case CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID: return { ...state, diff --git a/ts/test-mock/storage/chat_folder_test.ts b/ts/test-mock/storage/chat_folder_test.ts index 796c1644b2..6897509b74 100644 --- a/ts/test-mock/storage/chat_folder_test.ts +++ b/ts/test-mock/storage/chat_folder_test.ts @@ -5,14 +5,70 @@ import { v4 as generateUuid } from 'uuid'; import { Proto, StorageState } from '@signalapp/mock-server'; import type { Page } from 'playwright/test'; import { expect } from 'playwright/test'; +import type { StorageStateNewRecord } from '@signalapp/mock-server/src/api/storage-state.js'; import * as durations from '../../util/durations/index.js'; import type { App } from './fixtures.js'; import { Bootstrap, debug, getChatFolderRecordPredicate } from './fixtures.js'; -import { uuidToBytes } from '../../util/uuidToBytes.js'; +import { bytesToUuid, uuidToBytes } from '../../util/uuidToBytes.js'; import { CHAT_FOLDER_DELETED_POSITION } from '../../types/ChatFolder.js'; +import { strictAssert } from '../../util/assert.js'; const IdentifierType = Proto.ManifestRecord.Identifier.Type; +const ALL_CHATS_PREDICATE = getChatFolderRecordPredicate('ALL', '', false); + +async function openChatFolderSettings(window: Page) { + 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")' + ); + + await openSettingsBtn.click(); + await openChatsSettingsBtn.click(); + await openChatFoldersSettingsBtn.click(); +} + +function countAllChatsInStorageState(state: StorageState): number { + return state.filterRecords(ALL_CHATS_PREDICATE).length; +} + +function getAllChatsListItem(window: Page) { + return window + .getByTestId('ChatFoldersList') + .locator('.Preferences__ChatFolders__ChatSelection__Item') + .getByText('All chats'); +} + +function getGroupsPresetAddButton(window: Page) { + return window + .getByTestId('ChatFolderPreset--GroupChats') + .locator('button:has-text("Add")'); +} + +function createAllChatsRecord(id: string): StorageStateNewRecord { + return { + type: IdentifierType.CHAT_FOLDER, + record: { + chatFolder: { + id: uuidToBytes(id), + folderType: Proto.ChatFolderRecord.FolderType.ALL, + name: '', + position: 0, + showOnlyUnread: false, + showMutedChats: false, + includeAllIndividualChats: false, + includeAllGroupChats: true, + includedRecipients: [], + excludedRecipients: [], + deletedAtTimestampMs: Long.fromNumber(0), + }, + }, + }; +} + describe('storage service/chat folders', function (this: Mocha.Suite) { this.timeout(durations.MINUTE); @@ -49,34 +105,15 @@ describe('storage service/chat folders', function (this: Mocha.Suite) { await bootstrap.teardown(); }); - async function openChatFolderSettings(window: Page) { - 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")' - ); - - await openSettingsBtn.click(); - await openChatsSettingsBtn.click(); - await openChatFoldersSettingsBtn.click(); - } - it('should update from storage service', async () => { const { phone } = bootstrap; const window = await app.getWindow(); - const ALL_CHATS_PREDICATE = getChatFolderRecordPredicate('ALL', '', false); - const ALL_GROUPS_ID = generateUuid(); const ALL_GROUPS_NAME = 'All Groups'; const ALL_GROUPS_NAME_UPDATED = 'The Groups'; - const allChatsListItem = window - .getByTestId('ChatFoldersList') - .locator('.Preferences__ChatFolders__ChatSelection__Item') - .getByText('All chats'); + const allChatsListItem = getAllChatsListItem(window); const allGroupsListItem = window.getByTestId( `ChatFolder--${ALL_GROUPS_ID}` @@ -182,16 +219,8 @@ describe('storage service/chat folders', function (this: Mocha.Suite) { const { phone } = bootstrap; const window = await app.getWindow(); - const ALL_CHATS_PREDICATE = getChatFolderRecordPredicate('ALL', '', false); - - const allChatsListItem = window - .getByTestId('ChatFoldersList') - .locator('.Preferences__ChatFolders__ChatSelection__Item') - .getByText('All chats'); - - const groupPresetBtn = window - .getByTestId('ChatFolderPreset--GroupChats') - .locator('button:has-text("Add")'); + const allChatsListItem = getAllChatsListItem(window); + const groupPresetBtn = getGroupsPresetAddButton(window); const groupsFolderBtn = window .getByTestId('ChatFoldersList') @@ -279,8 +308,6 @@ describe('storage service/chat folders', function (this: Mocha.Suite) { const { phone } = bootstrap; const window = await app.getWindow(); - const ALL_CHATS_PREDICATE = getChatFolderRecordPredicate('ALL', '', false); - let state = await phone.expectStorageState('initial state'); expect(state.version).toBe(1); expect(state.hasRecord(ALL_CHATS_PREDICATE)).toBe(false); @@ -291,18 +318,11 @@ describe('storage service/chat folders', function (this: Mocha.Suite) { expect(state.hasRecord(ALL_CHATS_PREDICATE)).toBe(true); await openChatFolderSettings(window); + const allChatsListItem = getAllChatsListItem(window); + const groupPresetAddButton = getGroupsPresetAddButton(window); // update record - state = state.updateRecord(ALL_CHATS_PREDICATE, item => { - return { - ...item, - chatFolder: { - ...item.chatFolder, - position: CHAT_FOLDER_DELETED_POSITION, - deletedAtTimestampMs: Long.fromNumber(Date.now()), - }, - }; - }); + state = state.removeRecord(ALL_CHATS_PREDICATE); state = await phone.setStorageState(state); expect(state.version).toBe(3); expect(state.hasRecord(ALL_CHATS_PREDICATE)).toBe(false); @@ -311,9 +331,60 @@ describe('storage service/chat folders', function (this: Mocha.Suite) { await phone.sendFetchStorage({ timestamp: bootstrap.getTimestamp() }); await app.waitForManifestVersion(state.version); - // wait for app to insert a new "All chats" chat folder + await expect(allChatsListItem).toBeVisible(); + + // Trigger another storage upload + await groupPresetAddButton.click(); state = await phone.waitForStorageState({ after: state }); + expect(state.version).toBe(4); expect(state.hasRecord(ALL_CHATS_PREDICATE)).toBe(true); }); + + it('should remove duplicate all chats folders from storage service', async () => { + const { phone } = bootstrap; + const window = await app.getWindow(); + + let state = await phone.expectStorageState('initial state'); + // wait for initial creation of story distribution list and "all chats" chat folder + state = await phone.waitForStorageState({ after: state }); + + expect(countAllChatsInStorageState(state)).toBe(1); + + await openChatFolderSettings(window); + const allChatsListItem = getAllChatsListItem(window); + const groupPresetAddButton = getGroupsPresetAddButton(window); + + const ONE = generateUuid(); + const TWO = generateUuid(); + + state = state.addRecord(createAllChatsRecord(ONE)); + state = state.addRecord(createAllChatsRecord(TWO)); + + state = await phone.setStorageState(state); + await phone.sendFetchStorage({ timestamp: bootstrap.getTimestamp() }); + await app.waitForManifestVersion(state.version); + + expect(countAllChatsInStorageState(state)).toBe(3); + + // It should not have created two "all chats" folders + await expect(allChatsListItem).toHaveCount(1); + + // Trigger another storage upload + await groupPresetAddButton.click(); + state = await phone.waitForStorageState({ after: state }); + + // App should have removed one of the "all chats" folders + expect(countAllChatsInStorageState(state)).toBe(1); + + // Make sure we took the updated id + const item = state.findRecord(ALL_CHATS_PREDICATE); + const idBytes = item?.record.chatFolder?.id; + strictAssert(idBytes != null, 'Missing all chats record with id'); + const id = bytesToUuid(idBytes); + strictAssert(id != null, 'All chats record id was not valid uuid'); + + // Records are processed concurrently so it could be either id + expect([ONE, TWO].includes(id)).toBe(true); + }); }); diff --git a/ts/test-mock/storage/drop_test.ts b/ts/test-mock/storage/drop_test.ts index ed70f641e3..a3770e396f 100644 --- a/ts/test-mock/storage/drop_test.ts +++ b/ts/test-mock/storage/drop_test.ts @@ -115,7 +115,7 @@ describe('storage service', function (this: Mocha.Suite) { type: IdentifierType.ACCOUNT, record: oldAccount.record, }) - .updateAccount({}) + .updateManyAccounts({}) ); debug('sending fetch storage');