Init Chat Folders Settings UI

This commit is contained in:
Jamie Kyle 2025-06-25 10:17:33 -07:00 committed by GitHub
parent 791ccda7aa
commit 157496f822
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1829 additions and 13 deletions

View file

@ -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 youve 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"

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -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'

View file

@ -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 = (
<ListTile.checkbox
leading={
<i
className={`module-conversation-list__generic-checkbox-icon module-conversation-list__generic-checkbox-icon--${row.icon}`}
/>
}
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 = (
<div className="module-conversation-list__empty-results">
{row.message}
</div>
);
break;
default:
throw missingCaseError(row);
}

View file

@ -52,10 +52,9 @@ export function ListView({
const style: React.CSSProperties = useMemo(() => {
return {
// See `<Timeline>` 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]);

View file

@ -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,

View file

@ -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<T = string | number> = (value: T) => unknown;
export type PropsDataType = {
conversations: ReadonlyArray<ConversationType>;
conversationSelector: GetConversationByIdType;
// Settings
accountEntropyPool: string | undefined;
autoDownloadAttachment: AutoDownloadAttachmentType;
@ -207,6 +221,7 @@ type PropsFunctionType = {
version: number
) => Promise<Array<MessageAttributesType>>;
getConversationsWithCustomColor: (colorId: string) => Array<ConversationType>;
getPreferredBadge: PreferredBadgeSelectorType;
makeSyncRequest: () => unknown;
onStartUpdate: () => unknown;
pickLocalBackupFolder: () => Promise<string | undefined>;
@ -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<ChatFolderRecord>
>([]);
const [editChatFolderPageId, setEditChatFolderPageId] =
useState<ChatFolderId | null>(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({
/>
</SettingsRow>
</SettingsRow>
{isChatFoldersEnabled() && (
<SettingsRow
title={i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__Title'
)}
>
<Control
left={
<>
<div>
{i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Title'
)}
</div>
<div className="Preferences__description">
{i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Description'
)}
</div>
</>
}
right={null}
onClick={() => setPage(Page.ChatFolders)}
/>
</SettingsRow>
)}
{isSyncSupported && (
<SettingsRow>
<Control
@ -1828,6 +1923,44 @@ export function Preferences({
title={i18n('icu:ChatColorPicker__menu-title')}
/>
);
} else if (page === Page.ChatFolders) {
content = (
<ChatFoldersPage
i18n={i18n}
settingsPaneRef={settingsPaneRef}
onBack={() => 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 = (
<EditChatFoldersPage
i18n={i18n}
settingsPaneRef={settingsPaneRef}
onBack={handleCloseEditChatFoldersPage}
conversations={conversations}
getPreferredBadge={getPreferredBadge}
theme={theme}
existingChatFolderId={editChatFolderPageId}
initChatFolderParams={initChatFolderParam}
conversationSelector={conversationSelector}
onCreateChatFolder={handleCreateChatFolder}
onUpdateChatFolder={handleUpdateChatFolder}
onDeleteChatFolder={handleDeleteChatFolder}
/>
);
} 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<HTMLDivElement | null>;
title: string | undefined;
actions?: ReactNode;
}): JSX.Element {
return (
<div className="Preferences__content">
@ -2267,6 +2402,7 @@ export function PreferencesContent({
</div>
<div className="Preferences__settings-pane-spacer" />
</div>
{actions && <div className="Preferences__actions">{actions}</div>}
</div>
);
}

View file

@ -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<ChatFolderRecord>;
onCreateChatFolder: (params: ChatFolderParams) => void;
settingsPaneRef: MutableRefObject<HTMLDivElement | null>;
}>;
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 (
<PreferencesContent
backButton={
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={props.onBack}
type="button"
/>
}
contents={
<>
<p className="Preferences__description Preferences__padding">
{i18n('icu:Preferences__ChatFoldersPage__Description')}
</p>
<SettingsRow
title={i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__Title'
)}
>
<ListBox>
<ListBoxItem
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
onAction={handleOpenEditChatFoldersPageForNew}
>
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__CreateAFolderButton'
)}
</span>
</ListBoxItem>
<ListBoxItem className="Preferences__ChatFolders__ChatSelection__Item">
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
)}
</span>
</ListBoxItem>
{props.chatFolders.map(chatFolder => {
return (
<ChatFolderListItem
key={chatFolder.id}
chatFolder={chatFolder}
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
/>
);
})}
</ListBox>
</SettingsRow>
<SettingsRow
title={i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__Title'
)}
>
<ul className="Preferences__ChatFolders__ChatSelection__List">
<ChatFolderPresetItem
i18n={i18n}
icon="UnreadChats"
title={i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__UnreadFolder__Title'
)}
description={i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__UnreadFolder__Description'
)}
preset={CHAT_FOLDER_PRESETS.UNREAD_CHATS}
chatFolders={props.chatFolders}
onCreateChatFolder={props.onCreateChatFolder}
/>
<ChatFolderPresetItem
i18n={i18n}
icon="DirectChats"
title={i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__DirectChatsFolder__Title'
)}
description={i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__DirectChatsFolder__Description'
)}
preset={CHAT_FOLDER_PRESETS.INDIVIDUAL_CHATS}
chatFolders={props.chatFolders}
onCreateChatFolder={props.onCreateChatFolder}
/>
<ChatFolderPresetItem
i18n={i18n}
icon="GroupChats"
title={i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__GroupChatsFolder__Title'
)}
description={i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__GroupChatsFolder__Description'
)}
preset={CHAT_FOLDER_PRESETS.GROUP_CHATS}
chatFolders={props.chatFolders}
onCreateChatFolder={props.onCreateChatFolder}
/>
</ul>
</SettingsRow>
</>
}
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<ChatFolderRecord>;
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 (
<li className="Preferences__ChatFolders__ChatSelection__Item">
<span
className={`Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--${props.icon}`}
/>
<span className="Preferences__ChatFolders__ChatSelection__ItemBody">
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{props.title}
</span>
<span className="Preferences__ChatFolders__ChatSelection__ItemDescription">
{props.description}
</span>
</span>
<Button
variant={ButtonVariant.Secondary}
onClick={handleCreateChatFolder}
>
{i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton'
)}
</Button>
</li>
);
}
function ChatFolderListItem(props: {
chatFolder: ChatFolderRecord;
onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId) => void;
}): JSX.Element {
const { chatFolder, onOpenEditChatFoldersPage } = props;
const handleAction = useCallback(() => {
onOpenEditChatFoldersPage(chatFolder.id);
}, [chatFolder, onOpenEditChatFoldersPage]);
return (
<ListBoxItem
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
onAction={handleAction}
>
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{props.chatFolder.name}
</span>
</ListBoxItem>
);
}
// function ChatFolderContextMenu(props: {
// i18n: LocalizerType;
// children: ReactNode;
// }) {
// const { i18n } = props;
// return (
// <AxoContextMenu.Root>
// <AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
// <AxoContextMenu.Content>
// <AxoContextMenu.Item>
// {i18n(
// eslint-disable-next-line max-len
// 'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder'
// )}
// </AxoContextMenu.Item>
// <AxoContextMenu.Item>
// {i18n(
// eslint-disable-next-line max-len
// 'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__DeleteFolder'
// )}
// </AxoContextMenu.Item>
// </AxoContextMenu.Content>
// </AxoContextMenu.Root>
// );
// }
// function DeleteChatFolderDialog(props: { i18n: LocalizerType }): JSX.Element {
// const { i18n } = props;
// return (
// <ConfirmationDialog
// i18n={i18n}
// dialogName="Preferences__ChatsPage__DeleteChatFolderDialog"
// title={i18n('icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title')}
// cancelText={i18n(
// 'icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton'
// )}
// actions={[
// {
// text: i18n(
// 'icu:Preferences__ChatsPage__DeleteChatFolderDialog__DeleteButton'
// ),
// style: 'affirmative',
// action: () => null,
// },
// ]}
// onClose={() => null}
// >
// {i18n('icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description', {
// chatFolderTitle: '',
// })}
// </ConfirmationDialog>
// );
// }

