diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 50b32870c38..d5cc6c6d660 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -1714,6 +1714,222 @@
"messageformat": "Emoji skin tone",
"description": "Preferences Window > Chats Tab > Emoji skin tone default setting > Label"
},
+ "icu:Preferences__ChatsPage__ChatFoldersSection__Title": {
+ "messageformat": "Chat folders",
+ "description": "Preferences > Chats Page > Chat Folders Section > Title"
+ },
+ "icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Title": {
+ "messageformat": "Add a chat folder",
+ "description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Title"
+ },
+ "icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Description": {
+ "messageformat": "Organize your chats into folders and quickly switch between them on your chat list.",
+ "description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Description"
+ },
+ "icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder": {
+ "messageformat": "Edit folder",
+ "description": "Preferences > Chats Page > Chat Folders Section > Chat Folder Item > Context Menu > Edit Folder"
+ },
+ "icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__DeleteFolder": {
+ "messageformat": "Delete",
+ "description": "Preferences > Chats Page > Chat Folders Section > Chat Folder Item > Context Menu > Delete Folder"
+ },
+ "icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title": {
+ "messageformat": "Delete folder?",
+ "description": "Preferences > Chats Page > Delete Chat Folder Dialog > Title"
+ },
+ "icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description": {
+ "messageformat": "Do you want to delete the folder “{chatFolderTitle}”?",
+ "description": "Preferences > Chats Page > Delete Chat Folder Dialog > Description"
+ },
+ "icu:Preferences__ChatsPage__DeleteChatFolderDialog__DeleteButton": {
+ "messageformat": "Delete",
+ "description": "Preferences > Chats Page > Delete Chat Folder Dialog > Delete Button"
+ },
+ "icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton": {
+ "messageformat": "Cancel",
+ "description": "Preferences > Chats Page > Delete Chat Folder Dialog > Cancel Button"
+ },
+ "icu:Preferences__ChatFoldersPage__Title": {
+ "messageformat": "Chat folders",
+ "description": "Preferences > Chat Folders Page > Title"
+ },
+ "icu:Preferences__ChatFoldersPage__Description": {
+ "messageformat": "Organize your chats into folders and quickly switch between them on your chat list",
+ "description": "Preferences > Chat Folders Page > Description"
+ },
+ "icu:Preferences__ChatFoldersPage__FoldersSection__Title": {
+ "messageformat": "Folders",
+ "description": "Preferences > Chat Folders Page > Folders > Title"
+ },
+ "icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title": {
+ "messageformat": "All chats",
+ "description": "Preferences > Chat Folders Page > Folders > All Chats Folder (Default folder, always present)"
+ },
+ "icu:Preferences__ChatFoldersPage__FoldersSection__CreateAFolderButton": {
+ "messageformat": "Create a folder",
+ "description": "Preferences > Chat Folders Page > Folders > Create A Folder Button"
+ },
+ "icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__Title": {
+ "messageformat": "Suggested folders",
+ "description": "Preferences > Chat Folders Page > Suggested Folders > Title"
+ },
+ "icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton": {
+ "messageformat": "Add",
+ "description": "Preferences > Chat Folders Page > Suggested Folders > Add Button"
+ },
+ "icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast": {
+ "messageformat": "{chatFolderTitle} folder added",
+ "description": "Preferences > Chat Folders Page > Suggested Folders > Add Button > Toast"
+ },
+ "icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__UnreadFolder__Title": {
+ "messageformat": "Unread",
+ "description": "Preferences > Chat Folders Page > Suggested Folders > Unread Folder > Title"
+ },
+ "icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__UnreadFolder__Description": {
+ "messageformat": "Unread messages from all chats",
+ "description": "Preferences > Chat Folders Page > Suggested Folders > Unread Folder > Description"
+ },
+ "icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__DirectChatsFolder__Title": {
+ "messageformat": "1:1 chats",
+ "description": "Preferences > Chat Folders Page > Suggested Folders > Direct chats Folder > Title"
+ },
+ "icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__DirectChatsFolder__Description": {
+ "messageformat": "Only messages from direct chats",
+ "description": "Preferences > Chat Folders Page > Suggested Folders > Direct chats Folder > Description"
+ },
+ "icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__GroupChatsFolder__Title": {
+ "messageformat": "Groups",
+ "description": "Preferences > Chat Folders Page > Suggested Folders > Group chats Folder > Title"
+ },
+ "icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__GroupChatsFolder__Description": {
+ "messageformat": "Only messages from group chats",
+ "description": "Preferences > Chat Folders Page > Suggested Folders > Group chats Folder > Description"
+ },
+ "icu:Preferences__EditChatFolderPage__Title": {
+ "messageformat": "Create a folder",
+ "description": "Preferences > Edit Chat Folder Page > Title"
+ },
+ "icu:Preferences__EditChatFolderPage__FolderNameField__Label": {
+ "messageformat": "Folder name",
+ "description": "Preferences > Edit Chat Folder Page > Folder Name Field > Label"
+ },
+ "icu:Preferences__EditChatFolderPage__FolderNameField__Placeholder": {
+ "messageformat": "Folder name (required)",
+ "description": "Preferences > Edit Chat Folder Page > Folder Name Field > Placeholder"
+ },
+ "icu:Preferences__EditChatFolderPage__IncludedChatsSection__Title": {
+ "messageformat": "Included chats",
+ "description": "Preferences > Edit Chat Folder Page > Included Chats Section > Title"
+ },
+ "icu:Preferences__EditChatFolderPage__IncludedChatsSection__Help": {
+ "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__AddChatsButton": {
+ "messageformat": "Add chats",
+ "description": "Preferences > Edit Chat Folder Page > Included Chats Section > Add Chats Button"
+ },
+ "icu:Preferences__EditChatFolderPage__SelectChatsDialog--IncludedChats__Title": {
+ "messageformat": "Included chats",
+ "description": "Preferences > Edit Chat Folder Page > Select Chats Dialog (Included Chats Mode) > Title"
+ },
+ "icu:Preferences__EditChatFolderPage__SelectChatsDialog--ExcludedChats__Title": {
+ "messageformat": "Excluded chats",
+ "description": "Preferences > Edit Chat Folder Page > Select Chats Dialog (Excluded Chats Mode) > Title"
+ },
+ "icu:Preferences__EditChatFolderPage__SelectChatsDialog__Search__Placeholder": {
+ "messageformat": "Search",
+ "description": "Preferences > Edit Chat Folder Page > Select Chats Dialog > Search > Placeholder"
+ },
+ "icu:Preferences__EditChatFolderPage__SelectChatsDialog__ChatTypesSection__Title": {
+ "messageformat": "Chat types",
+ "description": "Preferences > Edit Chat Folder Page > Select Chats Dialog > Chat Types Section > Title"
+ },
+ "icu:Preferences__EditChatFolderPage__SelectChatsDialog__ChatTypesSection__DirectChats": {
+ "messageformat": "1:1 chats",
+ "description": "Preferences > Edit Chat Folder Page > Select Chats Dialog > Chat Types Section > Direct Chats"
+ },
+ "icu:Preferences__EditChatFolderPage__SelectChatsDialog__ChatTypesSection__GroupChats": {
+ "messageformat": "Groups",
+ "description": "Preferences > Edit Chat Folder Page > Select Chats Dialog > Chat Types Section > Group Chats"
+ },
+ "icu:Preferences__EditChatFolderPage__SelectChatsDialog__RecentChats__Title": {
+ "messageformat": "Recent chats",
+ "description": "Preferences > Edit Chat Folder Page > Select Chats Dialog > Recent Chats Section > Title"
+ },
+ "icu:Preferences__EditChatFolderPage__SelectChatsDialog__DoneButton": {
+ "messageformat": "Done",
+ "description": "Preferences > Edit Chat Folder Page > Select Chats Dialog > Done Button"
+ },
+ "icu:Preferences__EditChatFolderPage__ExceptionsSection__Title": {
+ "messageformat": "Exceptions",
+ "description": "Preferences > Edit Chat Folder Page > Exceptions Section > Title"
+ },
+ "icu:Preferences__EditChatFolderPage__ExceptionsSection__Help": {
+ "messageformat": "Choose chats that you do not want to appear in this folder",
+ "description": "Preferences > Edit Chat Folder Page > Exceptions Section > Help"
+ },
+ "icu:Preferences__EditChatFolderPage__ExceptionsSection__ExcludeChatsButton": {
+ "messageformat": "Exclude chats",
+ "description": "Preferences > Edit Chat Folder Page > Exceptions Section > Exclude chats button"
+ },
+ "icu:Preferences__EditChatFolderPage__OnlyShowUnreadChatsCheckbox__Label": {
+ "messageformat": "Only show unread chats",
+ "description": "Preferences > Edit Chat Folder Page > Only Show Unread Chats Checkbox > Label"
+ },
+ "icu:Preferences__EditChatFolderPage__OnlyShowUnreadChatsCheckbox__Description": {
+ "messageformat": "Only chats with unread messages will be shown in this folder.",
+ "description": "Preferences > Edit Chat Folder Page > Only Show Unread Chats Checkbox > Description"
+ },
+ "icu:Preferences__EditChatFolderPage__IncludeMutedChatsCheckbox__Label": {
+ "messageformat": "Include muted chats",
+ "description": "Preferences > Edit Chat Folder Page > Include Muted Chats Checkbox > Label"
+ },
+ "icu:Preferences__EditChatFolderPage__DeleteFolderButton": {
+ "messageformat": "Delete folder",
+ "description": "Preferences > Edit Chat Folder Page > Delete Folder Button"
+ },
+ "icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Title": {
+ "messageformat": "Delete this chat folder?",
+ "description": "Preferences > Delete Chat Folder Dialog > Title"
+ },
+ "icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Description": {
+ "messageformat": "This folder will be removed from your chat list.",
+ "description": "Preferences > Delete Chat Folder Dialog > Description"
+ },
+ "icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__DeleteButton": {
+ "messageformat": "Delete",
+ "description": "Preferences > Delete Chat Folder Dialog > Delete Button"
+ },
+ "icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__CancelButton": {
+ "messageformat": "Cancel",
+ "description": "Preferences > Delete Chat Folder Dialog > Cancel Button"
+ },
+ "icu:Preferences__EditChatFolderPage__SaveChangesFolderDialog__Title": {
+ "messageformat": "Save changes?",
+ "description": "Preferences > Edit Chat Folder Page > Save Changes Folder Dialog > Title"
+ },
+ "icu:Preferences__EditChatFolderPage__SaveChangesFolderDialog__Description": {
+ "messageformat": "Do you want to save the changes you’ve made to this chat folder?",
+ "description": "Preferences > Edit Chat Folder Page > Save Changes Folder Dialog > Description"
+ },
+ "icu:Preferences__EditChatFolderPage__SaveChangesFolderDialog__SaveButton": {
+ "messageformat": "Save",
+ "description": "Preferences > Edit Chat Folder Page > Save Changes Folder Dialog > Save Button"
+ },
+ "icu:Preferences__EditChatFolderPage__SaveChangesFolderDialog__DiscardButton": {
+ "messageformat": "Discard",
+ "description": "Preferences > Edit Chat Folder Page > Save Changes Folder Dialog > Discard Button"
+ },
+ "icu:Preferences__EditChatFolderPage__SaveButton": {
+ "messageformat": "Save",
+ "description": "Preferences > Edit Chat Folder Page > Save Button"
+ },
+ "icu:Preferences__EditChatFolderPage__CancelButton": {
+ "messageformat": "Cancel",
+ "description": "Preferences > Edit Chat Folder Page > Cancel Button"
+ },
"icu:initialSync": {
"messageformat": "Syncing contacts and groups",
"description": "Shown during initial link while contacts and groups are being pulled from mobile device"
diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss
index 74aaf257e50..b6056a57dcf 100644
--- a/stylesheets/_modules.scss
+++ b/stylesheets/_modules.scss
@@ -5098,7 +5098,8 @@ button.module-calling-participants-list__contact {
$normal-row-height: 72px;
@include mixins.NavTabs__Scroller;
- padding-inline: 10px;
+ padding-inline-start: 10px;
+ padding-inline-end: 1px; /* leaving room for scrollbar */
// list tiles in choose-group-members and compose extend to the edge
.module-left-pane--mode-choose-group-members &,
@@ -5797,6 +5798,48 @@ button.module-calling-participants-list__contact {
}
}
+.module-conversation-list__empty-results {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-block: 48px;
+ padding-inline: 12px;
+}
+
+.module-conversation-list__generic-checkbox-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 16px;
+ background-color: light-dark(
+ variables.$color-black-alpha-06,
+ variables.$color-white-alpha-12
+ );
+
+ &::before {
+ content: '';
+ display: block;
+ width: 20px;
+ height: 20px;
+ }
+
+ &--contact::before {
+ @include mixins.color-svg(
+ '../images/icons/v3/person/person.svg',
+ light-dark(variables.$color-black, variables.$color-gray-05)
+ );
+ }
+
+ &--group::before {
+ @include mixins.color-svg(
+ '../images/icons/v3/group/group.svg',
+ light-dark(variables.$color-black, variables.$color-gray-05)
+ );
+ }
+}
+
// Module: Left Pane
.module-left-pane {
diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss
index 2d0a6e091d0..beda1f9af3e 100644
--- a/stylesheets/components/Preferences.scss
+++ b/stylesheets/components/Preferences.scss
@@ -289,6 +289,16 @@ $secondary-text-color: light-dark(
container-type: inline-size;
}
+ &__actions {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ align-items: center;
+ width: 100%;
+ padding: 16px;
+ gap: 16px;
+ }
+
&__settings-pane {
display: flex;
flex-grow: 1;
@@ -1297,3 +1307,118 @@ $secondary-text-color: light-dark(
user-select: text;
overflow-x: scroll;
}
+
+.Preferences__ChatFolders__ChatSelection__List {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.Preferences__ChatFolders__ChatSelection__Item--Button {
+ @include mixins.button-reset();
+ &:hover {
+ background: light-dark(variables.$color-gray-02, variables.$color-gray-80);
+ }
+ @include mixins.keyboard-mode {
+ &:focus {
+ outline: 2px solid variables.$color-ultramarine;
+ }
+ }
+}
+
+.Preferences__ChatFolders__ChatSelection__Item {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ gap: 12px;
+ padding-block: 8px;
+ padding-inline: 24px;
+ border-radius: 1px;
+}
+
+.Preferences__ChatFolders__ChatSelection__ItemAvatar {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 9999px;
+ background: light-dark(variables.$color-gray-05, variables.$color-gray-90);
+ &::before {
+ content: '';
+ display: block;
+ width: 20px;
+ height: 20px;
+ }
+}
+
+.Preferences__ChatFolders__ChatSelection__ItemAvatar--Add::before {
+ @include mixins.color-svg(
+ '../images/icons/v3/plus/plus.svg',
+ light-dark(variables.$color-gray-75, variables.$color-gray-15)
+ );
+}
+
+.Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder::before {
+ @include mixins.color-svg(
+ '../images/icons/v3/folder/folder.svg',
+ light-dark(variables.$color-gray-75, variables.$color-gray-15)
+ );
+}
+
+.Preferences__ChatFolders__ChatSelection__ItemAvatar--UnreadChats::before {
+ @include mixins.color-svg(
+ '../images/icons/v3/chat/chat-badge.svg',
+ light-dark(variables.$color-gray-75, variables.$color-gray-15)
+ );
+}
+
+.Preferences__ChatFolders__ChatSelection__ItemAvatar--DirectChats::before {
+ @include mixins.color-svg(
+ '../images/icons/v3/person/person.svg',
+ light-dark(variables.$color-gray-75, variables.$color-gray-15)
+ );
+}
+
+.Preferences__ChatFolders__ChatSelection__ItemAvatar--GroupChats::before {
+ @include mixins.color-svg(
+ '../images/icons/v3/group/group.svg',
+ light-dark(variables.$color-gray-75, variables.$color-gray-15)
+ );
+}
+
+.Preferences__ChatFolders__ChatSelection__ItemBody {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+}
+
+.Preferences__ChatFolders__ChatSelection__ItemTitle {
+ @include mixins.font-body-1;
+ color: light-dark(variables.$color-gray-90, variables.$color-gray-05);
+}
+
+.Preferences__ChatFolders__ChatSelection__ItemDescription {
+ @include mixins.font-body-2;
+ color: light-dark(variables.$color-gray-60, variables.$color-gray-25);
+}
+
+.Preferences__ChatFolders__ChatList__DeleteButton {
+ @include mixins.button-reset();
+ & {
+ color: variables.$color-accent-red;
+ border-radius: 1px;
+ }
+ @include mixins.keyboard-mode {
+ &:focus {
+ outline: 2px solid variables.$color-ultramarine;
+ }
+ }
+}
+
+.Preferences__EditChatFolderPage__SelectChatsDialog__width-container {
+ // Override .module-modal-host__width-container
+ &.module-modal-host__width-container {
+ max-width: 360px;
+ }
+}
diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts
index e9c76f8d405..769de771371 100644
--- a/ts/RemoteConfig.ts
+++ b/ts/RemoteConfig.ts
@@ -21,6 +21,9 @@ export type ConfigKeyType =
| 'desktop.calling.ringrtcAdmFull.3'
| 'desktop.calling.ringrtcAdmInternal'
| 'desktop.calling.ringrtcAdmPreStable'
+ | 'desktop.chatFolders.alpha'
+ | 'desktop.chatFolders.beta'
+ | 'desktop.chatFolders.prod'
| 'desktop.clientExpiration'
| 'desktop.backup.credentialFetch'
| 'desktop.donations'
diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx
index 9d3c5ed0878..01d0b1bb8a6 100644
--- a/ts/components/ConversationList.tsx
+++ b/ts/components/ConversationList.tsx
@@ -37,6 +37,7 @@ import { UsernameSearchResultListItem } from './conversationList/UsernameSearchR
import { GroupListItem } from './conversationList/GroupListItem';
import { ListView } from './ListView';
import { Button, ButtonVariant } from './Button';
+import { ListTile } from './ListTile';
export enum RowType {
ArchiveButton = 'ArchiveButton',
@@ -46,6 +47,7 @@ export enum RowType {
ContactCheckbox = 'ContactCheckbox',
PhoneNumberCheckbox = 'PhoneNumberCheckbox',
UsernameCheckbox = 'UsernameCheckbox',
+ GenericCheckbox = 'GenericCheckbox',
Conversation = 'Conversation',
CreateNewGroup = 'CreateNewGroup',
FindByUsername = 'FindByUsername',
@@ -58,6 +60,7 @@ export enum RowType {
SelectSingleGroup = 'SelectSingleGroup',
StartNewConversation = 'StartNewConversation',
UsernameSearchResult = 'UsernameSearchResult',
+ EmptyResults = 'EmptyResults',
}
type ArchiveButtonRowType = {
@@ -100,6 +103,19 @@ type UsernameCheckboxRowType = {
isFetching: boolean;
};
+export enum GenericCheckboxRowIcon {
+ Contact = 'contact',
+ Group = 'group',
+}
+
+type GenericCheckboxRowType = {
+ type: RowType.GenericCheckbox;
+ icon: GenericCheckboxRowIcon;
+ label: string;
+ isChecked: boolean;
+ onClick: () => void;
+};
+
type ConversationRowType = {
type: RowType.Conversation;
conversation: ConversationListItemPropsType;
@@ -160,6 +176,11 @@ type UsernameRowType = {
isFetchingUsername: boolean;
};
+type EmptyResultsRowType = {
+ type: RowType.EmptyResults;
+ message: string;
+};
+
export type Row =
| ArchiveButtonRowType
| BlankRowType
@@ -168,6 +189,7 @@ export type Row =
| ClearFilterButtonRowType
| PhoneNumberCheckboxRowType
| UsernameCheckboxRowType
+ | GenericCheckboxRowType
| ConversationRowType
| CreateNewGroupRowType
| FindByUsername
@@ -178,7 +200,8 @@ export type Row =
| SearchResultsLoadingFakeRowType
| StartNewConversationRowType
| SelectSingleGroupRowType
- | UsernameRowType;
+ | UsernameRowType
+ | EmptyResultsRowType;
export type PropsType = {
dimensions?: {
@@ -222,6 +245,7 @@ export type PropsType = {
const NORMAL_ROW_HEIGHT = 76;
const SELECT_ROW_HEIGHT = 52;
const HEADER_ROW_HEIGHT = 40;
+const EMPTY_RESULTS_ROW_HEIGHT = 48 + 20 + 48;
export function ConversationList({
dimensions,
@@ -260,19 +284,34 @@ export function ConversationList({
assertDev(false, `Expected a row at index ${index}`);
return NORMAL_ROW_HEIGHT;
}
- switch (row.type) {
+ const { type } = row;
+ switch (type) {
case RowType.Header:
case RowType.SearchResultsLoadingFakeHeader:
return HEADER_ROW_HEIGHT;
case RowType.SelectSingleGroup:
case RowType.ContactCheckbox:
+ case RowType.GenericCheckbox:
case RowType.Contact:
case RowType.CreateNewGroup:
case RowType.FindByUsername:
case RowType.FindByPhoneNumber:
return SELECT_ROW_HEIGHT;
- default:
+ case RowType.ArchiveButton:
+ case RowType.Blank:
+ case RowType.ClearFilterButton:
+ case RowType.PhoneNumberCheckbox:
+ case RowType.UsernameCheckbox:
+ case RowType.Conversation:
+ case RowType.MessageSearchResult:
+ case RowType.SearchResultsLoadingFakeRow:
+ case RowType.StartNewConversation:
+ case RowType.UsernameSearchResult:
return NORMAL_ROW_HEIGHT;
+ case RowType.EmptyResults:
+ return EMPTY_RESULTS_ROW_HEIGHT;
+ default:
+ throw missingCaseError(type);
}
},
[getRow]
@@ -400,6 +439,21 @@ export function ConversationList({
/>
);
break;
+ case RowType.GenericCheckbox:
+ result = (
+
+ }
+ title={row.label}
+ isChecked={row.isChecked}
+ onClick={row.onClick}
+ clickable
+ />
+ );
+ break;
case RowType.Conversation: {
const itemProps = pick(row.conversation, [
'avatarPlaceholderGradient',
@@ -539,6 +593,13 @@ export function ConversationList({
/>
);
break;
+ case RowType.EmptyResults:
+ result = (
+
+ {row.message}
+
+ );
+ break;
default:
throw missingCaseError(row);
}
diff --git a/ts/components/ListView.tsx b/ts/components/ListView.tsx
index ad3602ac385..6fee9cee863 100644
--- a/ts/components/ListView.tsx
+++ b/ts/components/ListView.tsx
@@ -52,10 +52,9 @@ export function ListView({
const style: React.CSSProperties = useMemo(() => {
return {
- // See `` for an explanation of this `any` cast.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- overflowY: scrollable ? ('overlay' as any) : 'hidden',
+ overflowY: scrollable ? 'auto' : 'hidden',
direction: 'inherit',
+ scrollbarGutter: 'stable',
};
}, [scrollable]);
diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx
index f5a4396fe08..412c8bf5843 100644
--- a/ts/components/Preferences.stories.tsx
+++ b/ts/components/Preferences.stories.tsx
@@ -5,6 +5,7 @@ import type { Meta, StoryFn } from '@storybook/react';
import React, { useRef, useState } from 'react';
import { action } from '@storybook/addon-actions';
+import { shuffle } from 'lodash';
import { Page, Preferences } from './Preferences';
import { DEFAULT_CONVERSATION_COLOR } from '../types/Colors';
import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode';
@@ -14,7 +15,10 @@ import { DAY, DurationInSeconds, WEEK } from '../util/durations';
import { DialogUpdate } from './DialogUpdate';
import { DialogType } from '../types/Dialogs';
import { ThemeType } from '../types/Util';
-import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
+import {
+ getDefaultConversation,
+ getDefaultGroup,
+} from '../test-both/helpers/getDefaultConversation';
import { EditState, ProfileEditor } from './ProfileEditor';
import {
UsernameEditState,
@@ -25,6 +29,7 @@ import type { PropsType } from './Preferences';
import type { WidthBreakpoint } from './_util';
import type { MessageAttributesType } from '../model-types';
import { PreferencesDonations } from './PreferencesDonations';
+import { strictAssert } from '../util/assert';
const { i18n } = window.SignalContext;
@@ -34,6 +39,20 @@ const me = {
username: 'someone.243',
};
+const conversations = shuffle([
+ ...Array.from(Array(20), getDefaultGroup),
+ ...Array.from(Array(20), getDefaultConversation),
+]);
+
+function conversationSelector(conversationId?: string) {
+ strictAssert(conversationId, 'Missing conversation id');
+ const found = conversations.find(conversation => {
+ return conversation.id === conversationId;
+ });
+ strictAssert(found, 'Missing conversation');
+ return found;
+}
+
const availableMicrophones = [
{
name: 'DefAuLt (Headphones)',
@@ -164,6 +183,9 @@ export default {
args: {
i18n,
+ conversations,
+ conversationSelector,
+
accountEntropyPool:
'uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t',
autoDownloadAttachment: {
@@ -273,6 +295,7 @@ export default {
renderToastManager,
renderUpdateDialog,
getConversationsWithCustomColor: () => [],
+ getPreferredBadge: () => undefined,
addCustomColor: action('addCustomColor'),
doDeleteAllData: action('doDeleteAllData'),
@@ -379,6 +402,14 @@ export const Chats = Template.bind({});
Chats.args = {
page: Page.Chats,
};
+export const ChatFolders = Template.bind({});
+ChatFolders.args = {
+ page: Page.ChatFolders,
+};
+export const EditChatFolder = Template.bind({});
+EditChatFolder.args = {
+ page: Page.EditChatFolder,
+};
export const Calls = Template.bind({});
Calls.args = {
page: Page.Calls,
diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx
index 8c9e6a26efa..2702155c95d 100644
--- a/ts/components/Preferences.tsx
+++ b/ts/components/Preferences.tsx
@@ -13,9 +13,7 @@ import React, {
import { isNumber, noop, partition } from 'lodash';
import classNames from 'classnames';
import * as LocaleMatcher from '@formatjs/intl-localematcher';
-
-import type { MutableRefObject } from 'react';
-
+import type { MutableRefObject, ReactNode } from 'react';
import { Button, ButtonVariant } from './Button';
import { ChatColorPicker } from './ChatColorPicker';
import { Checkbox } from './Checkbox';
@@ -37,7 +35,7 @@ import { focusableSelector } from '../util/focusableSelectors';
import { Modal } from './Modal';
import { SearchInput } from './SearchInput';
import { removeDiacritics } from '../util/removeDiacritics';
-import { assertDev } from '../util/assert';
+import { assertDev, strictAssert } from '../util/assert';
import { I18n } from './I18n';
import { FunSkinTonesList } from './fun/FunSkinTones';
import { emojiParentKeyConstant, type EmojiSkinTone } from './fun/data/emojis';
@@ -89,11 +87,27 @@ import type {
PromptOSAuthReasonType,
PromptOSAuthResultType,
} from '../util/os/promptOSAuthMain';
+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';
type CheckboxChangeHandlerType = (value: boolean) => unknown;
type SelectChangeHandlerType = (value: T) => unknown;
export type PropsDataType = {
+ conversations: ReadonlyArray;
+ conversationSelector: GetConversationByIdType;
+
// Settings
accountEntropyPool: string | undefined;
autoDownloadAttachment: AutoDownloadAttachmentType;
@@ -207,6 +221,7 @@ type PropsFunctionType = {
version: number
) => Promise>;
getConversationsWithCustomColor: (colorId: string) => Array;
+ getPreferredBadge: PreferredBadgeSelectorType;
makeSyncRequest: () => unknown;
onStartUpdate: () => unknown;
pickLocalBackupFolder: () => Promise;
@@ -296,6 +311,8 @@ export enum Page {
// Sub pages
ChatColor = 'ChatColor',
+ ChatFolders = 'ChatFolders',
+ EditChatFolder = 'EditChatFolder',
PNP = 'PNP',
BackupsDetails = 'BackupsDetails',
LocalBackups = 'LocalBackups',
@@ -333,6 +350,8 @@ const DEFAULT_ZOOM_FACTORS = [
];
export function Preferences({
+ conversations,
+ conversationSelector,
accountEntropyPool,
addCustomColor,
autoDownloadAttachment,
@@ -358,6 +377,7 @@ export function Preferences({
getConversationsWithCustomColor,
getMessageCountBySchemaVersion,
getMessageSampleForSchemaVersion,
+ getPreferredBadge,
hasAudioNotifications,
hasAutoConvertEmoji,
hasAutoDownloadUpdate,
@@ -497,6 +517,54 @@ export function Preferences({
const [confirmPnpNotDiscoverable, setConfirmPnpNoDiscoverable] =
useState(false);
+ const [chatFolders, setChatFolders] = useState<
+ ReadonlyArray
+ >([]);
+
+ const [editChatFolderPageId, setEditChatFolderPageId] =
+ useState(null);
+
+ const handleOpenEditChatFoldersPage = useCallback(
+ (chatFolderId: ChatFolderId | null) => {
+ setPage(Page.EditChatFolder);
+ setEditChatFolderPageId(chatFolderId);
+ },
+ [setPage]
+ );
+
+ const handleCloseEditChatFoldersPage = useCallback(() => {
+ setPage(Page.ChatFolders);
+ 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);
@@ -1103,6 +1171,33 @@ export function Preferences({
/>
+ {isChatFoldersEnabled() && (
+
+
+
+ {i18n(
+ 'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Title'
+ )}
+
+
+ {i18n(
+ 'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Description'
+ )}
+
+ >
+ }
+ right={null}
+ onClick={() => setPage(Page.ChatFolders)}
+ />
+
+ )}
+
{isSyncSupported && (
);
+ } else if (page === Page.ChatFolders) {
+ content = (
+ setPage(Page.Chats)}
+ onOpenEditChatFoldersPage={handleOpenEditChatFoldersPage}
+ chatFolders={chatFolders}
+ onCreateChatFolder={handleCreateChatFolder}
+ />
+ );
+ } else if (page === Page.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 = (
+
+ );
} else if (page === Page.PNP) {
let sharingDescription: string;
@@ -2248,11 +2381,13 @@ export function PreferencesContent({
contents,
contentsRef,
title,
+ actions,
}: {
backButton?: JSX.Element | undefined;
contents: JSX.Element | undefined;
contentsRef: MutableRefObject;
title: string | undefined;
+ actions?: ReactNode;
}): JSX.Element {
return (
@@ -2267,6 +2402,7 @@ export function PreferencesContent({
+ {actions && {actions}
}
);
}
diff --git a/ts/components/preferences/ChatFoldersPage.tsx b/ts/components/preferences/ChatFoldersPage.tsx
new file mode 100644
index 00000000000..0fa25d3764d
--- /dev/null
+++ b/ts/components/preferences/ChatFoldersPage.tsx
@@ -0,0 +1,272 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+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 {
+ CHAT_FOLDER_PRESETS,
+ matchesChatFolderPreset,
+ type ChatFolderParams,
+ type ChatFolderPreset,
+ type ChatFolderRecord,
+} from '../../types/ChatFolder';
+import { Button, ButtonVariant } from '../Button';
+// import { showToast } from '../../state/ducks/toast';
+
+export type ChatFoldersPageProps = Readonly<{
+ i18n: LocalizerType;
+ onBack: () => void;
+ onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void;
+ chatFolders: ReadonlyArray;
+ onCreateChatFolder: (params: ChatFolderParams) => void;
+ settingsPaneRef: MutableRefObject;
+}>;
+
+export function ChatFoldersPage(props: ChatFoldersPageProps): JSX.Element {
+ const { i18n, onOpenEditChatFoldersPage } = props;
+
+ // showToast(
+ // i18n("icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast")
+ // )
+
+ const handleOpenEditChatFoldersPageForNew = useCallback(() => {
+ onOpenEditChatFoldersPage(null);
+ }, [onOpenEditChatFoldersPage]);
+
+ return (
+
+ }
+ contents={
+ <>
+
+ {i18n('icu:Preferences__ChatFoldersPage__Description')}
+
+
+
+
+
+
+ {i18n(
+ 'icu:Preferences__ChatFoldersPage__FoldersSection__CreateAFolderButton'
+ )}
+
+
+
+
+
+ {i18n(
+ 'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
+ )}
+
+
+ {props.chatFolders.map(chatFolder => {
+ return (
+
+ );
+ })}
+
+
+
+
+
+ >
+ }
+ contentsRef={props.settingsPaneRef}
+ title={i18n('icu:Preferences__ChatFoldersPage__Title')}
+ />
+ );
+}
+
+function ChatFolderPresetItem(props: {
+ i18n: LocalizerType;
+ icon: 'UnreadChats' | 'DirectChats' | 'GroupChats';
+ title: string;
+ description: string;
+ preset: ChatFolderPreset;
+ chatFolders: ReadonlyArray;
+ onCreateChatFolder: (params: ChatFolderParams) => void;
+}) {
+ const { i18n, title, preset, chatFolders, onCreateChatFolder } = props;
+
+ 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.description}
+
+
+
+
+ );
+}
+
+function ChatFolderListItem(props: {
+ chatFolder: ChatFolderRecord;
+ onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId) => void;
+}): JSX.Element {
+ const { chatFolder, onOpenEditChatFoldersPage } = props;
+ const handleAction = useCallback(() => {
+ onOpenEditChatFoldersPage(chatFolder.id);
+ }, [chatFolder, onOpenEditChatFoldersPage]);
+ return (
+
+
+
+ {props.chatFolder.name}
+
+
+ );
+}
+
+// function ChatFolderContextMenu(props: {
+// i18n: LocalizerType;
+// children: ReactNode;
+// }) {
+// const { i18n } = props;
+// return (
+//
+// {props.children}
+//
+//
+// {i18n(
+// eslint-disable-next-line max-len
+// 'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder'
+// )}
+//
+//
+// {i18n(
+// eslint-disable-next-line max-len
+// 'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__DeleteFolder'
+// )}
+//
+//
+//
+// );
+// }
+
+// function DeleteChatFolderDialog(props: { i18n: LocalizerType }): JSX.Element {
+// const { i18n } = props;
+// return (
+// null,
+// },
+// ]}
+// onClose={() => null}
+// >
+// {i18n('icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description', {
+// chatFolderTitle: '',
+// })}
+//
+// );
+// }
diff --git a/ts/components/preferences/EditChatFoldersPage.tsx b/ts/components/preferences/EditChatFoldersPage.tsx
new file mode 100644
index 00000000000..c4a7ee0e35a
--- /dev/null
+++ b/ts/components/preferences/EditChatFoldersPage.tsx
@@ -0,0 +1,507 @@
+// Copyright 2025 Signal Messenger, LLC
+// 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 {
+ CHAT_FOLDER_NAME_MAX_CHAR_LENGTH,
+ isSameChatFolderParams,
+ normalizeChatFolderParams,
+ validateChatFolderParams,
+ type ChatFolderParams,
+} from '../../types/ChatFolder';
+import type { GetConversationByIdType } from '../../state/selectors/conversations';
+import { strictAssert } from '../../util/assert';
+
+export function EditChatFoldersPage(props: {
+ i18n: LocalizerType;
+ existingChatFolderId: ChatFolderId | null;
+ initChatFolderParams: ChatFolderParams;
+ onBack: () => void;
+ conversations: ReadonlyArray;
+ getPreferredBadge: PreferredBadgeSelectorType;
+ theme: ThemeType;
+ settingsPaneRef: MutableRefObject;
+ conversationSelector: GetConversationByIdType;
+ onDeleteChatFolder: (chatFolderId: ChatFolderId) => void;
+ onCreateChatFolder: (chatFolderParams: ChatFolderParams) => void;
+ onUpdateChatFolder: (
+ chatFolderId: ChatFolderId,
+ chatFolderParams: ChatFolderParams
+ ) => void;
+}): JSX.Element {
+ const {
+ i18n,
+ initChatFolderParams,
+ existingChatFolderId,
+ onCreateChatFolder,
+ onUpdateChatFolder,
+ onDeleteChatFolder,
+ onBack,
+ conversationSelector,
+ } = props;
+
+ const [chatFolderParams, setChatFolderParams] =
+ useState(initChatFolderParams);
+
+ const [showInclusionsDialog, setShowInclusionsDialog] = useState(false);
+ const [showExclusionsDialog, setShowExclusionsDialog] = useState(false);
+ const [showDeleteFolderDialog, setShowDeleteFolderDialog] = useState(false);
+ const [showSaveChangesDialog, setShowSaveChangesDialog] = useState(false);
+
+ const normalizedChatFolderParams = useMemo(() => {
+ return normalizeChatFolderParams(chatFolderParams);
+ }, [chatFolderParams]);
+
+ const isChanged = useMemo(() => {
+ return !isSameChatFolderParams(
+ initChatFolderParams,
+ normalizedChatFolderParams
+ );
+ }, [initChatFolderParams, normalizedChatFolderParams]);
+
+ const isValid = useMemo(() => {
+ return validateChatFolderParams(normalizedChatFolderParams);
+ }, [normalizedChatFolderParams]);
+
+ const handleNameChange = useCallback((newName: string) => {
+ setChatFolderParams(prevParams => {
+ return { ...prevParams, name: newName };
+ });
+ }, []);
+
+ const handleShowOnlyUnreadChange = useCallback((newValue: boolean) => {
+ setChatFolderParams(prevParams => {
+ return { ...prevParams, showOnlyUnread: newValue };
+ });
+ }, []);
+
+ const handleShowMutedChatsChange = useCallback((newValue: boolean) => {
+ setChatFolderParams(prevParams => {
+ return { ...prevParams, showMutedChats: newValue };
+ });
+ }, []);
+
+ const handleBackInit = useCallback(() => {
+ if (!isChanged) {
+ onBack();
+ } else {
+ setShowSaveChangesDialog(true);
+ }
+ }, [isChanged, onBack]);
+
+ const handleDiscard = useCallback(() => {
+ onBack();
+ }, [onBack]);
+
+ const handleSaveClose = useCallback(() => {
+ setShowSaveChangesDialog(false);
+ }, []);
+
+ const handleSave = useCallback(() => {
+ strictAssert(isChanged, 'tried saving when unchanged');
+ strictAssert(isValid, 'tried saving when invalid');
+
+ if (existingChatFolderId != null) {
+ onUpdateChatFolder(existingChatFolderId, chatFolderParams);
+ } else {
+ onCreateChatFolder(chatFolderParams);
+ }
+ onBack();
+ }, [
+ onBack,
+ existingChatFolderId,
+ isChanged,
+ isValid,
+ chatFolderParams,
+ onCreateChatFolder,
+ onUpdateChatFolder,
+ ]);
+
+ const handleDeleteInit = useCallback(() => {
+ setShowDeleteFolderDialog(true);
+ }, []);
+ const handleDeleteConfirm = useCallback(() => {
+ strictAssert(existingChatFolderId, 'Missing existing chat folder id');
+ onDeleteChatFolder(existingChatFolderId);
+ setShowDeleteFolderDialog(false);
+ onBack();
+ }, [existingChatFolderId, onDeleteChatFolder, onBack]);
+ const handleDeleteClose = useCallback(() => {
+ setShowDeleteFolderDialog(false);
+ }, []);
+ const handleSelectInclusions = useCallback(() => {
+ setShowInclusionsDialog(true);
+ }, []);
+ const handleSelectExclusions = useCallback(() => {
+ setShowExclusionsDialog(true);
+ }, []);
+
+ const handleCloseInclusions = useCallback(
+ (selection: ChatFolderSelection) => {
+ setChatFolderParams(prevParams => {
+ return {
+ ...prevParams,
+ includeAllIndividualChats: selection.selectAllIndividualChats,
+ includeAllGroupChats: selection.selectAllGroupChats,
+ includedConversationIds: selection.selectedRecipientIds,
+ };
+ });
+ setShowInclusionsDialog(false);
+ },
+ []
+ );
+
+ const handleCloseExclusions = useCallback(
+ (selection: ChatFolderSelection) => {
+ setChatFolderParams(prevParams => {
+ return {
+ ...prevParams,
+ includeAllIndividualChats: !selection.selectAllIndividualChats,
+ includeAllGroupChats: !selection.selectAllGroupChats,
+ excludedConversationIds: selection.selectedRecipientIds,
+ };
+ });
+ setShowExclusionsDialog(false);
+ },
+ []
+ );
+
+ return (
+
+ }
+ contents={
+ <>
+
+
+
+
+
+
+
+
+ {chatFolderParams.includeAllIndividualChats && (
+ -
+
+
+ 1:1 Chats
+
+
+ )}
+ {chatFolderParams.includeAllGroupChats && (
+ -
+
+
+ Group Chats
+
+
+ )}
+ {chatFolderParams.includedConversationIds.map(conversationId => {
+ const conversation = conversationSelector(conversationId);
+ return (
+ -
+
+
+ {conversation.title}
+
+
+ );
+ })}
+
+
+
+ {i18n(
+ 'icu:Preferences__EditChatFolderPage__IncludedChatsSection__Help'
+ )}
+
+
+
+
+
+
+ {chatFolderParams.excludedConversationIds.map(conversationId => {
+ const conversation = conversationSelector(conversationId);
+ return (
+ -
+
+
+ {conversation.title}
+
+
+ );
+ })}
+
+
+
+ {i18n(
+ 'icu:Preferences__EditChatFolderPage__ExceptionsSection__Help'
+ )}
+
+
+
+
+
+
+
+ {props.existingChatFolderId != null && (
+
+
+
+
+
+ )}
+ {showInclusionsDialog && (
+
+ )}
+ {showExclusionsDialog && (
+
+ )}
+ {showDeleteFolderDialog && (
+
+ )}
+ {showSaveChangesDialog && (
+
+ )}
+ >
+ }
+ contentsRef={props.settingsPaneRef}
+ title={i18n('icu:Preferences__EditChatFolderPage__Title')}
+ actions={
+ <>
+
+
+ >
+ }
+ />
+ );
+}
+
+function DeleteChatFolderDialog(props: {
+ i18n: LocalizerType;
+ onConfirm: () => void;
+ onClose: () => void;
+}) {
+ const { i18n } = props;
+ return (
+
+ {i18n(
+ 'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Description'
+ )}
+
+ );
+}
+
+function SaveChangesFolderDialog(props: {
+ i18n: LocalizerType;
+ onSave: () => void;
+ onCancel: () => void;
+ onClose: () => void;
+}) {
+ const { i18n } = props;
+
+ return (
+
+ {i18n(
+ 'icu:Preferences__EditChatFolderPage__SaveChangesFolderDialog__Description'
+ )}
+
+ );
+}
diff --git a/ts/components/preferences/EditChatFoldersSelectChatsDialog.tsx b/ts/components/preferences/EditChatFoldersSelectChatsDialog.tsx
new file mode 100644
index 00000000000..cbd5af82448
--- /dev/null
+++ b/ts/components/preferences/EditChatFoldersSelectChatsDialog.tsx
@@ -0,0 +1,270 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+import type { ChangeEvent } 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 { filterAndSortConversations } from '../../util/filterAndSortConversations';
+import { ContactPills } from '../ContactPills';
+import { ContactPill } from '../ContactPill';
+import {
+ asyncShouldNeverBeCalled,
+ shouldNeverBeCalled,
+} from '../../util/shouldNeverBeCalled';
+import { SearchInput } from '../SearchInput';
+import { Button, ButtonVariant } from '../Button';
+import { Modal } from '../Modal';
+import type { Row } from '../ConversationList';
+import {
+ ConversationList,
+ GenericCheckboxRowIcon,
+ RowType,
+} from '../ConversationList';
+import type { GetConversationByIdType } from '../../state/selectors/conversations';
+
+export type ChatFolderSelection = Readonly<{
+ selectedRecipientIds: ReadonlyArray;
+ selectAllIndividualChats: boolean;
+ selectAllGroupChats: boolean;
+}>;
+
+export function EditChatFoldersSelectChatsDialog(props: {
+ i18n: LocalizerType;
+ title: string;
+ conversations: ReadonlyArray;
+ conversationSelector: GetConversationByIdType;
+ onClose: (selection: ChatFolderSelection) => void;
+ getPreferredBadge: PreferredBadgeSelectorType;
+ theme: ThemeType;
+ initialSelection: ChatFolderSelection;
+ showChatTypes: boolean;
+}): JSX.Element {
+ const {
+ i18n,
+ conversations,
+ conversationSelector,
+ initialSelection,
+ onClose,
+ showChatTypes,
+ } = props;
+ const [searchInput, setSearchInput] = useState('');
+
+ const [selectAllIndividualChats, setSelectAllIndividualChats] = useState(
+ initialSelection.selectAllIndividualChats
+ );
+ const [selectAllGroupChats, setSelectAllGroupChats] = useState(
+ initialSelection.selectAllGroupChats
+ );
+ const [selectedRecipientIds, setSelectedRecipientIds] = useState(() => {
+ return new Set(initialSelection.selectedRecipientIds);
+ });
+
+ const handleSearchInputChange = useCallback(
+ (event: ChangeEvent) => {
+ setSearchInput(event.currentTarget.value);
+ },
+ []
+ );
+
+ const filteredConversations = useMemo(() => {
+ return filterAndSortConversations(
+ conversations,
+ searchInput,
+ undefined,
+ false,
+ undefined
+ );
+ }, [conversations, searchInput]);
+
+ const handleToggleDirectChats = useCallback(() => {
+ setSelectAllIndividualChats(value => !value);
+ }, []);
+
+ const handleToggleGroupChats = useCallback(() => {
+ setSelectAllGroupChats(value => !value);
+ }, []);
+
+ const handleToggleSelectedConversation = useCallback(
+ (conversationId: string) => {
+ setSelectedRecipientIds(prev => {
+ const copy = new Set(prev);
+ if (copy.has(conversationId)) {
+ copy.delete(conversationId);
+ } else {
+ copy.add(conversationId);
+ }
+ return copy;
+ });
+ },
+ []
+ );
+
+ const rows = useMemo((): ReadonlyArray => {
+ const result: Array = [];
+
+ if (showChatTypes && searchInput.trim() === '') {
+ result.push({
+ type: RowType.Header,
+ getHeaderText: () => {
+ return i18n(
+ 'icu:Preferences__EditChatFolderPage__SelectChatsDialog__ChatTypesSection__Title'
+ );
+ },
+ });
+
+ result.push({
+ type: RowType.GenericCheckbox,
+ icon: GenericCheckboxRowIcon.Contact,
+ label: i18n(
+ 'icu:Preferences__EditChatFolderPage__SelectChatsDialog__ChatTypesSection__DirectChats'
+ ),
+ isChecked: selectAllIndividualChats,
+ onClick: handleToggleDirectChats,
+ });
+
+ result.push({
+ type: RowType.GenericCheckbox,
+ icon: GenericCheckboxRowIcon.Group,
+ label: i18n(
+ 'icu:Preferences__EditChatFolderPage__SelectChatsDialog__ChatTypesSection__GroupChats'
+ ),
+ isChecked: selectAllGroupChats,
+ onClick: handleToggleGroupChats,
+ });
+
+ result.push({
+ type: RowType.Header,
+ getHeaderText: () => {
+ return i18n(
+ 'icu:Preferences__EditChatFolderPage__SelectChatsDialog__RecentChats__Title'
+ );
+ },
+ });
+ }
+
+ for (const conversation of filteredConversations) {
+ result.push({
+ type: RowType.ContactCheckbox,
+ contact: conversation,
+ isChecked: selectedRecipientIds.has(conversation.id),
+ disabledReason: undefined,
+ });
+ }
+
+ if (filteredConversations.length === 0) {
+ result.push({
+ type: RowType.EmptyResults,
+ message: 'No items',
+ });
+ }
+
+ return result;
+ }, [
+ i18n,
+ searchInput,
+ filteredConversations,
+ selectAllIndividualChats,
+ selectAllGroupChats,
+ selectedRecipientIds,
+ handleToggleDirectChats,
+ handleToggleGroupChats,
+ showChatTypes,
+ ]);
+
+ const handleClose = useCallback(() => {
+ onClose({
+ selectAllIndividualChats,
+ selectAllGroupChats,
+ selectedRecipientIds: Array.from(selectedRecipientIds),
+ });
+ }, [
+ onClose,
+ selectAllIndividualChats,
+ selectAllGroupChats,
+ selectedRecipientIds,
+ ]);
+
+ return (
+
+ {i18n(
+ 'icu:Preferences__EditChatFolderPage__SelectChatsDialog__DoneButton'
+ )}
+
+ }
+ >
+
+ {selectedRecipientIds.size > 0 && (
+
+ {Array.from(selectedRecipientIds, conversationId => {
+ const conversation = conversationSelector(conversationId);
+ return (
+
+ );
+ })}
+
+ )}
+ rows[index]}
+ onClickContactCheckbox={handleToggleSelectedConversation}
+ rowCount={rows.length}
+ shouldRecomputeRowHeights={false}
+ theme={props.theme}
+ // never called:
+ blockConversation={shouldNeverBeCalled}
+ lookupConversationWithoutServiceId={asyncShouldNeverBeCalled}
+ onClickArchiveButton={shouldNeverBeCalled}
+ onClickClearFilterButton={shouldNeverBeCalled}
+ onOutgoingAudioCallInConversation={shouldNeverBeCalled}
+ onOutgoingVideoCallInConversation={shouldNeverBeCalled}
+ onPreloadConversation={shouldNeverBeCalled}
+ onSelectConversation={shouldNeverBeCalled}
+ removeConversation={shouldNeverBeCalled}
+ setIsFetchingUUID={shouldNeverBeCalled}
+ showChooseGroupMembers={shouldNeverBeCalled}
+ showConversation={shouldNeverBeCalled}
+ showFindByPhoneNumber={shouldNeverBeCalled}
+ showFindByUsername={shouldNeverBeCalled}
+ showUserNotFoundModal={shouldNeverBeCalled}
+ />
+
+ );
+}
diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx
index 04264a6e011..47052209e16 100644
--- a/ts/state/smart/Preferences.tsx
+++ b/ts/state/smart/Preferences.tsx
@@ -10,6 +10,8 @@ import type { MutableRefObject } from 'react';
import { useItemsActions } from '../ducks/items';
import { useConversationsActions } from '../ducks/conversations';
import {
+ getAllComposableConversations,
+ getConversationSelector,
getConversationsWithCustomColorSelector,
getMe,
} from '../selectors/conversations';
@@ -154,6 +156,8 @@ 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);
@@ -163,8 +167,9 @@ export function SmartPreferences(): JSX.Element | null {
const preferredWidthFromStorage = useSelector(getPreferredLeftPaneWidth);
const theme = useSelector(getTheme);
- const badge = useSelector(getPreferredBadgeSelector)(me.badges);
const shouldShowUpdateDialog = dialogType !== DialogType.None;
+ const getPreferredBadge = useSelector(getPreferredBadgeSelector);
+ const badge = getPreferredBadge(me.badges);
// The weird ones
@@ -676,6 +681,8 @@ export function SmartPreferences(): JSX.Element | null {
return (
;
+ excludedConversationIds: ReadonlyArray;
+}>;
+
+export type ChatFolderParams = Omit;
+export type ChatFolderPreset = Omit;
+
+export const CHAT_FOLDER_DEFAULTS: ChatFolderParams = {
+ name: '',
+ showOnlyUnread: false,
+ showMutedChats: false,
+ includeAllIndividualChats: false,
+ includeAllGroupChats: false,
+ folderType: ChatFolderType.CUSTOM,
+ includedConversationIds: [],
+ excludedConversationIds: [],
+};
+
+export const CHAT_FOLDER_PRESETS = {
+ UNREAD_CHATS: {
+ 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,
+ includeAllIndividualChats: true, // all 1:1's
+ includeAllGroupChats: false,
+ folderType: ChatFolderType.CUSTOM,
+ includedConversationIds: [],
+ excludedConversationIds: [],
+ },
+ GROUP_CHATS: {
+ showOnlyUnread: false,
+ showMutedChats: false,
+ includeAllIndividualChats: false,
+ 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 !== '' &&
+ grapheme.count(params.name) <= CHAT_FOLDER_NAME_MAX_CHAR_LENGTH
+ );
+}
+
+export function matchesChatFolderPreset(
+ params: ChatFolderParams,
+ preset: ChatFolderPreset
+): boolean {
+ return (
+ 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
+ ) &&
+ isSameConversationIds(
+ params.excludedConversationIds,
+ preset.excludedConversationIds
+ )
+ );
+}
+
+export function isSameChatFolderParams(
+ a: ChatFolderParams,
+ b: ChatFolderParams
+): boolean {
+ return a.name === b.name && matchesChatFolderPreset(a, b);
+}
+
+function isSameConversationIds(
+ a: ReadonlyArray,
+ b: ReadonlyArray
+): boolean {
+ return new Set(a).symmetricDifference(new Set(b)).size === 0;
+}
+
+export function isChatFoldersEnabled(): boolean {
+ const version = window.getVersion?.();
+
+ if (version != null) {
+ if (isProduction(version)) {
+ return RemoteConfig.isEnabled('desktop.chatFolders.prod');
+ }
+ if (isBeta(version)) {
+ return RemoteConfig.isEnabled('desktop.chatFolders.beta');
+ }
+ if (isAlpha(version)) {
+ return RemoteConfig.isEnabled('desktop.chatFolders.alpha');
+ }
+ }
+
+ const env = getEnvironment();
+ return env === Environment.Development || env === Environment.Test;
+}
diff --git a/ts/window.d.ts b/ts/window.d.ts
index a5a1b22009c..d11f51ebd15 100644
--- a/ts/window.d.ts
+++ b/ts/window.d.ts
@@ -324,6 +324,7 @@ declare global {
interface Set {
// Needed until TS upgrade
difference(other: ReadonlySet): Set;
+ symmetricDifference(other: ReadonlySet): Set;
}
}