From ec7d07269d72fd1b2a250727152555f42a61041a Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:34:24 -0700 Subject: [PATCH] Init Chat Folders UI --- .eslintrc.js | 18 + _locales/en/messages.json | 36 ++ stylesheets/components/Preferences.scss | 4 + stylesheets/tailwind-config.css | 6 + ts/ConversationController.ts | 21 +- ts/axo/AriaClickable.stories.tsx | 8 +- ts/axo/AriaClickable.tsx | 3 +- ts/axo/AxoBadge.stories.tsx | 57 +++ ts/axo/AxoBadge.tsx | 125 ++++++ ts/axo/AxoButton.stories.tsx | 24 +- ts/axo/AxoButton.tsx | 83 ++-- ts/axo/AxoCheckbox.stories.tsx | 2 +- ts/axo/AxoCheckbox.tsx | 79 ++-- ts/axo/AxoContextMenu.tsx | 11 +- ts/axo/AxoDropdownMenu.stories.tsx | 4 +- ts/axo/AxoDropdownMenu.tsx | 9 +- ts/axo/AxoSegmentedControl.stories.tsx | 120 +++++ ts/axo/AxoSegmentedControl.tsx | 130 ++++++ ts/axo/AxoSelect.stories.tsx | 41 +- ts/axo/AxoSelect.tsx | 168 ++++++- ts/axo/AxoSwitch.stories.tsx | 2 +- ts/axo/AxoSwitch.tsx | 131 +++--- ts/axo/AxoSymbol.tsx | 4 +- ts/axo/_internal/AxoBaseMenu.tsx | 5 +- ts/axo/_internal/AxoBaseSegmentedControl.tsx | 269 +++++++++++ ts/axo/_internal/assert.tsx | 6 + ts/components/ChatsTab.stories.tsx | 2 +- ts/components/ConversationList.stories.tsx | 24 +- ts/components/ConversationList.tsx | 9 + .../DeleteMessagesConfirmationDialog.tsx | 53 +++ ts/components/DisappearingTimerSelect.tsx | 2 +- ts/components/LeftPane.stories.tsx | 36 +- ts/components/LeftPane.tsx | 11 + ts/components/NavSidebar.tsx | 142 +++--- ts/components/NavTabs.stories.tsx | 2 +- ts/components/NavTabs.tsx | 18 +- ts/components/Preferences.stories.tsx | 152 ++++--- ts/components/Preferences.tsx | 205 +++++---- ts/components/PreferencesBackups.tsx | 44 +- ts/components/PreferencesDonations.tsx | 61 ++- ts/components/PreferencesLocalBackups.tsx | 21 +- .../conversation/ConversationHeader.tsx | 49 +- .../poll-message/PollMessageContents.tsx | 4 +- .../BaseConversationListItem.tsx | 40 +- .../conversationList/ConversationListItem.tsx | 8 + .../leftPane/LeftPaneChatFolders.tsx | 390 ++++++++++++++++ ...eftPaneConversationListItemContextMenu.tsx | 275 ++++++++++++ ts/components/leftPane/LeftPaneHelper.tsx | 1 + .../leftPane/LeftPaneInboxHelper.tsx | 8 + .../chatFolders/DeleteChatFolderDialog.tsx | 36 ++ .../PreferencesChatFoldersPage.tsx | 418 +++++++++++------ .../PreferencesEditChatFoldersPage.tsx | 135 +++--- ts/hooks/useNavBlocker.ts | 96 ++++ ts/model-types.d.ts | 3 +- ts/services/BeforeNavigate.ts | 12 +- ts/services/storage.ts | 22 +- ts/sql/Interface.ts | 1 + ts/sql/Server.ts | 6 +- ts/sql/server/chatFolders.ts | 109 +++-- ts/state/ducks/chatFolders.ts | 145 ++++-- ts/state/ducks/conversations.ts | 78 ++++ ts/state/ducks/search.ts | 17 +- ts/state/getInitialState.ts | 3 +- ts/state/roots/createApp.tsx | 13 +- ts/state/selectors/chatFolders.ts | 39 +- ts/state/selectors/conversations.ts | 138 +++++- ts/state/selectors/nav.ts | 7 +- ts/state/smart/LeftPane.tsx | 15 + ts/state/smart/LeftPaneChatFolders.tsx | 76 ++++ ...eftPaneConversationListItemContextMenu.tsx | 71 +++ ts/state/smart/Preferences.tsx | 37 +- ts/state/smart/PreferencesChatFoldersPage.tsx | 9 +- ts/state/smart/PreferencesDonations.tsx | 14 +- .../smart/PreferencesEditChatFolderPage.tsx | 12 +- ts/state/types.ts | 4 + .../sql/notificationProfiles_test.ts | 6 +- ts/test-mock/storage/chat_folder_test.ts | 4 +- ts/test-mock/storage/fixtures.ts | 17 + .../state/selectors/conversations_test.ts | 39 +- ts/test-node/util/countUnreadStats_test.ts | 423 ++++++++---------- ts/types/ChatFolder.ts | 146 +++++- ts/types/Nav.ts | 26 +- ts/util/countMutedStats.ts | 60 +++ ts/util/countUnreadStats.ts | 155 +++++-- ts/util/filterAndSortConversations.ts | 6 +- ts/util/getMuteOptions.ts | 40 +- ts/util/lint/exceptions.json | 21 + tsconfig.json | 6 +- 88 files changed, 4082 insertions(+), 1306 deletions(-) create mode 100644 ts/axo/AxoBadge.stories.tsx create mode 100644 ts/axo/AxoBadge.tsx create mode 100644 ts/axo/AxoSegmentedControl.stories.tsx create mode 100644 ts/axo/AxoSegmentedControl.tsx create mode 100644 ts/axo/_internal/AxoBaseSegmentedControl.tsx create mode 100644 ts/components/DeleteMessagesConfirmationDialog.tsx create mode 100644 ts/components/leftPane/LeftPaneChatFolders.tsx create mode 100644 ts/components/leftPane/LeftPaneConversationListItemContextMenu.tsx create mode 100644 ts/components/preferences/chatFolders/DeleteChatFolderDialog.tsx create mode 100644 ts/hooks/useNavBlocker.ts create mode 100644 ts/state/smart/LeftPaneChatFolders.tsx create mode 100644 ts/state/smart/LeftPaneConversationListItemContextMenu.tsx create mode 100644 ts/util/countMutedStats.ts diff --git a/.eslintrc.js b/.eslintrc.js index cec6acf3a6..d07da83d00 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -436,6 +436,24 @@ module.exports = { ], }, }, + { + files: ['ts/axo/**/*.tsx'], + rules: { + '@typescript-eslint/no-namespace': 'off', + '@typescript-eslint/no-redeclare': [ + 'error', + { + ignoreDeclarationMerge: true, + }, + ], + '@typescript-eslint/explicit-module-boundary-types': [ + 'error', + { + allowHigherOrderFunctions: false, + }, + ], + }, + }, ], rules: { diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 32caf65d2b..78f54ca34c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -379,6 +379,38 @@ "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: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)" + }, + "icu:LeftPaneChatFolders__ItemLabel--All": { + "messageformat": "All chats", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats" + }, + "icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount": { + "messageformat": "{maxCount, number}+", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Badge Count > When over the max count (Example: 1000 or more would be 999+)" + }, + "icu:LeftPaneChatFolders__Item__ContextMenu__MarkAllRead": { + "messageformat": "Mark all read", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mark all unread chats in chat folder as read" + }, + "icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications": { + "messageformat": "Mute notifications", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications" + }, + "icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll": { + "messageformat": "Unmute all", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications > Sub-Menu > Unmute all" + }, + "icu:LeftPaneChatFolders__Item__ContextMenu__UnmuteAll": { + "messageformat": "Unmute all", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Unmute all chats in chat folder" + }, + "icu:LeftPaneChatFolders__Item__ContextMenu__EditFolder": { + "messageformat": "Edit folder", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Open settings for current chat folder" + }, "icu:CountryCodeSelect__placeholder": { "messageformat": "Country code", "description": "Placeholder displayed as default value of country code select element" @@ -447,6 +479,10 @@ "messageformat": "Mark as unread", "description": "Shown in menu for conversation, and marks conversation as unread" }, + "icu:markRead": { + "messageformat": "Mark read", + "description": "Shown in menu for conversation, and marks conversation read" + }, "icu:ConversationHeader__menu__selectMessages": { "messageformat": "Select messages", "description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation" diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index 2974064a1b..1e05124145 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -1374,6 +1374,10 @@ $secondary-text-color: light-dark( padding-block: 8px; padding-inline: 24px; border-radius: 1px; + + &[data-dragging='true'] { + opacity: 50%; + } } .Preferences__ChatFolders__ChatSelection__ItemAvatar { diff --git a/stylesheets/tailwind-config.css b/stylesheets/tailwind-config.css index 649d25b8cd..061f88791a 100644 --- a/stylesheets/tailwind-config.css +++ b/stylesheets/tailwind-config.css @@ -410,3 +410,9 @@ } } } + +@property --axo-select-trigger-mask-start { + syntax: ''; + inherits: false; + initial-value: transparent; +} diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index f046b6beea..971503b61a 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -368,6 +368,8 @@ export class ConversationController { // because `conversation.format()` can return cached props by the // time this runs return { + id: conversation.get('id'), + type: conversation.get('type') === 'private' ? 'direct' : 'group', activeAt: conversation.get('active_at') ?? undefined, isArchived: conversation.get('isArchived'), markedUnread: conversation.get('markedUnread'), @@ -383,15 +385,16 @@ export class ConversationController { drop(window.storage.put('unreadCount', unreadStats.unreadCount)); if (unreadStats.unreadCount > 0) { - window.IPC.setBadge(unreadStats.unreadCount); - window.IPC.updateTrayIcon(unreadStats.unreadCount); - window.document.title = `${window.getTitle()} (${ - unreadStats.unreadCount - })`; - } else if (unreadStats.markedUnread) { - window.IPC.setBadge('marked-unread'); - window.IPC.updateTrayIcon(1); - window.document.title = `${window.getTitle()} (1)`; + const total = + unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount; + window.IPC.setBadge(total); + window.IPC.updateTrayIcon(total); + window.document.title = `${window.getTitle()} (${total})`; + } else if (unreadStats.readChatsMarkedUnreadCount > 0) { + const total = unreadStats.readChatsMarkedUnreadCount; + window.IPC.setBadge(total); + window.IPC.updateTrayIcon(total); + window.document.title = `${window.getTitle()} (${total})`; } else { window.IPC.setBadge(0); window.IPC.updateTrayIcon(0); diff --git a/ts/axo/AriaClickable.stories.tsx b/ts/axo/AriaClickable.stories.tsx index 99ce148f53..5417058dbb 100644 --- a/ts/axo/AriaClickable.stories.tsx +++ b/ts/axo/AriaClickable.stories.tsx @@ -78,9 +78,13 @@ function CardButton(props: { }) { return ( - + {props.children} - + ); } diff --git a/ts/axo/AriaClickable.tsx b/ts/axo/AriaClickable.tsx index 984dba87c0..6a89c212df 100644 --- a/ts/axo/AriaClickable.tsx +++ b/ts/axo/AriaClickable.tsx @@ -27,7 +27,7 @@ const Namespace = 'AriaClickable'; * *

* - * Delete + * Delete * * * Edit @@ -36,7 +36,6 @@ const Namespace = 'AriaClickable'; * ); * ``` */ -// eslint-disable-next-line @typescript-eslint/no-namespace export namespace AriaClickable { type TriggerState = Readonly<{ hovered: boolean; diff --git a/ts/axo/AxoBadge.stories.tsx b/ts/axo/AxoBadge.stories.tsx new file mode 100644 index 0000000000..7977928ad5 --- /dev/null +++ b/ts/axo/AxoBadge.stories.tsx @@ -0,0 +1,57 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { Meta } from '@storybook/react'; +import React from 'react'; +import { ExperimentalAxoBadge } from './AxoBadge.js'; +import { tw } from './tw.js'; + +export default { + title: 'Axo/AriaBadge (Experimental)', +} satisfies Meta; + +export function All(): JSX.Element { + const values: ReadonlyArray = [ + -1, + 0, + 1, + 10, + 123, + 1234, + 12345, + 'mention', + 'unread', + ]; + + return ( + + + + {values.map(value => { + return ; + })} + + + {ExperimentalAxoBadge._getAllBadgeSizes().map(size => { + return ( + + + {values.map(value => { + return ( + + ); + })} + + ); + })} + +
size{value}
{size} + +
+ ); +} diff --git a/ts/axo/AxoBadge.tsx b/ts/axo/AxoBadge.tsx new file mode 100644 index 0000000000..ede14876bf --- /dev/null +++ b/ts/axo/AxoBadge.tsx @@ -0,0 +1,125 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { FC } from 'react'; +import React, { memo, useMemo } from 'react'; +import { AxoSymbol } from './AxoSymbol.js'; +import type { TailwindStyles } from './tw.js'; +import { tw } from './tw.js'; +import { unreachable } from './_internal/assert.js'; + +const Namespace = 'AxoBadge'; + +/** + * @example Anatomy + * ```tsx + * + * + * + * + * + * + * + * + * + * ```` + */ +export namespace ExperimentalAxoBadge { + export type BadgeSize = 'sm' | 'md' | 'lg'; + export type BadgeValue = number | 'mention' | 'unread'; + + const baseStyles = tw( + 'flex size-fit items-center justify-center-safe overflow-clip', + 'rounded-full font-semibold', + 'bg-color-fill-primary text-label-primary-on-color', + 'select-none' + ); + + type BadgeConfig = Readonly<{ + rootStyles: TailwindStyles; + countStyles: TailwindStyles; + }>; + + const BadgeSizes: Record = { + sm: { + rootStyles: tw(baseStyles, 'min-h-3.5 min-w-3.5 text-[8px] leading-3.5'), + countStyles: tw('px-[3px]'), + }, + md: { + rootStyles: tw(baseStyles, 'min-h-4 min-w-4 text-[11px] leading-4'), + countStyles: tw('px-[4px]'), + }, + lg: { + rootStyles: tw(baseStyles, 'min-h-4.5 min-w-4.5 text-[11px] leading-4.5'), + countStyles: tw('px-[5px]'), + }, + }; + + export function _getAllBadgeSizes(): ReadonlyArray { + return Object.keys(BadgeSizes) as Array; + } + + let cachedNumberFormat: Intl.NumberFormat; + + // eslint-disable-next-line no-inner-declarations + function formatBadgeCount( + value: number, + max: number, + maxDisplay: string + ): string { + if (value > max) { + return maxDisplay; + } + cachedNumberFormat ??= new Intl.NumberFormat(); + return cachedNumberFormat.format(value); + } + + /** + * Component: + * -------------------------- + */ + + export type RootProps = Readonly<{ + size: BadgeSize; + value: BadgeValue; + max: number; + maxDisplay: string; + 'aria-label': string | null; + }>; + + export const Root: FC = memo(props => { + const { value, max, maxDisplay } = props; + const config = BadgeSizes[props.size]; + + const children = useMemo(() => { + if (value === 'unread') { + return null; + } + if (value === 'mention') { + return ( + + + + ); + } + if (typeof value === 'number') { + return ( + + {formatBadgeCount(value, max, maxDisplay)} + + ); + } + unreachable(value); + }, [value, max, maxDisplay, config]); + + return ( + + {children} + + ); + }); + + Root.displayName = `${Namespace}.Root`; +} diff --git a/ts/axo/AxoButton.stories.tsx b/ts/axo/AxoButton.stories.tsx index 8b26680e91..a201b3d3c0 100644 --- a/ts/axo/AxoButton.stories.tsx +++ b/ts/axo/AxoButton.stories.tsx @@ -26,33 +26,33 @@ export function Basic(): JSX.Element { {variants.map(variant => { return (
- {variant} - + - Disabled - + - Icon - + - Disabled - + - Arrow - + - Disabled - +
); })} diff --git a/ts/axo/AxoButton.tsx b/ts/axo/AxoButton.tsx index 69ea32068b..1905acd520 100644 --- a/ts/axo/AxoButton.tsx +++ b/ts/axo/AxoButton.tsx @@ -139,15 +139,6 @@ type BaseButtonAttrs = Omit< type AxoButtonVariant = keyof typeof AxoButtonVariants; type AxoButtonSize = keyof typeof AxoButtonSizes; -type AxoButtonProps = BaseButtonAttrs & - Readonly<{ - variant: AxoButtonVariant; - size: AxoButtonSize; - symbol?: AxoSymbol.InlineGlyphName; - arrow?: boolean; - children: ReactNode; - }>; - export function _getAllAxoButtonVariants(): ReadonlyArray { return Object.keys(AxoButtonVariants) as Array; } @@ -156,41 +147,47 @@ export function _getAllAxoButtonSizes(): ReadonlyArray { return Object.keys(AxoButtonSizes) as Array; } -// eslint-disable-next-line import/export -export const AxoButton: FC = memo( - forwardRef((props, ref: ForwardedRef) => { - const { variant, size, symbol, arrow, children, ...rest } = props; - const variantStyles = assert( - AxoButtonVariants[variant], - `${Namespace}: Invalid variant ${variant}` - ); - const sizeStyles = assert( - AxoButtonSizes[size], - `${Namespace}: Invalid size ${size}` - ); - return ( - - ); - }) -); - -AxoButton.displayName = `${Namespace}`; - -// eslint-disable-next-line max-len -// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export export namespace AxoButton { export type Variant = AxoButtonVariant; export type Size = AxoButtonSize; - export type Props = AxoButtonProps; + export type RootProps = BaseButtonAttrs & + Readonly<{ + variant: AxoButtonVariant; + size: AxoButtonSize; + symbol?: AxoSymbol.InlineGlyphName; + arrow?: boolean; + children: ReactNode; + }>; + + export const Root: FC = memo( + forwardRef((props, ref: ForwardedRef) => { + const { variant, size, symbol, arrow, children, ...rest } = props; + const variantStyles = assert( + AxoButtonVariants[variant], + `${Namespace}: Invalid variant ${variant}` + ); + const sizeStyles = assert( + AxoButtonSizes[size], + `${Namespace}: Invalid size ${size}` + ); + return ( + + ); + }) + ); + + Root.displayName = `${Namespace}.Root`; } diff --git a/ts/axo/AxoCheckbox.stories.tsx b/ts/axo/AxoCheckbox.stories.tsx index a5685c7b9c..61caaa8133 100644 --- a/ts/axo/AxoCheckbox.stories.tsx +++ b/ts/axo/AxoCheckbox.stories.tsx @@ -17,7 +17,7 @@ function Template(props: { const [checked, setChecked] = useState(props.defaultChecked); return (