diff --git a/_locales/en/messages.json b/_locales/en/messages.json index bb357e5ef1..1e9119a4f7 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -339,6 +339,14 @@ "messageformat": "Archived Chats", "description": "Shown in place of the search box when showing archived conversation list" }, + "icu:LeftPane__MoreActionsMenu__AddChatFolder": { + "messageformat": "Add chat folder", + "description": "Chats Tab > Chats List > Header > More Actions > Menu Item > Add chat folder (when you don't have any chat folders)" + }, + "icu:LeftPane__MoreActionsMenu__FolderSettings": { + "messageformat": "Folder settings", + "description": "Chats Tab > Chats List > Header > More Actions > Menu Item > Folder settings (when you have chat folders)" + }, "icu:LeftPane--pinned": { "messageformat": "Pinned", "description": "Shown as a header for pinned conversations in the left pane" @@ -391,6 +399,14 @@ "messageformat": "Enter a username followed by a dot and its set of numbers.", "description": "Description displayed under search input in left pane when looking up someone by username" }, + "icu:LeftPane__EmptyView--WithSelectedChatFolder": { + "messageformat": "No chats to display", + "description": "Left Pane > Inbox > Chat List > Empty View (when you have a custom chat folder selected)" + }, + "icu:LeftPane__EmptyView--WithSelectedChatFolder__FolderSettings": { + "messageformat": "Folder Settings", + "description": "Left Pane > Inbox > Chat List > Empty View (when you have a custom chat folder selected) > Folder Settings Button" + }, "icu:LeftPaneChatFolders__ItemLabel--All--Short": { "messageformat": "All", "description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats (needs to fit in very small space)" @@ -872,6 +888,10 @@ "messageformat": "Error saving receipt. Please try again.", "description": "Toast message shown when a donation receipt fails to save to disk" }, + "icu:Toast--ChatFolderCreated": { + "messageformat": "{chatFolderName} folder added", + "description": "Toast message show when a chat folder is created (in some places like creating a preset)" + }, "icu:cannotSelectPhotosAndVideosAlongWithFiles": { "messageformat": "You can't select photos and videos along with files.", "description": "An error popup when the user has attempted to add an attachment" @@ -1802,10 +1822,26 @@ "messageformat": "Add a chat folder", "description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Title" }, + "icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Title--WithChatFolders": { + "messageformat": "Add or edit folders", + "description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Title (with chat folders)" + }, "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__AddChatFolderItem__Description--WithChatFolders": { + "messageformat": "{chatFoldersCount, plural, one {# folder} other {# folders}}", + "description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Description (with chat folders)" + }, + "icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Button": { + "messageformat": "Set up", + "description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Button" + }, + "icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Button--WithChatFolders": { + "messageformat": "Manage", + "description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Button (with chat folders)" + }, "icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder": { "messageformat": "Edit folder", "description": "Preferences > Chats Page > Chat Folders Section > Chat Folder Item > Context Menu > Edit Folder" @@ -1850,6 +1886,10 @@ "messageformat": "Create a folder", "description": "Preferences > Chat Folders Page > Folders > Create A Folder Button" }, + "icu:Preferences__ChatFoldersPage__FoldersSection__DragHandle__Label": { + "messageformat": "Drag to move chat folder", + "description": "Preferences > Chat Folders Page > Folders > Item > Drag Handle > Accessibility Label" + }, "icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__Title": { "messageformat": "Suggested folders", "description": "Preferences > Chat Folders Page > Suggested Folders > Title" @@ -1858,10 +1898,6 @@ "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" diff --git a/stylesheets/components/CallsTab.scss b/stylesheets/components/CallsTab.scss index 95384d6eb6..fdaface7d7 100644 --- a/stylesheets/components/CallsTab.scss +++ b/stylesheets/components/CallsTab.scss @@ -118,21 +118,6 @@ user-select: none; } -.CallsTab__ClearCallHistoryIcon { - @include mixins.light-theme { - @include mixins.color-svg( - '../images/icons/v3/trash/trash-compact.svg', - variables.$color-gray-90 - ); - } - @include mixins.dark-theme { - @include mixins.color-svg( - '../images/icons/v3/trash/trash-compact.svg', - variables.$color-white - ); - } -} - .CallsList__Header { display: flex; gap: 0px; diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index 399ff8a829..625c26e029 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -1357,29 +1357,47 @@ $secondary-text-color: light-dark( 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 { + &[data-dragging='true'] { + opacity: 0%; + // delay making the item transparent until the browser has a chance to take + // a snapshot of the item before for the drag preview + transition: opacity 0ms linear; + transition-delay: 1ms; } } -.Preferences__ChatFolders__ChatSelection__Item { +.Preferences__ChatFolders__ChatSelection__Item--Button { + @include mixins.button-reset(); + & { + width: 100%; + } +} + +.Preferences__ChatFolders__ChatSelection__Item--Button, +.Preferences__ChatFolders__ChatSelection__Item--ListItem { + outline: 0; +} + +.Preferences__ChatFolders__ChatSelection__Item--Button, +.Preferences__ChatFolders__ChatSelection__Item--Clickable { + &:hover .Preferences__ChatFolders__ChatSelection__ItemContent { + background: light-dark(variables.$color-gray-02, variables.$color-gray-80); + } +} + +.Preferences__ChatFolders__ChatSelection__ItemContent { display: flex; - width: 100%; align-items: center; gap: 12px; padding-block: 8px; padding-inline: 24px; border-radius: 1px; - &[data-dragging='true'] { - opacity: 50%; + @include mixins.keyboard-mode { + .Preferences__ChatFolders__ChatSelection__Item:focus & { + outline: 2px solid variables.$color-ultramarine; + } } } diff --git a/stylesheets/tailwind-config.css b/stylesheets/tailwind-config.css index a5e423a28a..061f88791a 100644 --- a/stylesheets/tailwind-config.css +++ b/stylesheets/tailwind-config.css @@ -54,7 +54,6 @@ --color-background-primary: light-dark(/* */ #FFFFFF /* */, /* */ #1A1A1A /* */); --color-background-secondary: light-dark(/* */ #F6F6F6 /* */, /* */ #262626 /* */); --color-background-overlay: light-dark(--alpha(#000000 / 20%), --alpha(#000000 / 40%)); - --color-background-overlay-secondary: light-dark(--alpha(#000000 / 15%), --alpha(#FFFFFF / 15%)); /* Colors/Elevated Background */ --color-elevated-background-primary: light-dark(#FAFAFA, #2A2A2A); @@ -146,7 +145,6 @@ --color-background-primary: light-dark(/* */ #FFFFFF /* */, /* */ #121212 /* */); --color-background-secondary: light-dark(/* */ #F6F6F6 /* */, /* */ #1E1E1E /* */); --color-background-overlay: light-dark(--alpha(#000000 / 40%), --alpha(#000000 / 60%)); - --color-background-overlay-secondary: light-dark(--alpha(#000000 / 15%), --alpha(#FFFFFF / 15%)); /* Colors/Elevated Background */ --color-elevated-background-primary: light-dark(#FFFFFF, #222222); @@ -312,8 +310,6 @@ @theme { /* box-shadow */ --shadow-*: initial; /* reset defaults */ - --shadow-legacy-outline: - 0 0 0 2px #2c6bed; --shadow-elevation-0: 0 1px 2px 0 var(--color-shadow-elevation-1); --shadow-elevation-1: diff --git a/ts/axo/AxoDropdownMenu.stories.tsx b/ts/axo/AxoDropdownMenu.stories.tsx index 6df3a7e4a4..f1156b8240 100644 --- a/ts/axo/AxoDropdownMenu.stories.tsx +++ b/ts/axo/AxoDropdownMenu.stories.tsx @@ -1,5 +1,6 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import type { ReactNode } from 'react'; import React, { useState } from 'react'; import type { Meta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; @@ -11,12 +12,20 @@ export default { title: 'Axo/AxoDropdownMenu', } satisfies Meta; +function Container(props: { children: ReactNode }) { + return ( +
+ {props.children} +
+ ); +} + export function Basic(): JSX.Element { const [showBookmarks, setShowBookmarks] = useState(true); const [showFullUrls, setShowFullUrls] = useState(false); const [selectedPerson, setSelectedPerson] = useState('jamie'); return ( -
+ @@ -96,6 +105,135 @@ export function Basic(): JSX.Element { -
+ + ); +} + +export function WithHeader(): JSX.Element { + return ( + + + + + Open Dropdown Menu + + + + + + Sleep + + + Work + + + Sub-menu + + + + Sleep + + + Work + + + + + + + ); +} + +const LONG_TEXT = + 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Cum nostrum, inventore quia tenetur sunt non ab fuga explicabo ullam tempore.'; + +export function StressTestLongText(): JSX.Element { + const items = ( + <> + + Item: {LONG_TEXT} + + + Item: {LONG_TEXT} + + + ); + + const checkboxItems = ( + <> + + CheckboxItem: {LONG_TEXT} + + + CheckboxItem: {LONG_TEXT} + + + ); + const content = ( + <> + + {items} + {checkboxItems} + + + RadioItem: {LONG_TEXT} + + + RadioItem: {LONG_TEXT} + + + + + + Label: {LONG_TEXT} + + {items} + {checkboxItems} + + + ); + + return ( + + + + + Open Dropdown Menu + + + + {content} + + {LONG_TEXT} + {content} + + + + ); } diff --git a/ts/axo/AxoDropdownMenu.tsx b/ts/axo/AxoDropdownMenu.tsx index 1839654c40..ff7a708c25 100644 --- a/ts/axo/AxoDropdownMenu.tsx +++ b/ts/axo/AxoDropdownMenu.tsx @@ -1,11 +1,16 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { memo } from 'react'; +import React, { memo, useId } from 'react'; import { DropdownMenu } from 'radix-ui'; -import type { FC } from 'react'; +import type { FC, ReactNode } from 'react'; import { AxoSymbol } from './AxoSymbol.js'; import { AxoBaseMenu } from './_internal/AxoBaseMenu.js'; import { tw } from './tw.js'; +import { + AriaLabellingProvider, + useAriaLabellingContext, + useCreateAriaLabellingContext, +} from './_internal/AriaLabellingContext.js'; const Namespace = 'AxoDropdownMenu'; @@ -57,17 +62,21 @@ export namespace AxoDropdownMenu { * --------------------------------- */ - export type RootProps = AxoBaseMenu.MenuRootProps & { - open?: boolean; - defaultOpen?: boolean; - onOpenChange?: (open: boolean) => void; - }; + export type RootProps = AxoBaseMenu.MenuRootProps & + Readonly<{ + open?: boolean; + onOpenChange?: (open: boolean) => void; + }>; /** * Contains all the parts of a dropdown menu. */ export const Root: FC = memo(props => { - return {props.children}; + return ( + + {props.children} + + ); }); Root.displayName = `${Namespace}.Root`; @@ -104,30 +113,73 @@ export namespace AxoDropdownMenu { * Uses a portal to render the content part into the `body`. */ export const Content: FC = memo(props => { + const { context, labelId, descriptionId } = useCreateAriaLabellingContext(); return ( - - - {props.children} - - + + + + {props.children} + + + ); }); Content.displayName = `${Namespace}.Content`; + /** + * Component: + * ------------------------------------- + */ + + export type CustomItemProps = Pick< + AxoBaseMenu.MenuItemProps, + 'disabled' | 'textValue' | 'keyboardShortcut' | 'onSelect' + > & + Readonly<{ + leading?: ReactNode; + // trailing?: ReactNode; + text: ReactNode; + // prefix?: ReactNode; + suffix?: ReactNode; + }>; + + export const CustomItem: FC = memo(props => { + return ( + + {props.leading && ( + + {props.leading} + + )} + + {props.text} + {props.suffix} + + + ); + }); + + CustomItem.displayName = `${Namespace}.CustomItem`; + /** * Component: * --------------------------------- */ - export type ItemProps = AxoBaseMenu.MenuItemProps & { - customIcon?: React.ReactNode; - }; + export type ItemProps = AxoBaseMenu.MenuItemProps; /** * The component that contains the dropdown menu items. @@ -151,11 +203,6 @@ export namespace AxoDropdownMenu { )} - {props.customIcon && ( - - {props.customIcon} - - )} {props.children} {props.keyboardShortcut && ( @@ -212,6 +259,49 @@ export namespace AxoDropdownMenu { Label.displayName = `${Namespace}.Label`; + /** + * Component: + * ----------------------------------- + */ + + export type HeaderProps = Readonly<{ + label: ReactNode; + description?: ReactNode; + }>; + + export const Header: FC = memo(props => { + const labelId = useId(); + const descriptionId = useId(); + + const { labelRef, descriptionRef } = useAriaLabellingContext( + `<${Namespace}.Header>`, + `<${Namespace}.Content/SubContent>` + ); + + return ( + + ); + }); + + Header.displayName = `${Namespace}.Header`; + /** * Component: * ----------------------------------------- @@ -343,6 +433,20 @@ export namespace AxoDropdownMenu { Separator.displayName = `${Namespace}.Separator`; + /** + * Component: + */ + + export const ContentSeparator: FC = memo(() => { + return ( + + ); + }); + + ContentSeparator.displayName = `${Namespace}.ContentSeparator`; + /** * Component: * ------------------------------- @@ -402,14 +506,19 @@ export namespace AxoDropdownMenu { * inside {@link AxoDropdownMenu.Sub}. */ export const SubContent: FC = memo(props => { + const { context, labelId, descriptionId } = useCreateAriaLabellingContext(); return ( - - {props.children} - + + + {props.children} + + ); }); diff --git a/ts/axo/_internal/AriaLabellingContext.tsx b/ts/axo/_internal/AriaLabellingContext.tsx new file mode 100644 index 0000000000..3c7f0a6f55 --- /dev/null +++ b/ts/axo/_internal/AriaLabellingContext.tsx @@ -0,0 +1,52 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { RefCallback } from 'react'; +import { createContext, useContext, useMemo, useState } from 'react'; +import { assert } from './assert.js'; + +type AriaLabellingContextType = Readonly<{ + labelRef: RefCallback; + descriptionRef: RefCallback; +}>; + +const AriaLabellingContext = createContext( + null +); + +export type CreateAriaLabellingContextResult = Readonly<{ + context: AriaLabellingContextType; + labelId: string | undefined; + descriptionId: string | undefined; +}>; + +export function useCreateAriaLabellingContext(): CreateAriaLabellingContextResult { + const [labelId, setLabelId] = useState(); + const [descriptionId, setDescriptionId] = useState(); + + const context = useMemo((): AriaLabellingContextType => { + function labelRef(element: HTMLElement | null) { + setLabelId(element?.id); + } + + function descriptionRef(element: HTMLElement | null) { + setDescriptionId(element?.id); + } + + return { labelRef, descriptionRef }; + }, []); + + return { context, labelId, descriptionId }; +} + +export const AriaLabellingProvider = AriaLabellingContext.Provider; + +export function useAriaLabellingContext( + componentName: string, + providerName: string +): AriaLabellingContextType { + return assert( + useContext(AriaLabellingContext), + `${componentName} must be wrapped with a ${providerName}` + ); +} diff --git a/ts/axo/_internal/AxoBaseMenu.tsx b/ts/axo/_internal/AxoBaseMenu.tsx index 90e4d2675f..00d806e0c0 100644 --- a/ts/axo/_internal/AxoBaseMenu.tsx +++ b/ts/axo/_internal/AxoBaseMenu.tsx @@ -17,7 +17,7 @@ export namespace AxoBaseMenu { 'forced-colors:text-[CanvasText]' ); - const baseContentGridStyles = tw('grid grid-cols-[min-content_auto] p-1.5'); + const baseContentGridStyles = tw('grid grid-cols-[min-content_1fr] p-1.5'); // const baseGroupStyles = tw('col-span-full grid grid-cols-subgrid'); @@ -252,6 +252,18 @@ export namespace AxoBaseMenu { export const menuLabelStyles = tw(baseLabelStyles); export const selectLabelStyles = tw(baseLabelStyles); + /** + * AxoBaseMenu: Header + */ + + export const menuHeaderStyles = tw('col-span-full col-start-1 p-1.5'); + export const menuHeaderLabelStyles = tw( + 'block truncate type-title-small text-label-primary' + ); + export const menuHeaderDescriptionStyles = tw( + 'block truncate type-caption text-label-secondary' + ); + /** * AxoBaseMenu: CheckboxItem * ------------------------- @@ -315,13 +327,17 @@ export namespace AxoBaseMenu { // N/A }>; - const baseSeparatorStyles = tw( - baseItemStyles, - 'mx-0.5 my-1 border-t-[0.5px] border-border-primary' - ); + const baseSeparatorStyles = tw('my-1 border-t-[0.5px] border-border-primary'); - export const menuSeparatorStyles = tw(baseSeparatorStyles); - export const selectSeperatorStyles = tw(baseSeparatorStyles); + export const menuSeparatorStyles = tw( + 'col-span-full col-start-1 mx-0.5', + baseSeparatorStyles + ); + export const menuContentSeparatorStyles = tw( + 'col-span-full col-start-2', + baseSeparatorStyles + ); + export const selectSeperatorStyles = tw(baseItemStyles, baseSeparatorStyles); /** * AxoBaseMenu: Sub diff --git a/ts/components/CallsTab.tsx b/ts/components/CallsTab.tsx index 4727d99a34..f9b985d8d3 100644 --- a/ts/components/CallsTab.tsx +++ b/ts/components/CallsTab.tsx @@ -17,7 +17,6 @@ import type { ActiveCallStateType, PeekNotConnectedGroupCallType, } from '../state/ducks/calling.js'; -import { ContextMenu } from './ContextMenu.js'; import { ConfirmationDialog } from './ConfirmationDialog.js'; import type { UnreadStats } from '../util/countUnreadStats.js'; import type { WidthBreakpoint } from './_util.js'; @@ -25,6 +24,7 @@ import type { CallLinkType } from '../types/CallLink.js'; import type { CallStateType } from '../state/selectors/calling.js'; import type { StartCallData } from './ConfirmLeaveCallModal.js'; import { I18n } from './I18n.js'; +import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.js'; enum CallsTabSidebarView { CallsListView, @@ -235,33 +235,22 @@ export function CallsTab({ updateSidebarView(CallsTabSidebarView.NewCallView); }} /> - - {({ onClick, onKeyDown, ref }) => { - return ( - } - label={i18n('icu:CallsTab__MoreActionsLabel')} - /> - ); - }} - + + + } + label={i18n('icu:CallsTab__MoreActionsLabel')} + /> + + + + {i18n('icu:CallsTab__ClearCallHistoryLabel')} + + + )} diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index a0b3c30bb3..e48875b0b7 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -36,6 +36,7 @@ import type { GroupListItemConversationType } from './conversationList/GroupList import { ServerAlert } from '../types/ServerAlert.js'; import { LeftPaneChatFolders } from './leftPane/LeftPaneChatFolders.js'; import { LeftPaneConversationListItemContextMenu } from './leftPane/LeftPaneConversationListItemContextMenu.js'; +import { CurrentChatFolders } from '../types/CurrentChatFolders.js'; const { i18n } = window.SignalContext; @@ -119,6 +120,7 @@ const defaultModeSpecificProps = { conversations: defaultConversations, archivedConversations: defaultArchivedConversations, isAboutToSearch: false, + selectedChatFolder: null, }; const emptySearchResultsGroup = { isLoading: false, results: [] }; @@ -195,6 +197,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { challengeStatus: 'idle', crashReportCount: 0, + hasAnyCurrentCustomChatFolders: false, hasNetworkDialog: false, hasExpiredDialog: false, hasRelinkDialog: false, @@ -214,6 +217,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { preloadConversation: action('preloadConversation'), showConversation: action('showConversation'), blockConversation: action('blockConversation'), + onChatFoldersOpenSettings: action('onChatFoldersOpenSettings'), onOutgoingAudioCallInConversation: action( 'onOutgoingAudioCallInConversation' ), @@ -249,8 +253,8 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { {...props} /> ), - renderNotificationProfilesMenu: ({ trigger }) => ( -
{trigger}
+ renderNotificationProfilesMenu: () => ( +
), renderRelinkDialog: props => ( { { {props.children} ), + selectedChatFolder: null, selectedConversationId: undefined, targetedMessageId: undefined, openUsernameReservationModal: action('openUsernameReservationModal'), @@ -400,6 +405,7 @@ export function InboxNoConversations(): JSX.Element { conversations: [], archivedConversations: [], isAboutToSearch: false, + selectedChatFolder: null, }, })} /> @@ -439,6 +445,7 @@ export function InboxBackupMediaDownloadWithDialogsAndUnpinnedConversations(): J conversations: defaultConversations, archivedConversations: [], isAboutToSearch: false, + selectedChatFolder: null, }, })} /> @@ -496,6 +503,7 @@ export function InboxUsernameCorrupted(): JSX.Element { conversations: [], archivedConversations: [], isAboutToSearch: false, + selectedChatFolder: null, }, usernameCorrupted: true, })} @@ -514,6 +522,7 @@ export function InboxUsernameLinkCorrupted(): JSX.Element { conversations: [], archivedConversations: [], isAboutToSearch: false, + selectedChatFolder: null, }, usernameLinkCorrupted: true, })} @@ -532,6 +541,7 @@ export function InboxOnlyPinnedConversations(): JSX.Element { conversations: [], archivedConversations: [], isAboutToSearch: false, + selectedChatFolder: null, }, })} /> @@ -549,6 +559,7 @@ export function InboxOnlyNonPinnedConversations(): JSX.Element { conversations: defaultConversations, archivedConversations: [], isAboutToSearch: false, + selectedChatFolder: null, }, })} /> @@ -566,6 +577,7 @@ export function InboxOnlyArchivedConversations(): JSX.Element { conversations: [], archivedConversations: defaultArchivedConversations, isAboutToSearch: false, + selectedChatFolder: null, }, })} /> @@ -583,6 +595,7 @@ export function InboxPinnedAndArchivedConversations(): JSX.Element { conversations: [], archivedConversations: defaultArchivedConversations, isAboutToSearch: false, + selectedChatFolder: null, }, })} /> @@ -600,6 +613,7 @@ export function InboxNonPinnedAndArchivedConversations(): JSX.Element { conversations: defaultConversations, archivedConversations: defaultArchivedConversations, isAboutToSearch: false, + selectedChatFolder: null, }, })} /> @@ -617,6 +631,7 @@ export function InboxPinnedAndNonPinnedConversations(): JSX.Element { conversations: defaultConversations, archivedConversations: [], isAboutToSearch: false, + selectedChatFolder: null, }, })} /> @@ -634,6 +649,7 @@ export function InboxPinnedAndNonPinnedConversationsWithBackupDownload(): JSX.El conversations: defaultConversations, archivedConversations: [], isAboutToSearch: false, + selectedChatFolder: null, }, backupMediaDownloadProgress, })} @@ -1080,6 +1096,7 @@ export function CaptchaDialogRequired(): JSX.Element { archivedConversations: [], isAboutToSearch: false, searchTerm: '', + selectedChatFolder: null, }, challengeStatus: 'required', })} @@ -1099,6 +1116,7 @@ export function CaptchaDialogPending(): JSX.Element { archivedConversations: [], isAboutToSearch: false, searchTerm: '', + selectedChatFolder: null, }, challengeStatus: 'pending', })} @@ -1118,6 +1136,7 @@ export function _CrashReportDialog(): JSX.Element { archivedConversations: [], isAboutToSearch: false, searchTerm: '', + selectedChatFolder: null, }, crashReportCount: 42, })} @@ -1270,6 +1289,7 @@ export function SearchingConversation(): JSX.Element { isAboutToSearch: false, searchConversation: getDefaultConversation(), searchTerm: '', + selectedChatFolder: null, }, })} /> diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index f79657f2e6..2366250e15 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useCallback, useMemo, useRef } from 'react'; import classNames from 'classnames'; import lodash from 'lodash'; -import type { LeftPaneHelper, ToFindType } from './leftPane/LeftPaneHelper.js'; +import type { ToFindType } from './leftPane/LeftPaneHelper.js'; import { FindDirection } from './leftPane/LeftPaneHelper.js'; import type { LeftPaneInboxPropsType } from './leftPane/LeftPaneInboxHelper.js'; import { LeftPaneInboxHelper } from './leftPane/LeftPaneInboxHelper.js'; @@ -53,7 +53,6 @@ import { NavSidebarActionButton, NavSidebarSearchHeader, } from './NavSidebar.js'; -import { ContextMenu } from './ContextMenu.js'; import type { UnreadStats } from '../util/countUnreadStats.js'; import { BackupMediaDownloadProgress } from './BackupMediaDownloadProgress.js'; import type { ServerAlertsType, ServerAlert } from '../types/ServerAlert.js'; @@ -61,7 +60,9 @@ import { getServerAlertDialog } from './ServerAlerts.js'; import { NavTab, SettingsPage, ProfileEditorPage } from '../types/Nav.js'; import type { Location } from '../types/Nav.js'; import type { RenderConversationListItemContextMenuProps } from './conversationList/BaseConversationListItem.js'; -import type { ExternalProps as NotificationProfilesMenuProps } from '../state/smart/NotificationProfilesMenu.js'; +import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.js'; +import type { ChatFolder } from '../types/ChatFolder.js'; +import { isChatFoldersEnabled } from '../types/ChatFolder.js'; import { ProfileAvatar } from './PreferencesNotificationProfiles.js'; import { tw } from '../axo/tw.js'; @@ -77,6 +78,7 @@ export type PropsType = { downloadBannerDismissed: boolean; }; otherTabsUnreadStats: UnreadStats; + hasAnyCurrentCustomChatFolders: boolean; hasExpiredDialog: boolean; hasFailedStorySends: boolean; hasNetworkDialog: boolean; @@ -123,6 +125,7 @@ export type PropsType = { isMacOS: boolean; isNotificationProfileActive: boolean; preferredWidthFromStorage: number; + selectedChatFolder: ChatFolder | null; selectedConversationId: undefined | string; targetedMessageId: undefined | string; challengeStatus: 'idle' | 'required' | 'pending'; @@ -150,6 +153,7 @@ export type PropsType = { endSearch: () => void; navTabsCollapsed: boolean; openUsernameReservationModal: () => void; + onChatFoldersOpenSettings: () => void; onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void; removeConversation: (conversationId: string) => void; @@ -199,9 +203,7 @@ export type PropsType = { renderCrashReportDialog: () => JSX.Element; renderExpiredBuildDialog: (_: DialogExpiredBuildPropsType) => JSX.Element; renderLeftPaneChatFolders: () => JSX.Element; - renderNotificationProfilesMenu: ( - props: NotificationProfilesMenuProps - ) => JSX.Element; + renderNotificationProfilesMenu: () => JSX.Element; renderToastManager: (_: { containerWidthBreakpoint: WidthBreakpoint; }) => JSX.Element; @@ -228,6 +230,7 @@ export function LeftPane({ endSearch, getPreferredBadge, getServerAlertToShow, + hasAnyCurrentCustomChatFolders, hasExpiredDialog, hasFailedStorySends, hasNetworkDialog, @@ -242,6 +245,7 @@ export function LeftPane({ isUpdateDownloaded, modeSpecificProps, navTabsCollapsed, + onChatFoldersOpenSettings, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, @@ -266,6 +270,7 @@ export function LeftPane({ saveAlerts, savePreferredLeftPaneWidth, searchInConversation, + selectedChatFolder, selectedConversationId, targetedMessageId, toggleNavTabsCollapse, @@ -320,7 +325,15 @@ export function LeftPane({ // // Unfortunately, there's a little bit of repetition here because TypeScript isn't quite // smart enough. - let helper: LeftPaneHelper; + let helper: + | LeftPaneInboxHelper + | LeftPaneSearchHelper + | LeftPaneArchiveHelper + | LeftPaneComposeHelper + | LeftPaneFindByUsernameHelper + | LeftPaneFindByPhoneNumberHelper + | LeftPaneChooseGroupMembersHelper + | LeftPaneSetGroupMetadataHelper; let shouldRecomputeRowHeights: boolean; switch (modeSpecificProps.mode) { case LeftPaneMode.Inbox: { @@ -523,9 +536,7 @@ export function LeftPane({ startSearch, ]); - const backgroundNode = helper.getBackgroundNode({ - i18n, - }); + const isEmpty = helper.getRowCount() === 0; const preRowsNode = helper.getPreRowsNode({ clearConversationSearch, @@ -744,21 +755,6 @@ export function LeftPane({ const hasDialogs = dialogs.length ? !hideHeader : false; - // The notification profile menu shows in two places - under its own icon and - // under the more actions context menu. - const [isNotificationProfilesMenuOpen, setIsNotificationProfilesMenuOpen] = - React.useState(false); - const [ - isNotificationProfilesSubMenuOpen, - setIsNotificationProfilesSubMenuOpen, - ] = React.useState(false); - - React.useEffect(() => { - if (!isNotificationProfileActive) { - setIsNotificationProfilesMenuOpen(false); - } - }, [isNotificationProfileActive, setIsNotificationProfilesMenuOpen]); - return ( - {isNotificationProfileActive && - renderNotificationProfilesMenu({ - isOpen: isNotificationProfilesMenuOpen, - onClose: () => { - setIsNotificationProfilesMenuOpen(false); - }, - trigger: ( + {isNotificationProfileActive && ( + + - ), - })} + + + {renderNotificationProfilesMenu()} + + + )} } onClick={startComposing} /> - setIsNotificationProfilesSubMenuOpen(true), - }, - ]} - popperOptions={{ - placement: 'bottom', - strategy: 'absolute', - }} - portalToRoot - > - {({ onClick, onKeyDown, ref }) => - renderNotificationProfilesMenu({ - isOpen: isNotificationProfilesSubMenuOpen, - onClose: () => { - setIsNotificationProfilesSubMenuOpen(false); - }, - trigger: ( - - } - label="More Actions" - /> - ), - }) - } - + + + } + label="More Actions" + /> + + + + {i18n('icu:avatarMenuViewArchive')} + + {isChatFoldersEnabled() && !hasAnyCurrentCustomChatFolders && ( + + {i18n('icu:LeftPane__MoreActionsMenu__AddChatFolder')} + + )} + {isChatFoldersEnabled() && hasAnyCurrentCustomChatFolders && ( + + {i18n('icu:LeftPane__MoreActionsMenu__FolderSettings')} + + )} + + + {i18n('icu:NotificationProfileMenuItem')} + + + {renderNotificationProfilesMenu()} + + + + } > - {backgroundNode}
- + } /> - + } /> {props.existingChatFolderId != null && ( diff --git a/ts/services/chatFoldersLoader.ts b/ts/services/chatFoldersLoader.ts index 79fc7ef5bd..59157c6fd9 100644 --- a/ts/services/chatFoldersLoader.ts +++ b/ts/services/chatFoldersLoader.ts @@ -2,16 +2,16 @@ // SPDX-License-Identifier: AGPL-3.0-only import { DataReader } from '../sql/Client.js'; -import type { ChatFolder } from '../types/ChatFolder.js'; +import type { CurrentChatFolder } from '../types/CurrentChatFolders.js'; import { strictAssert } from '../util/assert.js'; -let chatFolders: ReadonlyArray; +let chatFolders: ReadonlyArray; export async function loadChatFolders(): Promise { chatFolders = await DataReader.getCurrentChatFolders(); } -export function getChatFoldersForRedux(): ReadonlyArray { +export function getChatFoldersForRedux(): ReadonlyArray { strictAssert(chatFolders != null, 'chatFolders has not been loaded'); return chatFolders; } diff --git a/ts/services/storage.ts b/ts/services/storage.ts index af4431dab0..c4de252e7e 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -96,8 +96,8 @@ import { isDone as isRegistrationDone } from '../util/registration.js'; import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue.js'; import { isMockEnvironment } from '../environment.js'; import { validateConversation } from '../util/validateConversation.js'; -import { hasAllChatsChatFolder } from '../types/ChatFolder.js'; import type { ChatFolder } from '../types/ChatFolder.js'; +import { isCurrentAllChatFolder } from '../types/CurrentChatFolders.js'; import type { NotificationProfileType } from '../types/NotificationProfile.js'; import { itemStorage } from '../textsecure/Storage.js'; @@ -1755,9 +1755,15 @@ async function processManifest( storageID: null, storageVersion: null, }); + + window.reduxActions.chatFolders.refetchChatFolders(); }); - if (!hasAllChatsChatFolder(chatFolders)) { + const hasCurrentAllChatFolder = chatFolders.some(chatFolder => { + return isCurrentAllChatFolder(chatFolder); + }); + + if (!hasCurrentAllChatFolder) { log.info(`process(${version}): creating all chats chat folder`); window.reduxActions.chatFolders.createAllChatsChatFolder(); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 7b8e6e271c..c11c5e6c5a 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -60,6 +60,7 @@ import type { NotificationProfileType } from '../types/NotificationProfile.js'; import type { DonationReceipt } from '../types/Donations.js'; import type { InsertOrUpdateCallLinkFromSyncResult } from './server/callLinks.js'; import type { ChatFolderId, ChatFolder } from '../types/ChatFolder.js'; +import type { CurrentChatFolder } from '../types/CurrentChatFolders.js'; export type ReadableDB = Database & { __readable_db: never }; export type WritableDB = ReadableDB & { __writable_db: never }; @@ -927,7 +928,7 @@ type ReadableInterface = { getDonationReceiptById(id: string): DonationReceipt | undefined; getAllChatFolders: () => ReadonlyArray; - getCurrentChatFolders: () => ReadonlyArray; + getCurrentChatFolders: () => ReadonlyArray; getChatFolder: (id: ChatFolderId) => ChatFolder | null; hasAllChatsChatFolder: () => boolean; getOldestDeletedChatFolder: () => ChatFolder | null; diff --git a/ts/sql/server/chatFolders.ts b/ts/sql/server/chatFolders.ts index 40662521b9..9fe97bfa0a 100644 --- a/ts/sql/server/chatFolders.ts +++ b/ts/sql/server/chatFolders.ts @@ -11,6 +11,8 @@ import { import type { ReadableDB, WritableDB } from '../Interface.js'; import { sql } from '../util.js'; import { strictAssert } from '../../util/assert.js'; +import type { CurrentChatFolder } from '../../types/CurrentChatFolders.js'; +import { isCurrentChatFolder } from '../../types/CurrentChatFolders.js'; export type ChatFolderRow = Readonly< Omit< @@ -71,17 +73,25 @@ export function getAllChatFolders(db: ReadableDB): ReadonlyArray { export function getCurrentChatFolders( db: ReadableDB -): ReadonlyArray { +): ReadonlyArray { const [query, params] = sql` SELECT * FROM chatFolders - WHERE deletedAtTimestampMs IS 0 + WHERE folderType IS NOT ${ChatFolderType.UNKNOWN} + AND deletedAtTimestampMs IS 0 ORDER BY position ASC `; return db .prepare(query) .all(params) - .map(row => rowToChatFolder(row)); + .map(row => { + const chatFolder = rowToChatFolder(row); + strictAssert( + isCurrentChatFolder(chatFolder), + `Query returned row that is not a current chat folder (${chatFolder.id})` + ); + return chatFolder; + }); } export function getChatFolder( diff --git a/ts/state/ducks/chatFolders.ts b/ts/state/ducks/chatFolders.ts index 34f366f337..d30bb90fe5 100644 --- a/ts/state/ducks/chatFolders.ts +++ b/ts/state/ducks/chatFolders.ts @@ -9,12 +9,9 @@ import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.j import { useBoundActions } from '../../hooks/useBoundActions.js'; import { ChatFolderParamsSchema, - lookupCurrentChatFolder, - toCurrentChatFolders, type ChatFolder, type ChatFolderId, type ChatFolderParams, - type CurrentChatFolders, } from '../../types/ChatFolder.js'; import { getCurrentChatFolders } from '../selectors/chatFolders.js'; import { DataReader, DataWriter } from '../../sql/Client.js'; @@ -26,6 +23,11 @@ import { TARGETED_CONVERSATION_CHANGED, type TargetedConversationChangedActionType, } from './conversations.js'; +import type { ShowToastActionType } from './toast.js'; +import { showToast } from './toast.js'; +import { ToastType } from '../../types/Toast.js'; +import type { CurrentChatFolder } from '../../types/CurrentChatFolders.js'; +import { CurrentChatFolders } from '../../types/CurrentChatFolders.js'; export type ChatFoldersState = ReadonlyDeep<{ currentChatFolders: CurrentChatFolders; @@ -54,15 +56,20 @@ export type ChatFolderAction = ReadonlyDeep< export function getEmptyState(): ChatFoldersState { return { - currentChatFolders: { - order: [], - lookup: {}, - }, + currentChatFolders: CurrentChatFolders.createEmpty(), selectedChatFolderId: null, stableSelectedConversationIdInChatFolder: null, }; } +export function getInitialChatFoldersState( + chatFolders: ReadonlyArray +): ChatFoldersState { + return toNextChatFoldersState(getEmptyState(), { + currentChatFolders: CurrentChatFolders.fromArray(chatFolders), + }); +} + function replaceAllChatFolderRecords( currentChatFolders: CurrentChatFolders ): ChatFolderRecordReplaceAll { @@ -80,7 +87,7 @@ function _refetchChatFolders(): ThunkAction< > { return async dispatch => { const chatFolders = await DataReader.getCurrentChatFolders(); - const currentChatFolders = toCurrentChatFolders(chatFolders); + const currentChatFolders = CurrentChatFolders.fromArray(chatFolders); dispatch(replaceAllChatFolderRecords(currentChatFolders)); }; } @@ -90,15 +97,17 @@ function _refetchChatFolders(): ThunkAction< const refetchChatFolders = throttle(_refetchChatFolders, 100); function createChatFolder( - chatFolderParams: ChatFolderParams -): ThunkAction { + chatFolderParams: ChatFolderParams, + showToastOnSuccess: boolean +): ThunkAction { return async (dispatch, getState) => { - const chatFolders = getCurrentChatFolders(getState()); + const currentChatFolders = getCurrentChatFolders(getState()); + const size = CurrentChatFolders.size(currentChatFolders); const chatFolder: ChatFolder = { ...chatFolderParams, id: generateUuid() as ChatFolderId, - position: chatFolders.order.length, + position: size, deletedAtTimestampMs: 0, storageID: null, storageVersion: null, @@ -109,6 +118,15 @@ function createChatFolder( await DataWriter.createChatFolder(chatFolder); storageServiceUploadJob({ reason: 'createChatFolder' }); dispatch(_refetchChatFolders()); + + if (showToastOnSuccess) { + dispatch( + showToast({ + toastType: ToastType.ChatFolderCreated, + parameters: { chatFolderName: chatFolder.name }, + }) + ); + } }; } @@ -132,9 +150,10 @@ function updateChatFolder( return async (dispatch, getState) => { const currentChatFolders = getCurrentChatFolders(getState()); - const prevChatFolder = lookupCurrentChatFolder( + const prevChatFolder = CurrentChatFolders.expect( currentChatFolders, - chatFolderId + chatFolderId, + 'updateChatFolder' ); const nextChatFolder: ChatFolder = { @@ -166,9 +185,10 @@ function updateChatFoldersPositions( return async (dispatch, getState) => { const currentChatFolders = getCurrentChatFolders(getState()); const chatFolders = chatFolderIds.map((chatFolderId, index) => { - const chatFolder = lookupCurrentChatFolder( + const chatFolder = CurrentChatFolders.expect( currentChatFolders, - chatFolderId + chatFolderId, + 'updateChatFoldersPositions' ); return { ...chatFolder, position: index + 1 }; }); @@ -201,28 +221,66 @@ export const useChatFolderActions = (): BoundActionCreatorsMapObject< typeof actions > => useBoundActions(actions); +function getDefaultSelectedChatFolderId( + currentChatFolders: CurrentChatFolders +): ChatFolderId | null { + // Default to the first chat folder in the list rather than "All chats" + return CurrentChatFolders.at(currentChatFolders, 0)?.id ?? null; +} + +function toNextChatFoldersState( + prevState: ChatFoldersState, + changes: Partial +): ChatFoldersState { + const nextState = { ...prevState, ...changes }; + + // Ensure that the `selectedChatFolderId` is not referencing a chat folder + // that is no longer current + if (nextState.selectedChatFolderId != null) { + const isSelectedChatFolderIdCurrent = CurrentChatFolders.has( + nextState.currentChatFolders, + nextState.selectedChatFolderId + ); + + if (!isSelectedChatFolderIdCurrent) { + nextState.selectedChatFolderId = null; + } + } + + // Ensure `selectedChatFolderId` is set if `currentChatFolders` isnt empty + // Components should still handle if `selectedChatFolderId` is `null` though + // But some of them could assume they should render "All chats" + nextState.selectedChatFolderId ??= getDefaultSelectedChatFolderId( + nextState.currentChatFolders + ); + + // Ensure `stableSelectedConversationIdInChatFolder` + // is reset if `selectedChatFolderId` changes + if (nextState.selectedChatFolderId !== prevState.selectedChatFolderId) { + nextState.stableSelectedConversationIdInChatFolder = null; + } + + return nextState; +} + export function reducer( state: ChatFoldersState = getEmptyState(), action: ChatFolderAction | TargetedConversationChangedActionType ): ChatFoldersState { switch (action.type) { case CHAT_FOLDER_RECORD_REPLACE_ALL: - return { - ...state, + return toNextChatFoldersState(state, { currentChatFolders: action.payload, - }; + }); case CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID: - return { - ...state, + return toNextChatFoldersState(state, { selectedChatFolderId: action.payload, - stableSelectedConversationIdInChatFolder: null, - }; + }); case TARGETED_CONVERSATION_CHANGED: - return { - ...state, + return toNextChatFoldersState(state, { stableSelectedConversationIdInChatFolder: action.payload.conversationId ?? null, - }; + }); default: return state; } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 87bcc14172..1e55444a39 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -225,12 +225,10 @@ import type { ConversationModel } from '../../models/conversations.js'; import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent.js'; import { JobCancelReason } from '../../jobs/types.js'; import type { ChatFolderId } from '../../types/ChatFolder.js'; -import { - isConversationInChatFolder, - lookupCurrentChatFolder, -} from '../../types/ChatFolder.js'; +import { isConversationInChatFolder } from '../../types/ChatFolder.js'; import { getCurrentChatFolders } from '../selectors/chatFolders.js'; import { isConversationUnread } from '../../util/isConversationUnread.js'; +import { CurrentChatFolders } from '../../types/CurrentChatFolders.js'; import { itemStorage } from '../../textsecure/Storage.js'; const { @@ -1473,7 +1471,11 @@ function _getAllConversationsInChatFolder( chatFolderId: ChatFolderId ) { const currentChatFolders = getCurrentChatFolders(state); - const chatFolder = lookupCurrentChatFolder(currentChatFolders, chatFolderId); + const chatFolder = CurrentChatFolders.expect( + currentChatFolders, + chatFolderId, + '_getAllConversationsInChatFolder' + ); const allConversations = getAllConversations(state); return allConversations.filter(conversation => { return isConversationInChatFolder(chatFolder, conversation); diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index 9338473376..d7c855129e 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -8,7 +8,10 @@ import { getEmptyState as audioRecorderEmptyState } from './ducks/audioRecorder. import { getEmptyState as badgesEmptyState } from './ducks/badges.js'; import { getEmptyState as callHistoryEmptyState } from './ducks/callHistory.js'; import { getEmptyState as callingEmptyState } from './ducks/calling.js'; -import { getEmptyState as chatFoldersEmptyState } from './ducks/chatFolders.js'; +import { + getEmptyState as chatFoldersEmptyState, + getInitialChatFoldersState, +} from './ducks/chatFolders.js'; import { getEmptyState as composerEmptyState } from './ducks/composer.js'; import { getEmptyState as conversationsEmptyState } from './ducks/conversations.js'; import { getEmptyState as crashReportsEmptyState } from './ducks/crashReports.js'; @@ -45,7 +48,6 @@ import { STICKERS_PATH, TEMP_PATH, } from '../util/basePaths.js'; -import { toCurrentChatFolders } from '../types/ChatFolder.js'; import type { StateType } from './reducer.js'; import type { MainWindowStatsType } from '../windows/context.js'; @@ -96,10 +98,7 @@ export function getInitialState( ...callingEmptyState(), callLinks: makeLookup(callLinks, 'roomId'), }, - chatFolders: { - ...chatFoldersEmptyState(), - currentChatFolders: toCurrentChatFolders(chatFolders), - }, + chatFolders: getInitialChatFoldersState(chatFolders), donations, emojis: recentEmoji, gifs, diff --git a/ts/state/initializeRedux.ts b/ts/state/initializeRedux.ts index 016925247a..2b7279733e 100644 --- a/ts/state/initializeRedux.ts +++ b/ts/state/initializeRedux.ts @@ -19,14 +19,14 @@ import type { RecentEmojiObjectType } from '../util/loadRecentEmojis.js'; import type { StickersStateType } from './ducks/stickers.js'; import type { GifsStateType } from './ducks/gifs.js'; import type { NotificationProfileType } from '../types/NotificationProfile.js'; -import type { ChatFolder } from '../types/ChatFolder.js'; +import type { CurrentChatFolder } from '../types/CurrentChatFolders.js'; export type ReduxInitData = { badgesState: BadgesStateType; callHistory: ReadonlyArray; callHistoryUnreadCount: number; callLinks: ReadonlyArray; - chatFolders: ReadonlyArray; + chatFolders: ReadonlyArray; donations: DonationsStateType; gifs: GifsStateType; mainWindowStats: MainWindowStatsType; diff --git a/ts/state/selectors/chatFolders.ts b/ts/state/selectors/chatFolders.ts index 0fd503c7b9..0def13dad1 100644 --- a/ts/state/selectors/chatFolders.ts +++ b/ts/state/selectors/chatFolders.ts @@ -5,11 +5,8 @@ import { createSelector } from 'reselect'; import type { StateType } from '../reducer.js'; import type { StateSelector } from '../types.js'; import type { ChatFoldersState } from '../ducks/chatFolders.js'; -import type { CurrentChatFolders, ChatFolder } from '../../types/ChatFolder.js'; -import { - getSortedCurrentChatFolders, - lookupCurrentChatFolder, -} from '../../types/ChatFolder.js'; +import type { CurrentChatFolder } from '../../types/CurrentChatFolders.js'; +import { CurrentChatFolders } from '../../types/CurrentChatFolders.js'; export function getChatFoldersState(state: StateType): ChatFoldersState { return state.chatFolders; @@ -20,22 +17,32 @@ export const getCurrentChatFolders: StateSelector = return state.currentChatFolders; }); -export const getSortedChatFolders: StateSelector> = +export const getCurrentChatFoldersCount: StateSelector = createSelector( + getCurrentChatFolders, + currentChatFolders => { + return CurrentChatFolders.size(currentChatFolders); + } +); + +export const getHasAnyCurrentCustomChatFolders: StateSelector = createSelector(getCurrentChatFolders, currentChatFolders => { - return getSortedCurrentChatFolders(currentChatFolders); + return currentChatFolders.hasAnyCurrentCustomChatFolders; }); -export const getSelectedChatFolder: StateSelector = +export const getSelectedChatFolder: StateSelector = createSelector( getChatFoldersState, getCurrentChatFolders, (state, currentChatFolders) => { - const selectedChatFolderId = - state.selectedChatFolderId ?? currentChatFolders.order.at(0); + const { selectedChatFolderId } = state; if (selectedChatFolderId == null) { return null; } - return lookupCurrentChatFolder(currentChatFolders, selectedChatFolderId); + return CurrentChatFolders.expect( + currentChatFolders, + selectedChatFolderId, + 'getSelectedChatFolder' + ); } ); diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index a376ec3152..293ac6e4bc 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -72,7 +72,7 @@ import { } from '../../types/ChatFolder.js'; import { getSelectedChatFolder, - getSortedChatFolders, + getCurrentChatFolders, getStableSelectedConversationIdInChatFolder, } from './chatFolders.js'; import { @@ -692,11 +692,11 @@ export const getAllConversationsUnreadStats = createSelector( export const getAllChatFoldersUnreadStats: StateSelector = createSelector( - getSortedChatFolders, + getCurrentChatFolders, getAllConversations, - (sortedChatFolders, allConversations) => { + (currentChatFolders, allConversations) => { return countAllChatFoldersUnreadStats( - sortedChatFolders, + currentChatFolders, allConversations, { includeMuted: false, @@ -707,10 +707,13 @@ export const getAllChatFoldersUnreadStats: StateSelector = createSelector( - getSortedChatFolders, + getCurrentChatFolders, getAllConversations, - (sortedChatFolders, allConversations) => { - return countAllChatFoldersMutedStats(sortedChatFolders, allConversations); + (currentChatFolders, allConversations) => { + return countAllChatFoldersMutedStats( + currentChatFolders, + allConversations + ); } ); diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 863c7bfcd3..558ec42849 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -1,7 +1,7 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { memo } from 'react'; +import React, { memo, useCallback } from 'react'; import { useSelector } from 'react-redux'; import type { PropsType as DialogExpiredBuildPropsType } from '../../components/DialogExpiredBuild.js'; import { DialogExpiredBuild } from '../../components/DialogExpiredBuild.js'; @@ -116,8 +116,12 @@ import { useNavActions } from '../ducks/nav.js'; import { SmartLeftPaneChatFolders } from './LeftPaneChatFolders.js'; import { SmartLeftPaneConversationListItemContextMenu } from './LeftPaneConversationListItemContextMenu.js'; import type { RenderConversationListItemContextMenuProps } from '../../components/conversationList/BaseConversationListItem.js'; +import { + getHasAnyCurrentCustomChatFolders, + getSelectedChatFolder, +} from '../selectors/chatFolders.js'; +import { NavTab, SettingsPage } from '../../types/Nav.js'; import { SmartNotificationProfilesMenu } from './NotificationProfilesMenu.js'; -import type { ExternalProps as NotificationProfilesMenuProps } from './NotificationProfilesMenu.js'; import { getActiveProfile } from '../selectors/notificationProfiles.js'; function renderMessageSearchResult(id: string): JSX.Element { @@ -174,10 +178,8 @@ function renderToastManagerWithoutMegaphone(props: { return ; } -function renderNotificationProfilesMenu( - props: NotificationProfilesMenuProps -): JSX.Element { - return ; +function renderNotificationProfilesMenu(): JSX.Element { + return ; } const getModeSpecificProps = ( @@ -220,6 +222,7 @@ const getModeSpecificProps = ( searchTerm: getQuery(state), startSearchCounter: getStartSearchCounter(state), filterByUnread: getFilterByUnread(state), + selectedChatFolder: getSelectedChatFolder(state), ...getLeftPaneLists(state), }; case ComposerStep.StartDirectConversation: @@ -310,6 +313,9 @@ export const SmartLeftPane = memo(function SmartLeftPane({ const crashReportCount = useSelector(getCrashReportCount); const getPreferredBadge = useSelector(getPreferredBadgeSelector); const hasAppExpired = useSelector(hasExpired); + const hasAnyCurrentCustomChatFolders = useSelector( + getHasAnyCurrentCustomChatFolders + ); const hasNetworkDialog = useSelector(getHasNetworkDialog); const hasSearchQuery = useSelector(getHasSearchQuery); const hasUnsupportedOS = useSelector(isOSUnsupported); @@ -320,6 +326,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({ const modeSpecificProps = useSelector(getModeSpecificProps); const navTabsCollapsed = useSelector(getNavTabsCollapsed); const preferredWidthFromStorage = useSelector(getPreferredLeftPaneWidth); + const selectedChatFolder = useSelector(getSelectedChatFolder); const selectedConversationId = useSelector(getSelectedConversationId); const showArchived = useSelector(getShowArchived); const targetedMessage = useSelector(getTargetedMessage); @@ -381,6 +388,18 @@ export const SmartLeftPane = memo(function SmartLeftPane({ const { showUserNotFoundModal } = useGlobalModalActions(); const { changeLocation } = useNavActions(); + const handleChatFolderOpenSettings = useCallback(() => { + changeLocation({ + tab: NavTab.Settings, + details: { + page: SettingsPage.ChatFolders, + previousLocation: { + tab: NavTab.Chats, + }, + }, + }); + }, [changeLocation]); + let hasExpiredDialog = false; let unsupportedOSDialogType: 'error' | 'warning' | undefined; if (hasAppExpired) { @@ -425,6 +444,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({ endSearch={endSearch} getPreferredBadge={getPreferredBadge} getServerAlertToShow={getServerAlertToShow} + hasAnyCurrentCustomChatFolders={hasAnyCurrentCustomChatFolders} hasExpiredDialog={hasExpiredDialog} hasFailedStorySends={hasFailedStorySends} hasNetworkDialog={hasNetworkDialog} @@ -439,6 +459,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({ lookupConversationWithoutServiceId={lookupConversationWithoutServiceId} modeSpecificProps={modeSpecificProps} navTabsCollapsed={navTabsCollapsed} + onChatFoldersOpenSettings={handleChatFolderOpenSettings} onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation} onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation} openUsernameReservationModal={openUsernameReservationModal} @@ -465,6 +486,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({ saveAlerts={saveAlerts} savePreferredLeftPaneWidth={savePreferredLeftPaneWidth} searchInConversation={searchInConversation} + selectedChatFolder={selectedChatFolder} selectedConversationId={selectedConversationId} serverAlerts={serverAlerts} setChallengeStatus={setChallengeStatus} diff --git a/ts/state/smart/LeftPaneChatFolders.tsx b/ts/state/smart/LeftPaneChatFolders.tsx index 34bf24f766..76c4bf19c2 100644 --- a/ts/state/smart/LeftPaneChatFolders.tsx +++ b/ts/state/smart/LeftPaneChatFolders.tsx @@ -4,8 +4,8 @@ import React, { memo, useCallback, useContext } from 'react'; import { useSelector } from 'react-redux'; import { LeftPaneChatFolders } from '../../components/leftPane/LeftPaneChatFolders.js'; import { + getCurrentChatFolders, getSelectedChatFolder, - getSortedChatFolders, } from '../selectors/chatFolders.js'; import { getIntl } from '../selectors/user.js'; import { @@ -26,7 +26,7 @@ import { useConversationsActions } from '../ducks/conversations.js'; export const SmartLeftPaneChatFolders = memo( function SmartLeftPaneChatFolders() { const i18n = useSelector(getIntl); - const sortedChatFolders = useSelector(getSortedChatFolders); + const currentChatFolders = useSelector(getCurrentChatFolders); const allChatFoldersUnreadStats = useSelector(getAllChatFoldersUnreadStats); const allChatFoldersMutedStats = useSelector(getAllChatFoldersMutedStats); const selectedChatFolder = useSelector(getSelectedChatFolder); @@ -62,7 +62,7 @@ export const SmartLeftPaneChatFolders = memo( void; - trigger?: React.ReactNode; -}; - -export function SmartNotificationProfilesMenu({ - isOpen, - onClose, - trigger, -}: ExternalProps): JSX.Element { +export function SmartNotificationProfilesMenu(): JSX.Element { const i18n = useSelector(getIntl); const allProfiles = useSelector(getProfiles); @@ -52,12 +42,9 @@ export function SmartNotificationProfilesMenu({ allProfiles={allProfiles} currentOverride={currentOverride} i18n={i18n} - isOpen={isOpen} loading={loading} - onClose={onClose} onGoToSettings={goToSettings} setProfileOverride={setProfileOverride} - trigger={trigger} /> ); } diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx index 2f9f05b7a3..171c14f50b 100644 --- a/ts/state/smart/Preferences.tsx +++ b/ts/state/smart/Preferences.tsx @@ -100,6 +100,10 @@ import { SmartPreferencesChatFoldersPage } from './PreferencesChatFoldersPage.js import type { SmartPreferencesEditChatFolderPageProps } from './PreferencesEditChatFolderPage.js'; import { SmartPreferencesEditChatFolderPage } from './PreferencesEditChatFolderPage.js'; import { AxoProvider } from '../../axo/AxoProvider.js'; +import { + getCurrentChatFoldersCount, + getHasAnyCurrentCustomChatFolders, +} from '../selectors/chatFolders.js'; import { SmartNotificationProfilesCreateFlow, SmartNotificationProfilesHome, @@ -241,6 +245,10 @@ export function SmartPreferences(): JSX.Element | null { const shouldShowUpdateDialog = dialogType !== DialogType.None; const badge = getPreferredBadge(me.badges); + const currentChatFoldersCount = useSelector(getCurrentChatFoldersCount); + const hasAnyCurrentCustomChatFolders = useSelector( + getHasAnyCurrentCustomChatFolders + ); // The weird ones @@ -774,6 +782,7 @@ export function SmartPreferences(): JSX.Element | null { backupLocalBackupsEnabled={backupLocalBackupsEnabled} badge={badge} blockedCount={blockedCount} + currentChatFoldersCount={currentChatFoldersCount} cloudBackupStatus={cloudBackupStatus} customColors={customColors} defaultConversationColor={defaultConversationColor} @@ -790,6 +799,7 @@ export function SmartPreferences(): JSX.Element | null { getMessageSampleForSchemaVersion={ DataReader.getMessageSampleForSchemaVersion } + hasAnyCurrentCustomChatFolders={hasAnyCurrentCustomChatFolders} hasAudioNotifications={hasAudioNotifications} hasAutoConvertEmoji={hasAutoConvertEmoji} hasAutoDownloadUpdate={hasAutoDownloadUpdate} diff --git a/ts/state/smart/PreferencesChatFoldersPage.tsx b/ts/state/smart/PreferencesChatFoldersPage.tsx index 8cb8b8de85..6340820189 100644 --- a/ts/state/smart/PreferencesChatFoldersPage.tsx +++ b/ts/state/smart/PreferencesChatFoldersPage.tsx @@ -5,13 +5,15 @@ import { useSelector } from 'react-redux'; import type { PreferencesChatFoldersPageProps } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js'; import { PreferencesChatFoldersPage } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js'; import { getIntl } from '../selectors/user.js'; -import { getSortedChatFolders } from '../selectors/chatFolders.js'; +import { getCurrentChatFolders } from '../selectors/chatFolders.js'; import type { ChatFolderId } from '../../types/ChatFolder.js'; import { useChatFolderActions } from '../ducks/chatFolders.js'; +import type { Location } from '../../types/Nav.js'; +import { useNavActions } from '../ducks/nav.js'; export type SmartPreferencesChatFoldersPageProps = Readonly<{ settingsPaneRef: PreferencesChatFoldersPageProps['settingsPaneRef']; - onBack: () => void; + previousLocation: Location | null; onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void; }>; @@ -19,16 +21,18 @@ export function SmartPreferencesChatFoldersPage( props: SmartPreferencesChatFoldersPageProps ): JSX.Element { const i18n = useSelector(getIntl); - const chatFolders = useSelector(getSortedChatFolders); + const currentChatFolders = useSelector(getCurrentChatFolders); const { createChatFolder, deleteChatFolder, updateChatFoldersPositions } = useChatFolderActions(); + const { changeLocation } = useNavActions(); return ( ; @@ -33,7 +33,7 @@ export function SmartPreferencesEditChatFolderPage( const conversations = useSelector(getAllComposableConversations); const conversationSelector = useSelector(getConversationSelector); const preferredBadgeSelector = useSelector(getPreferredBadgeSelector); - const chatFolders = useSelector(getSortedChatFolders); + const currentChatFolders = useSelector(getCurrentChatFolders); const { createChatFolder, updateChatFolder, deleteChatFolder } = useChatFolderActions(); const { changeLocation } = useNavActions(); @@ -42,12 +42,12 @@ export function SmartPreferencesEditChatFolderPage( if (existingChatFolderId == null) { return CHAT_FOLDER_DEFAULTS; } - const found = chatFolders.find(chatFolder => { - return chatFolder.id === existingChatFolderId; - }); - strictAssert(found, 'Unable to find chat folder'); - return found; - }, [chatFolders, existingChatFolderId]); + return CurrentChatFolders.expect( + currentChatFolders, + existingChatFolderId, + 'initChatFolderParams' + ); + }, [currentChatFolders, existingChatFolderId]); return ( { searchDisabled: false, searchTerm: '', startSearchCounter: 0, + selectedChatFolder: null, }; describe('getBackAction', () => { diff --git a/ts/types/ChatFolder.ts b/ts/types/ChatFolder.ts index 2a197ccd23..b2de95b54d 100644 --- a/ts/types/ChatFolder.ts +++ b/ts/types/ChatFolder.ts @@ -11,7 +11,6 @@ import * as grapheme from '../util/grapheme.js'; import * as RemoteConfig from '../RemoteConfig.js'; import { isAlpha, isBeta, isProduction } from '../util/version.js'; import type { ConversationType } from '../state/ducks/conversations.js'; -import { strictAssert } from '../util/assert.js'; import { isConversationUnread } from '../util/isConversationUnread.js'; export const CHAT_FOLDER_NAME_MAX_CHAR_LENGTH = 32; @@ -234,51 +233,3 @@ export function isConversationInChatFolder( !_isConversationExcludedFromChatFolder(chatFolder, conversation) ); } - -export type CurrentChatFolders = Readonly<{ - order: ReadonlyArray; - lookup: Partial>; -}>; - -export function toCurrentChatFolders( - chatFolders: ReadonlyArray -): CurrentChatFolders { - const order = chatFolders - .toSorted((a, b) => a.position - b.position) - .map(chatFolder => chatFolder.id); - - const lookup: Record = {}; - for (const chatFolder of chatFolders) { - lookup[chatFolder.id] = chatFolder; - } - - return { order, lookup }; -} - -export function getSortedCurrentChatFolders( - currentChatFolders: CurrentChatFolders -): ReadonlyArray { - return currentChatFolders.order.map(chatFolderId => { - return lookupCurrentChatFolder(currentChatFolders, chatFolderId); - }); -} - -export function lookupCurrentChatFolder( - currentChatFolders: CurrentChatFolders, - chatFolderId: ChatFolderId -): ChatFolder { - const chatFolder = currentChatFolders.lookup[chatFolderId]; - strictAssert(chatFolder != null, 'Missing chat folder'); - return chatFolder; -} - -export function hasAllChatsChatFolder( - chatFolders: ReadonlyArray -): boolean { - return chatFolders.some(chatFolder => { - return ( - chatFolder.folderType === ChatFolderType.ALL && - chatFolder.deletedAtTimestampMs === 0 - ); - }); -} diff --git a/ts/types/CurrentChatFolders.ts b/ts/types/CurrentChatFolders.ts new file mode 100644 index 0000000000..ddbaf671f3 --- /dev/null +++ b/ts/types/CurrentChatFolders.ts @@ -0,0 +1,157 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { strictAssert } from '../util/assert.js'; +import { ChatFolderType } from './ChatFolder.js'; +import type { ChatFolder, ChatFolderId } from './ChatFolder.js'; + +export type CurrentChatFolder = ChatFolder & + Readonly<{ + folderType: ChatFolderType.ALL | ChatFolderType.CUSTOM; + deletedAtTimestampMs: 0; + }>; + +export type CurrentAllChatFolder = CurrentChatFolder & + Readonly<{ + folderType: ChatFolderType.ALL; + }>; + +export type CurrentCustomChatFolder = CurrentChatFolder & + Readonly<{ + folderType: ChatFolderType.CUSTOM; + }>; + +export function isCurrentChatFolder( + chatFolder: ChatFolder +): chatFolder is CurrentChatFolder { + return ( + chatFolder.deletedAtTimestampMs === 0 && + chatFolder.folderType !== ChatFolderType.UNKNOWN + ); +} + +export function isCurrentAllChatFolder( + chatFolder: ChatFolder +): chatFolder is CurrentAllChatFolder { + return ( + isCurrentChatFolder(chatFolder) && + chatFolder.folderType === ChatFolderType.ALL + ); +} + +export function isCurrentCustomChatFolder( + chatFolder: ChatFolder +): chatFolder is CurrentCustomChatFolder { + return ( + isCurrentChatFolder(chatFolder) && + chatFolder.folderType === ChatFolderType.CUSTOM + ); +} + +export type CurrentChatFolders = Readonly<{ + order: ReadonlyArray; + lookup: Partial>; + currentAllChatFolder: CurrentAllChatFolder | null; + hasAnyCurrentCustomChatFolders: boolean; +}>; + +// eslint-disable-next-line max-len +// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare +export namespace CurrentChatFolders { + export function createEmpty(): CurrentChatFolders { + return { + order: [], + lookup: {}, + currentAllChatFolder: null, + hasAnyCurrentCustomChatFolders: false, + }; + } + + export function fromArray( + chatFolders: ReadonlyArray + ): CurrentChatFolders { + let currentAllChatFolder: CurrentAllChatFolder | null = null; + let hasAnyCurrentCustomChatFolders = false; + + const order = chatFolders + .toSorted((a, b) => a.position - b.position) + .map(chatFolder => chatFolder.id); + + const lookup: Record = {}; + for (const chatFolder of chatFolders) { + if (isCurrentCustomChatFolder(chatFolder)) { + hasAnyCurrentCustomChatFolders = true; + } else if (isCurrentAllChatFolder(chatFolder)) { + if (currentAllChatFolder != null) { + throw new Error( + `Multiple current all chats chat folders (${currentAllChatFolder.id}, ${chatFolder.id})` + ); + } + currentAllChatFolder = chatFolder; + } else { + throw new TypeError( + `Chat folder is not current ${chatFolder.id} (${chatFolder.folderType}, ${chatFolder.deletedAtTimestampMs})` + ); + } + + lookup[chatFolder.id] = chatFolder; + } + + return { + order, + lookup, + currentAllChatFolder, + hasAnyCurrentCustomChatFolders, + }; + } + + export function size(state: CurrentChatFolders): number { + return state.order.length; + } + + export function has(state: CurrentChatFolders, id: ChatFolderId): boolean { + return Object.hasOwn(state.lookup, id); + } + + export function get( + state: CurrentChatFolders, + id: ChatFolderId + ): CurrentChatFolder | null { + if (has(state, id)) { + return state.lookup[id] ?? null; + } + return null; + } + + export function expect( + state: CurrentChatFolders, + id: ChatFolderId, + reason: string + ): CurrentChatFolder { + const chatFolder = get(state, id); + strictAssert( + chatFolder != null, + `Expected chat folder to exist in state ${id} (${reason})` + ); + return chatFolder; + } + + export function at( + state: CurrentChatFolders, + index: number + ): CurrentChatFolder | null { + const chatFolderId = state.order.at(index); + if (chatFolderId != null) { + return get(state, chatFolderId); + } + return null; + } + + export function toSortedArray( + state: CurrentChatFolders + ): ReadonlyArray { + return state.order.map(id => { + return expect(state, id, 'toSortedArray'); + }); + } +} diff --git a/ts/types/Nav.ts b/ts/types/Nav.ts index 118bcd7069..d779e15e97 100644 --- a/ts/types/Nav.ts +++ b/ts/types/Nav.ts @@ -9,15 +9,21 @@ export type SettingsLocation = ReadonlyDeep< page: SettingsPage.Profile; state: ProfileEditorPage; } + | { + page: SettingsPage.ChatFolders; + previousLocation: Location | null; + } | { page: SettingsPage.EditChatFolder; chatFolderId: ChatFolderId | null; - previousLocation: Location; + previousLocation: Location | null; } | { page: Exclude< SettingsPage, - SettingsPage.Profile | SettingsPage.EditChatFolder + | SettingsPage.Profile + | SettingsPage.ChatFolders + | SettingsPage.EditChatFolder >; } >; diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index e9e024aa1c..a34feaa929 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -19,6 +19,7 @@ export enum ToastType { CannotOpenGiftBadgeIncoming = 'CannotOpenGiftBadgeIncoming', CannotOpenGiftBadgeOutgoing = 'CannotOpenGiftBadgeOutgoing', CannotStartGroupCall = 'CannotStartGroupCall', + ChatFolderCreated = 'ChatFolderCreated', ConversationArchived = 'ConversationArchived', ConversationMarkedUnread = 'ConversationMarkedUnread', ConversationRemoved = 'ConversationRemoved', @@ -117,6 +118,10 @@ export type AnyToast = | { toastType: ToastType.CannotStartGroupCall } | { toastType: ToastType.CaptchaFailed } | { toastType: ToastType.CaptchaSolved } + | { + toastType: ToastType.ChatFolderCreated; + parameters: { chatFolderName: string }; + } | { toastType: ToastType.ConversationArchived; parameters: { conversationId: string; wasPinned: boolean }; diff --git a/ts/util/countMutedStats.ts b/ts/util/countMutedStats.ts index 75e5024349..ccc5b78aa1 100644 --- a/ts/util/countMutedStats.ts +++ b/ts/util/countMutedStats.ts @@ -4,9 +4,9 @@ import type { ConversationType } from '../state/ducks/conversations.js'; import { isConversationInChatFolder, - type ChatFolder, type ChatFolderId, } from '../types/ChatFolder.js'; +import { CurrentChatFolders } from '../types/CurrentChatFolders.js'; import { isConversationMuted } from './isConversationMuted.js'; type MutableMutedStats = { @@ -30,10 +30,12 @@ export type ConversationPropsForMutedStats = Readonly< >; export function countAllChatFoldersMutedStats( - sortedChatFolders: ReadonlyArray, + currentChatFolders: CurrentChatFolders, conversations: ReadonlyArray ): AllChatFoldersMutedStats { const results = new Map(); + const sortedChatFolders = + CurrentChatFolders.toSortedArray(currentChatFolders); for (const conversation of conversations) { const isMuted = isConversationMuted(conversation); diff --git a/ts/util/countUnreadStats.ts b/ts/util/countUnreadStats.ts index 62dbdd6369..7872931574 100644 --- a/ts/util/countUnreadStats.ts +++ b/ts/util/countUnreadStats.ts @@ -3,7 +3,8 @@ import type { ConversationType } from '../state/ducks/conversations.js'; import { isConversationInChatFolder } from '../types/ChatFolder.js'; -import type { ChatFolder, ChatFolderId } from '../types/ChatFolder.js'; +import type { ChatFolderId } from '../types/ChatFolder.js'; +import { CurrentChatFolders } from '../types/CurrentChatFolders.js'; import { isConversationMuted } from './isConversationMuted.js'; type MutableUnreadStats = { @@ -150,11 +151,13 @@ export function countAllConversationsUnreadStats( } export function countAllChatFoldersUnreadStats( - sortedChatFolders: ReadonlyArray, + currentChatFolders: CurrentChatFolders, conversations: ReadonlyArray, options: UnreadStatsOptions ): AllChatFoldersUnreadStats { const results = new Map(); + const sortedChatFolders = + CurrentChatFolders.toSortedArray(currentChatFolders); for (const conversation of conversations) { // skip if we shouldn't count it diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 980497ed05..f10dfad89d 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2092,6 +2092,13 @@ "reasonCategory": "usageTrusted", "updated": "2025-09-24T17:08:10.620Z" }, + { + "rule": "React-useRef", + "path": "ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx", + "line": " const inputRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-10-09T23:03:57.441Z" + }, { "rule": "React-useRef", "path": "ts/components/preferences/donations/DonateInputAmount.tsx",