View file

@ -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<ConversationType>;
getPreferredBadge: PreferredBadgeSelectorType;
theme: ThemeType;
settingsPaneRef: MutableRefObject<HTMLDivElement | null>;
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 (
<PreferencesContent
backButton={
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={handleBackInit}
type="button"
/>
}
contents={
<>
<SettingsRow
title={i18n(
'icu:Preferences__EditChatFolderPage__FolderNameField__Label'
)}
>
<div className="Preferences__padding">
<Input
i18n={i18n}
value={chatFolderParams.name}
onChange={handleNameChange}
placeholder={i18n(
'icu:Preferences__EditChatFolderPage__FolderNameField__Placeholder'
)}
maxLengthCount={CHAT_FOLDER_NAME_MAX_CHAR_LENGTH}
whenToShowRemainingCount={CHAT_FOLDER_NAME_MAX_CHAR_LENGTH - 10}
/>
</div>
</SettingsRow>
<SettingsRow
title={i18n(
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__Title'
)}
>
<button
type="button"
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
onClick={handleSelectInclusions}
>
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{i18n(
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__AddChatsButton'
)}
</span>
</button>
<ul className="Preferences__ChatFolders__ChatSelection__List">
{chatFolderParams.includeAllIndividualChats && (
<li className="Preferences__ChatFolders__ChatSelection__Item">
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--DirectChats" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
1:1 Chats
</span>
</li>
)}
{chatFolderParams.includeAllGroupChats && (
<li className="Preferences__ChatFolders__ChatSelection__Item">
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--GroupChats" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
Group Chats
</span>
</li>
)}
{chatFolderParams.includedConversationIds.map(conversationId => {
const conversation = conversationSelector(conversationId);
return (
<li
key={conversationId}
className="Preferences__ChatFolders__ChatSelection__Item"
>
<Avatar
i18n={i18n}
conversationType={conversation.type}
size={AvatarSize.THIRTY_SIX}
badge={undefined}
{...conversation}
/>
<span className="Preferences__ChatFolders__ChatList__ItemTitle">
{conversation.title}
</span>
</li>
);
})}
</ul>
<div className="Preferences__padding">
<p className="Preferences__description">
{i18n(
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__Help'
)}
</p>
</div>
</SettingsRow>
<SettingsRow
title={i18n(
'icu:Preferences__EditChatFolderPage__ExceptionsSection__Title'
)}
>
<button
type="button"
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
onClick={handleSelectExclusions}
>
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{i18n(
'icu:Preferences__EditChatFolderPage__ExceptionsSection__ExcludeChatsButton'
)}
</span>
</button>
<ul className="Preferences__ChatFolders__ChatSelection__List">
{chatFolderParams.excludedConversationIds.map(conversationId => {
const conversation = conversationSelector(conversationId);
return (
<li
key={conversationId}
className="Preferences__ChatFolders__ChatSelection__Item"
>
<Avatar
i18n={i18n}
conversationType={conversation.type}
size={AvatarSize.THIRTY_SIX}
badge={undefined}
{...conversation}
/>
<span className="Preferences__ChatFolders__ChatList__ItemTitle">
{conversation.title}
</span>
</li>
);
})}
</ul>
<div className="Preferences__padding">
<p className="Preferences__description">
{i18n(
'icu:Preferences__EditChatFolderPage__ExceptionsSection__Help'
)}
</p>
</div>
</SettingsRow>
<SettingsRow>
<Checkbox
checked={chatFolderParams.showOnlyUnread}
label={i18n(
'icu:Preferences__EditChatFolderPage__OnlyShowUnreadChatsCheckbox__Label'
)}
description={i18n(
'icu:Preferences__EditChatFolderPage__OnlyShowUnreadChatsCheckbox__Description'
)}
moduleClassName="Preferences__checkbox"
name="showOnlyUnread"
onChange={handleShowOnlyUnreadChange}
/>
<Checkbox
checked={chatFolderParams.showMutedChats}
label={i18n(
'icu:Preferences__EditChatFolderPage__IncludeMutedChatsCheckbox__Label'
)}
moduleClassName="Preferences__checkbox"
name="showMutedChats"
onChange={handleShowMutedChatsChange}
/>
</SettingsRow>
{props.existingChatFolderId != null && (
<SettingsRow>
<div className="Preferences__padding">
<button
type="button"
onClick={handleDeleteInit}
className="Preferences__ChatFolders__ChatList__DeleteButton"
>
{i18n(
'icu:Preferences__EditChatFolderPage__DeleteFolderButton'
)}
</button>
</div>
</SettingsRow>
)}
{showInclusionsDialog && (
<EditChatFoldersSelectChatsDialog
i18n={i18n}
title={i18n(
'icu:Preferences__EditChatFolderPage__SelectChatsDialog--IncludedChats__Title'
)}
onClose={handleCloseInclusions}
conversations={props.conversations}
getPreferredBadge={props.getPreferredBadge}
theme={props.theme}
conversationSelector={props.conversationSelector}
initialSelection={{
selectAllIndividualChats:
chatFolderParams.includeAllIndividualChats,
selectAllGroupChats: chatFolderParams.includeAllGroupChats,
selectedRecipientIds: chatFolderParams.includedConversationIds,
}}
showChatTypes
/>
)}
{showExclusionsDialog && (
<EditChatFoldersSelectChatsDialog
i18n={i18n}
title={i18n(
'icu:Preferences__EditChatFolderPage__SelectChatsDialog--ExcludedChats__Title'
)}
onClose={handleCloseExclusions}
conversations={props.conversations}
getPreferredBadge={props.getPreferredBadge}
theme={props.theme}
conversationSelector={props.conversationSelector}
initialSelection={{
selectAllIndividualChats:
!chatFolderParams.includeAllIndividualChats,
selectAllGroupChats: !chatFolderParams.includeAllGroupChats,
selectedRecipientIds: chatFolderParams.excludedConversationIds,
}}
showChatTypes={false}
/>
)}
{showDeleteFolderDialog && (
<DeleteChatFolderDialog
i18n={i18n}
onConfirm={handleDeleteConfirm}
onClose={handleDeleteClose}
/>
)}
{showSaveChangesDialog && (
<SaveChangesFolderDialog
i18n={i18n}
onSave={handleSave}
onCancel={handleDiscard}
onClose={handleSaveClose}
/>
)}
</>
}
contentsRef={props.settingsPaneRef}
title={i18n('icu:Preferences__EditChatFolderPage__Title')}
actions={
<>
<Button variant={ButtonVariant.Secondary} onClick={handleDiscard}>
{i18n('icu:Preferences__EditChatFolderPage__CancelButton')}
</Button>
<Button
variant={ButtonVariant.Primary}
onClick={handleSave}
disabled={!(isChanged && isValid)}
>
{i18n('icu:Preferences__EditChatFolderPage__SaveButton')}
</Button>
</>
}
/>
);
}
function DeleteChatFolderDialog(props: {
i18n: LocalizerType;
onConfirm: () => void;
onClose: () => void;
}) {
const { i18n } = props;
return (
<ConfirmationDialog
i18n={i18n}
dialogName="Preferences__EditChatFolderPage__DeleteChatFolderDialog"
title={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Title'
)}
cancelText={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__CancelButton'
)}
actions={[
{
text: i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__DeleteButton'
),
style: 'affirmative',
action: props.onConfirm,
},
]}
onClose={props.onClose}
>
{i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Description'
)}
</ConfirmationDialog>
);
}
function SaveChangesFolderDialog(props: {
i18n: LocalizerType;
onSave: () => void;
onCancel: () => void;
onClose: () => void;
}) {
const { i18n } = props;
return (
<ConfirmationDialog
i18n={i18n}
dialogName="Preferences__EditChatFolderPage__SaveChangesFolderDialog"
title={i18n(
'icu:Preferences__EditChatFolderPage__SaveChangesFolderDialog__Title'
)}
cancelText={i18n(
'icu:Preferences__EditChatFolderPage__SaveChangesFolderDialog__DiscardButton'
)}
actions={[
{
text: i18n(
'icu:Preferences__EditChatFolderPage__SaveChangesFolderDialog__SaveButton'
),
style: 'affirmative',
action: props.onSave,
},
]}
onCancel={props.onCancel}
onClose={props.onClose}
>
{i18n(
'icu:Preferences__EditChatFolderPage__SaveChangesFolderDialog__Description'
)}
</ConfirmationDialog>
);
}

