Fix missing all chat folder on startup without new manifest

This commit is contained in:
Jamie Kyle 2025-10-01 16:59:29 -07:00 committed by GitHub
commit 4973b9b204
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 192 additions and 92 deletions

View file

@ -1014,6 +1014,17 @@ export async function startApp(): Promise<void> {
) { ) {
await window.storage.put('needProfileMovedModal', true); await window.storage.put('needProfileMovedModal', true);
} }
if (window.isBeforeVersion(lastVersion, 'v7.75.0-beta.1')) {
const hasAllChatsChatFolder = await DataReader.hasAllChatsChatFolder();
if (!hasAllChatsChatFolder) {
log.info('Creating "all chats" chat folder');
await DataWriter.createAllChatsChatFolder();
StorageService.storageServiceUploadJobAfterEnabled({
reason: 'createAllChatsChatFolder',
});
}
}
} }
setAppLoadingScreenMessage( setAppLoadingScreenMessage(

View file

@ -88,11 +88,7 @@ import { isDone as isRegistrationDone } from '../util/registration.js';
import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue.js'; import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue.js';
import { isMockEnvironment } from '../environment.js'; import { isMockEnvironment } from '../environment.js';
import { validateConversation } from '../util/validateConversation.js'; import { validateConversation } from '../util/validateConversation.js';
import { import { hasAllChatsChatFolder, type ChatFolder } from '../types/ChatFolder.js';
ChatFolderType,
toCurrentChatFolders,
type ChatFolder,
} from '../types/ChatFolder.js';
const { debounce, isNumber, chunk } = lodash; const { debounce, isNumber, chunk } = lodash;
@ -1663,20 +1659,9 @@ async function processManifest(
}); });
}); });
const chatFoldersHasAllChatsFolder = chatFolders.some(chatFolder => { if (!hasAllChatsChatFolder(chatFolders)) {
return (
chatFolder.folderType === ChatFolderType.ALL &&
chatFolder.deletedAtTimestampMs === 0
);
});
if (!chatFoldersHasAllChatsFolder) {
log.info(`process(${version}): creating all chats chat folder`); log.info(`process(${version}): creating all chats chat folder`);
await DataWriter.createAllChatsChatFolder(); window.reduxActions.chatFolders.createAllChatsChatFolder();
const currentChatFolders = await DataReader.getCurrentChatFolders();
window.reduxActions.chatFolders.replaceAllChatFolderRecords(
toCurrentChatFolders(currentChatFolders)
);
} }
} }
@ -2235,6 +2220,7 @@ async function upload({
} }
let storageServiceEnabled = false; let storageServiceEnabled = false;
let storageServiceNeedsUploadAfterEnabled = false;
export function enableStorageService(): void { export function enableStorageService(): void {
if (storageServiceEnabled) { if (storageServiceEnabled) {
@ -2243,6 +2229,12 @@ export function enableStorageService(): void {
storageServiceEnabled = true; storageServiceEnabled = true;
log.info('enableStorageService'); log.info('enableStorageService');
if (storageServiceNeedsUploadAfterEnabled) {
storageServiceUploadJob({
reason: 'storageServiceNeedsUploadAfterEnabled',
});
}
} }
export function disableStorageService(reason: string): void { export function disableStorageService(reason: string): void {
@ -2351,6 +2343,18 @@ export async function reprocessUnknownFields(): Promise<void> {
); );
} }
export function storageServiceUploadJobAfterEnabled({
reason,
}: {
reason: string;
}): void {
if (storageServiceEnabled) {
return storageServiceUploadJob({ reason });
}
log.info(`storageServiceNeedsUploadAfterEnabled: ${reason}`);
storageServiceNeedsUploadAfterEnabled = true;
}
export const storageServiceUploadJob = debounce( export const storageServiceUploadJob = debounce(
({ reason }: { reason: string }) => { ({ reason }: { reason: string }) => {
if (!storageServiceEnabled) { if (!storageServiceEnabled) {

View file

@ -923,6 +923,7 @@ type ReadableInterface = {
getAllChatFolders: () => ReadonlyArray<ChatFolder>; getAllChatFolders: () => ReadonlyArray<ChatFolder>;
getCurrentChatFolders: () => ReadonlyArray<ChatFolder>; getCurrentChatFolders: () => ReadonlyArray<ChatFolder>;
getChatFolder: (id: ChatFolderId) => ChatFolder | null; getChatFolder: (id: ChatFolderId) => ChatFolder | null;
hasAllChatsChatFolder: () => boolean;
getOldestDeletedChatFolder: () => ChatFolder | null; getOldestDeletedChatFolder: () => ChatFolder | null;
getMessagesNeedingUpgrade: ( getMessagesNeedingUpgrade: (

View file

@ -240,6 +240,7 @@ import {
getCurrentChatFolders, getCurrentChatFolders,
getChatFolder, getChatFolder,
createChatFolder, createChatFolder,
hasAllChatsChatFolder,
createAllChatsChatFolder, createAllChatsChatFolder,
updateChatFolder, updateChatFolder,
markChatFolderDeleted, markChatFolderDeleted,
@ -459,6 +460,7 @@ export const DataReader: ServerReadableInterface = {
getAllChatFolders, getAllChatFolders,
getCurrentChatFolders, getCurrentChatFolders,
getChatFolder, getChatFolder,
hasAllChatsChatFolder,
getOldestDeletedChatFolder, getOldestDeletedChatFolder,
callLinkExists, callLinkExists,

View file

@ -146,6 +146,19 @@ export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
})(); })();
} }
export function hasAllChatsChatFolder(db: ReadableDB): boolean {
const [query, params] = sql`
SELECT EXISTS (
SELECT 1 FROM chatFolders
WHERE folderType IS ${ChatFolderType.ALL}
AND deletedAtTimestampMs IS 0
LIMIT 1
)
`;
const result = db.prepare(query, { pluck: true }).get<number>(params);
return result === 1;
}
export function createAllChatsChatFolder(db: WritableDB): ChatFolder { export function createAllChatsChatFolder(db: WritableDB): ChatFolder {
return db.transaction(() => { return db.transaction(() => {
const allChatsChatFolder: ChatFolder = { const allChatsChatFolder: ChatFolder = {

View file

@ -17,7 +17,7 @@ import {
type CurrentChatFolders, type CurrentChatFolders,
} from '../../types/ChatFolder.js'; } from '../../types/ChatFolder.js';
import { getCurrentChatFolders } from '../selectors/chatFolders.js'; import { getCurrentChatFolders } from '../selectors/chatFolders.js';
import { DataWriter } from '../../sql/Client.js'; import { DataReader, DataWriter } from '../../sql/Client.js';
import { storageServiceUploadJob } from '../../services/storage.js'; import { storageServiceUploadJob } from '../../services/storage.js';
import { parseStrict } from '../../util/schemas.js'; import { parseStrict } from '../../util/schemas.js';
import { chatFolderCleanupService } from '../../services/expiring/chatFolderCleanupService.js'; import { chatFolderCleanupService } from '../../services/expiring/chatFolderCleanupService.js';
@ -142,6 +142,20 @@ function createChatFolder(
}; };
} }
function createAllChatsChatFolder(): ThunkAction<
void,
RootStateType,
unknown,
ChatFolderRecordReplaceAll
> {
return async dispatch => {
await DataWriter.createAllChatsChatFolder();
storageServiceUploadJob({ reason: 'createAllChatsChatFolder' });
const chatFolders = await DataReader.getCurrentChatFolders();
dispatch(replaceAllChatFolderRecords(toCurrentChatFolders(chatFolders)));
};
}
function updateChatFolder( function updateChatFolder(
chatFolderId: ChatFolderId, chatFolderId: ChatFolderId,
chatFolderParams: ChatFolderParams chatFolderParams: ChatFolderParams
@ -210,6 +224,7 @@ export const actions = {
replaceChatFolderRecord, replaceChatFolderRecord,
removeChatFolderRecord, removeChatFolderRecord,
createChatFolder, createChatFolder,
createAllChatsChatFolder,
updateChatFolder, updateChatFolder,
deleteChatFolder, deleteChatFolder,
updateChatFoldersPositions, updateChatFoldersPositions,

View file

@ -3,10 +3,11 @@
import Long from 'long'; import Long from 'long';
import { v4 as generateUuid } from 'uuid'; import { v4 as generateUuid } from 'uuid';
import { Proto, StorageState } from '@signalapp/mock-server'; import { Proto, StorageState } from '@signalapp/mock-server';
import type { Page } from 'playwright/test';
import { expect } from 'playwright/test'; import { expect } from 'playwright/test';
import * as durations from '../../util/durations/index.js'; import * as durations from '../../util/durations/index.js';
import type { App } from './fixtures.js'; import type { App } from './fixtures.js';
import { Bootstrap, debug } from './fixtures.js'; import { Bootstrap, debug, getChatFolderRecordPredicate } from './fixtures.js';
import { uuidToBytes } from '../../util/uuidToBytes.js'; import { uuidToBytes } from '../../util/uuidToBytes.js';
import { CHAT_FOLDER_DELETED_POSITION } from '../../types/ChatFolder.js'; import { CHAT_FOLDER_DELETED_POSITION } from '../../types/ChatFolder.js';
@ -48,10 +49,7 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
await bootstrap.teardown(); await bootstrap.teardown();
}); });
it('should update from storage service', async () => { async function openChatFolderSettings(window: Page) {
const { phone } = bootstrap;
const window = await app.getWindow();
const openSettingsBtn = window.locator( const openSettingsBtn = window.locator(
'[data-testid="NavTabsItem--Settings"]' '[data-testid="NavTabsItem--Settings"]'
); );
@ -60,50 +58,42 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
'.Preferences__control:has-text("Add a chat folder")' '.Preferences__control:has-text("Add a chat folder")'
); );
const ALL_CHATS_ID = generateUuid(); 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_ID = generateUuid();
const ALL_GROUPS_NAME = 'All Groups'; const ALL_GROUPS_NAME = 'All Groups';
const ALL_GROUPS_NAME_UPDATED = 'The Groups'; const ALL_GROUPS_NAME_UPDATED = 'The Groups';
const allChatsListItem = window.getByTestId(`ChatFolder--${ALL_CHATS_ID}`); const allChatsListItem = window
.getByTestId('ChatFoldersList')
.locator('.Preferences__ChatFolders__ChatSelection__Item')
.getByText('All chats');
const allGroupsListItem = window.getByTestId( const allGroupsListItem = window.getByTestId(
`ChatFolder--${ALL_GROUPS_ID}` `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'); let state = await phone.expectStorageState('initial state');
// wait for initial creation of story distribution list and "all chats" chat folder
state = state.addRecord({ state = await phone.waitForStorageState({ after: state });
type: IdentifierType.CHAT_FOLDER, expect(state.hasRecord(ALL_CHATS_PREDICATE)).toBe(true);
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();
} }
await openChatFolderSettings(window);
debug('expect all chats folder to be created');
await expect(allChatsListItem).toBeVisible();
debug('adding "All Groups" chat folder via storage service'); debug('adding "All Groups" chat folder via storage service');
{ {
let state = await phone.expectStorageState('adding all groups'); let state = await phone.expectStorageState('adding all groups');
@ -140,9 +130,7 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
let state = await phone.expectStorageState('updating all groups'); let state = await phone.expectStorageState('updating all groups');
state = state.updateRecord( state = state.updateRecord(
item => { getChatFolderRecordPredicate('CUSTOM', ALL_GROUPS_NAME, false),
return item.record.chatFolder?.name === ALL_GROUPS_NAME;
},
item => { item => {
return { return {
...item, ...item,
@ -168,9 +156,7 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
let state = await phone.expectStorageState('removing all groups'); let state = await phone.expectStorageState('removing all groups');
state = state.updateRecord( state = state.updateRecord(
item => { getChatFolderRecordPredicate('CUSTOM', ALL_GROUPS_NAME_UPDATED, false),
return item.record.chatFolder?.name === ALL_GROUPS_NAME_UPDATED;
},
item => { item => {
return { return {
...item, ...item,
@ -196,13 +182,12 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
const { phone } = bootstrap; const { phone } = bootstrap;
const window = await app.getWindow(); const window = await app.getWindow();
const openSettingsBtn = window.locator( const ALL_CHATS_PREDICATE = getChatFolderRecordPredicate('ALL', '', false);
'[data-testid="NavTabsItem--Settings"]'
); const allChatsListItem = window
const openChatsSettingsBtn = window.locator('.Preferences__button--chats'); .getByTestId('ChatFoldersList')
const openChatFoldersSettingsBtn = window.locator( .locator('.Preferences__ChatFolders__ChatSelection__Item')
'.Preferences__control:has-text("Add a chat folder")' .getByText('All chats');
);
const groupPresetBtn = window const groupPresetBtn = window
.getByTestId('ChatFolderPreset--GroupChats') .getByTestId('ChatFolderPreset--GroupChats')
@ -228,27 +213,26 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
.locator('button:has-text("Delete")'); .locator('button:has-text("Delete")');
let state = await phone.expectStorageState('initial state'); let state = await phone.expectStorageState('initial state');
// wait for initial creation of story distribution list // wait for initial creation of story distribution list and "all chats" chat folder
state = await phone.waitForStorageState({ after: state }); state = await phone.waitForStorageState({ after: state });
expect(state.hasRecord(ALL_CHATS_PREDICATE)).toBe(true);
await openChatFolderSettings(window);
debug('expect all chats folder to be created');
await expect(allChatsListItem).toBeVisible();
debug('creating group'); debug('creating group');
{ {
await openSettingsBtn.click();
await openChatsSettingsBtn.click();
await openChatFoldersSettingsBtn.click();
await groupPresetBtn.click(); await groupPresetBtn.click();
await expect(groupsFolderBtn).toBeVisible(); await expect(groupsFolderBtn).toBeVisible();
debug('waiting for storage sync'); debug('waiting for storage sync');
state = await phone.waitForStorageState({ after: state }); state = await phone.waitForStorageState({ after: state });
const found = state.hasRecord(item => { const found = state.hasRecord(
return ( getChatFolderRecordPredicate('CUSTOM', 'Groups', false)
item.type === IdentifierType.CHAT_FOLDER &&
item.record.chatFolder?.name === 'Groups'
); );
});
expect(found).toBe(true); expect(found).toBe(true);
} }
@ -262,12 +246,9 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
debug('waiting for storage sync'); debug('waiting for storage sync');
state = await phone.waitForStorageState({ after: state }); state = await phone.waitForStorageState({ after: state });
const found = state.hasRecord(item => { const found = state.hasRecord(
return ( getChatFolderRecordPredicate('CUSTOM', 'My Groups', false)
item.type === IdentifierType.CHAT_FOLDER &&
item.record.chatFolder?.name === 'My Groups'
); );
});
expect(found).toBe(true); expect(found).toBe(true);
} }
@ -281,12 +262,9 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
debug('waiting for storage sync'); debug('waiting for storage sync');
state = await phone.waitForStorageState({ after: state }); state = await phone.waitForStorageState({ after: state });
const found = state.findRecord(item => { const found = state.findRecord(
return ( getChatFolderRecordPredicate('CUSTOM', 'My Groups', true)
item.type === IdentifierType.CHAT_FOLDER &&
item.record.chatFolder?.name === 'My Groups'
); );
});
await expect(groupsFolderBtn).not.toBeAttached(); await expect(groupsFolderBtn).not.toBeAttached();
await expect(groupPresetBtn).toBeVisible(); await expect(groupPresetBtn).toBeVisible();
@ -296,4 +274,46 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
).toBeGreaterThan(0); ).toBeGreaterThan(0);
} }
}); });
it('should recover from all chats folder being deleted', async () => {
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);
// wait for initial creation of story distribution list and "all chats" chat folder
state = await phone.waitForStorageState({ after: state });
expect(state.version).toBe(2);
expect(state.hasRecord(ALL_CHATS_PREDICATE)).toBe(true);
await openChatFolderSettings(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 = await phone.setStorageState(state);
expect(state.version).toBe(3);
expect(state.hasRecord(ALL_CHATS_PREDICATE)).toBe(false);
// sync from phone to app
await phone.sendFetchStorage({ timestamp: bootstrap.getTimestamp() });
await app.waitForManifestVersion(state.version);
// wait for app to insert a new "All chats" chat folder
state = await phone.waitForStorageState({ after: state });
expect(state.version).toBe(4);
expect(state.hasRecord(ALL_CHATS_PREDICATE)).toBe(true);
});
}); });

View file

@ -239,3 +239,26 @@ export function getCallLinkRecordPredicate(
return roomId === recordRoomId; return roomId === recordRoomId;
}; };
} }
export function getChatFolderRecordPredicate(
folderType: keyof typeof Proto.ChatFolderRecord.FolderType,
name: string,
deleted: boolean
): (record: StorageStateRecord) => boolean {
return ({ type, record }) => {
const { chatFolder } = record;
if (type !== IdentifierType.CHAT_FOLDER || chatFolder == null) {
return false;
}
const deletedAtTimestampMs =
chatFolder.deletedAtTimestampMs?.toNumber() ?? 0;
const isDeleted = deletedAtTimestampMs > 0;
return (
chatFolder.folderType === Proto.ChatFolderRecord.FolderType[folderType] &&
chatFolder.name === name &&
isDeleted === deleted
);
};
}

View file

@ -271,3 +271,14 @@ export function lookupCurrentChatFolder(
strictAssert(chatFolder != null, 'Missing chat folder'); strictAssert(chatFolder != null, 'Missing chat folder');
return chatFolder; return chatFolder;
} }
export function hasAllChatsChatFolder(
chatFolders: ReadonlyArray<ChatFolder>
): boolean {
return chatFolders.some(chatFolder => {
return (
chatFolder.folderType === ChatFolderType.ALL &&
chatFolder.deletedAtTimestampMs === 0
);
});
}