Chat folders bugfixes & ui updates

This commit is contained in:
Jamie
2025-10-10 15:34:28 -07:00
committed by GitHub
parent bfb390e644
commit 1f3fa9bd59
56 changed files with 1703 additions and 777 deletions
+40 -4
View File
@@ -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"
-15
View File
@@ -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;
+31 -13
View File
@@ -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;
}
}
}
-4
View File
@@ -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:
+140 -2
View File
@@ -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 (
<div className={tw('flex h-96 w-full items-center justify-center')}>
{props.children}
</div>
);
}
export function Basic(): JSX.Element {
const [showBookmarks, setShowBookmarks] = useState(true);
const [showFullUrls, setShowFullUrls] = useState(false);
const [selectedPerson, setSelectedPerson] = useState('jamie');
return (
<div className={tw('flex h-96 w-full items-center justify-center')}>
<Container>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoButton.Root variant="secondary" size="medium">
@@ -96,6 +105,135 @@ export function Basic(): JSX.Element {
</AxoDropdownMenu.RadioGroup>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
</div>
</Container>
);
}
export function WithHeader(): JSX.Element {
return (
<Container>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoButton.Root variant="secondary" size="medium">
Open Dropdown Menu
</AxoButton.Root>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Header
label="Notification profile"
description="On"
/>
<AxoDropdownMenu.Item onSelect={action('onSleep')}>
Sleep
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item onSelect={action('onWork')}>
Work
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Sub>
<AxoDropdownMenu.SubTrigger>Sub-menu</AxoDropdownMenu.SubTrigger>
<AxoDropdownMenu.SubContent>
<AxoDropdownMenu.Header
label="Notification profile"
description="On"
/>
<AxoDropdownMenu.Item onSelect={action('onSleep')}>
Sleep
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item onSelect={action('onWork')}>
Work
</AxoDropdownMenu.Item>
</AxoDropdownMenu.SubContent>
</AxoDropdownMenu.Sub>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
</Container>
);
}
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 = (
<>
<AxoDropdownMenu.Item onSelect={action('onSelect')}>
<strong>Item:</strong> {LONG_TEXT}
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item
symbol="megaphone"
keyboardShortcut="⌘["
onSelect={action('onSelect')}
>
<strong>Item:</strong> {LONG_TEXT}
</AxoDropdownMenu.Item>
</>
);
const checkboxItems = (
<>
<AxoDropdownMenu.CheckboxItem
checked={false}
onCheckedChange={action('onCheckedChange')}
>
<strong>CheckboxItem:</strong> {LONG_TEXT}
</AxoDropdownMenu.CheckboxItem>
<AxoDropdownMenu.CheckboxItem
symbol="megaphone"
keyboardShortcut="⌘["
checked={false}
onCheckedChange={action('onCheckedChange')}
>
<strong>CheckboxItem:</strong> {LONG_TEXT}
</AxoDropdownMenu.CheckboxItem>
</>
);
const content = (
<>
<AxoDropdownMenu.Header label={LONG_TEXT} description={LONG_TEXT} />
{items}
{checkboxItems}
<AxoDropdownMenu.RadioGroup
value="value"
onValueChange={action('onValueChange')}
>
<AxoDropdownMenu.RadioItem value="value">
<strong>RadioItem:</strong> {LONG_TEXT}
</AxoDropdownMenu.RadioItem>
<AxoDropdownMenu.RadioItem
symbol="megaphone"
keyboardShortcut="⌘["
value="value"
>
<strong>RadioItem:</strong> {LONG_TEXT}
</AxoDropdownMenu.RadioItem>
</AxoDropdownMenu.RadioGroup>
<AxoDropdownMenu.Separator />
<AxoDropdownMenu.Group>
<AxoDropdownMenu.Label>
<strong>Label:</strong> {LONG_TEXT}
</AxoDropdownMenu.Label>
{items}
{checkboxItems}
</AxoDropdownMenu.Group>
</>
);
return (
<Container>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoButton.Root variant="secondary" size="medium">
Open Dropdown Menu
</AxoButton.Root>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
{content}
<AxoDropdownMenu.Sub>
<AxoDropdownMenu.SubTrigger>{LONG_TEXT}</AxoDropdownMenu.SubTrigger>
<AxoDropdownMenu.SubContent>{content}</AxoDropdownMenu.SubContent>
</AxoDropdownMenu.Sub>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
</Container>
);
}
+142 -33
View File
@@ -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<RootProps> = memo(props => {
return <DropdownMenu.Root {...props}>{props.children}</DropdownMenu.Root>;
return (
<DropdownMenu.Root open={props.open} onOpenChange={props.onOpenChange}>
{props.children}
</DropdownMenu.Root>
);
});
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<ContentProps> = memo(props => {
const { context, labelId, descriptionId } = useCreateAriaLabellingContext();
return (
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={4}
align="start"
collisionPadding={6}
className={AxoBaseMenu.menuContentStyles}
>
{props.children}
</DropdownMenu.Content>
</DropdownMenu.Portal>
<AriaLabellingProvider value={context}>
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={4}
align="start"
collisionPadding={6}
className={AxoBaseMenu.menuContentStyles}
aria-labelledby={labelId}
aria-describedby={descriptionId}
>
{props.children}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</AriaLabellingProvider>
);
});
Content.displayName = `${Namespace}.Content`;
/**
* Component: <AxoDropdownMenu.CustomItem>
* -------------------------------------
*/
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<CustomItemProps> = memo(props => {
return (
<DropdownMenu.Item
disabled={props.disabled}
textValue={props.textValue}
onSelect={props.onSelect}
className={AxoBaseMenu.menuItemStyles}
>
{props.leading && (
<AxoBaseMenu.ItemLeadingSlot>
{props.leading}
</AxoBaseMenu.ItemLeadingSlot>
)}
<AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText>{props.text}</AxoBaseMenu.ItemText>
{props.suffix}
</AxoBaseMenu.ItemContentSlot>
</DropdownMenu.Item>
);
});
CustomItem.displayName = `${Namespace}.CustomItem`;
/**
* Component: <AxoDropdownMenu.Item>
* ---------------------------------
*/
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 {
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
</AxoBaseMenu.ItemLeadingSlot>
)}
{props.customIcon && (
<AxoBaseMenu.ItemLeadingSlot>
{props.customIcon}
</AxoBaseMenu.ItemLeadingSlot>
)}
<AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
{props.keyboardShortcut && (
@@ -212,6 +259,49 @@ export namespace AxoDropdownMenu {
Label.displayName = `${Namespace}.Label`;
/**
* Component: <AxoDropdownMenu.Header>
* -----------------------------------
*/
export type HeaderProps = Readonly<{
label: ReactNode;
description?: ReactNode;
}>;
export const Header: FC<HeaderProps> = memo(props => {
const labelId = useId();
const descriptionId = useId();
const { labelRef, descriptionRef } = useAriaLabellingContext(
`<${Namespace}.Header>`,
`<${Namespace}.Content/SubContent>`
);
return (
<span aria-hidden="true" className={AxoBaseMenu.menuHeaderStyles}>
<span
ref={labelRef}
id={labelId}
className={AxoBaseMenu.menuHeaderLabelStyles}
>
{props.label}
</span>
{props.description && (
<span
ref={descriptionRef}
id={descriptionId}
className={AxoBaseMenu.menuHeaderDescriptionStyles}
>
{props.description}
</span>
)}
</span>
);
});
Header.displayName = `${Namespace}.Header`;
/**
* Component: <AxoDropdownMenu.CheckboxItem>
* -----------------------------------------
@@ -343,6 +433,20 @@ export namespace AxoDropdownMenu {
Separator.displayName = `${Namespace}.Separator`;
/**
* Component: <AxoDropdownMenu.ContentSeparator>
*/
export const ContentSeparator: FC<SeparatorProps> = memo(() => {
return (
<DropdownMenu.Separator
className={AxoBaseMenu.menuContentSeparatorStyles}
/>
);
});
ContentSeparator.displayName = `${Namespace}.ContentSeparator`;
/**
* Component: <AxoDropdownMenu.Sub>
* -------------------------------
@@ -402,14 +506,19 @@ export namespace AxoDropdownMenu {
* inside {@link AxoDropdownMenu.Sub}.
*/
export const SubContent: FC<SubContentProps> = memo(props => {
const { context, labelId, descriptionId } = useCreateAriaLabellingContext();
return (
<DropdownMenu.SubContent
alignOffset={-6}
collisionPadding={6}
className={AxoBaseMenu.menuSubContentStyles}
>
{props.children}
</DropdownMenu.SubContent>
<AriaLabellingProvider value={context}>
<DropdownMenu.SubContent
alignOffset={-6}
collisionPadding={6}
className={AxoBaseMenu.menuSubContentStyles}
aria-labelledby={labelId}
aria-describedby={descriptionId}
>
{props.children}
</DropdownMenu.SubContent>
</AriaLabellingProvider>
);
});
+52
View File
@@ -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<HTMLElement>;
descriptionRef: RefCallback<HTMLElement>;
}>;
const AriaLabellingContext = createContext<AriaLabellingContextType | null>(
null
);
export type CreateAriaLabellingContextResult = Readonly<{
context: AriaLabellingContextType;
labelId: string | undefined;
descriptionId: string | undefined;
}>;
export function useCreateAriaLabellingContext(): CreateAriaLabellingContextResult {
const [labelId, setLabelId] = useState<string | undefined>();
const [descriptionId, setDescriptionId] = useState<string | undefined>();
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}`
);
}
+23 -7
View File
@@ -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');
// <Group/RadioGroup>
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
+17 -28
View File
@@ -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);
}}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'CallsTab__ClearCallHistoryIcon',
label: i18n('icu:CallsTab__ClearCallHistoryLabel'),
onClick: handleOpenClearCallHistoryDialog,
},
]}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
portalToRoot
>
{({ onClick, onKeyDown, ref }) => {
return (
<NavSidebarActionButton
ref={ref}
onClick={onClick}
onKeyDown={onKeyDown}
icon={<span className="CallsTab__MoreActionsIcon" />}
label={i18n('icu:CallsTab__MoreActionsLabel')}
/>
);
}}
</ContextMenu>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<NavSidebarActionButton
icon={<span className="CallsTab__MoreActionsIcon" />}
label={i18n('icu:CallsTab__MoreActionsLabel')}
/>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item
symbol="trash"
onSelect={handleOpenClearCallHistoryDialog}
>
{i18n('icu:CallsTab__ClearCallHistoryLabel')}
</AxoDropdownMenu.Item>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
</>
)}
</>
+23 -3
View File
@@ -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 }) => (
<div className="fakeNotificationProfilesMenu">{trigger}</div>
renderNotificationProfilesMenu: () => (
<div className="fakeNotificationProfilesMenu" />
),
renderRelinkDialog: props => (
<DialogRelink
@@ -319,7 +323,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
<LeftPaneChatFolders
i18n={i18n}
navSidebarWidthBreakpoint={null}
sortedChatFolders={[]}
currentChatFolders={CurrentChatFolders.createEmpty()}
allChatFoldersUnreadStats={new Map()}
allChatFoldersMutedStats={new Map()}
selectedChatFolder={null}
@@ -347,6 +351,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
{props.children}
</LeftPaneConversationListItemContextMenu>
),
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,
},
})}
/>
+149 -148
View File
@@ -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<unknown>;
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 (
<NavSidebar
title={i18n('icu:LeftPane--chats')}
@@ -775,76 +771,73 @@ export function LeftPane({
renderToastManager={renderToastManager}
actions={
<>
{isNotificationProfileActive &&
renderNotificationProfilesMenu({
isOpen: isNotificationProfilesMenuOpen,
onClose: () => {
setIsNotificationProfilesMenuOpen(false);
},
trigger: (
{isNotificationProfileActive && (
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<button
className={tw(
'rounded-sm focus:outline-none focus-visible:shadow-legacy-outline'
)}
type="button"
onClick={() => setIsNotificationProfilesMenuOpen(true)}
onKeyUp={(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.code === 'Enter' || event.code === 'Space') {
setIsNotificationProfilesMenuOpen(true);
}
}}
className={tw(
'rounded-full outline-0 outline-border-focused focused:outline-[2.5px]'
)}
>
<ProfileAvatar i18n={i18n} size="medium-small" />
</button>
),
})}
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
{renderNotificationProfilesMenu()}
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
)}
<NavSidebarActionButton
label={i18n('icu:newConversation')}
icon={<span className="module-left-pane__startComposingIcon" />}
onClick={startComposing}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
label: i18n('icu:avatarMenuViewArchive'),
onClick: showArchivedConversations,
},
{
label: i18n('icu:NotificationProfileMenuItem'),
onClick: () => setIsNotificationProfilesSubMenuOpen(true),
},
]}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
portalToRoot
>
{({ onClick, onKeyDown, ref }) =>
renderNotificationProfilesMenu({
isOpen: isNotificationProfilesSubMenuOpen,
onClose: () => {
setIsNotificationProfilesSubMenuOpen(false);
},
trigger: (
<NavSidebarActionButton
ref={ref}
onClick={onClick}
onKeyDown={onKeyDown}
icon={
<span className="module-left-pane__moreActionsIcon" />
}
label="More Actions"
/>
),
})
}
</ContextMenu>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<NavSidebarActionButton
icon={<span className="module-left-pane__moreActionsIcon" />}
label="More Actions"
/>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item
symbol="archive"
onSelect={showArchivedConversations}
>
{i18n('icu:avatarMenuViewArchive')}
</AxoDropdownMenu.Item>
{isChatFoldersEnabled() && !hasAnyCurrentCustomChatFolders && (
<AxoDropdownMenu.Item
symbol="folder"
onSelect={onChatFoldersOpenSettings}
>
{i18n('icu:LeftPane__MoreActionsMenu__AddChatFolder')}
</AxoDropdownMenu.Item>
)}
{isChatFoldersEnabled() && hasAnyCurrentCustomChatFolders && (
<AxoDropdownMenu.Item
// TODO: This should be the "folder-settings" symbol
// once it is added to the font
symbol="folder"
onSelect={onChatFoldersOpenSettings}
>
{i18n('icu:LeftPane__MoreActionsMenu__FolderSettings')}
</AxoDropdownMenu.Item>
)}
<AxoDropdownMenu.Sub>
<AxoDropdownMenu.SubTrigger symbol="moon">
{i18n('icu:NotificationProfileMenuItem')}
</AxoDropdownMenu.SubTrigger>
<AxoDropdownMenu.SubContent>
{renderNotificationProfilesMenu()}
</AxoDropdownMenu.SubContent>
</AxoDropdownMenu.Sub>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
</>
}
>
{backgroundNode}
<nav
className={classNames(
'module-left-pane',
@@ -907,74 +900,82 @@ export function LeftPane({
) : null}
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
<div className="module-left-pane__list--measure" ref={measureRef}>
<div className="module-left-pane__list--wrapper">
<div
aria-live="polite"
className="module-left-pane__list"
data-supertab
key={listKey}
role="presentation"
tabIndex={-1}
>
<ConversationList
key={modeSpecificProps.mode}
dimensions={measureSize ?? undefined}
getPreferredBadge={getPreferredBadge}
getRow={getRow}
i18n={i18n}
hasDialogPadding={hasDialogs}
onClickArchiveButton={showArchivedConversations}
onClickContactCheckbox={(
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => {
switch (disabledReason) {
case undefined:
toggleConversationInChooseMembers(conversationId);
break;
case ContactCheckboxDisabledReason.AlreadyAdded:
case ContactCheckboxDisabledReason.MaximumContactsSelected:
// These are no-ops.
break;
default:
throw missingCaseError(disabledReason);
{isEmpty &&
helper.getEmptyViewNode({
i18n,
selectedChatFolder,
changeLocation,
})}
{!isEmpty && (
<div className="module-left-pane__list--wrapper">
<div
aria-live="polite"
className="module-left-pane__list"
data-supertab
key={listKey}
role="presentation"
tabIndex={-1}
>
<ConversationList
key={modeSpecificProps.mode}
dimensions={measureSize ?? undefined}
getPreferredBadge={getPreferredBadge}
getRow={getRow}
i18n={i18n}
hasDialogPadding={hasDialogs}
onClickArchiveButton={showArchivedConversations}
onClickContactCheckbox={(
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => {
switch (disabledReason) {
case undefined:
toggleConversationInChooseMembers(conversationId);
break;
case ContactCheckboxDisabledReason.AlreadyAdded:
case ContactCheckboxDisabledReason.MaximumContactsSelected:
// These are no-ops.
break;
default:
throw missingCaseError(disabledReason);
}
}}
onClickClearFilterButton={() => {
updateFilterByUnread(false);
}}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
lookupConversationWithoutServiceId={
lookupConversationWithoutServiceId
}
}}
onClickClearFilterButton={() => {
updateFilterByUnread(false);
}}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
lookupConversationWithoutServiceId={
lookupConversationWithoutServiceId
}
showConversation={showConversation}
blockConversation={blockConversation}
onPreloadConversation={preloadConversation}
onSelectConversation={onSelectConversation}
onOutgoingAudioCallInConversation={
onOutgoingAudioCallInConversation
}
onOutgoingVideoCallInConversation={
onOutgoingVideoCallInConversation
}
removeConversation={removeConversation}
renderMessageSearchResult={renderMessageSearchResult}
renderConversationListItemContextMenu={
renderConversationListItemContextMenu
}
rowCount={helper.getRowCount()}
scrollBehavior={scrollBehavior}
scrollToRowIndex={rowIndexToScrollTo}
scrollable={isScrollable}
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
showChooseGroupMembers={showChooseGroupMembers}
showFindByUsername={showFindByUsername}
showFindByPhoneNumber={showFindByPhoneNumber}
theme={theme}
/>
showConversation={showConversation}
blockConversation={blockConversation}
onPreloadConversation={preloadConversation}
onSelectConversation={onSelectConversation}
onOutgoingAudioCallInConversation={
onOutgoingAudioCallInConversation
}
onOutgoingVideoCallInConversation={
onOutgoingVideoCallInConversation
}
removeConversation={removeConversation}
renderMessageSearchResult={renderMessageSearchResult}
renderConversationListItemContextMenu={
renderConversationListItemContextMenu
}
rowCount={helper.getRowCount()}
scrollBehavior={scrollBehavior}
scrollToRowIndex={rowIndexToScrollTo}
scrollable={isScrollable}
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
showChooseGroupMembers={showChooseGroupMembers}
showFindByUsername={showFindByUsername}
showFindByPhoneNumber={showFindByPhoneNumber}
theme={theme}
/>
</div>
</div>
</div>
)}
</div>
{footerContents && (
<div className="module-left-pane__footer">{footerContents}</div>
+8 -10
View File
@@ -1,7 +1,7 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import React, { createContext, useEffect, useState } from 'react';
import classNames from 'classnames';
import { useMove } from 'react-aria';
@@ -19,27 +19,25 @@ import type { UnreadStats } from '../util/countUnreadStats.js';
export const NavSidebarWidthBreakpointContext =
createContext<WidthBreakpoint | null>(null);
type NavSidebarActionButtonProps = {
icon: ReactNode;
label: ReactNode;
onClick: MouseEventHandler<HTMLButtonElement>;
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
};
type NavSidebarActionButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
Readonly<{
icon: ReactNode;
label: ReactNode;
}>;
export const NavSidebarActionButton = React.forwardRef<
HTMLButtonElement,
NavSidebarActionButtonProps
>(function NavSidebarActionButtonInner(
{ icon, label, onClick, onKeyDown },
{ icon, label, ...rest },
ref
): JSX.Element {
return (
<button
{...rest}
ref={ref}
type="button"
className="NavSidebar__ActionButton"
onClick={onClick}
onKeyDown={onKeyDown}
>
{icon}
<span className="NavSidebar__ActionButtonLabel">{label}</span>
@@ -15,6 +15,8 @@ import {
import { DayOfWeek } from '../types/NotificationProfile.js';
import type { NotificationProfileIdString } from '../types/NotificationProfile.js';
import { HOUR } from '../util/durations/index.js';
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.js';
import { AxoButton } from '../axo/AxoButton.js';
const { i18n } = window.SignalContext;
@@ -121,11 +123,8 @@ export default {
allProfiles: threeProfiles,
currentOverride: undefined,
i18n,
isOpen: true,
onClose: action('onClose'),
onGoToSettings: action('onGoToSettings'),
setProfileOverride: action('setProfileOverride'),
trigger: <div>This is the trigger</div>,
},
} satisfies Meta<Props>;
@@ -135,7 +134,6 @@ function createProps(args: Partial<Props>) {
allProfiles: threeProfiles,
currentOverride: undefined,
i18n,
onClose: action('onClose'),
onGoToSettings: action('onGoToSettings'),
setProfileOverride: action('setProfileOverride'),
...args,
@@ -144,7 +142,18 @@ function createProps(args: Partial<Props>) {
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<Props> = args => {
return <NotificationProfilesMenu {...args} />;
return (
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoButton.Root size="medium" variant="secondary">
Open
</AxoButton.Root>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<NotificationProfilesMenu {...args} />
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
);
};
export const Default = Template.bind({});
+54 -98
View File
@@ -13,20 +13,15 @@ import {
import { DAY, HOUR, SECOND } from '../util/durations/index.js';
import { formatTimestamp } from '../util/formatTimestamp.js';
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.js';
import { tw } from '../axo/tw.js';
import { ProfileAvatar } from './PreferencesNotificationProfiles.js';
import { AxoSymbol } from '../axo/AxoSymbol.js';
export type Props = Readonly<{
activeProfileId: NotificationProfileIdString | undefined;
allProfiles: ReadonlyArray<NotificationProfileType>;
currentOverride: NotificationProfileOverride | undefined;
i18n: LocalizerType;
isOpen: boolean;
loading: boolean;
onClose: () => void;
onGoToSettings: () => void;
trigger: React.ReactNode;
setProfileOverride: (
id: NotificationProfileIdString,
enabled: boolean,
@@ -52,11 +47,8 @@ export function NotificationProfilesMenu({
allProfiles,
currentOverride,
i18n,
isOpen,
loading,
onClose,
onGoToSettings,
trigger,
setProfileOverride,
}: Props): JSX.Element {
const enabledOverrideEndTime = currentOverride?.enabled?.endsAtMs;
@@ -111,103 +103,67 @@ export function NotificationProfilesMenu({
}, []);
return (
<AxoDropdownMenu.Root
open={isOpen}
onOpenChange={open => {
if (!open) {
onClose();
}
}}
>
<AxoDropdownMenu.Trigger>{trigger}</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<div className={tw('col-span-2 p-1.5')}>
<div className={tw('type-title-small text-label-primary')}>
{i18n('icu:NotificationProfileMenu--header')}
</div>
<div className={tw('type-caption text-label-secondary')}>
{enabledLabel}
</div>
</div>
{profilesToRender.length > 0 ? (
<AxoDropdownMenu.Separator />
) : undefined}
{profilesToRender.map((profile, index) => {
const isActive = activeProfileId && profile.id === activeProfileId;
<>
<AxoDropdownMenu.Header
label={i18n('icu:NotificationProfileMenu--header')}
description={enabledLabel}
/>
{profilesToRender.length > 0 ? <AxoDropdownMenu.Separator /> : undefined}
{profilesToRender.map((profile, index) => {
const isActive = activeProfileId && profile.id === activeProfileId;
return (
<React.Fragment key={profile.id}>
{index > 0 && (
<div
key={`${profile.id}_separator`}
role="separator"
aria-orientation="horizontal"
className={tw(
'col-span-1 col-start-2 mx-0.5 my-1 border-t-[0.5px] border-border-primary'
)}
return (
<React.Fragment key={profile.id}>
{index > 0 && <AxoDropdownMenu.ContentSeparator />}
<AxoDropdownMenu.CustomItem
key={profile.id}
leading={
<ProfileAvatar
i18n={i18n}
isActive={isActive}
profile={profile}
size="small"
/>
)}
}
text={profile.name}
onSelect={event => {
event.preventDefault();
setProfileOverride(profile.id, !isActive);
}}
/>
{isActive ? (
<AxoDropdownMenu.Item
key={profile.id}
customIcon={
<ProfileAvatar
i18n={i18n}
isActive={isActive}
profile={profile}
size="small"
/>
}
key={`${profile.id}_one-hour`}
onSelect={event => {
event.preventDefault();
setProfileOverride(profile.id, !isActive);
setProfileOverride(profile.id, true, Date.now() + HOUR);
}}
>
{profile.name}
{i18n('icu:NotificationProfileMenu--for-one-hour')}
</AxoDropdownMenu.Item>
{isActive ? (
<AxoDropdownMenu.Item
key={`${profile.id}_one-hour`}
onSelect={event => {
event.preventDefault();
setProfileOverride(profile.id, true, Date.now() + HOUR);
}}
>
{i18n('icu:NotificationProfileMenu--for-one-hour')}
</AxoDropdownMenu.Item>
) : null}
{isActive ? (
<AxoDropdownMenu.Item
key={`${profile.id}_until-time`}
onSelect={event => {
event.preventDefault();
setProfileOverride(profile.id, true, targetTime);
}}
>
{i18n('icu:NotificationProfileMenu--until-time', {
time: formatTimestamp(targetTime, {
timeStyle: 'short',
}),
})}
</AxoDropdownMenu.Item>
) : null}
</React.Fragment>
);
})}
<AxoDropdownMenu.Separator />
<AxoDropdownMenu.Item
onSelect={event => {
event.preventDefault();
onGoToSettings();
}}
customIcon={
<span className={tw('p-0.5 leading-0')}>
<AxoSymbol.Icon size={16} symbol="settings" label={null} />
</span>
}
>
{i18n('icu:NotificationProfileMenu--settings')}
</AxoDropdownMenu.Item>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
) : null}
{isActive ? (
<AxoDropdownMenu.Item
key={`${profile.id}_until-time`}
onSelect={event => {
event.preventDefault();
setProfileOverride(profile.id, true, targetTime);
}}
>
{i18n('icu:NotificationProfileMenu--until-time', {
time: formatTimestamp(targetTime, {
timeStyle: 'short',
}),
})}
</AxoDropdownMenu.Item>
) : null}
</React.Fragment>
);
})}
<AxoDropdownMenu.Separator />
<AxoDropdownMenu.Item onSelect={onGoToSettings} symbol="settings">
{i18n('icu:NotificationProfileMenu--settings')}
</AxoDropdownMenu.Item>
</>
);
}
+12 -8
View File
@@ -50,6 +50,7 @@ import type {
import type { AnyToast } from '../types/Toast.js';
import type { SmartPreferencesChatFoldersPageProps } from '../state/smart/PreferencesChatFoldersPage.js';
import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/PreferencesEditChatFolderPage.js';
import { CurrentChatFolders } from '../types/CurrentChatFolders.js';
import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.js';
import type { NotificationProfileIdString } from '../types/NotificationProfile.js';
@@ -287,9 +288,10 @@ function renderPreferencesChatFoldersPage(
return (
<PreferencesChatFoldersPage
i18n={i18n}
onBack={props.onBack}
previousLocation={props.previousLocation}
settingsPaneRef={props.settingsPaneRef}
chatFolders={[]}
changeLocation={action('changeLocation')}
currentChatFolders={CurrentChatFolders.createEmpty()}
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
onCreateChatFolder={action('onCreateChatFolder')}
onDeleteChatFolder={action('onDeletChatFolder')}
@@ -307,7 +309,7 @@ function renderPreferencesEditChatFolderPage(
theme={ThemeType.light}
previousLocation={{
tab: NavTab.Settings,
details: { page: SettingsPage.ChatFolders },
details: { page: SettingsPage.ChatFolders, previousLocation: null },
}}
settingsPaneRef={props.settingsPaneRef}
existingChatFolderId={props.existingChatFolderId}
@@ -406,11 +408,13 @@ export default {
backupSubscriptionStatus: { status: 'off' },
badge: undefined,
blockedCount: 0,
currentChatFoldersCount: 0,
customColors: {},
defaultConversationColor: DEFAULT_CONVERSATION_COLOR,
deviceName: 'Work Windows ME',
emojiSkinToneDefault: EmojiSkinTone.None,
phoneNumber: '+1 555 123-4567',
hasAnyCurrentCustomChatFolders: false,
hasAudioNotifications: true,
hasAutoConvertEmoji: true,
hasAutoDownloadUpdate: true,
@@ -651,17 +655,17 @@ Chats.args = {
};
export const ChatFolders = Template.bind({});
ChatFolders.args = {
settingsLocation: { page: SettingsPage.ChatFolders },
settingsLocation: {
page: SettingsPage.ChatFolders,
previousLocation: null,
},
};
export const EditChatFolder = Template.bind({});
EditChatFolder.args = {
settingsLocation: {
page: SettingsPage.EditChatFolder,
chatFolderId: null,
previousLocation: {
tab: NavTab.Settings,
details: { page: SettingsPage.ChatFolders },
},
previousLocation: null,
},
};
export const Calls = Template.bind({});
+46 -25
View File
@@ -15,7 +15,6 @@ import classNames from 'classnames';
import * as LocaleMatcher from '@formatjs/intl-localematcher';
import type { MutableRefObject, ReactNode } from 'react';
import type { RowType } from '@signalapp/sqlcipher';
import { Button, ButtonVariant } from './Button.js';
import { ChatColorPicker } from './ChatColorPicker.js';
import { Checkbox } from './Checkbox.js';
@@ -53,12 +52,9 @@ import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider
import { Avatar, AvatarSize } from './Avatar.js';
import { NavSidebar } from './NavSidebar.js';
import type { SettingsLocation } from '../types/Nav.js';
import { SettingsPage, ProfileEditorPage, NavTab } from '../types/Nav.js';
import { SettingsPage, ProfileEditorPage } from '../types/Nav.js';
import { tw } from '../axo/tw.js';
import { isBackupPage } from '../types/PreferencesBackupPage.js';
import { isChatFoldersEnabled } from '../types/ChatFolder.js';
import { FullWidthButton } from './PreferencesNotificationProfiles.js';
import type { EmojiSkinTone } from './fun/data/emojis.js';
import type { MediaDeviceSettings } from '../types/Calling.js';
import type { ValidationResultType as BackupValidationResultType } from '../services/backups/index.js';
@@ -91,6 +87,7 @@ import type { UnreadStats } from '../util/countUnreadStats.js';
import type { BadgeType } from '../badges/types.js';
import type { MessageCountBySchemaVersionType } from '../sql/Interface.js';
import type { MessageAttributesType } from '../model-types.js';
import { isBackupPage } from '../types/PreferencesBackupPage.js';
import type { PreferencesBackupPage } from '../types/PreferencesBackupPage.js';
import type {
PromptOSAuthReasonType,
@@ -98,8 +95,10 @@ import type {
} from '../util/os/promptOSAuthMain.js';
import type { DonationReceipt } from '../types/Donations.js';
import type { ChatFolderId } from '../types/ChatFolder.js';
import { isChatFoldersEnabled } from '../types/ChatFolder.js';
import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/PreferencesEditChatFolderPage.js';
import type { SmartPreferencesChatFoldersPageProps } from '../state/smart/PreferencesChatFoldersPage.js';
import { AxoButton } from '../axo/AxoButton.js';
import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.js';
const { isNumber, noop, partition } = lodash;
@@ -115,6 +114,7 @@ export type PropsDataType = {
backupKeyViewed: boolean;
backupLocalBackupsEnabled: boolean;
localBackupFolder: string | undefined;
currentChatFoldersCount: number;
cloudBackupStatus?: BackupStatusType;
backupSubscriptionStatus: BackupsSubscriptionType;
backupMediaDownloadStatus?: BackupMediaDownloadStatusType;
@@ -126,6 +126,7 @@ export type PropsDataType = {
defaultConversationColor: DefaultConversationColorType;
deviceName?: string;
emojiSkinToneDefault: EmojiSkinTone;
hasAnyCurrentCustomChatFolders: boolean;
hasAudioNotifications?: boolean;
hasAutoConvertEmoji: boolean;
hasAutoDownloadUpdate: boolean;
@@ -387,6 +388,7 @@ export function Preferences({
backupLocalBackupsEnabled,
badge,
blockedCount,
currentChatFoldersCount,
cloudBackupStatus,
customColors,
defaultConversationColor,
@@ -398,6 +400,7 @@ export function Preferences({
getConversationsWithCustomColor,
getMessageCountBySchemaVersion,
getMessageSampleForSchemaVersion,
hasAnyCurrentCustomChatFolders,
hasAudioNotifications,
hasAutoConvertEmoji,
hasAutoDownloadUpdate,
@@ -554,13 +557,10 @@ export function Preferences({
setSettingsLocation({
page: SettingsPage.EditChatFolder,
chatFolderId,
previousLocation: {
tab: NavTab.Settings,
details: settingsLocation,
},
previousLocation: null,
});
},
[setSettingsLocation, settingsLocation]
[setSettingsLocation]
);
function closeLanguageDialog() {
@@ -1183,22 +1183,43 @@ export function Preferences({
>
<Control
left={
<>
<div>
{i18n(
hasAnyCurrentCustomChatFolders
? i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Title--WithChatFolders'
)
: i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Title'
)}
</div>
<div className="Preferences__description">
{i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Description'
)}
</div>
</>
)
}
right={null}
onClick={() =>
setSettingsLocation({ page: SettingsPage.ChatFolders })
description={
hasAnyCurrentCustomChatFolders
? i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Description--WithChatFolders',
{ chatFoldersCount: currentChatFoldersCount }
)
: i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Description'
)
}
right={
<AxoButton.Root
size="medium"
variant="secondary"
onClick={() => {
setSettingsLocation({
page: SettingsPage.ChatFolders,
previousLocation: null,
});
}}
>
{hasAnyCurrentCustomChatFolders
? i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Button--WithChatFolders'
)
: i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Button'
)}
</AxoButton.Root>
}
/>
</SettingsRow>
@@ -1984,7 +2005,7 @@ export function Preferences({
);
} else if (settingsLocation.page === SettingsPage.ChatFolders) {
content = renderPreferencesChatFoldersPage({
onBack: () => setSettingsLocation({ page: SettingsPage.Chats }),
previousLocation: settingsLocation.previousLocation,
onOpenEditChatFoldersPage: handleOpenEditChatFoldersPage,
settingsPaneRef,
});
@@ -2130,7 +2130,7 @@ function TimePicker({
<button
className={classNames(
tw(
'ms-3 p-0.5 leading-none outline-0 focus-visible:bg-background-overlay-secondary'
'ms-3 p-0.5 leading-none outline-0 focus-visible:bg-fill-selected'
),
isDisabled ? tw('text-label-placeholder') : null
)}
+7 -5
View File
@@ -64,7 +64,7 @@ export function SettingsControl({
left: ReactNode;
onClick?: () => unknown;
right: ReactNode;
description?: boolean;
description?: ReactNode;
}): JSX.Element {
const content = (
<>
@@ -76,11 +76,13 @@ export function SettingsControl({
)}
/>
)}
<div className="Preferences__control--key">{left}</div>
<div className="Preferences__control--key">
{left}
{description ? (
<div className="Preferences__description">{description}</div>
) : undefined}
</div>
<div className="Preferences__control--value">{right}</div>
{description ? (
<div className="Preferences__control--value">{description}</div>
) : undefined}
</>
);
+17 -28
View File
@@ -23,10 +23,10 @@ import { MyStories } from './MyStories.js';
import { StoriesPane } from './StoriesPane.js';
import { NavSidebar, NavSidebarActionButton } from './NavSidebar.js';
import { StoriesAddStoryButton } from './StoriesAddStoryButton.js';
import { ContextMenu } from './ContextMenu.js';
import { I18n } from './I18n.js';
import type { WidthBreakpoint } from './_util.js';
import type { UnreadStats } from '../util/countUnreadStats.js';
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.js';
export type PropsType = {
addStoryData: AddStoryData;
@@ -160,33 +160,22 @@ export function StoriesTab({
onAddStory={onAddStory}
showToast={showToast}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
label: i18n('icu:StoriesSettings__context-menu'),
onClick: showStoriesSettings,
},
]}
moduleClassName="Stories__pane__settings"
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
portalToRoot
>
{({ onClick, onKeyDown, ref }) => {
return (
<NavSidebarActionButton
ref={ref}
onClick={onClick}
onKeyDown={onKeyDown}
icon={<span className="StoriesTab__MoreActionsIcon" />}
label={i18n('icu:StoriesTab__MoreActionsLabel')}
/>
);
}}
</ContextMenu>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<NavSidebarActionButton
icon={<span className="StoriesTab__MoreActionsIcon" />}
label={i18n('icu:StoriesTab__MoreActionsLabel')}
/>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item
symbol="lock"
onSelect={showStoriesSettings}
>
{i18n('icu:StoriesSettings__context-menu')}
</AxoDropdownMenu.Item>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
</>
}
>
+5
View File
@@ -61,6 +61,11 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.CaptchaFailed };
case ToastType.CaptchaSolved:
return { toastType: ToastType.CaptchaSolved };
case ToastType.ChatFolderCreated:
return {
toastType: ToastType.ChatFolderCreated,
parameters: { chatFolderName: 'Unread' },
};
case ToastType.ConversationArchived:
return {
toastType: ToastType.ConversationArchived,
+16
View File
@@ -21,6 +21,8 @@ import type { LocalizerType } from '../types/Util.js';
import type { AnyToast } from '../types/Toast.js';
import type { AnyActionableMegaphone } from '../types/Megaphone.js';
import type { Location } from '../types/Nav.js';
import { I18n } from './I18n.js';
import { UserText } from './UserText.js';
export type PropsType = {
changeLocation: (newLocation: Location) => unknown;
@@ -187,6 +189,20 @@ export function renderToast({
);
}
if (toastType === ToastType.ChatFolderCreated) {
return (
<Toast onClose={hideToast}>
<I18n
i18n={i18n}
id="icu:Toast--ChatFolderCreated"
components={{
chatFolderName: <UserText text={toast.parameters.chatFolderName} />,
}}
/>
</Toast>
);
}
if (toastType === ToastType.ConversationArchived) {
return (
<Toast
+13 -7
View File
@@ -27,11 +27,13 @@ import type {
MutedStats,
} from '../../util/countMutedStats.js';
import type { AxoSymbol } from '../../axo/AxoSymbol.js';
import { UserText } from '../UserText.js';
import { CurrentChatFolders } from '../../types/CurrentChatFolders.js';
export type LeftPaneChatFoldersProps = Readonly<{
i18n: LocalizerType;
navSidebarWidthBreakpoint: WidthBreakpoint | null;
sortedChatFolders: ReadonlyArray<ChatFolder>;
currentChatFolders: CurrentChatFolders;
allChatFoldersUnreadStats: AllChatFoldersUnreadStats;
allChatFoldersMutedStats: AllChatFoldersMutedStats;
selectedChatFolder: ChatFolder | null;
@@ -60,7 +62,7 @@ function getChatFolderLabel(
i18n: LocalizerType,
chatFolder: ChatFolder,
preferShort: boolean
): string {
): ReactNode {
if (chatFolder.folderType === ChatFolderType.ALL) {
if (preferShort) {
return i18n('icu:LeftPaneChatFolders__ItemLabel--All--Short');
@@ -68,7 +70,7 @@ function getChatFolderLabel(
return i18n('icu:LeftPaneChatFolders__ItemLabel--All');
}
if (chatFolder.folderType === ChatFolderType.CUSTOM) {
return chatFolder.name;
return <UserText text={chatFolder.name} />;
}
return '';
}
@@ -86,7 +88,11 @@ function getChatFolderIconName(
export function LeftPaneChatFolders(
props: LeftPaneChatFoldersProps
): JSX.Element | null {
const { i18n, onSelectedChatFolderIdChange } = props;
const { i18n, currentChatFolders, onSelectedChatFolderIdChange } = props;
const sortedChatFolders = useMemo(() => {
return CurrentChatFolders.toSortedArray(currentChatFolders);
}, [currentChatFolders]);
const handleValueChange = useCallback(
(newValue: string | null) => {
@@ -104,7 +110,7 @@ export function LeftPaneChatFolders(
});
}, []);
if (props.sortedChatFolders.length < 2) {
if (!currentChatFolders.hasAnyCurrentCustomChatFolders) {
return null;
}
@@ -122,7 +128,7 @@ export function LeftPaneChatFolders(
chevron="on-hover"
/>
<AxoSelect.Content position="dropdown">
{props.sortedChatFolders.map(chatFolder => {
{sortedChatFolders.map(chatFolder => {
const unreadStats =
props.allChatFoldersUnreadStats.get(chatFolder.id) ?? null;
return (
@@ -154,7 +160,7 @@ export function LeftPaneChatFolders(
value={props.selectedChatFolder?.id ?? null}
onValueChange={handleValueChange}
>
{props.sortedChatFolders.map(chatFolder => {
{sortedChatFolders.map(chatFolder => {
const unreadStats =
props.allChatFoldersUnreadStats.get(chatFolder.id) ?? null;
const mutedStats =
+1 -1
View File
@@ -67,7 +67,7 @@ export abstract class LeftPaneHelper<T> {
return undefined;
}
getBackgroundNode(
getEmptyViewNode(
_: Readonly<{
i18n: LocalizerType;
}>
+80 -9
View File
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import lodash from 'lodash';
import type { ReactChild } from 'react';
import type { ReactChild, ReactNode } from 'react';
import React from 'react';
import type { ToFindType } from './LeftPaneHelper.js';
@@ -14,11 +14,15 @@ import { LeftPaneHelper } from './LeftPaneHelper.js';
import { getConversationInDirection } from './getConversationInDirection.js';
import type { Row } from '../ConversationList.js';
import { RowType } from '../ConversationList.js';
import { NavSidebarEmpty } from '../NavSidebar.js';
import type { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem.js';
import type { LocalizerType } from '../../types/Util.js';
import { handleKeydownForSearch } from './handleKeydownForSearch.js';
import { LeftPaneSearchInput } from '../LeftPaneSearchInput.js';
import type { ChatFolder } from '../../types/ChatFolder.js';
import { ChatFolderType } from '../../types/ChatFolder.js';
import { AxoButton } from '../../axo/AxoButton.js';
import { NavTab, SettingsPage, type Location } from '../../types/Nav.js';
import { tw } from '../../axo/tw.js';
const { last } = lodash;
@@ -32,6 +36,7 @@ export type LeftPaneInboxPropsType = {
searchDisabled: boolean;
searchTerm: string;
searchConversation: undefined | ConversationType;
selectedChatFolder: ChatFolder | null;
filterByUnread: boolean;
};
@@ -45,6 +50,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
readonly #searchDisabled: boolean;
readonly #searchTerm: string;
readonly #searchConversation: undefined | ConversationType;
readonly #selectedChatFolder: ChatFolder | null;
readonly #filterByUnread: boolean;
constructor({
@@ -57,6 +63,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
searchDisabled,
searchTerm,
searchConversation,
selectedChatFolder,
filterByUnread,
}: Readonly<LeftPaneInboxPropsType>) {
super();
@@ -70,6 +77,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
this.#searchDisabled = searchDisabled;
this.#searchTerm = searchTerm;
this.#searchConversation = searchConversation;
this.#selectedChatFolder = selectedChatFolder;
this.#filterByUnread = filterByUnread;
}
@@ -82,7 +90,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
headerCount += 1;
}
}
const buttonCount = this.#archivedConversations.length ? 1 : 0;
const buttonCount = this.#shouldShowArchiveButton() ? 1 : 0;
return (
headerCount +
this.#pinnedConversations.length +
@@ -139,23 +147,76 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
return renderLeftPaneChatFolders();
}
override getBackgroundNode({
override getEmptyViewNode({
i18n,
selectedChatFolder,
changeLocation,
}: Readonly<{
i18n: LocalizerType;
selectedChatFolder: ChatFolder | null;
changeLocation: (location: Location) => void;
}>): ReactChild | null {
if (this.getRowCount() === 0) {
if (selectedChatFolder?.folderType === ChatFolderType.CUSTOM) {
return (
<EmptyView>
<h3 className={tw('type-body-large text-label-primary')}>
{i18n('icu:LeftPane__EmptyView--WithSelectedChatFolder')}
</h3>
<AxoButton.Root
variant="floating-secondary"
size="medium"
onClick={() => {
changeLocation({
tab: NavTab.Settings,
details: {
page: SettingsPage.EditChatFolder,
chatFolderId: selectedChatFolder.id,
previousLocation: {
tab: NavTab.Chats,
},
},
});
}}
>
{i18n(
'icu:LeftPane__EmptyView--WithSelectedChatFolder__FolderSettings'
)}
</AxoButton.Root>
</EmptyView>
);
}
return (
<NavSidebarEmpty
title={i18n('icu:emptyInbox__title')}
subtitle={i18n('icu:emptyInbox__subtitle')}
/>
<EmptyView>
<h3 className={tw('type-title-medium text-label-secondary')}>
{i18n('icu:emptyInbox__title')}
</h3>
<p className={tw('type-body-medium text-label-secondary')}>
{i18n('icu:emptyInbox__subtitle')}
</p>
</EmptyView>
);
}
return null;
}
#shouldShowArchiveButton(): boolean {
if (this.#archivedConversations.length === 0) {
return false;
}
if (
this.#selectedChatFolder != null &&
this.#selectedChatFolder.folderType !== ChatFolderType.ALL
) {
return false;
}
return true;
}
getRow(rowIndex: number): undefined | Row {
const pinnedConversations = this.#pinnedConversations;
const archivedConversations = this.#archivedConversations;
@@ -202,7 +263,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
}
index -= conversations.length;
if (index === 0 && archivedConversationsCount) {
if (index === 0 && this.#shouldShowArchiveButton()) {
return {
type: RowType.ArchiveButton,
archivedConversationsCount,
@@ -297,3 +358,13 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
return this.#conversations.length !== 0;
}
}
function EmptyView(props: { children: ReactNode }) {
return (
<div
className={tw('flex h-full flex-col items-center justify-center gap-3')}
>
{props.children}
</div>
);
}
@@ -1,6 +1,7 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React from 'react';
import type { LocalizerType } from '../../../types/I18N.js';
import { ConfirmationDialog } from '../../ConfirmationDialog.js';
@@ -8,7 +9,7 @@ import { ConfirmationDialog } from '../../ConfirmationDialog.js';
export function DeleteChatFolderDialog(props: {
i18n: LocalizerType;
title: string;
description: string;
description: ReactNode;
cancelText: string;
deleteText: string;
onConfirm: () => void;
@@ -0,0 +1,82 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import React from 'react';
import type { ReactNode } from 'react';
import { tw } from '../../../axo/tw.js';
import type { LocalizerType } from '../../../types/I18N.js';
import { AxoSymbol } from '../../../axo/AxoSymbol.js';
import type { ChatFolderPresetId } from './PreferencesChatFoldersPage.js';
export const itemClassName = classNames(
tw('group'),
'Preferences__ChatFolders__ChatSelection__Item'
);
export const itemListItemClassName =
'Preferences__ChatFolders__ChatSelection__Item--ListItem';
export const itemClickableClassName =
'Preferences__ChatFolders__ChatSelection__Item--Clickable';
export const itemButtonClassName =
'Preferences__ChatFolders__ChatSelection__Item--Button';
export function ItemContent(props: { children: ReactNode }): JSX.Element {
return (
<span className="Preferences__ChatFolders__ChatSelection__ItemContent">
{props.children}
</span>
);
}
export function ItemAvatar(props: {
kind: 'Folder' | 'Add' | ChatFolderPresetId;
}): JSX.Element {
return (
<span
className={`Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--${props.kind}`}
/>
);
}
export function ItemBody(props: { children: ReactNode }): JSX.Element {
return (
<span className="Preferences__ChatFolders__ChatSelection__ItemBody">
{props.children}
</span>
);
}
export function ItemTitle(props: { children: ReactNode }): JSX.Element {
return (
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{props.children}
</span>
);
}
export function ItemDescription(props: { children: ReactNode }): JSX.Element {
return (
<span className="Preferences__ChatFolders__ChatSelection__ItemDescription">
{props.children}
</span>
);
}
export function ItemDragHandle(props: { i18n: LocalizerType }): JSX.Element {
const { i18n } = props;
return (
<span
className={tw(
'cursor-grab text-label-primary opacity-0 group-hovered:opacity-100 group-focused:opacity-100'
)}
>
<AxoSymbol.Icon
symbol="draghandle-alt"
size={24}
label={i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__DragHandle__Label'
)}
/>
</span>
);
}
@@ -4,7 +4,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { MutableRefObject, ReactNode } from 'react';
import { ListBox, ListBoxItem, useDragAndDrop } from 'react-aria-components';
import { partition } from 'lodash';
import { isEqual, partition } from 'lodash';
import classNames from 'classnames';
import type { LocalizerType } from '../../../types/I18N.js';
import { PreferencesContent } from '../../Preferences.js';
import { SettingsRow } from '../../PreferencesUtil.js';
@@ -19,19 +20,35 @@ import type {
ChatFolderPreset,
ChatFolder,
} from '../../../types/ChatFolder.js';
import { Button, ButtonVariant } from '../../Button.js';
import { AxoContextMenu } from '../../../axo/AxoContextMenu.js';
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
import { strictAssert } from '../../../util/assert.js';
import { tw } from '../../../axo/tw.js';
// import { showToast } from '../../state/ducks/toast';
import { UserText } from '../../UserText.js';
import { I18n } from '../../I18n.js';
import { NavTab, SettingsPage, type Location } from '../../../types/Nav.js';
import { AxoButton } from '../../../axo/AxoButton.js';
import type { CurrentChatFolder } from '../../../types/CurrentChatFolders.js';
import { CurrentChatFolders } from '../../../types/CurrentChatFolders.js';
import {
ItemAvatar,
ItemBody,
itemButtonClassName,
itemClassName,
itemClickableClassName,
ItemContent,
ItemDescription,
ItemDragHandle,
itemListItemClassName,
ItemTitle,
} from './PreferencesChatFolderItems.js';
function moveChatFolders(
chatFolders: ReadonlyArray<ChatFolder>,
chatFolders: ReadonlyArray<CurrentChatFolder>,
target: ChatFolderId,
moving: Set<ChatFolderId>,
position: 'before' | 'after'
) {
): ReadonlyArray<CurrentChatFolder> {
const [toSplice, toInsert] = partition(chatFolders, chatFolder => {
return !moving.has(chatFolder.id);
});
@@ -49,16 +66,29 @@ function moveChatFolders(
return toSplice.toSpliced(spliceIndex, 0, ...toInsert);
}
function hasChatFoldersOrderChanged(
sortedChatFolders: ReadonlyArray<ChatFolder>,
chatFoldersReordered: ReadonlyArray<ChatFolder>
): boolean {
const a = sortedChatFolders.map(chatFolder => chatFolder.id);
const b = chatFoldersReordered.map(chatFolder => chatFolder.id);
return !isEqual(a, b);
}
export type PreferencesChatFoldersPageProps = Readonly<{
i18n: LocalizerType;
onBack: () => void;
onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void;
chatFolders: ReadonlyArray<ChatFolder>;
onCreateChatFolder: (params: ChatFolderParams) => void;
changeLocation: (location: Location) => void;
currentChatFolders: CurrentChatFolders;
onCreateChatFolder: (
params: ChatFolderParams,
showToastOnSuccess: boolean
) => void;
onDeleteChatFolder: (chatFolderId: ChatFolderId) => void;
onUpdateChatFoldersPositions: (
chatFolderIds: ReadonlyArray<ChatFolderId>
) => void;
previousLocation: Location | null;
settingsPaneRef: MutableRefObject<HTMLDivElement | null>;
}>;
@@ -70,15 +100,13 @@ export function PreferencesChatFoldersPage(
onOpenEditChatFoldersPage,
onDeleteChatFolder,
onUpdateChatFoldersPositions,
chatFolders,
previousLocation,
changeLocation,
currentChatFolders,
} = props;
const [confirmDeleteChatFolder, setConfirmDeleteChatFolder] =
useState<ChatFolder | null>(null);
// showToast(
// i18n("icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast")
// )
const handleChatFolderCreate = useCallback(() => {
onOpenEditChatFoldersPage(null);
}, [onOpenEditChatFoldersPage]);
@@ -103,21 +131,46 @@ export function PreferencesChatFoldersPage(
onDeleteChatFolder(confirmDeleteChatFolder.id);
}, [confirmDeleteChatFolder, onDeleteChatFolder]);
const [chatFoldersReordered, setChatFoldersReordered] = useState(chatFolders);
const handleBack = useCallback(() => {
changeLocation(
previousLocation ?? {
tab: NavTab.Settings,
details: {
page: SettingsPage.Chats,
},
}
);
}, [changeLocation, previousLocation]);
const sortedChatFolders = useMemo(() => {
return CurrentChatFolders.toSortedArray(currentChatFolders);
}, [currentChatFolders]);
const [chatFoldersReordered, setChatFoldersReordered] =
useState(sortedChatFolders);
useEffect(() => {
setChatFoldersReordered(chatFolders);
}, [chatFolders]);
setChatFoldersReordered(sortedChatFolders);
}, [sortedChatFolders]);
const { dragAndDropHooks } = useDragAndDrop({
getItems: () => {
return chatFolders.map(chatFolder => {
return sortedChatFolders.map(chatFolder => {
return { 'signal-chat-folder-id': chatFolder.id.slice(-3) };
});
},
getAllowedDropOperations() {
return ['move'];
},
acceptedDragTypes: ['signal-chat-folder-id'],
getDropOperation: () => 'move',
onDragEnd: () => {
if (
!hasChatFoldersOrderChanged(sortedChatFolders, chatFoldersReordered)
) {
return;
}
onUpdateChatFoldersPositions(
chatFoldersReordered.map(chatFolder => {
return chatFolder.id;
@@ -138,7 +191,9 @@ export function PreferencesChatFoldersPage(
});
},
renderDropIndicator: () => {
return <div className={tw('h-12')} />;
return (
<div className={tw('-my-px h-0.5 rounded-full bg-fill-inverted')} />
);
},
});
@@ -177,13 +232,13 @@ export function PreferencesChatFoldersPage(
];
const filtered = initial.filter(config => {
return !chatFolders.some(chatFolder => {
return !sortedChatFolders.some(chatFolder => {
return matchesChatFolderPreset(chatFolder, config.preset);
});
});
return filtered;
}, [i18n, chatFolders]);
}, [i18n, sortedChatFolders]);
return (
<>
@@ -193,7 +248,7 @@ export function PreferencesChatFoldersPage(
type="button"
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={props.onBack}
onClick={handleBack}
/>
}
contents={
@@ -209,15 +264,17 @@ export function PreferencesChatFoldersPage(
>
<button
type="button"
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
className={classNames(itemClassName, itemButtonClassName)}
onClick={handleChatFolderCreate}
>
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__CreateAFolderButton'
)}
</span>
<ItemContent>
<ItemAvatar kind="Add" />
<ItemTitle>
{i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__CreateAFolderButton'
)}
</ItemTitle>
</ItemContent>
</button>
<ListBox
selectionMode="single"
@@ -271,10 +328,17 @@ export function PreferencesChatFoldersPage(
title={i18n(
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title'
)}
description={i18n(
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description',
{ chatFolderTitle: confirmDeleteChatFolder.name }
)}
description={
<I18n
i18n={i18n}
id="icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description"
components={{
chatFolderTitle: (
<UserText text={confirmDeleteChatFolder.name} />
),
}}
/>
}
deleteText={i18n(
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__DeleteButton'
)}
@@ -289,8 +353,10 @@ export function PreferencesChatFoldersPage(
);
}
export type ChatFolderPresetId = 'UnreadChats' | 'DirectChats' | 'GroupChats';
type ChatFolderPresetItemConfig = Readonly<{
id: 'UnreadChats' | 'DirectChats' | 'GroupChats';
id: ChatFolderPresetId;
title: string;
description: string;
preset: ChatFolderPreset;
@@ -299,7 +365,10 @@ type ChatFolderPresetItemConfig = Readonly<{
type ChatFolderPresetItemProps = Readonly<{
i18n: LocalizerType;
config: ChatFolderPresetItemConfig;
onCreateChatFolder: (params: ChatFolderParams) => void;
onCreateChatFolder: (
params: ChatFolderParams,
showToastOnSuccess: boolean
) => void;
}>;
function ChatFolderPresetItem(props: ChatFolderPresetItemProps) {
@@ -307,33 +376,30 @@ function ChatFolderPresetItem(props: ChatFolderPresetItemProps) {
const { title, preset } = config;
const handleCreateChatFolder = useCallback(() => {
onCreateChatFolder({ ...preset, name: title });
onCreateChatFolder({ ...preset, name: title }, true);
}, [onCreateChatFolder, title, preset]);
return (
<li
data-testid={`ChatFolderPreset--${props.config.id}`}
className="Preferences__ChatFolders__ChatSelection__Item"
className={itemClassName}
>
<span
className={`Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--${props.config.id}`}
/>
<span className="Preferences__ChatFolders__ChatSelection__ItemBody">
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{props.config.title}
</span>
<span className="Preferences__ChatFolders__ChatSelection__ItemDescription">
{props.config.description}
</span>
</span>
<Button
variant={ButtonVariant.Secondary}
onClick={handleCreateChatFolder}
>
{i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton'
)}
</Button>
<ItemContent>
<ItemAvatar kind={props.config.id} />
<ItemBody>
<ItemTitle>{props.config.title}</ItemTitle>
<ItemDescription>{props.config.description}</ItemDescription>
</ItemBody>
<AxoButton.Root
size="medium"
variant="secondary"
onClick={handleCreateChatFolder}
>
{i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton'
)}
</AxoButton.Root>
</ItemContent>
</li>
);
}
@@ -356,12 +422,19 @@ function ChatFolderListItem(props: {
<ListBoxItem
id={chatFolder.id}
data-testid={`ChatFolder--${chatFolder.id}`}
className="Preferences__ChatFolders__ChatSelection__Item"
className={classNames(itemClassName, itemListItemClassName)}
>
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
{i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
)}
<ItemContent>
<ItemAvatar kind="Folder" />
<ItemBody>
<ItemTitle>
{i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
)}
</ItemTitle>
</ItemBody>
<ItemDragHandle i18n={i18n} />
</ItemContent>
</ListBoxItem>
)}
@@ -371,6 +444,11 @@ function ChatFolderListItem(props: {
data-testid={`ChatFolder--${chatFolder.id}`}
textValue={props.chatFolder.name}
onAction={handleClickChatFolder}
className={classNames(
itemClassName,
itemListItemClassName,
itemClickableClassName
)}
>
<ChatFolderListItemContextMenu
i18n={i18n}
@@ -378,10 +456,15 @@ function ChatFolderListItem(props: {
onChatFolderEdit={props.onChatFolderEdit}
onChatFolderDelete={props.onChatFolderDelete}
>
<div className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button">
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
{props.chatFolder.name}
</div>
<ItemContent>
<ItemAvatar kind="Folder" />
<ItemBody>
<ItemTitle>
<UserText text={props.chatFolder.name} />
</ItemTitle>
</ItemBody>
<ItemDragHandle i18n={i18n} />
</ItemContent>
</ChatFolderListItemContextMenu>
</ListBoxItem>
)}
@@ -2,13 +2,17 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { MutableRefObject } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import type { ConversationType } from '../../../state/ducks/conversations.js';
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js';
import type { LocalizerType } from '../../../types/I18N.js';
import type { ThemeType } from '../../../types/Util.js';
import { Input } from '../../Input.js';
import { Button, ButtonVariant } from '../../Button.js';
import { ConfirmationDialog } from '../../ConfirmationDialog.js';
import type { ChatFolderSelection } from '../PreferencesSelectChatsDialog.js';
import { SettingsControl, SettingsRow } from '../../PreferencesUtil.js';
import { PreferencesSelectChatsDialog } from '../PreferencesSelectChatsDialog.js';
import { SettingsRow } from '../../PreferencesUtil.js';
import { Checkbox } from '../../Checkbox.js';
import { Avatar, AvatarSize } from '../../Avatar.js';
import { PreferencesContent } from '../../Preferences.js';
import {
@@ -17,27 +21,36 @@ import {
isSameChatFolderParams,
validateChatFolderParams,
} from '../../../types/ChatFolder.js';
import { strictAssert } from '../../../util/assert.js';
import { parseStrict } from '../../../util/schemas.js';
import { BeforeNavigateResponse } from '../../../services/BeforeNavigate.js';
import { useNavBlocker } from '../../../hooks/useNavBlocker.js';
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
import type { ConversationType } from '../../../state/ducks/conversations.js';
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js';
import type { LocalizerType } from '../../../types/I18N.js';
import type { ThemeType } from '../../../types/Util.js';
import type { ChatFolderSelection } from '../PreferencesSelectChatsDialog.js';
import type {
ChatFolderId,
ChatFolderParams,
} from '../../../types/ChatFolder.js';
import type { GetConversationByIdType } from '../../../state/selectors/conversations.js';
import { strictAssert } from '../../../util/assert.js';
import { parseStrict } from '../../../util/schemas.js';
import { BeforeNavigateResponse } from '../../../services/BeforeNavigate.js';
import { NavTab, SettingsPage } from '../../../types/Nav.js';
import type { Location } from '../../../types/Nav.js';
import { useNavBlocker } from '../../../hooks/useNavBlocker.js';
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
import { UserText } from '../../UserText.js';
import { AxoSwitch } from '../../../axo/AxoSwitch.js';
import { FunEmojiPickerButton } from '../../fun/FunButton.js';
import { FunEmojiPicker } from '../../fun/FunEmojiPicker.js';
import type { FunEmojiSelection } from '../../fun/panels/FunPanelEmojis.js';
import { getEmojiVariantByKey } from '../../fun/data/emojis.js';
import {
ItemAvatar,
ItemBody,
itemButtonClassName,
itemClassName,
ItemContent,
ItemTitle,
} from './PreferencesChatFolderItems.js';
export type PreferencesEditChatFolderPageProps = Readonly<{
i18n: LocalizerType;
previousLocation: Location;
previousLocation: Location | null;
existingChatFolderId: ChatFolderId | null;
initChatFolderParams: ChatFolderParams;
changeLocation: (location: Location) => void;
@@ -47,7 +60,10 @@ export type PreferencesEditChatFolderPageProps = Readonly<{
settingsPaneRef: MutableRefObject<HTMLDivElement | null>;
conversationSelector: GetConversationByIdType;
onDeleteChatFolder: (chatFolderId: ChatFolderId) => void;
onCreateChatFolder: (chatFolderParams: ChatFolderParams) => void;
onCreateChatFolder: (
chatFolderParams: ChatFolderParams,
showToastOnSuccess: boolean
) => void;
onUpdateChatFolder: (
chatFolderId: ChatFolderId,
chatFolderParams: ChatFolderParams
@@ -69,9 +85,12 @@ export function PreferencesEditChatFolderPage(
conversationSelector,
} = props;
const inputRef = useRef<HTMLInputElement>(null);
const [chatFolderParams, setChatFolderParams] =
useState(initChatFolderParams);
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const [showInclusionsDialog, setShowInclusionsDialog] = useState(false);
const [showExclusionsDialog, setShowExclusionsDialog] = useState(false);
const [showDeleteFolderDialog, setShowDeleteFolderDialog] = useState(false);
@@ -103,6 +122,28 @@ export function PreferencesEditChatFolderPage(
});
}, []);
const handleSelectEmoji = useCallback((emojiSelection: FunEmojiSelection) => {
setChatFolderParams(prevParams => {
strictAssert(inputRef.current, 'Missing input ref');
const input = inputRef.current;
const { selectionStart, selectionEnd } = input;
const variant = getEmojiVariantByKey(emojiSelection.variantKey);
const emoji = variant.value;
let newName: string;
if (selectionStart == null || selectionEnd == null) {
newName = `${prevParams.name}${emoji}`;
} else {
const before = prevParams.name.slice(0, selectionStart);
const after = prevParams.name.slice(selectionEnd);
newName = `${before}${emoji}${after}`;
}
return { ...prevParams, name: newName };
});
}, []);
const handleShowOnlyUnreadChange = useCallback((newValue: boolean) => {
setChatFolderParams(prevParams => {
return { ...prevParams, showOnlyUnread: newValue };
@@ -116,7 +157,15 @@ export function PreferencesEditChatFolderPage(
}, []);
const handleBack = useCallback(() => {
changeLocation(previousLocation);
changeLocation(
previousLocation ?? {
tab: NavTab.Settings,
details: {
page: SettingsPage.ChatFolders,
previousLocation: null,
},
}
);
}, [changeLocation, previousLocation]);
const handleDiscardAndBack = useCallback(() => {
@@ -131,7 +180,7 @@ export function PreferencesEditChatFolderPage(
if (existingChatFolderId != null) {
onUpdateChatFolder(existingChatFolderId, normalizedChatFolderParams);
} else {
onCreateChatFolder(normalizedChatFolderParams);
onCreateChatFolder(normalizedChatFolderParams, false);
}
didSaveOrDiscardChangesRef.current = true;
@@ -233,6 +282,7 @@ export function PreferencesEditChatFolderPage(
data-testid="EditChatFolderName"
>
<Input
ref={inputRef}
i18n={i18n}
value={chatFolderParams.name}
onChange={handleNameChange}
@@ -241,7 +291,16 @@ export function PreferencesEditChatFolderPage(
)}
maxLengthCount={CHAT_FOLDER_NAME_MAX_CHAR_LENGTH}
whenToShowRemainingCount={CHAT_FOLDER_NAME_MAX_CHAR_LENGTH - 10}
/>
>
<FunEmojiPicker
open={emojiPickerOpen}
onOpenChange={setEmojiPickerOpen}
onSelectEmoji={handleSelectEmoji}
closeOnSelect
>
<FunEmojiPickerButton i18n={i18n} />
</FunEmojiPicker>
</Input>
</div>
</SettingsRow>
<SettingsRow
@@ -251,54 +310,67 @@ export function PreferencesEditChatFolderPage(
>
<button
type="button"
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
className={classNames(itemClassName, itemButtonClassName)}
onClick={handleSelectInclusions}
>
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{i18n(
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__AddChatsButton'
)}
</span>
<ItemContent>
<ItemAvatar kind="Add" />
<ItemBody>
<ItemTitle>
{i18n(
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__AddChatsButton'
)}
</ItemTitle>
</ItemBody>
</ItemContent>
</button>
<ul className="Preferences__ChatFolders__ChatSelection__List">
{chatFolderParams.includeAllIndividualChats && (
<li className="Preferences__ChatFolders__ChatSelection__Item">
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--DirectChats" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{i18n(
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__DirectChats'
)}
</span>
<li className={itemClassName}>
<ItemContent>
<ItemAvatar kind="DirectChats" />
<ItemBody>
<ItemTitle>
{i18n(
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__DirectChats'
)}
</ItemTitle>
</ItemBody>
</ItemContent>
</li>
)}
{chatFolderParams.includeAllGroupChats && (
<li className="Preferences__ChatFolders__ChatSelection__Item">
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--GroupChats" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{i18n(
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__GroupChats'
)}
</span>
<li className={itemClassName}>
<ItemContent>
<ItemAvatar kind="GroupChats" />
<ItemBody>
<ItemTitle>
{i18n(
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__GroupChats'
)}
</ItemTitle>
</ItemBody>
</ItemContent>
</li>
)}
{chatFolderParams.includedConversationIds.map(conversationId => {
const conversation = conversationSelector(conversationId);
return (
<li
key={conversationId}
className="Preferences__ChatFolders__ChatSelection__Item"
>
<Avatar
i18n={i18n}
conversationType={conversation.type}
size={AvatarSize.THIRTY_SIX}
badge={undefined}
{...conversation}
/>
<span className="Preferences__ChatFolders__ChatList__ItemTitle">
{conversation.title}
</span>
<li key={conversationId} className={itemClassName}>
<ItemContent>
<Avatar
i18n={i18n}
conversationType={conversation.type}
size={AvatarSize.THIRTY_SIX}
badge={undefined}
{...conversation}
/>
<ItemBody>
<ItemTitle>
<UserText text={conversation.title} />
</ItemTitle>
</ItemBody>
</ItemContent>
</li>
);
})}
@@ -318,34 +390,39 @@ export function PreferencesEditChatFolderPage(
>
<button
type="button"
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
className={classNames(itemClassName, itemButtonClassName)}
onClick={handleSelectExclusions}
>
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{i18n(
'icu:Preferences__EditChatFolderPage__ExceptionsSection__ExcludeChatsButton'
)}
</span>
<ItemContent>
<ItemAvatar kind="Add" />
<ItemBody>
<ItemTitle>
{i18n(
'icu:Preferences__EditChatFolderPage__ExceptionsSection__ExcludeChatsButton'
)}
</ItemTitle>
</ItemBody>
</ItemContent>
</button>
<ul className="Preferences__ChatFolders__ChatSelection__List">
{chatFolderParams.excludedConversationIds.map(conversationId => {
const conversation = conversationSelector(conversationId);
return (
<li
key={conversationId}
className="Preferences__ChatFolders__ChatSelection__Item"
>
<Avatar
i18n={i18n}
conversationType={conversation.type}
size={AvatarSize.THIRTY_SIX}
badge={undefined}
{...conversation}
/>
<span className="Preferences__ChatFolders__ChatList__ItemTitle">
{conversation.title}
</span>
<li key={conversationId} className={itemClassName}>
<ItemContent>
<Avatar
i18n={i18n}
conversationType={conversation.type}
size={AvatarSize.THIRTY_SIX}
badge={undefined}
{...conversation}
/>
<ItemBody>
<ItemTitle>
<UserText text={conversation.title} />
</ItemTitle>
</ItemBody>
</ItemContent>
</li>
);
})}
@@ -359,26 +436,30 @@ export function PreferencesEditChatFolderPage(
</div>
</SettingsRow>
<SettingsRow>
<Checkbox
checked={chatFolderParams.showOnlyUnread}
label={i18n(
<SettingsControl
left={i18n(
'icu:Preferences__EditChatFolderPage__OnlyShowUnreadChatsCheckbox__Label'
)}
description={i18n(
'icu:Preferences__EditChatFolderPage__OnlyShowUnreadChatsCheckbox__Description'
)}
moduleClassName="Preferences__checkbox"
name="showOnlyUnread"
onChange={handleShowOnlyUnreadChange}
right={
<AxoSwitch.Root
checked={chatFolderParams.showOnlyUnread}
onCheckedChange={handleShowOnlyUnreadChange}
/>
}
/>
<Checkbox
checked={chatFolderParams.showMutedChats}
label={i18n(
<SettingsControl
left={i18n(
'icu:Preferences__EditChatFolderPage__IncludeMutedChatsCheckbox__Label'
)}
moduleClassName="Preferences__checkbox"
name="showMutedChats"
onChange={handleShowMutedChatsChange}
right={
<AxoSwitch.Root
checked={chatFolderParams.showMutedChats}
onCheckedChange={handleShowMutedChatsChange}
/>
}
/>
</SettingsRow>
{props.existingChatFolderId != null && (
+3 -3
View File
@@ -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<ChatFolder>;
let chatFolders: ReadonlyArray<CurrentChatFolder>;
export async function loadChatFolders(): Promise<void> {
chatFolders = await DataReader.getCurrentChatFolders();
}
export function getChatFoldersForRedux(): ReadonlyArray<ChatFolder> {
export function getChatFoldersForRedux(): ReadonlyArray<CurrentChatFolder> {
strictAssert(chatFolders != null, 'chatFolders has not been loaded');
return chatFolders;
}
+8 -2
View File
@@ -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();
}
+2 -1
View File
@@ -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<ChatFolder>;
getCurrentChatFolders: () => ReadonlyArray<ChatFolder>;
getCurrentChatFolders: () => ReadonlyArray<CurrentChatFolder>;
getChatFolder: (id: ChatFolderId) => ChatFolder | null;
hasAllChatsChatFolder: () => boolean;
getOldestDeletedChatFolder: () => ChatFolder | null;
+13 -3
View File
@@ -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<ChatFolder> {
export function getCurrentChatFolders(
db: ReadableDB
): ReadonlyArray<ChatFolder> {
): ReadonlyArray<CurrentChatFolder> {
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<ChatFolderRow>(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(
+84 -26
View File
@@ -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<CurrentChatFolder>
): 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<void, RootStateType, unknown, never> {
chatFolderParams: ChatFolderParams,
showToastOnSuccess: boolean
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
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>
): 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;
}
+7 -5
View File
@@ -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);
+5 -6
View File
@@ -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,
+2 -2
View File
@@ -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<CallHistoryDetails>;
callHistoryUnreadCount: number;
callLinks: ReadonlyArray<CallLinkType>;
chatFolders: ReadonlyArray<ChatFolder>;
chatFolders: ReadonlyArray<CurrentChatFolder>;
donations: DonationsStateType;
gifs: GifsStateType;
mainWindowStats: MainWindowStatsType;
+18 -11
View File
@@ -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<CurrentChatFolders> =
return state.currentChatFolders;
});
export const getSortedChatFolders: StateSelector<ReadonlyArray<ChatFolder>> =
export const getCurrentChatFoldersCount: StateSelector<number> = createSelector(
getCurrentChatFolders,
currentChatFolders => {
return CurrentChatFolders.size(currentChatFolders);
}
);
export const getHasAnyCurrentCustomChatFolders: StateSelector<boolean> =
createSelector(getCurrentChatFolders, currentChatFolders => {
return getSortedCurrentChatFolders(currentChatFolders);
return currentChatFolders.hasAnyCurrentCustomChatFolders;
});
export const getSelectedChatFolder: StateSelector<ChatFolder | null> =
export const getSelectedChatFolder: StateSelector<CurrentChatFolder | null> =
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'
);
}
);
+10 -7
View File
@@ -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<AllChatFoldersUnreadStats> =
createSelector(
getSortedChatFolders,
getCurrentChatFolders,
getAllConversations,
(sortedChatFolders, allConversations) => {
(currentChatFolders, allConversations) => {
return countAllChatFoldersUnreadStats(
sortedChatFolders,
currentChatFolders,
allConversations,
{
includeMuted: false,
@@ -707,10 +707,13 @@ export const getAllChatFoldersUnreadStats: StateSelector<AllChatFoldersUnreadSta
export const getAllChatFoldersMutedStats: StateSelector<AllChatFoldersMutedStats> =
createSelector(
getSortedChatFolders,
getCurrentChatFolders,
getAllConversations,
(sortedChatFolders, allConversations) => {
return countAllChatFoldersMutedStats(sortedChatFolders, allConversations);
(currentChatFolders, allConversations) => {
return countAllChatFoldersMutedStats(
currentChatFolders,
allConversations
);
}
);
+28 -6
View File
@@ -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 <SmartToastManager disableMegaphone {...props} />;
}
function renderNotificationProfilesMenu(
props: NotificationProfilesMenuProps
): JSX.Element {
return <SmartNotificationProfilesMenu {...props} />;
function renderNotificationProfilesMenu(): JSX.Element {
return <SmartNotificationProfilesMenu />;
}
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}
+3 -3
View File
@@ -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(
<LeftPaneChatFolders
i18n={i18n}
navSidebarWidthBreakpoint={navSidebarWidthBreakpoint}
sortedChatFolders={sortedChatFolders}
currentChatFolders={currentChatFolders}
allChatFoldersUnreadStats={allChatFoldersUnreadStats}
allChatFoldersMutedStats={allChatFoldersMutedStats}
selectedChatFolder={selectedChatFolder}
+1 -14
View File
@@ -16,17 +16,7 @@ import {
import { useNavActions } from '../ducks/nav.js';
import { NavTab, SettingsPage } from '../../types/Nav.js';
export type ExternalProps = {
isOpen: boolean;
onClose: () => 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}
/>
);
}
+10
View File
@@ -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}
@@ -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 (
<PreferencesChatFoldersPage
i18n={i18n}
settingsPaneRef={props.settingsPaneRef}
onBack={props.onBack}
previousLocation={props.previousLocation}
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
chatFolders={chatFolders}
changeLocation={changeLocation}
currentChatFolders={currentChatFolders}
onCreateChatFolder={createChatFolder}
onDeleteChatFolder={deleteChatFolder}
onUpdateChatFoldersPositions={updateChatFoldersPositions}
@@ -12,13 +12,13 @@ import {
} from '../selectors/conversations.js';
import { getPreferredBadgeSelector } from '../selectors/badges.js';
import { useChatFolderActions } from '../ducks/chatFolders.js';
import { getSortedChatFolders } from '../selectors/chatFolders.js';
import { strictAssert } from '../../util/assert.js';
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
import { useNavActions } from '../ducks/nav.js';
import type { Location } from '../../types/Nav.js';
import { CurrentChatFolders } from '../../types/CurrentChatFolders.js';
export type SmartPreferencesEditChatFolderPageProps = Readonly<{
previousLocation: Location;
previousLocation: Location | null;
existingChatFolderId: PreferencesEditChatFolderPageProps['existingChatFolderId'];
settingsPaneRef: PreferencesEditChatFolderPageProps['settingsPaneRef'];
}>;
@@ -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 (
<PreferencesEditChatFolderPage
+1 -1
View File
@@ -329,7 +329,7 @@ describe('backups', function (this: Mocha.Suite) {
debug('Opening story privacy');
await window.locator('.StoriesTab__MoreActionsIcon').click();
await window.getByRole('button', { name: 'Story Privacy' }).click();
await window.getByRole('menuitem', { name: 'Story Privacy' }).click();
await expect(
window.locator('.StoriesSettingsModal__overlay')
).toHaveCSS('opacity', '1');
+10 -9
View File
@@ -18,13 +18,14 @@ const IdentifierType = Proto.ManifestRecord.Identifier.Type;
const ALL_CHATS_PREDICATE = getChatFolderRecordPredicate('ALL', '', false);
async function openChatFolderSettings(window: Page) {
const openSettingsBtn = window.locator(
'[data-testid="NavTabsItem--Settings"]'
);
const openChatsSettingsBtn = window.locator('.Preferences__button--chats');
const openChatFoldersSettingsBtn = window.locator(
'.Preferences__control:has-text("Add a chat folder")'
);
const openSettingsBtn = window.getByRole('tab', { name: 'Settings' });
const openChatsSettingsBtn = window
.getByRole('tabpanel', { name: 'Settings' })
.getByRole('navigation')
.getByRole('button', { name: 'Chats' });
const openChatFoldersSettingsBtn = window
.getByRole('group', { name: 'Chat folders' })
.getByRole('button', { name: 'Set up' });
await openSettingsBtn.click();
await openChatsSettingsBtn.click();
@@ -159,7 +160,7 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
await app.waitForManifestVersion(state.version);
await expect(allGroupsListItem).toBeVisible();
await expect(allGroupsListItem).toHaveText(ALL_GROUPS_NAME);
await expect(allGroupsListItem).toContainText(ALL_GROUPS_NAME);
}
debug('updating "All Groups" chat folder via storage service');
@@ -185,7 +186,7 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
await expect(allChatsListItem).toBeVisible();
await expect(allGroupsListItem).toBeVisible();
await expect(allGroupsListItem).toHaveText(ALL_GROUPS_NAME_UPDATED);
await expect(allGroupsListItem).toContainText(ALL_GROUPS_NAME_UPDATED);
}
debug('removing "All Groups" chat folder via storage service');
+1 -1
View File
@@ -201,7 +201,7 @@ describe('storage service', function (this: Mocha.Suite) {
await window.getByTestId('NavTabsItem--Stories').click();
await window.locator('.StoriesTab__MoreActionsIcon').click();
await window.getByRole('button', { name: 'Story Privacy' }).click();
await window.getByRole('menuitem', { name: 'Story Privacy' }).click();
await window
.getByTestId('StoriesSettingsModal__list')
@@ -164,7 +164,7 @@ describe('storage service/notification profiles', function (this: Mocha.Suite) {
debug('Click triple-dot button');
await window.getByRole('button', { name: 'More Actions' }).click();
await window
.getByRole('button', { name: 'Notification profile', exact: true })
.getByRole('menuitem', { name: 'Notification profile', exact: true })
.click();
debug('Click to add enabled=true override');
@@ -25,6 +25,7 @@ describe('LeftPaneInboxHelper', () => {
searchDisabled: false,
searchTerm: '',
startSearchCounter: 0,
selectedChatFolder: null,
};
describe('getBackAction', () => {
-49
View File
@@ -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<ChatFolderId>;
lookup: Partial<Record<ChatFolderId, ChatFolder>>;
}>;
export function toCurrentChatFolders(
chatFolders: ReadonlyArray<ChatFolder>
): CurrentChatFolders {
const order = chatFolders
.toSorted((a, b) => a.position - b.position)
.map(chatFolder => chatFolder.id);
const lookup: Record<ChatFolderId, ChatFolder> = {};
for (const chatFolder of chatFolders) {
lookup[chatFolder.id] = chatFolder;
}
return { order, lookup };
}
export function getSortedCurrentChatFolders(
currentChatFolders: CurrentChatFolders
): ReadonlyArray<ChatFolder> {
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<ChatFolder>
): boolean {
return chatFolders.some(chatFolder => {
return (
chatFolder.folderType === ChatFolderType.ALL &&
chatFolder.deletedAtTimestampMs === 0
);
});
}
+157
View File
@@ -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<ChatFolderId>;
lookup: Partial<Record<ChatFolderId, CurrentChatFolder>>;
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<CurrentChatFolder>
): 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<ChatFolderId, CurrentChatFolder> = {};
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<CurrentChatFolder> {
return state.order.map(id => {
return expect(state, id, 'toSortedArray');
});
}
}
+8 -2
View File
@@ -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
>;
}
>;
+5
View File
@@ -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 };
+4 -2
View File
@@ -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<ChatFolder>,
currentChatFolders: CurrentChatFolders,
conversations: ReadonlyArray<ConversationPropsForMutedStats>
): AllChatFoldersMutedStats {
const results = new Map<ChatFolderId, MutableMutedStats>();
const sortedChatFolders =
CurrentChatFolders.toSortedArray(currentChatFolders);
for (const conversation of conversations) {
const isMuted = isConversationMuted(conversation);
+5 -2
View File
@@ -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<ChatFolder>,
currentChatFolders: CurrentChatFolders,
conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
options: UnreadStatsOptions
): AllChatFoldersUnreadStats {
const results = new Map<ChatFolderId, MutableUnreadStats>();
const sortedChatFolders =
CurrentChatFolders.toSortedArray(currentChatFolders);
for (const conversation of conversations) {
// skip if we shouldn't count it
+7
View File
@@ -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<HTMLInputElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-10-09T23:03:57.441Z"
},
{
"rule": "React-useRef",
"path": "ts/components/preferences/donations/DonateInputAmount.tsx",