View file

@ -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<string>;
selectAllIndividualChats: boolean;
selectAllGroupChats: boolean;
}>;
export function EditChatFoldersSelectChatsDialog(props: {
i18n: LocalizerType;
title: string;
conversations: ReadonlyArray<ConversationType>;
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<HTMLInputElement>) => {
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<Row> => {
const result: Array<Row> = [];
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 (
<Modal
modalName="Preferences__EditChatFolderPage__SelectChatsDialog"
moduleClassName="Preferences__EditChatFolderPage__SelectChatsDialog"
i18n={i18n}
title={props.title}
onClose={handleClose}
padded={false}
noMouseClose
noEscapeClose
modalFooter={
<Button variant={ButtonVariant.Primary} onClick={handleClose}>
{i18n(
'icu:Preferences__EditChatFolderPage__SelectChatsDialog__DoneButton'
)}
</Button>
}
>
<SearchInput
i18n={i18n}
placeholder={i18n(
'icu:Preferences__EditChatFolderPage__SelectChatsDialog__Search__Placeholder'
)}
value={searchInput}
onChange={handleSearchInputChange}
/>
{selectedRecipientIds.size > 0 && (
<ContactPills>
{Array.from(selectedRecipientIds, conversationId => {
const conversation = conversationSelector(conversationId);
return (
<ContactPill
key={conversationId}
avatarUrl={conversation.avatarUrl}
color={conversation.color}
firstName={conversation.firstName}
hasAvatar={conversation.hasAvatar}
i18n={i18n}
id={conversation.id}
isMe={conversation.isMe}
phoneNumber={conversation.phoneNumber}
profileName={conversation.profileName}
sharedGroupNames={conversation.sharedGroupNames}
title={conversation.title}
onClickRemove={handleToggleSelectedConversation}
/>
);
})}
</ContactPills>
)}
<ConversationList
dimensions={{
width: 360,
height: 404,
}}
i18n={i18n}
getPreferredBadge={props.getPreferredBadge}
getRow={index => 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}
/>
</Modal>
);
}

View file

@ -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 (
<StrictMode>
<Preferences
conversations={conversations}
conversationSelector={conversationSelector}
accountEntropyPool={accountEntropyPool}
addCustomColor={addCustomColor}
autoDownloadAttachment={autoDownloadAttachment}
@ -706,6 +713,7 @@ export function SmartPreferences(): JSX.Element | null {
getMessageSampleForSchemaVersion={
DataReader.getMessageSampleForSchemaVersion
}
getPreferredBadge={getPreferredBadge}
hasAudioNotifications={hasAudioNotifications}
hasAutoConvertEmoji={hasAutoConvertEmoji}
hasAutoDownloadUpdate={hasAutoDownloadUpdate}

144
ts/types/ChatFolder.ts Normal file
View file

@ -0,0 +1,144 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { Environment, getEnvironment } from '../environment';
import * as grapheme from '../util/grapheme';
import * as RemoteConfig from '../RemoteConfig';
import { isAlpha, isBeta, isProduction } from '../util/version';
export const CHAT_FOLDER_NAME_MAX_CHAR_LENGTH = 32;
export enum ChatFolderType {
ALL = 1,
CUSTOM = 2,
}
export type ChatFolderId = string & { ChatFolderId: never };
export type ChatFolderRecord = Readonly<{
id: ChatFolderId;
name: string;
showOnlyUnread: boolean;
showMutedChats: boolean;
includeAllIndividualChats: boolean;
includeAllGroupChats: boolean;
folderType: ChatFolderType;
includedConversationIds: ReadonlyArray<string>;
excludedConversationIds: ReadonlyArray<string>;
}>;
export type ChatFolderParams = Omit<ChatFolderRecord, 'id'>;
export type ChatFolderPreset = Omit<ChatFolderParams, 'name'>;
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<string, ChatFolderPreset>;
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<string>,
b: ReadonlyArray<string>
): 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;
}

1
ts/window.d.ts vendored
View file

@ -324,6 +324,7 @@ declare global {
interface Set<T> {
// Needed until TS upgrade
difference<U>(other: ReadonlySet<U>): Set<T>;
symmetricDifference<U>(other: ReadonlySet<U>): Set<T>;
}
}