diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 8436437458..89e2850003 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -1678,6 +1678,10 @@
"messageformat": "All messages in this chat will be deleted from all your devices.",
"description": "Description for confirmation modal to delete a conversation"
},
+ "icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync--still-member": {
+ "messageformat": "All messages in this chat will be deleted from all your devices. You will still be a member of this group.",
+ "description": "Description for confirmation modal to delete a conversation, if you are still part of the group."
+ },
"icu:ConversationHeader__ContextMenu__LeaveGroupAction__title": {
"messageformat": "Leave group",
"description": "This is a button to leave a group"
diff --git a/ts/components/DeleteMessagesConfirmationDialog.dom.stories.tsx b/ts/components/DeleteMessagesConfirmationDialog.dom.stories.tsx
index 5b7dee1df7..0c9dc9dfab 100644
--- a/ts/components/DeleteMessagesConfirmationDialog.dom.stories.tsx
+++ b/ts/components/DeleteMessagesConfirmationDialog.dom.stories.tsx
@@ -18,6 +18,18 @@ export function Default(): JSX.Element {
i18n={i18n}
onClose={action('onClose')}
onDestroyMessages={action('onDestroyMessages')}
+ areWeMember={false}
+ />
+ );
+}
+
+export function DefaultStillMember(): JSX.Element {
+ return (
+
);
}
diff --git a/ts/components/DeleteMessagesConfirmationDialog.dom.tsx b/ts/components/DeleteMessagesConfirmationDialog.dom.tsx
index c3be8ef370..676d4258b2 100644
--- a/ts/components/DeleteMessagesConfirmationDialog.dom.tsx
+++ b/ts/components/DeleteMessagesConfirmationDialog.dom.tsx
@@ -6,16 +6,22 @@ import { AxoConfirmDialog } from '../axo/AxoConfirmDialog.dom.tsx';
export function DeleteMessagesConfirmationDialog({
i18n,
+ areWeMember,
onDestroyMessages,
onClose,
}: {
i18n: LocalizerType;
+ areWeMember: boolean;
onDestroyMessages: () => void;
onClose: () => void;
}): JSX.Element {
- const dialogBody = i18n(
- 'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync'
- );
+ const dialogBody = areWeMember
+ ? i18n(
+ 'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync--still-member'
+ )
+ : i18n(
+ 'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync'
+ );
return (
{
setHasDeleteMessagesConfirmation(false);
}}
+ areWeMember={areWeMember}
/>
)}
{hasLeaveGroupConfirmation && (
diff --git a/ts/components/conversation/conversation-details/ConversationDetailsActions.dom.tsx b/ts/components/conversation/conversation-details/ConversationDetailsActions.dom.tsx
index 5cc2f5be06..108616c298 100644
--- a/ts/components/conversation/conversation-details/ConversationDetailsActions.dom.tsx
+++ b/ts/components/conversation/conversation-details/ConversationDetailsActions.dom.tsx
@@ -473,6 +473,7 @@ export function ConversationDetailsActions({
onClose={() => {
gGroupDelete(false);
}}
+ areWeMember={!left && !isGroupTerminated}
/>
)}
>
diff --git a/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx b/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx
index 2d88913a88..ffbdc9ce81 100644
--- a/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx
+++ b/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx
@@ -293,6 +293,11 @@ export const LeftPaneConversationListItemContextMenu: FC
)}
>
diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts
index ea8fe36d4b..f993a9f68f 100644
--- a/ts/models/conversations.preload.ts
+++ b/ts/models/conversations.preload.ts
@@ -237,7 +237,14 @@ import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus.std
import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes.preload.ts';
import { getIsInitialContactSync } from '../services/contactSync.preload.ts';
import { queueAttachmentDownloadsAndMaybeSaveMessage } from '../util/queueAttachmentDownloads.preload.ts';
-import { cleanupMessages } from '../util/cleanup.preload.ts';
+import {
+ safeCleanupAvatarDraftFiles,
+ safeCleanupAvatarFiles,
+ safeCleanupDraftFiles,
+ cleanupMessages,
+ GENERIC_CLEANUP_FIELDS,
+ GROUP_CLEANUP_FIELDS,
+} from '../util/cleanup.preload.ts';
import { MessageModel } from './messages.preload.ts';
import {
applyNewAvatar,
@@ -5520,15 +5527,18 @@ export class ConversationModel {
source: 'message-request' | 'local-delete-sync' | 'local-delete';
}): Promise {
const logId = `${providedLogId}/destroyMessagesInner`;
- this.set({
- lastMessage: null,
- lastMessageAuthor: null,
- lastMessageAuthorAci: undefined,
- timestamp: null,
- active_at: null,
- pendingUniversalTimer: undefined,
- messagesDeleted: true,
- });
+ await safeCleanupDraftFiles(this.attributes);
+ await safeCleanupAvatarDraftFiles(this.attributes);
+ this.set(GENERIC_CLEANUP_FIELDS);
+
+ if (
+ isGroup(this.attributes) &&
+ (this.get('left') || this.get('terminated'))
+ ) {
+ await safeCleanupAvatarFiles(this.attributes);
+ this.set(GROUP_CLEANUP_FIELDS);
+ }
+
await DataWriter.updateConversation(this.attributes);
if (
diff --git a/ts/services/storageRecordOps.preload.ts b/ts/services/storageRecordOps.preload.ts
index c979ed8a5a..41c7fe87fa 100644
--- a/ts/services/storageRecordOps.preload.ts
+++ b/ts/services/storageRecordOps.preload.ts
@@ -1293,6 +1293,9 @@ export async function mergeGroupV2Record(
addUnknownFieldsToConversation(groupV2Record, conversation, details);
+ const deletedAndTerminated =
+ conversation.get('messagesDeleted') && conversation.get('terminated');
+
if (isGroupV1(conversation.attributes)) {
// If we found a GroupV1 conversation from this incoming GroupV2 record, we need to
// migrate it!
@@ -1304,7 +1307,7 @@ export async function mergeGroupV2Record(
conversation,
})
);
- } else {
+ } else if (!deletedAndTerminated) {
const isFirstSync = !itemStorage.get('storageFetchComplete');
const dropInitialJoinMessage = isFirstSync;
diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts
index 83ddd31dce..aade5a0fc0 100644
--- a/ts/sql/Server.node.ts
+++ b/ts/sql/Server.node.ts
@@ -312,9 +312,12 @@ import { createMessagesOnInsertTrigger } from './migrations/1500-search-polls.st
import { isValidPlaintextHash } from '../types/Crypto.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
import { WalCheckpoints } from './WalCheckpoints.std.ts';
+import {
+ getExternalDraftFilesForConversation,
+ getExternalAvatarFilesForConversation,
+} from '../util/conversationFilePaths.std.ts';
const {
- forEach,
fromPairs,
groupBy,
isBoolean,
@@ -1033,7 +1036,7 @@ export function initialize({
// Only the first worker gets to upgrade the schema. The rest just folow.
if (isPrimary) {
- updateSchema(db, logger);
+ updateSchema(db, logger, { userDataPath: configDir });
WalCheckpoints.setupDeleteTriggers(db, logger);
}
@@ -1049,7 +1052,10 @@ export function initialize({
}
/** @testexport */
-export function setupTests(db: WritableDB): void {
+export function setupTests(
+ db: WritableDB,
+ data: { userDataPath: string }
+): void {
const silentLogger = {
...consoleLogger,
info: noop,
@@ -1059,7 +1065,7 @@ export function setupTests(db: WritableDB): void {
};
logger = silentLogger;
- updateSchema(db, logger);
+ updateSchema(db, logger, data);
}
function closeReadable(db: ReadableDB): void {
@@ -8987,47 +8993,6 @@ function getMessageServerGuidsForSpam(
.all({ conversationId, limit });
}
-function getExternalFilesForConversation(
- conversation: Pick
-): Array {
- const { avatar, profileAvatar } = conversation;
- const files: Array = [];
-
- if (avatar && avatar.path) {
- files.push(avatar.path);
- }
-
- if (profileAvatar && profileAvatar.path) {
- files.push(profileAvatar.path);
- }
-
- return files;
-}
-
-function getExternalDraftFilesForConversation(
- conversation: Pick
-): Array {
- const draftAttachments = conversation.draftAttachments || [];
- const files: Array = [];
-
- forEach(draftAttachments, attachment => {
- if (attachment.pending) {
- return;
- }
-
- const { path: file, screenshotPath } = attachment;
- if (file) {
- files.push(file);
- }
-
- if (screenshotPath) {
- files.push(screenshotPath);
- }
- });
-
- return files;
-}
-
function getKnownMessageAttachments(
db: ReadableDB,
cursor?: MessageAttachmentsCursorType
@@ -9251,7 +9216,7 @@ function getKnownConversationAttachments(db: ReadableDB): Array {
jsonToObject(row.json)
);
conversations.forEach(conversation => {
- const externalFiles = getExternalFilesForConversation(conversation);
+ const externalFiles = getExternalAvatarFilesForConversation(conversation);
externalFiles.forEach(file => result.add(file));
});
diff --git a/ts/sql/migrations/1740-cleanup-groups.node.ts b/ts/sql/migrations/1740-cleanup-groups.node.ts
new file mode 100644
index 0000000000..3a5078774c
--- /dev/null
+++ b/ts/sql/migrations/1740-cleanup-groups.node.ts
@@ -0,0 +1,579 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { unlinkSync } from 'node:fs';
+import { join, normalize } from 'node:path';
+
+import z from 'zod';
+import type { ReadonlyDeep } from 'type-fest';
+
+import { jsonToObject, objectToJSON } from '../util.std.ts';
+import { ID_LENGTH } from '../../types/groups.std.ts';
+import {
+ getAttachmentsPath,
+ getAvatarsPath,
+ getDraftPath,
+} from '../../../app/attachments.node.ts';
+import { isPathInside } from '../../util/isPathInside.node.ts';
+import { toLogFormat } from '../../types/errors.std.ts';
+
+import type { LoggerType } from '../../types/Logging.std.ts';
+import type { WritableDB } from '../Interface.std.ts';
+import type { AvatarColorType } from '../../types/Colors.std.ts';
+import type { AvatarIconType } from '../../types/Avatar.std.ts';
+
+export default function updateToSchemaVersion1740(
+ db: WritableDB,
+ logger: LoggerType,
+ _startingVersion: number,
+ { userDataPath }: { userDataPath: string }
+): void {
+ const updateConversationStmt = db.prepare(
+ `
+ UPDATE conversations SET
+ json = $json,
+ members = $members,
+ name = $name
+ WHERE id = $id;
+ `
+ );
+
+ const attachmentsPath = getAttachmentsPath(userDataPath);
+ const avatarsPath = getAvatarsPath(userDataPath);
+ const draftPath = getDraftPath(userDataPath);
+
+ const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
+ const getAbsoluteAvatarPath = createAbsolutePathGetter(avatarsPath);
+ const getAbsoluteDraftPath = createAbsolutePathGetter(draftPath);
+
+ const cleanConversation = (convo: ConversationAttributesType) => {
+ if (
+ convo.type === 'group' &&
+ isGroupV2(convo) &&
+ convo.active_at == null &&
+ convo.messagesDeleted &&
+ (convo.left || convo.terminated)
+ ) {
+ const logId = `cleanConversation(${convo.id}/groupv2(${convo.groupId})`;
+ logger.info(`${logId}: Cleaning...`);
+
+ const draftAttachments =
+ getExternalDraftFilesForConversation(convo).map(getAbsoluteDraftPath);
+ draftAttachments.forEach((item, index) => {
+ try {
+ unlinkSync(item);
+ } catch (error) {
+ logger.error(
+ `${logId}: Failed to deleft draft file at index ${index}`,
+ toLogFormat(error)
+ );
+ }
+ });
+
+ const avatarAttachments = getExternalAvatarFilesForConversation(
+ convo
+ ).map(getAbsoluteAttachmentPath);
+ avatarAttachments.forEach((item, index) => {
+ try {
+ unlinkSync(item);
+ } catch (error) {
+ logger.error(
+ `${logId}: Failed to delete avatar file at index ${index}`,
+ toLogFormat(error)
+ );
+ }
+ });
+
+ const avatarDraftAttachments = getExternalAvatarDraftsForConversation(
+ convo
+ ).map(getAbsoluteAvatarPath);
+ avatarDraftAttachments.forEach((item, index) => {
+ try {
+ unlinkSync(item);
+ } catch (error) {
+ logger.error(
+ `${logId}: Failed to delete avatar draft index ${index}`,
+ toLogFormat(error)
+ );
+ }
+ });
+
+ const cleaned = {
+ ...convo,
+ ...GENERIC_CLEANUP_FIELDS,
+ ...GROUP_CLEANUP_FIELDS,
+ };
+
+ let dbMembers: string | null;
+ if (cleaned.membersV2) {
+ dbMembers = cleaned.membersV2.map(item => item.aci).join(' ');
+ } else if (cleaned.members) {
+ dbMembers = cleaned.members.join(' ');
+ } else {
+ dbMembers = null;
+ }
+
+ updateConversationStmt.run({
+ id: cleaned.id,
+ json: objectToJSON(cleaned),
+ members: dbMembers,
+ name: cleaned.name ?? null,
+ });
+ }
+ };
+
+ const allConversations = db
+ .prepare(
+ `
+ SELECT json
+ FROM conversations
+ ORDER BY id ASC;
+ `,
+ { pluck: true }
+ )
+ .all()
+ .map(json => jsonToObject(json));
+
+ logger.info(
+ `About to iterate through ${allConversations.length} conversations`
+ );
+
+ for (const convo of allConversations) {
+ cleanConversation(convo);
+ }
+}
+
+function isGroupV2(convo: ConversationAttributesType): boolean {
+ return Boolean(
+ convo.groupVersion === 2 &&
+ convo.groupId &&
+ Buffer.from(convo.groupId, 'base64').byteLength === ID_LENGTH
+ );
+}
+
+type OpaqueType = object;
+
+// These types are copied so this migration doesn't change meaning over time
+
+// Copied from conversationFilePaths.std.ts
+function getExternalAvatarFilesForConversation(
+ conversation: Pick
+): Array {
+ const { avatar, profileAvatar } = conversation;
+ const files: Array = [];
+
+ if (avatar && avatar.path) {
+ files.push(avatar.path);
+ }
+
+ if (profileAvatar && profileAvatar.path) {
+ files.push(profileAvatar.path);
+ }
+
+ return files;
+}
+
+function getExternalAvatarDraftsForConversation(
+ conversation: Pick
+): Array {
+ const { avatars } = conversation;
+ const files: Array = [];
+
+ (avatars || []).forEach(item => {
+ if (item.imagePath) {
+ files.push(item.imagePath);
+ }
+ });
+
+ return files;
+}
+
+function getExternalDraftFilesForConversation(
+ conversation: Pick
+): Array {
+ const draftAttachments = conversation.draftAttachments || [];
+ const files: Array = [];
+
+ (draftAttachments || []).forEach(attachment => {
+ if (attachment.pending) {
+ return;
+ }
+
+ const { path: file, screenshotPath } = attachment;
+ if (file) {
+ files.push(file);
+ }
+
+ if (screenshotPath) {
+ files.push(screenshotPath);
+ }
+ });
+
+ return files;
+}
+
+// Copied from attachments.preload.ts - exported only for testing!
+export const createAbsolutePathGetter =
+ (rootPath: string) =>
+ (relativePath: string): string => {
+ const absolutePath = join(rootPath, relativePath);
+ const normalized = normalize(absolutePath);
+ if (!isPathInside(normalized, rootPath)) {
+ throw new Error('Invalid relative path');
+ }
+ return normalized;
+ };
+
+// Copied from Mime.std.ts
+export const MIMETypeSchema = z.string().brand('mimeType');
+export type MIMEType = z.infer;
+
+// Copied from Attachment.std.ts
+export type AddressableAttachmentType = Readonly<{
+ version?: 1 | 2;
+ path: string;
+ localKey?: string;
+ size?: number;
+ contentType: MIMEType;
+
+ // In-memory data, for outgoing attachments that are not saved to disk.
+ data?: Uint8Array;
+}>;
+
+// Copied from Avatar.std.ts
+export type ContactAvatarType =
+ | ({
+ // Downloaded avatar
+ path: string;
+ url?: string;
+ hash?: string;
+ } & Partial)
+ | {
+ // Not-yet downloaded avatar
+ path?: undefined;
+ url: string;
+ hash?: string;
+ };
+
+export type AvatarDataType = {
+ id: number | string;
+ buffer?: Uint8Array;
+ color?: AvatarColorType;
+ icon?: AvatarIconType;
+ text?: string;
+ imagePath?: string;
+
+ // LocalAttachmentV2Type compatibility (except for `path` being `imagePath`)
+ version?: 2;
+ localKey?: string;
+ size?: number;
+};
+
+// Copied from model-types.d.ts, most complex types replaced with OpaqueType
+export type GroupV2MemberType = {
+ aci: string;
+ role: OpaqueType;
+ joinedAtVersion: number;
+ labelString?: string;
+ labelEmoji?: OpaqueType;
+
+ // Note that these are temporary flags, generated by applyGroupChange, but eliminated
+ // by applyGroupState. They are used to make our diff-generation more intelligent but
+ // not after that.
+ joinedFromLink?: boolean;
+ approvedByAdmin?: boolean;
+};
+
+export type ConversationAttributesType = {
+ accessKey?: string | null;
+ addedBy?: string;
+ badges?: Array<
+ | { id: string }
+ | {
+ id: string;
+ expiresAt: number;
+ isVisible: boolean;
+ }
+ >;
+ capabilities?: OpaqueType;
+ color?: string;
+ // If present - the numeric value of `color` (possibly not yet supported) that
+ // we got the from primary during either backup or storage service import.
+ colorFromPrimary?: number;
+ conversationColor?: OpaqueType;
+ customColor?: OpaqueType;
+ customColorId?: string;
+
+ // Set at backup import time, exported as is.
+ wallpaperPhotoPointerBase64?: string;
+ wallpaperPreset?: number;
+ dimWallpaperInDarkMode?: boolean;
+ autoBubbleColor?: boolean;
+
+ discoveredUnregisteredAt?: number;
+ firstUnregisteredAt?: number;
+ draftChanged?: boolean;
+ draftAttachments?: ReadonlyArray<
+ OpaqueType & { path?: string; pending?: boolean; screenshotPath?: string }
+ >;
+ draftBodyRanges?: OpaqueType;
+ draftIsViewOnce?: boolean;
+ draftTimestamp?: number | null;
+ hideStory?: boolean;
+ inbox_position?: number;
+ // When contact is removed - it is initially placed into `justNotification`
+ // removal stage. In this stage user can still send messages (which will
+ // set `removalStage` to `undefined`), but if a new incoming message arrives -
+ // the stage will progress to `messageRequest` and composition area will be
+ // replaced with a message request.
+ removalStage?: 'justNotification' | 'messageRequest';
+ isPinned?: boolean;
+ lastMessageDeletedForEveryone?: boolean;
+ lastMessageDeletedForEveryoneByAdminAci?: OpaqueType;
+ lastMessageAuthorAci?: OpaqueType | null;
+ lastMessage?: string | null;
+ lastMessageBodyRanges?: ReadonlyArray;
+ lastMessagePrefix?: OpaqueType;
+ /** @deprecated Use lastMessageAuthorAci instead */
+ lastMessageAuthor?: string | null;
+ lastMessageStatus?: OpaqueType | null;
+ lastMessageReceivedAt?: number;
+ lastMessageReceivedAtMs?: number;
+ markedUnread?: boolean;
+ messageCount?: number;
+ messageCountBeforeMessageRequests?: number | null;
+ messageRequestResponseType?: number;
+ messagesDeleted?: boolean;
+ muteExpiresAt?: number;
+ dontNotifyForMentionsIfMuted?: boolean;
+ sharingPhoneNumber?: boolean;
+ profileAvatar?: ContactAvatarType | null;
+ profileKeyCredential?: string | null;
+ profileKeyCredentialExpiration?: number | null;
+ lastProfile?: OpaqueType;
+ needsTitleTransition?: boolean;
+ quotedMessageId?: string | null;
+ /**
+ * TODO: Rename this key to be specific to the accessKey on the conversation
+ * It's not used for group endorsements.
+ */
+ sealedSender?: OpaqueType;
+ sentMessageCount?: number;
+ voiceNotePlaybackRate?: number;
+
+ id: string;
+ type: 'private' | 'group';
+ timestamp?: number | null;
+
+ // Shared fields
+ active_at?: number | null;
+ draft?: string | null;
+ draftEditMessage?: OpaqueType;
+ hasPostedStory?: boolean;
+ isArchived?: boolean;
+ isReported?: boolean;
+ name?: string;
+ systemGivenName?: string;
+ systemFamilyName?: string;
+ systemNickname?: string;
+ nicknameGivenName?: string | null;
+ nicknameFamilyName?: string | null;
+ note?: string | null;
+ needsStorageServiceSync?: boolean;
+ needsVerification?: boolean;
+ profileSharing?: boolean;
+ storageID?: string;
+ storageVersion?: number;
+ storageUnknownFields?: string;
+ unreadCount?: number;
+ unreadMentionsCount?: number;
+ version: number;
+
+ // Private core info
+ serviceId?: OpaqueType;
+ pni?: OpaqueType;
+ pniSignatureVerified?: boolean;
+ e164?: string;
+
+ // Private other fields
+ about?: string;
+ aboutEmoji?: OpaqueType;
+ profileFamilyName?: string;
+ profileKey?: string;
+ profileName?: string;
+ verified?: number;
+ profileLastUpdatedAt?: number;
+ profileLastFetchedAt?: number;
+ pendingUniversalTimer?: string;
+ pendingRemovedContactNotification?: string;
+ username?: string;
+ shareMyPhoneNumber?: boolean;
+ previousIdentityKey?: string;
+ reportingToken?: string;
+
+ // Group-only
+ groupId?: string;
+ // A shorthand, representing whether the user is part of the group. Not strictly for
+ // when the user manually left the group. But historically, that was the only way
+ // to leave a group.
+ left?: boolean;
+ groupVersion?: number;
+ storySendMode?: OpaqueType;
+ groupVerifiedNameHash?: string;
+
+ // GroupV1 only
+ members?: Array;
+ derivedGroupV2Id?: string;
+
+ // GroupV2 core info
+ masterKey?: string;
+ secretParams?: string;
+ publicParams?: string;
+ revision?: number;
+ senderKeyInfo?: OpaqueType;
+ needsGroupUpdate?: boolean; // `true` only for groups we learned about through
+ // an incoming message. Reset when we update the
+ // group or fail.
+
+ // GroupV2 other fields
+ accessControl?: OpaqueType;
+ announcementsOnly?: boolean;
+ terminated?: boolean;
+ avatar?: ContactAvatarType | null;
+ avatars?: ReadonlyArray>;
+ description?: string;
+ expireTimer?: OpaqueType;
+ expireTimerVersion: number;
+ membersV2?: Array;
+ pendingMembersV2?: Array;
+ pendingAdminApprovalV2?: Array;
+ bannedMembersV2?: Array;
+ groupInviteLinkPassword?: string;
+ previousGroupV1Id?: string;
+ previousGroupV1Members?: Array;
+ acknowledgedGroupNameCollisions?: ReadonlyDeep;
+
+ // Used only when user is waiting for approval to join via link
+ isTemporary?: boolean;
+ temporaryMemberCount?: number;
+
+ // Legacy field, mapped to above in getConversation()
+ unblurredAvatarPath?: string;
+
+ // remoteAvatarUrl
+ remoteAvatarUrl?: string;
+
+ // Only used during backup integration tests. After import, our data model merges
+ // Contact and Chat frames from a backup, and we will then by default export both, even
+ // if the Chat frame was not imported. That's fine in normal usage, but breaks
+ // integration tests that aren't expecting to see a Chat frame on export that was not
+ // there on import.
+ test_chatFrameImportedFromBackup?: boolean;
+};
+
+// Copied from cleanup.preload.ts
+const GENERIC_CLEANUP_FIELDS: Partial = {
+ draftChanged: undefined,
+ draftAttachments: undefined,
+ draftBodyRanges: undefined,
+ draftIsViewOnce: undefined,
+ draftTimestamp: undefined,
+
+ inbox_position: undefined,
+
+ lastMessageDeletedForEveryone: undefined,
+ lastMessageDeletedForEveryoneByAdminAci: undefined,
+ lastMessageAuthorAci: undefined,
+ lastMessage: undefined,
+ lastMessageBodyRanges: undefined,
+ lastMessagePrefix: undefined,
+ lastMessageAuthor: undefined,
+ lastMessageStatus: undefined,
+ lastMessageReceivedAt: undefined,
+ lastMessageReceivedAtMs: undefined,
+
+ markedUnread: undefined,
+ messageCount: undefined,
+ messageCountBeforeMessageRequests: undefined,
+ messageRequestResponseType: undefined,
+
+ messagesDeleted: true,
+
+ quotedMessageId: undefined,
+
+ sentMessageCount: undefined,
+
+ timestamp: undefined,
+ unreadCount: undefined,
+ unreadMentionsCount: undefined,
+
+ active_at: undefined,
+ draft: undefined,
+ draftEditMessage: undefined,
+
+ isArchived: undefined,
+
+ pendingUniversalTimer: undefined,
+};
+
+const GROUP_CLEANUP_FIELDS: Partial = {
+ addedBy: undefined,
+
+ color: undefined,
+ colorFromPrimary: undefined,
+ conversationColor: undefined,
+ customColor: undefined,
+ customColorId: undefined,
+
+ wallpaperPhotoPointerBase64: undefined,
+ wallpaperPreset: undefined,
+ dimWallpaperInDarkMode: undefined,
+ autoBubbleColor: undefined,
+
+ hideStory: undefined,
+
+ isReported: undefined,
+ name: undefined,
+
+ profileSharing: undefined,
+
+ pendingUniversalTimer: undefined,
+ pendingRemovedContactNotification: undefined,
+ reportingToken: undefined,
+
+ left: undefined,
+ storySendMode: undefined,
+ groupVerifiedNameHash: undefined,
+
+ members: undefined,
+ derivedGroupV2Id: undefined,
+
+ secretParams: undefined,
+ publicParams: undefined,
+ revision: undefined,
+ senderKeyInfo: undefined,
+
+ accessControl: undefined,
+ announcementsOnly: undefined,
+ terminated: undefined,
+ avatar: undefined,
+ avatars: undefined,
+ description: undefined,
+ expireTimer: undefined,
+ expireTimerVersion: 1,
+ membersV2: undefined,
+ pendingMembersV2: undefined,
+ pendingAdminApprovalV2: undefined,
+ bannedMembersV2: undefined,
+ groupInviteLinkPassword: undefined,
+ previousGroupV1Id: undefined,
+ previousGroupV1Members: undefined,
+ acknowledgedGroupNameCollisions: undefined,
+
+ isTemporary: undefined,
+ temporaryMemberCount: undefined,
+
+ unblurredAvatarPath: undefined,
+
+ remoteAvatarUrl: undefined,
+};
diff --git a/ts/sql/migrations/index.node.ts b/ts/sql/migrations/index.node.ts
index 412900f01d..68e1721650 100644
--- a/ts/sql/migrations/index.node.ts
+++ b/ts/sql/migrations/index.node.ts
@@ -150,6 +150,7 @@ import updateToSchemaVersion1700 from './1700-trim-profile-names.std.ts';
import updateToSchemaVersion1710 from './1710-emoji-skin-tone-default.std.ts';
import updateToSchemaVersion1720 from './1720-update-recent-emoji.std.ts';
import updateToSchemaVersion1730 from './1730-protected-attachments-dedupe-token.std.ts';
+import updateToSchemaVersion1740 from './1740-cleanup-groups.node.ts';
import { DataWriter } from '../Server.node.ts';
import { strictAssert } from '../../util/assert.std.ts';
@@ -1465,7 +1466,8 @@ export type SchemaUpdateType = Readonly<{
update: (
db: WritableDB,
logger: LoggerType,
- startingVersion: number
+ startingVersion: number,
+ data: { userDataPath: string }
) => void | 'vacuum';
}>;
@@ -1662,6 +1664,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [
{ version: 1710, update: updateToSchemaVersion1710 },
{ version: 1720, update: updateToSchemaVersion1720 },
{ version: 1730, update: updateToSchemaVersion1730 },
+ { version: 1740, update: updateToSchemaVersion1740 },
];
class DBVersionFromFutureError extends Error {
@@ -1693,7 +1696,11 @@ function enableFTS5SecureDelete(db: Database, logger: LoggerType): void {
}
}
-export function updateSchema(db: WritableDB, logger: LoggerType): void {
+export function updateSchema(
+ db: WritableDB,
+ logger: LoggerType,
+ data: { userDataPath: string }
+): void {
const sqliteVersion = getSQLiteVersion(db);
const sqlcipherVersion = getSQLCipherVersion(db);
const startingVersion = getUserVersion(db);
@@ -1745,7 +1752,7 @@ export function updateSchema(db: WritableDB, logger: LoggerType): void {
}
const schemaLogger = logger.child(`updateSchema(${version})`);
- const result = update(db, schemaLogger, startingVersion);
+ const result = update(db, schemaLogger, startingVersion, data);
if (result === 'vacuum') {
schemaLogger.info('success, needs vacuum');
diff --git a/ts/test-electron/cleanupOrphanedAttachments_test.preload.ts b/ts/test-electron/cleanupOrphanedAttachments_test.preload.ts
index 6b191367c8..88f753e476 100644
--- a/ts/test-electron/cleanupOrphanedAttachments_test.preload.ts
+++ b/ts/test-electron/cleanupOrphanedAttachments_test.preload.ts
@@ -148,6 +148,24 @@ describe('cleanupOrphanedAttachments', () => {
assert.sameDeepMembers(listFiles('download'), []);
});
+ it('does not delete conversation draft attachments', async () => {
+ await writeFiles(2, 'draft');
+ await writeFiles(2, 'attachment');
+
+ await DataWriter.saveConversation({
+ id: generateUuid(),
+ type: 'private',
+ version: 2,
+ expireTimerVersion: 2,
+ draftAttachments: [{ path: 'draft0' }, { path: 'draft1' }],
+ });
+
+ await DataWriter.cleanupOrphanedAttachments({ _block: true });
+
+ assert.sameDeepMembers(listFiles('draft'), ['draft0', 'draft1']);
+ assert.sameDeepMembers(listFiles('attachment'), []);
+ });
+
it('does not delete conversation avatar and profileAvatar paths', async () => {
await writeFiles(6, 'attachment');
diff --git a/ts/test-node/sql/helpers.node.ts b/ts/test-node/sql/helpers.node.ts
index fc71bc1c86..324a8cc7a7 100644
--- a/ts/test-node/sql/helpers.node.ts
+++ b/ts/test-node/sql/helpers.node.ts
@@ -1,6 +1,8 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+import { cwd } from 'node:process';
+
import lodash from 'lodash';
import SQL from '@signalapp/sqlcipher';
@@ -17,7 +19,11 @@ export function createDB(): WritableDB {
return db;
}
-export function updateToVersion(db: WritableDB, version: number): void {
+export function updateToVersion(
+ db: WritableDB,
+ version: number,
+ data: { userDataPath: string } = { userDataPath: cwd() }
+): void {
const startVersion = db.pragma('user_version', { simple: true }) as number;
if (startVersion === version) {
return;
@@ -34,7 +40,7 @@ export function updateToVersion(db: WritableDB, version: number): void {
}
db.transaction(() => {
- update(db, silentLogger, startVersion);
+ update(db, silentLogger, startVersion, data);
db.pragma(`user_version = ${version}`);
})();
diff --git a/ts/test-node/sql/incrementMessagesMigrationAttempts_test.node.ts b/ts/test-node/sql/incrementMessagesMigrationAttempts_test.node.ts
index 53745ff7d4..201182bb03 100644
--- a/ts/test-node/sql/incrementMessagesMigrationAttempts_test.node.ts
+++ b/ts/test-node/sql/incrementMessagesMigrationAttempts_test.node.ts
@@ -1,6 +1,7 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+import { cwd } from 'node:process';
import { assert } from 'chai';
import type { WritableDB } from '../../sql/Interface.std.ts';
@@ -15,7 +16,7 @@ describe('SQL/incrementMessagesMigrationAttempts', () => {
beforeEach(() => {
db = createDB();
- setupTests(db);
+ setupTests(db, { userDataPath: cwd() });
});
afterEach(() => {
diff --git a/ts/test-node/sql/migrateConversationMessages_test.node.ts b/ts/test-node/sql/migrateConversationMessages_test.node.ts
index 308c50e50d..a4779004a3 100644
--- a/ts/test-node/sql/migrateConversationMessages_test.node.ts
+++ b/ts/test-node/sql/migrateConversationMessages_test.node.ts
@@ -1,6 +1,7 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+import { cwd } from 'node:process';
import { assert } from 'chai';
import type { WritableDB } from '../../sql/Interface.std.ts';
@@ -15,7 +16,7 @@ describe('SQL/migrateConversationMessages', () => {
beforeEach(() => {
db = createDB();
- setupTests(db);
+ setupTests(db, { userDataPath: cwd() });
});
afterEach(() => {
diff --git a/ts/test-node/sql/migration_1740_test.node.ts b/ts/test-node/sql/migration_1740_test.node.ts
new file mode 100644
index 0000000000..ebeb149674
--- /dev/null
+++ b/ts/test-node/sql/migration_1740_test.node.ts
@@ -0,0 +1,343 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import assert from 'node:assert/strict';
+import { randomBytes } from 'node:crypto';
+import fsExtra from 'fs-extra';
+
+import {
+ createDB,
+ getTableData,
+ insertData,
+ updateToVersion,
+} from './helpers.node.ts';
+
+import type { WritableDB } from '../../sql/Interface.std.ts';
+import {
+ createAbsolutePathGetter,
+ type ConversationAttributesType,
+} from '../../sql/migrations/1740-cleanup-groups.node.ts';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { mkdtempSync, readdirSync, rmdirSync } from 'node:fs';
+import { missingCaseError } from '../../util/missingCaseError.std.ts';
+import {
+ getAttachmentsPath,
+ getAvatarsPath,
+ getDraftPath,
+} from '../../../app/attachments.node.ts';
+
+const { emptyDir, ensureFile } = fsExtra;
+
+type TestAttachmentTypes = 'attachment' | 'avatar' | 'draft';
+
+describe('SQL/updateToSchemaVersion1740', () => {
+ let db: WritableDB;
+ let tempDir: string;
+
+ beforeEach(() => {
+ tempDir = mkdtempSync(join(tmpdir(), 'Signal'));
+ db = createDB();
+ updateToVersion(db, 1730, { userDataPath: tempDir });
+ });
+
+ afterEach(async () => {
+ db.close();
+ await emptyDir(tempDir);
+ rmdirSync(tempDir);
+ });
+
+ it('cleans up only left/terminated and deleted groups', async () => {
+ const attachmentsPath = getAttachmentsPath(tempDir);
+ const avatarsPath = getAvatarsPath(tempDir);
+ const draftPath = getDraftPath(tempDir);
+
+ const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
+ const getAbsoluteAvatarPath = createAbsolutePathGetter(avatarsPath);
+ const getAbsoluteDraftPath = createAbsolutePathGetter(draftPath);
+
+ function getAbsolutePath(path: string, type: TestAttachmentTypes) {
+ switch (type) {
+ case 'attachment':
+ return getAbsoluteAttachmentPath(path);
+ case 'avatar':
+ return getAbsoluteAvatarPath(path);
+ case 'draft':
+ return getAbsoluteDraftPath(path);
+ default:
+ throw missingCaseError(type);
+ }
+ }
+
+ async function writeFile(path: string, type: TestAttachmentTypes) {
+ await ensureFile(getAbsolutePath(path, type));
+ }
+
+ async function writeFiles(num: number, type: TestAttachmentTypes) {
+ for (let i = 0; i < num; i += 1) {
+ // oxlint-disable-next-line no-await-in-loop
+ await writeFile(`${type}${i}`, type);
+ }
+ }
+
+ function listFiles(type: TestAttachmentTypes): Array {
+ return readdirSync(dirname(getAbsolutePath('not used', type)));
+ }
+
+ await writeFiles(3, 'attachment');
+ await writeFiles(6, 'draft');
+ await writeFiles(6, 'avatar');
+
+ const now = Date.now();
+ const initialData: Array<{
+ id: string;
+ type: string;
+ name: string;
+ members?: string;
+ active_at?: number;
+ expireTimerVersion: number;
+ json: Partial;
+ }> = [
+ {
+ id: 'c0',
+ type: 'private',
+ name: 'John',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c0',
+ type: 'private',
+ timestamp: now,
+ name: 'John',
+ },
+ },
+ {
+ id: 'c1',
+ type: 'private',
+ name: 'John, deleted',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c1',
+ type: 'private',
+ timestamp: now,
+ name: 'John',
+ messagesDeleted: true,
+ },
+ },
+ {
+ id: 'c2',
+ type: 'group',
+ name: 'GV1 group',
+ members: '1 2 3',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c2',
+ type: 'group',
+ groupId: '249qw4kh23492p34',
+ timestamp: now,
+ name: 'GV1 group',
+ members: ['1', '2', '3'],
+ },
+ },
+ {
+ id: 'c3',
+ type: 'group',
+ active_at: now,
+ name: 'GV2 group with activeAt',
+ members: '1 2 3',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c3',
+ type: 'group',
+ groupId: getRandomGroupId(),
+ groupVersion: 2,
+ name: 'GV2 group with activeAt',
+ timestamp: now,
+ active_at: now,
+ members: ['1', '2', '3'],
+ },
+ },
+ {
+ id: 'c4',
+ type: 'group',
+ name: 'GV2 group with messagesDeleted',
+ members: '1 2 3',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c4',
+ type: 'group',
+ groupId: getRandomGroupId(),
+ groupVersion: 2,
+ name: 'GV2 group with messagesDeleted',
+ timestamp: now,
+ members: ['1', '2', '3'],
+ },
+ },
+ {
+ id: 'c5',
+ type: 'group',
+ name: 'GV2 group, left',
+ members: '1 2 3',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c5',
+ type: 'group',
+ groupId: getRandomGroupId(),
+ groupVersion: 2,
+ name: 'GV2 group, left',
+ timestamp: now,
+ active_at: now,
+ members: ['1', '2', '3'],
+ left: true,
+ },
+ },
+ {
+ id: 'c6',
+ type: 'group',
+ active_at: now,
+ name: 'GV2 group, terminated',
+ members: '1 2 3',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c6',
+ type: 'group',
+ groupId: getRandomGroupId(),
+ groupVersion: 2,
+ name: 'GV2 group, terminated',
+ timestamp: now,
+ active_at: now,
+ members: ['1', '2', '3'],
+ terminated: true,
+ },
+ },
+ {
+ id: 'c7',
+ type: 'group',
+ name: 'GV2 group, deleted but not left or terminated',
+ members: '1 2 3',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c7',
+ type: 'group',
+ groupId: getRandomGroupId(),
+ avatar: {
+ path: 'attachment0',
+ },
+ draftAttachments: [{ path: 'draft0' }, { screenshotPath: 'draft1' }],
+ avatars: [
+ { id: 0, imagePath: 'avatar0' },
+ { id: 1 },
+ { id: 2, imagePath: 'avatar1' },
+ ],
+ groupVersion: 2,
+ name: 'GV2 group, deleted but not left or terminated',
+ timestamp: now,
+ messagesDeleted: true,
+ members: ['1', '2', '3'],
+ },
+ },
+ {
+ id: 'c8',
+ type: 'group',
+ name: 'GV2 group, left and deleted - will be erased',
+ members: '1 2 3',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c8',
+ type: 'group',
+ groupId: getRandomGroupId(),
+ groupVersion: 2,
+ name: 'GV2 group, left and deleted - will be erased',
+ timestamp: now,
+ avatar: {
+ path: 'attachment1',
+ },
+ draftAttachments: [{ path: 'draft2' }, { screenshotPath: 'draft3' }],
+ avatars: [
+ { id: 0, imagePath: 'avatar2' },
+ { id: 1 },
+ { id: 2, imagePath: 'avatar3' },
+ ],
+ messagesDeleted: true,
+ members: ['1', '2', '3'],
+ left: true,
+ },
+ },
+ {
+ id: 'c9',
+ type: 'group',
+ name: 'GV2 group, terminated and deleted - will be erased',
+ members: '1 2 3',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c9',
+ type: 'group',
+ groupId: getRandomGroupId(),
+ groupVersion: 2,
+ name: 'GV2 group, terminated and deleted - will be erased',
+ timestamp: now,
+ avatar: {
+ path: 'attachment2',
+ },
+ draftAttachments: [{ path: 'draft4' }, { screenshotPath: 'draft5' }],
+ avatars: [
+ { id: 0, imagePath: 'avatar4' },
+ { id: 1 },
+ { id: 2, imagePath: 'avatar5' },
+ ],
+ messagesDeleted: true,
+ members: ['1', '2', '3'],
+ terminated: true,
+ },
+ },
+ ];
+
+ insertData(db, 'conversations', initialData);
+
+ updateToVersion(db, 1740, { userDataPath: tempDir });
+
+ assert.deepStrictEqual(getTableData(db, 'conversations'), [
+ initialData[0],
+ initialData[1],
+ initialData[2],
+ initialData[3],
+ initialData[4],
+ initialData[5],
+ initialData[6],
+ initialData[7],
+ {
+ id: 'c8',
+ type: 'group',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c8',
+ type: 'group',
+ groupId: initialData[8]?.json.groupId,
+ groupVersion: 2,
+ messagesDeleted: true,
+ expireTimerVersion: 1,
+ },
+ },
+ {
+ id: 'c9',
+ type: 'group',
+ expireTimerVersion: 1,
+ json: {
+ id: 'c9',
+ type: 'group',
+ groupId: initialData[9]?.json.groupId,
+ groupVersion: 2,
+ messagesDeleted: true,
+ expireTimerVersion: 1,
+ },
+ },
+ ]);
+
+ assert.deepEqual(listFiles('attachment'), ['attachment0']);
+ assert.deepEqual(listFiles('draft'), ['draft0', 'draft1']);
+ assert.deepEqual(listFiles('avatar'), ['avatar0', 'avatar1']);
+ });
+});
+
+function getRandomGroupId() {
+ return randomBytes(32).toBase64();
+}
diff --git a/ts/test-node/sql/server/pinnedMessages_test.node.ts b/ts/test-node/sql/server/pinnedMessages_test.node.ts
index e89edb55fa..f88b609d12 100644
--- a/ts/test-node/sql/server/pinnedMessages_test.node.ts
+++ b/ts/test-node/sql/server/pinnedMessages_test.node.ts
@@ -1,6 +1,8 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import assert from 'node:assert/strict';
+import { cwd } from 'node:process';
+
import type { WritableDB } from '../../../sql/Interface.std.ts';
import { setupTests } from '../../../sql/Server.node.ts';
import type { AppendPinnedMessageResult } from '../../../sql/server/pinnedMessages.std.ts';
@@ -50,7 +52,7 @@ describe('sql/server/pinnedMessages', () => {
beforeEach(() => {
db = createDB();
- setupTests(db);
+ setupTests(db, { userDataPath: cwd() });
setupData(db);
});
diff --git a/ts/util/cleanup.preload.ts b/ts/util/cleanup.preload.ts
index 1b45da7dde..35b87e0017 100644
--- a/ts/util/cleanup.preload.ts
+++ b/ts/util/cleanup.preload.ts
@@ -5,7 +5,10 @@ import PQueue from 'p-queue';
import { batch } from 'react-redux';
import { pick } from 'lodash';
-import type { MessageAttributesType } from '../model-types.d.ts';
+import type {
+ ConversationAttributesType,
+ MessageAttributesType,
+} from '../model-types.d.ts';
import { MessageModel } from '../models/messages.preload.ts';
import * as Errors from '../types/errors.std.ts';
@@ -31,7 +34,9 @@ import {
getFilePathsReferencedByMessage,
} from './messageFilePaths.std.ts';
import {
+ deleteAvatar,
deleteDownloadFile,
+ deleteDraftFile,
maybeDeleteAttachmentFile,
} from './migrations.preload.ts';
import { hydrateStoryContext } from './hydrateStoryContext.preload.ts';
@@ -43,6 +48,11 @@ import {
type EraseMessageReasonType,
} from '../types/Message.std.ts';
import type { AttachmentType } from '../types/Attachment.std.ts';
+import {
+ getExternalDraftFilesForConversation,
+ getExternalAvatarFilesForConversation,
+ getExternalAvatarDraftsForConversation,
+} from './conversationFilePaths.std.ts';
const log = createLogger('cleanup');
@@ -311,3 +321,151 @@ export async function cleanupAttachmentFiles(
);
await Promise.all([...result.externalDownloads].map(deleteDownloadFile));
}
+
+export async function safeCleanupDraftFiles(
+ conversation: ConversationAttributesType
+): Promise {
+ const result = getExternalDraftFilesForConversation(conversation);
+ await Promise.all(
+ result.map(async (relativeFile, index) => {
+ try {
+ await deleteDraftFile(relativeFile);
+ } catch (error) {
+ log.error(
+ `safeCleanupDraftFiles: Failed to delete draft at index ${index}`,
+ Errors.toLogFormat(error)
+ );
+ }
+ })
+ );
+}
+
+export async function safeCleanupAvatarFiles(
+ conversation: ConversationAttributesType
+): Promise {
+ const result = getExternalAvatarFilesForConversation(conversation);
+ await Promise.all(result.map(maybeDeleteAttachmentFile));
+}
+
+export async function safeCleanupAvatarDraftFiles(
+ conversation: ConversationAttributesType
+): Promise {
+ const result = getExternalAvatarDraftsForConversation(conversation);
+ await Promise.all(
+ result.map(async (relativeFile, index) => {
+ try {
+ await deleteAvatar(relativeFile);
+ } catch (error) {
+ log.error(
+ `safeCleanupAvatarDraftFiles: Failed to delete avatar draft at index ${index}`,
+ Errors.toLogFormat(error)
+ );
+ }
+ })
+ );
+}
+
+export const GENERIC_CLEANUP_FIELDS: Partial = {
+ draftChanged: undefined,
+ draftAttachments: undefined,
+ draftBodyRanges: undefined,
+ draftIsViewOnce: undefined,
+ draftTimestamp: undefined,
+
+ inbox_position: undefined,
+
+ lastMessageDeletedForEveryone: undefined,
+ lastMessageDeletedForEveryoneByAdminAci: undefined,
+ lastMessageAuthorAci: undefined,
+ lastMessage: undefined,
+ lastMessageBodyRanges: undefined,
+ lastMessagePrefix: undefined,
+ lastMessageAuthor: undefined,
+ lastMessageStatus: undefined,
+ lastMessageReceivedAt: undefined,
+ lastMessageReceivedAtMs: undefined,
+
+ markedUnread: undefined,
+ messageCount: undefined,
+ messageCountBeforeMessageRequests: undefined,
+ messageRequestResponseType: undefined,
+
+ messagesDeleted: true,
+
+ quotedMessageId: undefined,
+
+ sentMessageCount: undefined,
+
+ timestamp: undefined,
+ unreadCount: undefined,
+ unreadMentionsCount: undefined,
+
+ active_at: undefined,
+ draft: undefined,
+ draftEditMessage: undefined,
+
+ isArchived: undefined,
+
+ pendingUniversalTimer: undefined,
+
+ avatars: undefined,
+};
+
+export const GROUP_CLEANUP_FIELDS: Partial = {
+ addedBy: undefined,
+
+ color: undefined,
+ colorFromPrimary: undefined,
+ conversationColor: undefined,
+ customColor: undefined,
+ customColorId: undefined,
+
+ wallpaperPhotoPointerBase64: undefined,
+ wallpaperPreset: undefined,
+ dimWallpaperInDarkMode: undefined,
+ autoBubbleColor: undefined,
+
+ hideStory: undefined,
+
+ isReported: undefined,
+ name: undefined,
+
+ pendingUniversalTimer: undefined,
+ pendingRemovedContactNotification: undefined,
+ reportingToken: undefined,
+
+ left: undefined,
+ storySendMode: undefined,
+ groupVerifiedNameHash: undefined,
+
+ members: undefined,
+ derivedGroupV2Id: undefined,
+
+ secretParams: undefined,
+ publicParams: undefined,
+ revision: undefined,
+ senderKeyInfo: undefined,
+
+ accessControl: undefined,
+ announcementsOnly: undefined,
+ avatar: undefined,
+ avatars: undefined,
+ description: undefined,
+ expireTimer: undefined,
+ expireTimerVersion: 1,
+ membersV2: undefined,
+ pendingMembersV2: undefined,
+ pendingAdminApprovalV2: undefined,
+ bannedMembersV2: undefined,
+ groupInviteLinkPassword: undefined,
+ previousGroupV1Id: undefined,
+ previousGroupV1Members: undefined,
+ acknowledgedGroupNameCollisions: undefined,
+
+ isTemporary: undefined,
+ temporaryMemberCount: undefined,
+
+ unblurredAvatarPath: undefined,
+
+ remoteAvatarUrl: undefined,
+};
diff --git a/ts/util/conversationFilePaths.std.ts b/ts/util/conversationFilePaths.std.ts
new file mode 100644
index 0000000000..336c9004cb
--- /dev/null
+++ b/ts/util/conversationFilePaths.std.ts
@@ -0,0 +1,60 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import type { ConversationAttributesType } from '../model-types.d.ts';
+
+export function getExternalAvatarFilesForConversation(
+ conversation: Pick
+): Array {
+ const { avatar, profileAvatar } = conversation;
+ const files: Array = [];
+
+ if (avatar && avatar.path) {
+ files.push(avatar.path);
+ }
+
+ if (profileAvatar && profileAvatar.path) {
+ files.push(profileAvatar.path);
+ }
+
+ return files;
+}
+
+export function getExternalAvatarDraftsForConversation(
+ conversation: Pick
+): Array {
+ const { avatars } = conversation;
+ const files: Array = [];
+
+ (avatars || []).forEach(item => {
+ if (item.imagePath) {
+ files.push(item.imagePath);
+ }
+ });
+
+ return files;
+}
+
+export function getExternalDraftFilesForConversation(
+ conversation: Pick
+): Array {
+ const draftAttachments = conversation.draftAttachments || [];
+ const files: Array = [];
+
+ (draftAttachments || []).forEach(attachment => {
+ if (attachment.pending) {
+ return;
+ }
+
+ const { path: file, screenshotPath } = attachment;
+ if (file) {
+ files.push(file);
+ }
+
+ if (screenshotPath) {
+ files.push(screenshotPath);
+ }
+ });
+
+ return files;
+}