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