Init Chat Folders UI

This commit is contained in:
Jamie Kyle 2025-09-29 15:34:24 -07:00 committed by GitHub
commit ec7d07269d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 4082 additions and 1306 deletions

View file

@ -436,6 +436,24 @@ module.exports = {
], ],
}, },
}, },
{
files: ['ts/axo/**/*.tsx'],
rules: {
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-redeclare': [
'error',
{
ignoreDeclarationMerge: true,
},
],
'@typescript-eslint/explicit-module-boundary-types': [
'error',
{
allowHigherOrderFunctions: false,
},
],
},
},
], ],
rules: { rules: {

View file

@ -379,6 +379,38 @@
"messageformat": "Enter a username followed by a dot and its set of numbers.", "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" "description": "Description displayed under search input in left pane when looking up someone by username"
}, },
"icu:LeftPaneChatFolders__ItemLabel--All--Short": {
"messageformat": "All",
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats (needs to fit in very small space)"
},
"icu:LeftPaneChatFolders__ItemLabel--All": {
"messageformat": "All chats",
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats"
},
"icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount": {
"messageformat": "{maxCount, number}+",
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Badge Count > When over the max count (Example: 1000 or more would be 999+)"
},
"icu:LeftPaneChatFolders__Item__ContextMenu__MarkAllRead": {
"messageformat": "Mark all read",
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mark all unread chats in chat folder as read"
},
"icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications": {
"messageformat": "Mute notifications",
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications"
},
"icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll": {
"messageformat": "Unmute all",
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications > Sub-Menu > Unmute all"
},
"icu:LeftPaneChatFolders__Item__ContextMenu__UnmuteAll": {
"messageformat": "Unmute all",
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Unmute all chats in chat folder"
},
"icu:LeftPaneChatFolders__Item__ContextMenu__EditFolder": {
"messageformat": "Edit folder",
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Open settings for current chat folder"
},
"icu:CountryCodeSelect__placeholder": { "icu:CountryCodeSelect__placeholder": {
"messageformat": "Country code", "messageformat": "Country code",
"description": "Placeholder displayed as default value of country code select element" "description": "Placeholder displayed as default value of country code select element"
@ -447,6 +479,10 @@
"messageformat": "Mark as unread", "messageformat": "Mark as unread",
"description": "Shown in menu for conversation, and marks conversation as unread" "description": "Shown in menu for conversation, and marks conversation as unread"
}, },
"icu:markRead": {
"messageformat": "Mark read",
"description": "Shown in menu for conversation, and marks conversation read"
},
"icu:ConversationHeader__menu__selectMessages": { "icu:ConversationHeader__menu__selectMessages": {
"messageformat": "Select messages", "messageformat": "Select messages",
"description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation" "description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation"

View file

@ -1374,6 +1374,10 @@ $secondary-text-color: light-dark(
padding-block: 8px; padding-block: 8px;
padding-inline: 24px; padding-inline: 24px;
border-radius: 1px; border-radius: 1px;
&[data-dragging='true'] {
opacity: 50%;
}
} }
.Preferences__ChatFolders__ChatSelection__ItemAvatar { .Preferences__ChatFolders__ChatSelection__ItemAvatar {

View file

@ -410,3 +410,9 @@
} }
} }
} }
@property --axo-select-trigger-mask-start {
syntax: '<color>';
inherits: false;
initial-value: transparent;
}

View file

@ -368,6 +368,8 @@ export class ConversationController {
// because `conversation.format()` can return cached props by the // because `conversation.format()` can return cached props by the
// time this runs // time this runs
return { return {
id: conversation.get('id'),
type: conversation.get('type') === 'private' ? 'direct' : 'group',
activeAt: conversation.get('active_at') ?? undefined, activeAt: conversation.get('active_at') ?? undefined,
isArchived: conversation.get('isArchived'), isArchived: conversation.get('isArchived'),
markedUnread: conversation.get('markedUnread'), markedUnread: conversation.get('markedUnread'),
@ -383,15 +385,16 @@ export class ConversationController {
drop(window.storage.put('unreadCount', unreadStats.unreadCount)); drop(window.storage.put('unreadCount', unreadStats.unreadCount));
if (unreadStats.unreadCount > 0) { if (unreadStats.unreadCount > 0) {
window.IPC.setBadge(unreadStats.unreadCount); const total =
window.IPC.updateTrayIcon(unreadStats.unreadCount); unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount;
window.document.title = `${window.getTitle()} (${ window.IPC.setBadge(total);
unreadStats.unreadCount window.IPC.updateTrayIcon(total);
})`; window.document.title = `${window.getTitle()} (${total})`;
} else if (unreadStats.markedUnread) { } else if (unreadStats.readChatsMarkedUnreadCount > 0) {
window.IPC.setBadge('marked-unread'); const total = unreadStats.readChatsMarkedUnreadCount;
window.IPC.updateTrayIcon(1); window.IPC.setBadge(total);
window.document.title = `${window.getTitle()} (1)`; window.IPC.updateTrayIcon(total);
window.document.title = `${window.getTitle()} (${total})`;
} else { } else {
window.IPC.setBadge(0); window.IPC.setBadge(0);
window.IPC.updateTrayIcon(0); window.IPC.updateTrayIcon(0);

View file

@ -78,9 +78,13 @@ function CardButton(props: {
}) { }) {
return ( return (
<AriaClickable.SubWidget> <AriaClickable.SubWidget>
<AxoButton variant={props.variant} size="medium" onClick={props.onClick}> <AxoButton.Root
variant={props.variant}
size="medium"
onClick={props.onClick}
>
{props.children} {props.children}
</AxoButton> </AxoButton.Root>
</AriaClickable.SubWidget> </AriaClickable.SubWidget>
); );
} }

View file

@ -27,7 +27,7 @@ const Namespace = 'AriaClickable';
* <AriaClickable.HiddenTrigger aria-labelledby="see-more-1"/> * <AriaClickable.HiddenTrigger aria-labelledby="see-more-1"/>
* </p> * </p>
* <AriaClickable.SubWidget> * <AriaClickable.SubWidget>
* <AxoButton>Delete</AxoButton> * <AxoButton.Root>Delete</AxoButton.Root>
* </AriaClickable.SubWidget> * </AriaClickable.SubWidget>
* <AriaClickable.SubWidget> * <AriaClickable.SubWidget>
* <AxoLink>Edit</AxoLink> * <AxoLink>Edit</AxoLink>
@ -36,7 +36,6 @@ const Namespace = 'AriaClickable';
* ); * );
* ``` * ```
*/ */
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AriaClickable { export namespace AriaClickable {
type TriggerState = Readonly<{ type TriggerState = Readonly<{
hovered: boolean; hovered: boolean;

View file

@ -0,0 +1,57 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta } from '@storybook/react';
import React from 'react';
import { ExperimentalAxoBadge } from './AxoBadge.js';
import { tw } from './tw.js';
export default {
title: 'Axo/AriaBadge (Experimental)',
} satisfies Meta;
export function All(): JSX.Element {
const values: ReadonlyArray<ExperimentalAxoBadge.BadgeValue> = [
-1,
0,
1,
10,
123,
1234,
12345,
'mention',
'unread',
];
return (
<table className={tw('border-separate border-spacing-2 text-center')}>
<thead>
<th>size</th>
{values.map(value => {
return <th key={value}>{value}</th>;
})}
</thead>
<tbody>
{ExperimentalAxoBadge._getAllBadgeSizes().map(size => {
return (
<tr key={size}>
<th>{size}</th>
{values.map(value => {
return (
<td key={value} className={tw('')}>
<ExperimentalAxoBadge.Root
size={size}
value={value}
max={99}
maxDisplay="99+"
aria-label={null}
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
);
}

125
ts/axo/AxoBadge.tsx Normal file
View file

@ -0,0 +1,125 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC } from 'react';
import React, { memo, useMemo } from 'react';
import { AxoSymbol } from './AxoSymbol.js';
import type { TailwindStyles } from './tw.js';
import { tw } from './tw.js';
import { unreachable } from './_internal/assert.js';
const Namespace = 'AxoBadge';
/**
* @example Anatomy
* ```tsx
* <AxoBadge.Root aria-label="42 unread messages">
* <AxoBadge.Count value={42} max={999}/>
* </AxoBadge.Root>
*
* <AxoBadge.Root aria-label="Marked unread"/>
*
* <AxoBadge.Root aria-label="You were mentioned">
* <AxoBadge.Icon symbol="at" />
* </AxoBadge.Root>
* ````
*/
export namespace ExperimentalAxoBadge {
export type BadgeSize = 'sm' | 'md' | 'lg';
export type BadgeValue = number | 'mention' | 'unread';
const baseStyles = tw(
'flex size-fit items-center justify-center-safe overflow-clip',
'rounded-full font-semibold',
'bg-color-fill-primary text-label-primary-on-color',
'select-none'
);
type BadgeConfig = Readonly<{
rootStyles: TailwindStyles;
countStyles: TailwindStyles;
}>;
const BadgeSizes: Record<BadgeSize, BadgeConfig> = {
sm: {
rootStyles: tw(baseStyles, 'min-h-3.5 min-w-3.5 text-[8px] leading-3.5'),
countStyles: tw('px-[3px]'),
},
md: {
rootStyles: tw(baseStyles, 'min-h-4 min-w-4 text-[11px] leading-4'),
countStyles: tw('px-[4px]'),
},
lg: {
rootStyles: tw(baseStyles, 'min-h-4.5 min-w-4.5 text-[11px] leading-4.5'),
countStyles: tw('px-[5px]'),
},
};
export function _getAllBadgeSizes(): ReadonlyArray<BadgeSize> {
return Object.keys(BadgeSizes) as Array<BadgeSize>;
}
let cachedNumberFormat: Intl.NumberFormat;
// eslint-disable-next-line no-inner-declarations
function formatBadgeCount(
value: number,
max: number,
maxDisplay: string
): string {
if (value > max) {
return maxDisplay;
}
cachedNumberFormat ??= new Intl.NumberFormat();
return cachedNumberFormat.format(value);
}
/**
* Component: <AxoBadge.Root>
* --------------------------
*/
export type RootProps = Readonly<{
size: BadgeSize;
value: BadgeValue;
max: number;
maxDisplay: string;
'aria-label': string | null;
}>;
export const Root: FC<RootProps> = memo(props => {
const { value, max, maxDisplay } = props;
const config = BadgeSizes[props.size];
const children = useMemo(() => {
if (value === 'unread') {
return null;
}
if (value === 'mention') {
return (
<span aria-hidden className={tw('leading-none')}>
<AxoSymbol.InlineGlyph symbol="at" label={null} />
</span>
);
}
if (typeof value === 'number') {
return (
<span aria-hidden className={config.countStyles}>
{formatBadgeCount(value, max, maxDisplay)}
</span>
);
}
unreachable(value);
}, [value, max, maxDisplay, config]);
return (
<span
aria-label={props['aria-label'] ?? undefined}
className={config.rootStyles}
>
{children}
</span>
);
});
Root.displayName = `${Namespace}.Root`;
}

View file

@ -26,33 +26,33 @@ export function Basic(): JSX.Element {
{variants.map(variant => { {variants.map(variant => {
return ( return (
<div key={variant} className={tw('flex gap-1')}> <div key={variant} className={tw('flex gap-1')}>
<AxoButton <AxoButton.Root
variant={variant} variant={variant}
size={size} size={size}
onClick={action('click')} onClick={action('click')}
> >
{variant} {variant}
</AxoButton> </AxoButton.Root>
<AxoButton <AxoButton.Root
variant={variant} variant={variant}
size={size} size={size}
onClick={action('click')} onClick={action('click')}
disabled disabled
> >
Disabled Disabled
</AxoButton> </AxoButton.Root>
<AxoButton <AxoButton.Root
symbol="info" symbol="info"
variant={variant} variant={variant}
size={size} size={size}
onClick={action('click')} onClick={action('click')}
> >
Icon Icon
</AxoButton> </AxoButton.Root>
<AxoButton <AxoButton.Root
symbol="info" symbol="info"
variant={variant} variant={variant}
size={size} size={size}
@ -60,18 +60,18 @@ export function Basic(): JSX.Element {
disabled disabled
> >
Disabled Disabled
</AxoButton> </AxoButton.Root>
<AxoButton <AxoButton.Root
arrow arrow
variant={variant} variant={variant}
size={size} size={size}
onClick={action('click')} onClick={action('click')}
> >
Arrow Arrow
</AxoButton> </AxoButton.Root>
<AxoButton <AxoButton.Root
arrow arrow
variant={variant} variant={variant}
size={size} size={size}
@ -79,7 +79,7 @@ export function Basic(): JSX.Element {
disabled disabled
> >
Disabled Disabled
</AxoButton> </AxoButton.Root>
</div> </div>
); );
})} })}

View file

@ -139,15 +139,6 @@ type BaseButtonAttrs = Omit<
type AxoButtonVariant = keyof typeof AxoButtonVariants; type AxoButtonVariant = keyof typeof AxoButtonVariants;
type AxoButtonSize = keyof typeof AxoButtonSizes; type AxoButtonSize = keyof typeof AxoButtonSizes;
type AxoButtonProps = BaseButtonAttrs &
Readonly<{
variant: AxoButtonVariant;
size: AxoButtonSize;
symbol?: AxoSymbol.InlineGlyphName;
arrow?: boolean;
children: ReactNode;
}>;
export function _getAllAxoButtonVariants(): ReadonlyArray<AxoButtonVariant> { export function _getAllAxoButtonVariants(): ReadonlyArray<AxoButtonVariant> {
return Object.keys(AxoButtonVariants) as Array<AxoButtonVariant>; return Object.keys(AxoButtonVariants) as Array<AxoButtonVariant>;
} }
@ -156,8 +147,19 @@ export function _getAllAxoButtonSizes(): ReadonlyArray<AxoButtonSize> {
return Object.keys(AxoButtonSizes) as Array<AxoButtonSize>; return Object.keys(AxoButtonSizes) as Array<AxoButtonSize>;
} }
// eslint-disable-next-line import/export export namespace AxoButton {
export const AxoButton: FC<AxoButtonProps> = memo( export type Variant = AxoButtonVariant;
export type Size = AxoButtonSize;
export type RootProps = BaseButtonAttrs &
Readonly<{
variant: AxoButtonVariant;
size: AxoButtonSize;
symbol?: AxoSymbol.InlineGlyphName;
arrow?: boolean;
children: ReactNode;
}>;
export const Root: FC<RootProps> = memo(
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => { forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
const { variant, size, symbol, arrow, children, ...rest } = props; const { variant, size, symbol, arrow, children, ...rest } = props;
const variantStyles = assert( const variantStyles = assert(
@ -179,18 +181,13 @@ export const AxoButton: FC<AxoButtonProps> = memo(
<AxoSymbol.InlineGlyph symbol={symbol} label={null} /> <AxoSymbol.InlineGlyph symbol={symbol} label={null} />
)} )}
{children} {children}
{arrow && <AxoSymbol.InlineGlyph symbol="chevron-[end]" label={null} />} {arrow && (
<AxoSymbol.InlineGlyph symbol="chevron-[end]" label={null} />
)}
</button> </button>
); );
}) })
); );
AxoButton.displayName = `${Namespace}`; Root.displayName = `${Namespace}.Root`;
// eslint-disable-next-line max-len
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export
export namespace AxoButton {
export type Variant = AxoButtonVariant;
export type Size = AxoButtonSize;
export type Props = AxoButtonProps;
} }

View file

@ -17,7 +17,7 @@ function Template(props: {
const [checked, setChecked] = useState(props.defaultChecked); const [checked, setChecked] = useState(props.defaultChecked);
return ( return (
<label className={tw('my-2 flex items-center gap-2')}> <label className={tw('my-2 flex items-center gap-2')}>
<AxoCheckbox <AxoCheckbox.Root
checked={checked} checked={checked}
onCheckedChange={setChecked} onCheckedChange={setChecked}
disabled={props.disabled} disabled={props.disabled}

View file

@ -7,16 +7,16 @@ import { tw } from './tw.js';
const Namespace = 'AxoCheckbox'; const Namespace = 'AxoCheckbox';
type AxoCheckboxProps = Readonly<{ export namespace AxoCheckbox {
export type RootProps = Readonly<{
id?: string; id?: string;
checked: boolean; checked: boolean;
onCheckedChange: (nextChecked: boolean) => void; onCheckedChange: (nextChecked: boolean) => void;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
}>; }>;
// eslint-disable-next-line import/export export const Root = memo((props: RootProps) => {
export const AxoCheckbox = memo((props: AxoCheckboxProps) => {
return ( return (
<Checkbox.Root <Checkbox.Root
id={props.id} id={props.id}
@ -46,12 +46,7 @@ export const AxoCheckbox = memo((props: AxoCheckboxProps) => {
</Checkbox.Indicator> </Checkbox.Indicator>
</Checkbox.Root> </Checkbox.Root>
); );
}); });
AxoCheckbox.displayName = `${Namespace}`; Root.displayName = `${Namespace}.Root`;
// eslint-disable-next-line max-len
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export
export namespace AxoCheckbox {
export type Props = AxoCheckboxProps;
} }

View file

@ -48,7 +48,6 @@ const Namespace = 'AxoContextMenu';
* ) * )
* ``` * ```
*/ */
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoContextMenu { export namespace AxoContextMenu {
/** /**
* Component: <AxoContextMenu.Root> * Component: <AxoContextMenu.Root>
@ -71,7 +70,7 @@ export namespace AxoContextMenu {
export type TriggerProps = AxoBaseMenu.MenuTriggerProps; export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
export const Trigger: FC<TriggerProps> = memo(props => { export const Trigger: FC<TriggerProps> = memo(props => {
return <ContextMenu.Trigger>{props.children}</ContextMenu.Trigger>; return <ContextMenu.Trigger asChild>{props.children}</ContextMenu.Trigger>;
}); });
Trigger.displayName = `${Namespace}.Trigger`; Trigger.displayName = `${Namespace}.Trigger`;
@ -247,7 +246,7 @@ export namespace AxoContextMenu {
export const RadioGroup: FC<RadioGroupProps> = memo(props => { export const RadioGroup: FC<RadioGroupProps> = memo(props => {
return ( return (
<ContextMenu.RadioGroup <ContextMenu.RadioGroup
value={props.value} value={props.value ?? undefined}
onValueChange={props.onValueChange} onValueChange={props.onValueChange}
className={AxoBaseMenu.menuRadioGroupStyles} className={AxoBaseMenu.menuRadioGroupStyles}
> >
@ -283,7 +282,11 @@ export namespace AxoContextMenu {
</AxoBaseMenu.ItemCheckPlaceholder> </AxoBaseMenu.ItemCheckPlaceholder>
</AxoBaseMenu.ItemLeadingSlot> </AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemContentSlot> <AxoBaseMenu.ItemContentSlot>
{props.symbol && <AxoBaseMenu.ItemSymbol symbol={props.symbol} />} {props.symbol && (
<span className={tw('me-2')}>
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
</span>
)}
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText> <AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
{props.keyboardShortcut && ( {props.keyboardShortcut && (
<AxoBaseMenu.ItemKeyboardShortcut <AxoBaseMenu.ItemKeyboardShortcut

View file

@ -19,9 +19,9 @@ export function Basic(): JSX.Element {
<div className={tw('flex h-96 w-full items-center justify-center')}> <div className={tw('flex h-96 w-full items-center justify-center')}>
<AxoDropdownMenu.Root> <AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger> <AxoDropdownMenu.Trigger>
<AxoButton variant="secondary" size="medium"> <AxoButton.Root variant="secondary" size="medium">
Open Dropdown Menu Open Dropdown Menu
</AxoButton> </AxoButton.Root>
</AxoDropdownMenu.Trigger> </AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content> <AxoDropdownMenu.Content>
<AxoDropdownMenu.Item <AxoDropdownMenu.Item

View file

@ -51,7 +51,6 @@ const Namespace = 'AxoDropdownMenu';
* ) * )
* ``` * ```
*/ */
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoDropdownMenu { export namespace AxoDropdownMenu {
/** /**
* Component: <AxoDropdownMenu.Root> * Component: <AxoDropdownMenu.Root>
@ -261,7 +260,7 @@ export namespace AxoDropdownMenu {
export const RadioGroup: FC<RadioGroupProps> = memo(props => { export const RadioGroup: FC<RadioGroupProps> = memo(props => {
return ( return (
<DropdownMenu.RadioGroup <DropdownMenu.RadioGroup
value={props.value} value={props.value ?? undefined}
onValueChange={props.onValueChange} onValueChange={props.onValueChange}
className={AxoBaseMenu.menuRadioGroupStyles} className={AxoBaseMenu.menuRadioGroupStyles}
> >
@ -297,7 +296,11 @@ export namespace AxoDropdownMenu {
</AxoBaseMenu.ItemCheckPlaceholder> </AxoBaseMenu.ItemCheckPlaceholder>
</AxoBaseMenu.ItemLeadingSlot> </AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemContentSlot> <AxoBaseMenu.ItemContentSlot>
{props.symbol && <AxoBaseMenu.ItemSymbol symbol={props.symbol} />} {props.symbol && (
<span className={tw('me-2')}>
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
</span>
)}
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText> <AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
{props.keyboardShortcut && ( {props.keyboardShortcut && (
<AxoBaseMenu.ItemKeyboardShortcut <AxoBaseMenu.ItemKeyboardShortcut

View file

@ -0,0 +1,120 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta } from '@storybook/react';
import React, { useState } from 'react';
import { ExperimentalAxoSegmentedControl } from './AxoSegmentedControl.js';
import { tw } from './tw.js';
export default {
title: 'Axo/AxoSegmentedControl (Experimental)',
} satisfies Meta;
function Template(props: {
variant: ExperimentalAxoSegmentedControl.Variant;
width: ExperimentalAxoSegmentedControl.RootWidth;
itemWidth: ExperimentalAxoSegmentedControl.ItemWidth;
longNames?: boolean;
includeBadges?: boolean;
}) {
const [value, setValue] = useState('inbox');
return (
<>
<h2 className={tw('font-mono type-title-medium')}>
{`variant=${props.variant}, `}
{`width=${props.width}, `}
{`itemWidth=${props.itemWidth}`}
</h2>
<ExperimentalAxoSegmentedControl.Root
variant={props.variant}
width={props.width}
itemWidth={props.itemWidth}
value={value}
onValueChange={newValue => {
if (newValue != null) {
setValue(newValue);
}
}}
>
<ExperimentalAxoSegmentedControl.Item value="inbox">
<ExperimentalAxoSegmentedControl.ItemText>
{props.longNames && 'Really Really Long Name For '}
Inbox
</ExperimentalAxoSegmentedControl.ItemText>
{props.includeBadges && (
<ExperimentalAxoSegmentedControl.ExperimentalItemBadge
value={42}
max={99}
maxDisplay="99+"
aria-label={null}
/>
)}
</ExperimentalAxoSegmentedControl.Item>
<ExperimentalAxoSegmentedControl.Item value="drafts">
<ExperimentalAxoSegmentedControl.ItemText>
{props.longNames && 'Really Really Long Name For '}
Drafts
</ExperimentalAxoSegmentedControl.ItemText>
{props.includeBadges && (
<ExperimentalAxoSegmentedControl.ExperimentalItemBadge
value="mention"
max={99}
maxDisplay="99+"
aria-label={null}
/>
)}
</ExperimentalAxoSegmentedControl.Item>
<ExperimentalAxoSegmentedControl.Item value="sent">
<ExperimentalAxoSegmentedControl.ItemText>
Sent
</ExperimentalAxoSegmentedControl.ItemText>
{props.includeBadges && (
<ExperimentalAxoSegmentedControl.ExperimentalItemBadge
value="unread"
max={99}
maxDisplay="99+"
aria-label={null}
/>
)}
</ExperimentalAxoSegmentedControl.Item>
</ExperimentalAxoSegmentedControl.Root>
</>
);
}
function TemplateVariants(props: {
longNames?: boolean;
includeBadges?: boolean;
}) {
return (
<div className={tw('grid gap-4')}>
<Template variant="track" width="full" itemWidth="fit" {...props} />
<Template variant="no-track" width="full" itemWidth="fit" {...props} />
<Template variant="track" width="full" itemWidth="equal" {...props} />
<Template variant="no-track" width="full" itemWidth="equal" {...props} />
<Template variant="track" width="fit" itemWidth="fit" {...props} />
<Template variant="no-track" width="fit" itemWidth="fit" {...props} />
<Template variant="track" width="fit" itemWidth="equal" {...props} />
<Template variant="no-track" width="fit" itemWidth="equal" {...props} />
</div>
);
}
export function Basic(): JSX.Element {
return <TemplateVariants />;
}
export function LongNames(): JSX.Element {
return <TemplateVariants longNames />;
}
export function WithBadges(): JSX.Element {
return <TemplateVariants includeBadges />;
}
export function LongNamesWithBadges(): JSX.Element {
return <TemplateVariants longNames includeBadges />;
}

View file

@ -0,0 +1,130 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ButtonHTMLAttributes, FC, ForwardedRef, ReactNode } from 'react';
import React, { forwardRef, memo, useCallback } from 'react';
import { ToggleGroup } from 'radix-ui';
import { ExperimentalAxoBaseSegmentedControl } from './_internal/AxoBaseSegmentedControl.js';
const Namespace = 'AxoSegmentedControl';
/**
* @example Anatomy
* ```tsx
* <AxoSegmentedControl.Root>
* <AxoSegmentedControl.Item>
* <AxoSegmentedControl.ItemText/>
* <AxoSegmentedControl.ItemBadge/>
* </AxoSegmentedControl.Item>
* </AxoSegmentedControl.Root>
* ```
*/
export namespace ExperimentalAxoSegmentedControl {
export type Variant = ExperimentalAxoBaseSegmentedControl.Variant;
/**
* Component: <AxoSegmentedControl.Root>
* -------------------------------------
*/
export type RootWidth = ExperimentalAxoBaseSegmentedControl.RootWidth;
export type ItemWidth = ExperimentalAxoBaseSegmentedControl.ItemWidth;
export type RootProps = Readonly<{
width: RootWidth;
itemWidth: ItemWidth;
variant: Variant;
value: string | null;
onValueChange: (newValue: string | null) => void;
children: ReactNode;
}>;
export const Root = memo((props: RootProps) => {
const { onValueChange } = props;
const handleValueChange = useCallback(
(newValue: string) => {
onValueChange(newValue === '' ? null : newValue);
},
[onValueChange]
);
return (
<ToggleGroup.Root
type="single"
value={props.value ?? undefined}
onValueChange={handleValueChange}
orientation="horizontal"
loop
rovingFocus
asChild
>
<ExperimentalAxoBaseSegmentedControl.Root
value={props.value}
variant={props.variant}
width={props.width}
itemWidth={props.itemWidth}
>
{props.children}
</ExperimentalAxoBaseSegmentedControl.Root>
</ToggleGroup.Root>
);
});
Root.displayName = `${Namespace}.Root`;
/**
* Component: <AxoSegmentedControl.Item>
* -------------------------------------
*/
export type ItemProps = ButtonHTMLAttributes<HTMLButtonElement> &
Readonly<{
value: string;
children: ReactNode;
}>;
export const Item: FC<ItemProps> = memo(
forwardRef((props: ItemProps, ref: ForwardedRef<HTMLButtonElement>) => {
const { value, children, ...rest } = props;
return (
<ToggleGroup.Item {...rest} ref={ref} value={value} asChild>
<ExperimentalAxoBaseSegmentedControl.Item value={value}>
{children}
</ExperimentalAxoBaseSegmentedControl.Item>
</ToggleGroup.Item>
);
})
);
Item.displayName = `${Namespace}.Item`;
/**
* Component: <AxoSegmentedControl.ItemText>
* -----------------------------------------
*/
export type ItemTextProps = Readonly<{
maxWidth?: ExperimentalAxoBaseSegmentedControl.ItemMaxWidth;
children: ReactNode;
}>;
export const ItemText: FC<ItemTextProps> = memo((props: ItemTextProps) => {
return (
<ExperimentalAxoBaseSegmentedControl.ItemText maxWidth={props.maxWidth}>
{props.children}
</ExperimentalAxoBaseSegmentedControl.ItemText>
);
});
ItemText.displayName = `${Namespace}.ItemText`;
/**
* Component: <AxoSegmentedControl.ItemBadge>
* ------------------------------------------
*/
export type ExperimentalItemBadgeProps =
ExperimentalAxoBaseSegmentedControl.ExperimentalItemBadgeProps;
export const { ExperimentalItemBadge } = ExperimentalAxoBaseSegmentedControl;
}

View file

@ -1,5 +1,6 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { Meta } from '@storybook/react'; import type { Meta } from '@storybook/react';
import { AxoSelect } from './AxoSelect.js'; import { AxoSelect } from './AxoSelect.js';
@ -9,6 +10,18 @@ export default {
title: 'Axo/AxoSelect', title: 'Axo/AxoSelect',
} satisfies Meta; } satisfies Meta;
function TemplateItem(props: {
value: string;
disabled?: boolean;
children: ReactNode;
}): JSX.Element {
return (
<AxoSelect.Item value={props.value} disabled={props.disabled}>
<AxoSelect.ItemText>{props.children}</AxoSelect.ItemText>
</AxoSelect.Item>
);
}
function Template(props: { function Template(props: {
disabled?: boolean; disabled?: boolean;
triggerWidth?: AxoSelect.TriggerWidth; triggerWidth?: AxoSelect.TriggerWidth;
@ -29,29 +42,29 @@ function Template(props: {
<AxoSelect.Content> <AxoSelect.Content>
<AxoSelect.Group> <AxoSelect.Group>
<AxoSelect.Label>Fruits</AxoSelect.Label> <AxoSelect.Label>Fruits</AxoSelect.Label>
<AxoSelect.Item value="apple">Apple</AxoSelect.Item> <TemplateItem value="apple">Apple</TemplateItem>
<AxoSelect.Item value="banana">Banana</AxoSelect.Item> <TemplateItem value="banana">Banana</TemplateItem>
<AxoSelect.Item value="blueberry">Blueberry</AxoSelect.Item> <TemplateItem value="blueberry">Blueberry</TemplateItem>
<AxoSelect.Item value="grapes">Grapes</AxoSelect.Item> <TemplateItem value="grapes">Grapes</TemplateItem>
<AxoSelect.Item value="pineapple">Pineapple</AxoSelect.Item> <TemplateItem value="pineapple">Pineapple</TemplateItem>
</AxoSelect.Group> </AxoSelect.Group>
<AxoSelect.Separator /> <AxoSelect.Separator />
<AxoSelect.Group> <AxoSelect.Group>
<AxoSelect.Label>Vegetables</AxoSelect.Label> <AxoSelect.Label>Vegetables</AxoSelect.Label>
<AxoSelect.Item value="aubergine">Aubergine</AxoSelect.Item> <TemplateItem value="aubergine">Aubergine</TemplateItem>
<AxoSelect.Item value="broccoli">Broccoli</AxoSelect.Item> <TemplateItem value="broccoli">Broccoli</TemplateItem>
<AxoSelect.Item value="carrot" disabled> <TemplateItem value="carrot" disabled>
Carrot Carrot
</AxoSelect.Item> </TemplateItem>
<AxoSelect.Item value="leek">Leek</AxoSelect.Item> <TemplateItem value="leek">Leek</TemplateItem>
</AxoSelect.Group> </AxoSelect.Group>
<AxoSelect.Separator /> <AxoSelect.Separator />
<AxoSelect.Group> <AxoSelect.Group>
<AxoSelect.Label>Meat</AxoSelect.Label> <AxoSelect.Label>Meat</AxoSelect.Label>
<AxoSelect.Item value="beef">Beef</AxoSelect.Item> <TemplateItem value="beef">Beef</TemplateItem>
<AxoSelect.Item value="chicken">Chicken</AxoSelect.Item> <TemplateItem value="chicken">Chicken</TemplateItem>
<AxoSelect.Item value="lamb">Lamb</AxoSelect.Item> <TemplateItem value="lamb">Lamb</TemplateItem>
<AxoSelect.Item value="pork">Pork</AxoSelect.Item> <TemplateItem value="pork">Pork</TemplateItem>
</AxoSelect.Group> </AxoSelect.Group>
</AxoSelect.Content> </AxoSelect.Content>
</AxoSelect.Root> </AxoSelect.Root>

View file

@ -7,6 +7,7 @@ import { AxoBaseMenu } from './_internal/AxoBaseMenu.js';
import { AxoSymbol } from './AxoSymbol.js'; import { AxoSymbol } from './AxoSymbol.js';
import type { TailwindStyles } from './tw.js'; import type { TailwindStyles } from './tw.js';
import { tw } from './tw.js'; import { tw } from './tw.js';
import { ExperimentalAxoBadge } from './AxoBadge.js';
const Namespace = 'AxoSelect'; const Namespace = 'AxoSelect';
@ -19,18 +20,22 @@ const Namespace = 'AxoSelect';
* <AxoSelect.Root> * <AxoSelect.Root>
* <AxoSelect.Trigger/> * <AxoSelect.Trigger/>
* <AxoSelect.Content> * <AxoSelect.Content>
* <AxoSelect.Item/> * <AxoSelect.Item>
* <AxoSelect.ItemText/>
* <AxoSelect.ItemBadge/>
* </AxoSelect.Item>
* <AxoSelect.Separator/> * <AxoSelect.Separator/>
* <AxoSelect.Group> * <AxoSelect.Group>
* <AxoSelect.Label/> * <AxoSelect.Label/>
* <AxoSelect.Item/> * <AxoSelect.Item>
* <AxoSelect.ItemText/>
* </AxoSelect.Item>
* </AxoSelect.Group> * </AxoSelect.Group>
* </AxoSelect.Content> * </AxoSelect.Content>
* </AxoSelect.Root> * </AxoSelect.Root>
* ); * );
* ``` * ```
*/ */
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoSelect { export namespace AxoSelect {
/** /**
* Component: <AxoSelect.Root> * Component: <AxoSelect.Root>
@ -78,15 +83,19 @@ export namespace AxoSelect {
* --------------------------- * ---------------------------
*/ */
export type TriggerVariant = 'default' | 'floating' | 'borderless';
export type TriggerWidth = 'hug' | 'full';
export type TriggerChevron = 'always' | 'on-hover';
const baseTriggerStyles = tw( const baseTriggerStyles = tw(
'flex', 'group relative flex items-center',
'rounded-full py-[5px] ps-3 pe-2.5 type-body-medium text-label-primary', 'rounded-full text-start type-body-medium text-label-primary',
'disabled:text-label-disabled', 'disabled:text-label-disabled',
'outline-0 outline-border-focused focused:outline-[2.5px]', 'outline-0 outline-border-focused focused:outline-[2.5px]',
'forced-colors:border' 'forced-colors:border'
); );
const TriggerVariants = { const TriggerVariants: Record<TriggerVariant, TailwindStyles> = {
default: tw( default: tw(
baseTriggerStyles, baseTriggerStyles,
'bg-fill-secondary', 'bg-fill-secondary',
@ -104,19 +113,52 @@ export namespace AxoSelect {
'hovered:bg-fill-secondary', 'hovered:bg-fill-secondary',
'pressed:bg-fill-secondary-pressed' 'pressed:bg-fill-secondary-pressed'
), ),
} as const satisfies Record<string, TailwindStyles>; };
const TriggerWidths = { const TriggerWidths: Record<TriggerWidth, TailwindStyles> = {
hug: tw(), hug: tw(),
full: tw('w-full'), full: tw('w-full'),
}; };
export type TriggerVariant = keyof typeof TriggerVariants; type TriggerChevronConfig = {
export type TriggerWidth = keyof typeof TriggerWidths; chevronStyles: TailwindStyles;
contentStyles: TailwindStyles;
};
const baseContentStyles = tw('flex min-w-0 flex-1');
const TriggerChevrons: Record<TriggerChevron, TriggerChevronConfig> = {
always: {
chevronStyles: tw('ps-2 pe-2.5'),
contentStyles: tw(baseContentStyles, 'py-[5px] ps-3'),
},
'on-hover': {
chevronStyles: tw(
'absolute inset-y-0 end-0 w-9.5',
'flex items-center justify-end pe-2',
'opacity-0 group-focus:opacity-100 group-data-[state=open]:opacity-100 group-hovered:opacity-100',
'transition-opacity duration-150'
),
contentStyles: tw(
baseContentStyles,
'px-3 py-[5px]',
'[--axo-select-trigger-mask-start:black]',
'group-hovered:[--axo-select-trigger-mask-start:transparent]',
'group-focus:[--axo-select-trigger-mask-start:transparent]',
'group-data-[state=open]:[--axo-select-trigger-mask-start:transparent]',
'[mask-image:linear-gradient(to_left,var(--axo-select-trigger-mask-start)_19px,black_38px)]',
'rtl:[mask-image:linear-gradient(to_right,var(--axo-select-trigger-mask-start)_19px,black_38px)]',
'[mask-repeat:no-repeat]',
'[mask-position:right] rtl:[mask-position:left]',
'[transition-property:--axo-select-trigger-mask-start] duration-150'
),
},
};
export type TriggerProps = Readonly<{ export type TriggerProps = Readonly<{
variant?: TriggerVariant; variant?: TriggerVariant;
width?: TriggerWidth; width?: TriggerWidth;
chevron?: TriggerChevron;
placeholder: string; placeholder: string;
children?: ReactNode; children?: ReactNode;
}>; }>;
@ -129,16 +171,20 @@ export namespace AxoSelect {
export const Trigger: FC<TriggerProps> = memo(props => { export const Trigger: FC<TriggerProps> = memo(props => {
const variant = props.variant ?? 'default'; const variant = props.variant ?? 'default';
const width = props.width ?? 'hug'; const width = props.width ?? 'hug';
const chevron = props.chevron ?? 'always';
const variantStyles = TriggerVariants[variant]; const variantStyles = TriggerVariants[variant];
const widthStyles = TriggerWidths[width]; const widthStyles = TriggerWidths[width];
const chevronConfig = TriggerChevrons[chevron];
return ( return (
<Select.Trigger className={tw(variantStyles, widthStyles)}> <Select.Trigger className={tw(variantStyles, widthStyles)}>
<div className={chevronConfig.contentStyles}>
<AxoBaseMenu.ItemText> <AxoBaseMenu.ItemText>
<Select.Value placeholder={props.placeholder}> <Select.Value placeholder={props.placeholder}>
{props.children} {props.children}
</Select.Value> </Select.Value>
</AxoBaseMenu.ItemText> </AxoBaseMenu.ItemText>
<Select.Icon className={tw('ms-2')}> </div>
<Select.Icon className={chevronConfig.chevronStyles}>
<AxoSymbol.Icon symbol="chevron-down" size={14} label={null} /> <AxoSymbol.Icon symbol="chevron-down" size={14} label={null} />
</Select.Icon> </Select.Icon>
</Select.Trigger> </Select.Trigger>
@ -152,7 +198,29 @@ export namespace AxoSelect {
* ------------------------------ * ------------------------------
*/ */
export type ContentPosition = 'item-aligned' | 'dropdown';
type ContentPositionConfig = {
position: Select.SelectContentProps['position'];
alignOffset?: Select.SelectContentProps['alignOffset'];
collisionPadding?: Select.SelectContentProps['collisionPadding'];
sideOffset?: Select.SelectContentProps['sideOffset'];
};
const ContentPositions: Record<ContentPosition, ContentPositionConfig> = {
'item-aligned': {
position: 'item-aligned',
},
dropdown: {
position: 'popper',
alignOffset: 0,
collisionPadding: 6,
sideOffset: 8,
},
};
export type ContentProps = Readonly<{ export type ContentProps = Readonly<{
position?: ContentPosition;
children: ReactNode; children: ReactNode;
}>; }>;
@ -161,9 +229,17 @@ export namespace AxoSelect {
* Uses a portal to render the content part into the `body`. * Uses a portal to render the content part into the `body`.
*/ */
export const Content: FC<ContentProps> = memo(props => { export const Content: FC<ContentProps> = memo(props => {
const position = props.position ?? 'item-aligned';
const positionConfig = ContentPositions[position];
return ( return (
<Select.Portal> <Select.Portal>
<Select.Content className={AxoBaseMenu.selectContentStyles}> <Select.Content
className={AxoBaseMenu.selectContentStyles}
position={positionConfig.position}
alignOffset={positionConfig.alignOffset}
collisionPadding={positionConfig.collisionPadding}
sideOffset={positionConfig.sideOffset}
>
<Select.ScrollUpButton <Select.ScrollUpButton
className={tw( className={tw(
'flex items-center justify-center p-1 text-label-primary' 'flex items-center justify-center p-1 text-label-primary'
@ -197,6 +273,7 @@ export namespace AxoSelect {
value: string; value: string;
disabled?: boolean; disabled?: boolean;
textValue?: string; textValue?: string;
symbol?: AxoSymbol.IconName;
children: ReactNode; children: ReactNode;
}>; }>;
@ -219,9 +296,12 @@ export namespace AxoSelect {
</AxoBaseMenu.ItemCheckPlaceholder> </AxoBaseMenu.ItemCheckPlaceholder>
</AxoBaseMenu.ItemLeadingSlot> </AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemContentSlot> <AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText> {props.symbol && (
<Select.ItemText>{props.children}</Select.ItemText> <span className={tw('me-2')}>
</AxoBaseMenu.ItemText> <AxoBaseMenu.ItemSymbol symbol={props.symbol} />
</span>
)}
{props.children}
</AxoBaseMenu.ItemContentSlot> </AxoBaseMenu.ItemContentSlot>
</Select.Item> </Select.Item>
); );
@ -229,9 +309,55 @@ export namespace AxoSelect {
Item.displayName = `${Namespace}.Content`; Item.displayName = `${Namespace}.Content`;
/**
* Component: <AxoSelect.ItemText>
*/
export type ItemTextProps = Readonly<{
children: ReactNode;
}>;
export const ItemText: FC<ItemTextProps> = memo(props => {
return (
<AxoBaseMenu.ItemText>
<Select.ItemText>{props.children}</Select.ItemText>
</AxoBaseMenu.ItemText>
);
});
ItemText.displayName = `${Namespace}.ItemText`;
/**
* Component: <AxoSelect.ItemBadge>
* --------------------------------
*/
export type ExperimentalItemBadgeProps = Omit<
ExperimentalAxoBadge.RootProps,
'size'
>;
export const ExperimentalItemBadge = memo(
(props: ExperimentalItemBadgeProps) => {
return (
<span className={tw('ms-[5px]')}>
<ExperimentalAxoBadge.Root
size="sm"
value={props.value}
max={props.max}
maxDisplay={props.maxDisplay}
aria-label={props['aria-label']}
/>
</span>
);
}
);
ExperimentalItemBadge.displayName = `${Namespace}.ItemBadge`;
/** /**
* Component: <AxoSelect.Group> * Component: <AxoSelect.Group>
* --------------------------- * ----------------------------
*/ */
export type GroupProps = Readonly<{ export type GroupProps = Readonly<{

View file

@ -17,7 +17,7 @@ function Template(props: {
const [checked, setChecked] = useState(props.defaultChecked); const [checked, setChecked] = useState(props.defaultChecked);
return ( return (
<label className={tw('my-2 flex items-center gap-2')}> <label className={tw('my-2 flex items-center gap-2')}>
<AxoSwitch <AxoSwitch.Root
checked={checked} checked={checked}
onCheckedChange={setChecked} onCheckedChange={setChecked}
disabled={props.disabled} disabled={props.disabled}

View file

@ -7,15 +7,15 @@ import { AxoSymbol } from './AxoSymbol.js';
const Namespace = 'AxoSwitch'; const Namespace = 'AxoSwitch';
type AxoSwitchProps = Readonly<{ export namespace AxoSwitch {
export type RootProps = Readonly<{
checked: boolean; checked: boolean;
onCheckedChange: (nextChecked: boolean) => void; onCheckedChange: (nextChecked: boolean) => void;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
}>; }>;
// eslint-disable-next-line import/export export const Root = memo((props: RootProps) => {
export const AxoSwitch = memo((props: AxoSwitchProps) => {
return ( return (
<Switch.Root <Switch.Root
checked={props.checked} checked={props.checked}
@ -72,12 +72,7 @@ export const AxoSwitch = memo((props: AxoSwitchProps) => {
/> />
</Switch.Root> </Switch.Root>
); );
}); });
AxoSwitch.displayName = `${Namespace}`; Root.displayName = `${Namespace}.Root`;
// eslint-disable-next-line max-len
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export
export namespace AxoSwitch {
export type Props = AxoSwitchProps;
} }

View file

@ -18,7 +18,6 @@ const { useDirection } = Direction;
const Namespace = 'AxoSymbol'; const Namespace = 'AxoSymbol';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoSymbol { export namespace AxoSymbol {
const symbolStyles = tw('font-symbols select-none'); const symbolStyles = tw('font-symbols select-none');
const labelStyles = tw('select-none'); const labelStyles = tw('select-none');
@ -83,7 +82,7 @@ export namespace AxoSymbol {
export type IconProps = Readonly<{ export type IconProps = Readonly<{
size: IconSize; size: IconSize;
symbol: AxoSymbolIconName; symbol: IconName;
label: string | null; label: string | null;
}>; }>;
@ -93,7 +92,6 @@ export namespace AxoSymbol {
export const Icon: FC<IconProps> = memo(props => { export const Icon: FC<IconProps> = memo(props => {
const config = IconSizes[props.size]; const config = IconSizes[props.size];
const direction = useDirection(); const direction = useDirection();
const glyph = getAxoSymbolIcon(props.symbol, direction); const glyph = getAxoSymbolIcon(props.symbol, direction);
const content = useRenderSymbol(glyph, props.label); const content = useRenderSymbol(glyph, props.label);

View file

@ -5,7 +5,6 @@ import type { ReactNode } from 'react';
import { tw } from '../tw.js'; import { tw } from '../tw.js';
import { AxoSymbol } from '../AxoSymbol.js'; import { AxoSymbol } from '../AxoSymbol.js';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoBaseMenu { export namespace AxoBaseMenu {
// <Content/SubContent> // <Content/SubContent>
const baseContentStyles = tw( const baseContentStyles = tw(
@ -114,7 +113,7 @@ export namespace AxoBaseMenu {
* ----------------------- * -----------------------
*/ */
export const itemTextStyles = tw('flex-1 truncate text-start'); export const itemTextStyles = tw('flex-auto grow-0 truncate text-start');
export type ItemTextProps = Readonly<{ export type ItemTextProps = Readonly<{
children: ReactNode; children: ReactNode;
@ -283,7 +282,7 @@ export namespace AxoBaseMenu {
/** /**
* The value of the selected item in the group. * The value of the selected item in the group.
*/ */
value: string; value: string | null;
/** /**
* Event handler called when the value changes. * Event handler called when the value changes.

View file

@ -0,0 +1,269 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type {
ButtonHTMLAttributes,
CSSProperties,
FC,
ForwardedRef,
HTMLAttributes,
ReactNode,
} from 'react';
import React, {
createContext,
forwardRef,
memo,
useContext,
useId,
useMemo,
} from 'react';
import type { Transition } from 'framer-motion';
import { motion } from 'framer-motion';
import type { TailwindStyles } from '../tw.js';
import { tw } from '../tw.js';
import { ExperimentalAxoBadge } from '../AxoBadge.js';
const Namespace = 'AxoBaseSegmentedControl';
/**
* Used to share styles/animations for SegmentedControls, Toolbar ToggleGroups,
* and Tabs.
*
* @example Anatomy
* ```tsx
* <ToggleGroup.Root asChild>
* <AxoBaseSegmentedControl.Root>
* <ToggleGroup.Item asChild>
* <AxoBaseSegmentedControl.Item/>
* </ToggleGroup.Item>
* </AxoBaseSegmentedControl.Root>
* </ToggleGroup.Root>
* ```
*/
export namespace ExperimentalAxoBaseSegmentedControl {
export type Variant = 'track' | 'no-track';
export type RootWidth = 'fit' | 'full';
export type ItemWidth = 'fit' | 'equal';
export type RootValue = string | ReadonlyArray<string> | null;
type RootContextType = Readonly<{
id: string;
value: RootValue;
variant: Variant;
rootWidth: RootWidth;
itemWidth: ItemWidth;
}>;
const RootContext = createContext<RootContextType | null>(null);
// eslint-disable-next-line no-inner-declarations
function useRootContext(componentName: string): RootContextType {
const context = useContext(RootContext);
if (context == null) {
throw new Error(
`<${Namespace}.${componentName}> must be wrapped with <${Namespace}.Root>`
);
}
return context;
}
type VariantConfig = {
rootStyles: TailwindStyles;
indicatorStyles: TailwindStyles;
};
const base: VariantConfig = {
rootStyles: tw(
'flex min-w-min flex-row items-center justify-items-stretch',
'rounded-full',
'forced-colors:border',
'forced-colors:border-[ButtonBorder]'
),
indicatorStyles: tw(
'pointer-events-none absolute inset-0 z-10 rounded-full',
'forced-colors:bg-[Highlight]'
),
};
const Variants: Record<Variant, VariantConfig> = {
track: {
rootStyles: tw(base.rootStyles, 'bg-fill-secondary'),
indicatorStyles: tw(
base.indicatorStyles,
'bg-fill-primary',
'shadow-elevation-1'
),
},
'no-track': {
rootStyles: tw(base.rootStyles),
indicatorStyles: tw(base.indicatorStyles, 'bg-fill-selected'),
},
};
const IndicatorTransition: Transition = {
type: 'spring',
stiffness: 422,
damping: 37.3,
mass: 1,
};
/**
* Component: <AxoBaseSegmentedControl.Root>
* -----------------------------------------
*/
const RootWidths: Record<RootWidth, TailwindStyles> = {
fit: tw('w-fit'),
full: tw('w-full'),
};
export type RootProps = HTMLAttributes<HTMLDivElement> &
Readonly<{
value: RootValue;
variant: Variant;
width: RootWidth;
itemWidth: ItemWidth;
}>;
export const Root: FC<RootProps> = memo(
forwardRef((props, ref: ForwardedRef<HTMLDivElement>) => {
const { value, variant, width, itemWidth, ...rest } = props;
const id = useId();
const config = Variants[variant];
const widthStyles = RootWidths[width];
const context = useMemo(() => {
return { id, value, variant, rootWidth: width, itemWidth };
}, [id, value, variant, width, itemWidth]);
return (
<RootContext.Provider value={context}>
<div
ref={ref}
{...rest}
className={tw(config.rootStyles, widthStyles)}
/>
</RootContext.Provider>
);
})
);
Root.displayName = `${Namespace}.Root`;
/**
* Component: <AxoBaseSegmentedControl.Item>
* -----------------------------------------
*/
const ItemWidths: Record<ItemWidth, TailwindStyles> = {
fit: tw('min-w-0 shrink grow basis-auto'),
equal: tw('flex-1'),
};
export type ItemProps = ButtonHTMLAttributes<HTMLButtonElement> &
Readonly<{
value: string;
}>;
export const Item: FC<ItemProps> = memo(
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
const { value, ...rest } = props;
const context = useRootContext('Item');
const config = Variants[context.variant];
const itemWidthStyles = ItemWidths[context.itemWidth];
const isSelected = useMemo(() => {
if (context.value == null) {
return false;
}
if (Array.isArray(context.value)) {
return context.value.includes(value);
}
return context.value === value;
}, [value, context.value]);
return (
<button
ref={ref}
type="button"
{...rest}
className={tw(
'group relative flex min-w-0 items-center justify-center px-3 py-[5px]',
'cursor-pointer rounded-full type-body-medium font-medium text-label-primary',
'outline-border-focused not-forced-colors:outline-0 not-forced-colors:focused:outline-[2.5px]',
'forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]',
itemWidthStyles,
isSelected && tw('forced-colors:text-[HighlightText]')
)}
>
{props.children}
{isSelected && (
<motion.span
layoutId={`${context.id}.Indicator`}
layoutDependency={isSelected}
className={config.indicatorStyles}
transition={IndicatorTransition}
style={{ borderRadius: 14 }}
/>
)}
</button>
);
})
);
Item.displayName = `${Namespace}.Item`;
/**
* Component: <AxoBaseSegmentedControl.ItemText>
* ---------------------------------------------
*/
export type ItemMaxWidth = CSSProperties['maxWidth'];
export type ItemTextProps = Readonly<{
maxWidth?: ItemMaxWidth;
children: ReactNode;
}>;
export const ItemText: FC<ItemTextProps> = memo(props => {
return (
<span
className={tw('relative z-20 block truncate forced-color-adjust-none')}
style={{ maxWidth: props.maxWidth }}
>
{props.children}
</span>
);
});
ItemText.displayName = `${Namespace}.ItemText`;
/**
* Component: <AxoBaseSegmentedControl.ItemBadge>
* ----------------------------------------------
*/
export type ExperimentalItemBadgeProps = Omit<
ExperimentalAxoBadge.RootProps,
'size'
>;
export const ExperimentalItemBadge = memo(
(props: ExperimentalItemBadgeProps) => {
return (
<span className={tw('relative z-20 ms-[5px]')}>
<ExperimentalAxoBadge.Root
size="md"
value={props.value}
max={props.max}
maxDisplay={props.maxDisplay}
aria-label={props['aria-label']}
/>
</span>
);
}
);
ExperimentalItemBadge.displayName = `${Namespace}.ItemBadge`;
}

View file

@ -15,3 +15,9 @@ export function assert<T>(input: T, message?: string): NonNullable<T> {
} }
return input; return input;
} }
export function unreachable(_value: never): never {
// eslint-disable-next-line no-debugger
debugger;
throw new AssertionError('unreachable');
}

View file

@ -20,7 +20,7 @@ export default {
otherTabsUnreadStats: { otherTabsUnreadStats: {
unreadCount: 0, unreadCount: 0,
unreadMentionsCount: 0, unreadMentionsCount: 0,
markedUnread: false, readChatsMarkedUnreadCount: 0,
}, },
isStaging: false, isStaging: false,
hasPendingUpdate: false, hasPendingUpdate: false,

View file

@ -373,7 +373,8 @@ export const ConversationMessageRequest = (): JSX.Element =>
export function ConversationsUnreadCount(): JSX.Element { export function ConversationsUnreadCount(): JSX.Element {
return ( return (
<Wrapper <Wrapper
rows={[4, 10, 34, 250, 2048].map(unreadCount => ({ rows={[4, 10, 34, 250, 2048, Number.MAX_SAFE_INTEGER].map(
unreadCount => ({
type: RowType.Conversation, type: RowType.Conversation,
conversation: createConversation({ conversation: createConversation({
lastMessage: { lastMessage: {
@ -383,7 +384,8 @@ export function ConversationsUnreadCount(): JSX.Element {
}, },
unreadCount, unreadCount,
}), }),
}))} })
)}
/> />
); );
} }

View file

@ -38,6 +38,7 @@ import { GroupListItem } from './conversationList/GroupListItem.js';
import { ListView } from './ListView.js'; import { ListView } from './ListView.js';
import { Button, ButtonVariant } from './Button.js'; import { Button, ButtonVariant } from './Button.js';
import { ListTile } from './ListTile.js'; import { ListTile } from './ListTile.js';
import type { RenderConversationListItemContextMenuProps } from './conversationList/BaseConversationListItem.js';
const { get, pick } = lodash; const { get, pick } = lodash;
@ -238,6 +239,9 @@ export type PropsType = {
onOutgoingVideoCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void;
removeConversation: (conversationId: string) => void; removeConversation: (conversationId: string) => void;
renderMessageSearchResult?: (id: string) => JSX.Element; renderMessageSearchResult?: (id: string) => JSX.Element;
renderConversationListItemContextMenu?: (
props: RenderConversationListItemContextMenuProps
) => JSX.Element;
showChooseGroupMembers: () => void; showChooseGroupMembers: () => void;
showFindByUsername: () => void; showFindByUsername: () => void;
showFindByPhoneNumber: () => void; showFindByPhoneNumber: () => void;
@ -264,6 +268,7 @@ export function ConversationList({
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
removeConversation, removeConversation,
renderMessageSearchResult, renderMessageSearchResult,
renderConversationListItemContextMenu,
rowCount, rowCount,
scrollBehavior = ScrollBehavior.Default, scrollBehavior = ScrollBehavior.Default,
scrollToRowIndex, scrollToRowIndex,
@ -514,6 +519,9 @@ export function ConversationList({
onClick={onSelectConversation} onClick={onSelectConversation}
i18n={i18n} i18n={i18n}
theme={theme} theme={theme}
renderConversationListItemContextMenu={
renderConversationListItemContextMenu
}
/> />
); );
break; break;
@ -650,6 +658,7 @@ export function ConversationList({
onSelectConversation, onSelectConversation,
removeConversation, removeConversation,
renderMessageSearchResult, renderMessageSearchResult,
renderConversationListItemContextMenu,
setIsFetchingUUID, setIsFetchingUUID,
showChooseGroupMembers, showChooseGroupMembers,
showFindByUsername, showFindByUsername,

View file

@ -0,0 +1,53 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/I18N.js';
import { ConfirmationDialog } from './ConfirmationDialog.js';
import { LocalDeleteWarningModal } from './LocalDeleteWarningModal.js';
export function DeleteMessagesConfirmationDialog({
i18n,
localDeleteWarningShown,
onDestroyMessages,
onClose,
setLocalDeleteWarningShown,
}: {
i18n: LocalizerType;
localDeleteWarningShown: boolean;
onDestroyMessages: () => void;
onClose: () => void;
setLocalDeleteWarningShown: () => void;
}): JSX.Element {
if (!localDeleteWarningShown) {
return (
<LocalDeleteWarningModal
i18n={i18n}
onClose={setLocalDeleteWarningShown}
/>
);
}
const dialogBody = i18n(
'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync'
);
return (
<ConfirmationDialog
dialogName="ConversationHeader.destroyMessages"
title={i18n(
'icu:ConversationHeader__DeleteConversationConfirmation__title'
)}
actions={[
{
action: onDestroyMessages,
style: 'negative',
text: i18n('icu:delete'),
},
]}
i18n={i18n}
onClose={onClose}
>
{dialogBody}
</ConfirmationDialog>
);
}

View file

@ -107,7 +107,7 @@ export function DisappearingTimerSelect(props: Props): JSX.Element {
{expirationTimerOptions.map(option => { {expirationTimerOptions.map(option => {
return ( return (
<AxoSelect.Item key={option.value} value={String(option.value)}> <AxoSelect.Item key={option.value} value={String(option.value)}>
{option.text} <AxoSelect.ItemText>{option.text}</AxoSelect.ItemText>
</AxoSelect.Item> </AxoSelect.Item>
); );
})} })}

View file

@ -34,6 +34,8 @@ import {
} from '../test-helpers/fakeLookupConversationWithoutServiceId.js'; } from '../test-helpers/fakeLookupConversationWithoutServiceId.js';
import type { GroupListItemConversationType } from './conversationList/GroupListItem.js'; import type { GroupListItemConversationType } from './conversationList/GroupListItem.js';
import { ServerAlert } from '../util/handleServerAlerts.js'; import { ServerAlert } from '../util/handleServerAlerts.js';
import { LeftPaneChatFolders } from './leftPane/LeftPaneChatFolders.js';
import { LeftPaneConversationListItemContextMenu } from './leftPane/LeftPaneConversationListItemContextMenu.js';
const { i18n } = window.SignalContext; const { i18n } = window.SignalContext;
@ -144,7 +146,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
otherTabsUnreadStats: { otherTabsUnreadStats: {
unreadCount: 0, unreadCount: 0,
unreadMentionsCount: 0, unreadMentionsCount: 0,
markedUnread: false, readChatsMarkedUnreadCount: 0,
}, },
backupMediaDownloadProgress: { backupMediaDownloadProgress: {
isBackupMediaEnabled: true, isBackupMediaEnabled: true,
@ -298,6 +300,38 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
isInFullScreenCall={false} isInFullScreenCall={false}
/> />
), ),
renderLeftPaneChatFolders: () => (
<LeftPaneChatFolders
i18n={i18n}
navSidebarWidthBreakpoint={null}
sortedChatFolders={[]}
allChatFoldersUnreadStats={new Map()}
allChatFoldersMutedStats={new Map()}
selectedChatFolder={null}
onSelectedChatFolderIdChange={action('onSelectedChatFolderIdChange')}
onChatFolderMarkRead={action('onChatFolderMarkRead')}
onChatFolderUpdateMute={action('onChatFolderUpdateMute')}
onChatFolderOpenSettings={action('onChatFolderOpenSettings')}
/>
),
renderConversationListItemContextMenu: props => (
<LeftPaneConversationListItemContextMenu
i18n={i18n}
conversation={getDefaultConversation()}
onMarkUnread={action('onMarkUnread')}
onMarkRead={action('onMarkRead')}
onPin={action('onPin')}
onUnpin={action('onUnpin')}
onUpdateMute={action('onUpdateMute')}
onArchive={action('onArchive')}
onUnarchive={action('onUnarchive')}
onDelete={action('onDelete')}
localDeleteWarningShown={false}
setLocalDeleteWarningShown={action('setLocalDeleteWarningShown')}
>
{props.children}
</LeftPaneConversationListItemContextMenu>
),
selectedConversationId: undefined, selectedConversationId: undefined,
targetedMessageId: undefined, targetedMessageId: undefined,
openUsernameReservationModal: action('openUsernameReservationModal'), openUsernameReservationModal: action('openUsernameReservationModal'),

View file

@ -60,6 +60,7 @@ import type { ServerAlertsType } from '../util/handleServerAlerts.js';
import { getServerAlertDialog } from './ServerAlerts.js'; import { getServerAlertDialog } from './ServerAlerts.js';
import { NavTab, SettingsPage, ProfileEditorPage } from '../types/Nav.js'; import { NavTab, SettingsPage, ProfileEditorPage } from '../types/Nav.js';
import type { Location } from '../types/Nav.js'; import type { Location } from '../types/Nav.js';
import type { RenderConversationListItemContextMenuProps } from './conversationList/BaseConversationListItem.js';
const { isNumber } = lodash; const { isNumber } = lodash;
@ -173,6 +174,9 @@ export type PropsType = {
// Render Props // Render Props
renderMessageSearchResult: (id: string) => JSX.Element; renderMessageSearchResult: (id: string) => JSX.Element;
renderConversationListItemContextMenu: (
props: RenderConversationListItemContextMenuProps
) => JSX.Element;
renderNetworkStatus: ( renderNetworkStatus: (
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }> _: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
) => JSX.Element; ) => JSX.Element;
@ -188,6 +192,7 @@ export type PropsType = {
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element; renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
renderCrashReportDialog: () => JSX.Element; renderCrashReportDialog: () => JSX.Element;
renderExpiredBuildDialog: (_: DialogExpiredBuildPropsType) => JSX.Element; renderExpiredBuildDialog: (_: DialogExpiredBuildPropsType) => JSX.Element;
renderLeftPaneChatFolders: () => JSX.Element;
renderToastManager: (_: { renderToastManager: (_: {
containerWidthBreakpoint: WidthBreakpoint; containerWidthBreakpoint: WidthBreakpoint;
}) => JSX.Element; }) => JSX.Element;
@ -237,7 +242,9 @@ export function LeftPane({
renderCaptchaDialog, renderCaptchaDialog,
renderCrashReportDialog, renderCrashReportDialog,
renderExpiredBuildDialog, renderExpiredBuildDialog,
renderLeftPaneChatFolders,
renderMessageSearchResult, renderMessageSearchResult,
renderConversationListItemContextMenu,
renderNetworkStatus, renderNetworkStatus,
renderUnsupportedOSDialog, renderUnsupportedOSDialog,
renderRelinkDialog, renderRelinkDialog,
@ -519,6 +526,7 @@ export function LeftPane({
createGroup, createGroup,
i18n, i18n,
removeSelectedContact: toggleConversationInChooseMembers, removeSelectedContact: toggleConversationInChooseMembers,
renderLeftPaneChatFolders,
setComposeGroupAvatar, setComposeGroupAvatar,
setComposeGroupExpireTimer, setComposeGroupExpireTimer,
setComposeGroupName, setComposeGroupName,
@ -887,6 +895,9 @@ export function LeftPane({
} }
removeConversation={removeConversation} removeConversation={removeConversation}
renderMessageSearchResult={renderMessageSearchResult} renderMessageSearchResult={renderMessageSearchResult}
renderConversationListItemContextMenu={
renderConversationListItemContextMenu
}
rowCount={helper.getRowCount()} rowCount={helper.getRowCount()}
scrollBehavior={scrollBehavior} scrollBehavior={scrollBehavior}
scrollToRowIndex={rowIndexToScrollTo} scrollToRowIndex={rowIndexToScrollTo}

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react'; import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react';
import React, { useEffect, useState } from 'react'; import React, { createContext, useEffect, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useMove } from 'react-aria'; import { useMove } from 'react-aria';
import { NavTabsToggle } from './NavTabs.js'; import { NavTabsToggle } from './NavTabs.js';
@ -16,6 +16,9 @@ import {
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util.js'; import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util.js';
import type { UnreadStats } from '../util/countUnreadStats.js'; import type { UnreadStats } from '../util/countUnreadStats.js';
export const NavSidebarWidthBreakpointContext =
createContext<WidthBreakpoint | null>(null);
type NavSidebarActionButtonProps = { type NavSidebarActionButtonProps = {
icon: ReactNode; icon: ReactNode;
label: ReactNode; label: ReactNode;
@ -158,6 +161,7 @@ export function NavSidebar({
}, [dragState]); }, [dragState]);
return ( return (
<NavSidebarWidthBreakpointContext.Provider value={widthBreakpoint}>
<div <div
role="navigation" role="navigation"
className={classNames('NavSidebar', { className={classNames('NavSidebar', {
@ -214,7 +218,8 @@ export function NavSidebar({
<div <div
className={classNames('NavSidebar__DragHandle', { className={classNames('NavSidebar__DragHandle', {
'NavSidebar__DragHandle--dragging': dragState === DragState.DRAGGING, 'NavSidebar__DragHandle--dragging':
dragState === DragState.DRAGGING,
})} })}
role="separator" role="separator"
aria-orientation="vertical" aria-orientation="vertical"
@ -228,6 +233,7 @@ export function NavSidebar({
{renderToastManager({ containerWidthBreakpoint: widthBreakpoint })} {renderToastManager({ containerWidthBreakpoint: widthBreakpoint })}
</div> </div>
</NavSidebarWidthBreakpointContext.Provider>
); );
} }

View file

@ -38,7 +38,7 @@ const createProps = (
unreadConversationsStats: overrideProps.unreadConversationsStats ?? { unreadConversationsStats: overrideProps.unreadConversationsStats ?? {
unreadCount: 0, unreadCount: 0,
unreadMentionsCount: 0, unreadMentionsCount: 0,
markedUnread: false, readChatsMarkedUnreadCount: 0,
}, },
unreadStoriesCount: overrideProps.unreadStoriesCount ?? 0, unreadStoriesCount: overrideProps.unreadStoriesCount ?? 0,
}); });

View file

@ -46,19 +46,21 @@ function NavTabsItemBadges({
if (unreadStats != null) { if (unreadStats != null) {
if (unreadStats.unreadCount > 0) { if (unreadStats.unreadCount > 0) {
const total =
unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount;
return ( return (
<span className="NavTabs__ItemUnreadBadge"> <span className="NavTabs__ItemUnreadBadge">
<span className="NavTabs__ItemIconLabel"> <span className="NavTabs__ItemIconLabel">
{i18n('icu:NavTabs__ItemIconLabel--UnreadCount', { {i18n('icu:NavTabs__ItemIconLabel--UnreadCount', {
count: unreadStats.unreadCount, count: total,
})} })}
</span> </span>
<span aria-hidden>{unreadStats.unreadCount}</span> <span aria-hidden>{total}</span>
</span> </span>
); );
} }
if (unreadStats.markedUnread) { if (unreadStats.readChatsMarkedUnreadCount > 0) {
return ( return (
<span className="NavTabs__ItemUnreadBadge"> <span className="NavTabs__ItemUnreadBadge">
<span className="NavTabs__ItemIconLabel"> <span className="NavTabs__ItemIconLabel">
@ -307,7 +309,7 @@ export function NavTabs({
unreadStats={{ unreadStats={{
unreadCount: unreadCallsCount, unreadCount: unreadCallsCount,
unreadMentionsCount: 0, unreadMentionsCount: 0,
markedUnread: false, readChatsMarkedUnreadCount: 0,
}} }}
/> />
{storiesEnabled && ( {storiesEnabled && (
@ -321,7 +323,7 @@ export function NavTabs({
unreadStats={{ unreadStats={{
unreadCount: unreadStoriesCount, unreadCount: unreadStoriesCount,
unreadMentionsCount: 0, unreadMentionsCount: 0,
markedUnread: false, readChatsMarkedUnreadCount: 0,
}} }}
/> />
)} )}
@ -331,11 +333,7 @@ export function NavTabs({
label={i18n('icu:NavTabs__ItemLabel--Settings')} label={i18n('icu:NavTabs__ItemLabel--Settings')}
iconClassName="NavTabs__ItemIcon--Settings" iconClassName="NavTabs__ItemIcon--Settings"
navTabClassName="NavTabs__Item--Settings" navTabClassName="NavTabs__Item--Settings"
unreadStats={{ unreadStats={null}
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
}}
hasPendingUpdate={hasPendingUpdate} hasPendingUpdate={hasPendingUpdate}
/> />
</TabList> </TabList>

View file

@ -25,7 +25,8 @@ import {
UsernameEditState, UsernameEditState,
UsernameLinkState, UsernameLinkState,
} from '../state/ducks/usernameEnums.js'; } from '../state/ducks/usernameEnums.js';
import { ProfileEditorPage, SettingsPage } from '../types/Nav.js'; import type { SettingsLocation } from '../types/Nav.js';
import { NavTab, ProfileEditorPage, SettingsPage } from '../types/Nav.js';
import { PreferencesDonations } from './PreferencesDonations.js'; import { PreferencesDonations } from './PreferencesDonations.js';
import { strictAssert } from '../util/assert.js'; import { strictAssert } from '../util/assert.js';
@ -220,8 +221,8 @@ function renderProfileEditor({
function renderDonationsPane(props: { function renderDonationsPane(props: {
contentsRef: MutableRefObject<HTMLDivElement | null>; contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage; settingsLocation: SettingsLocation;
setPage: (page: SettingsPage) => void; setSettingsLocation: (settingsLocation: SettingsLocation) => void;
me: typeof me; me: typeof me;
donationReceipts: ReadonlyArray<DonationReceipt>; donationReceipts: ReadonlyArray<DonationReceipt>;
saveAttachmentToDisk: (options: { saveAttachmentToDisk: (options: {
@ -245,8 +246,8 @@ function renderDonationsPane(props: {
initialCurrency="usd" initialCurrency="usd"
resumeWorkflow={action('resumeWorkflow')} resumeWorkflow={action('resumeWorkflow')}
isOnline isOnline
page={props.page} settingsLocation={props.settingsLocation}
setPage={props.setPage} setSettingsLocation={props.setSettingsLocation}
submitDonation={action('submitDonation')} submitDonation={action('submitDonation')}
lastError={undefined} lastError={undefined}
workflow={props.workflow} workflow={props.workflow}
@ -286,6 +287,8 @@ function renderPreferencesChatFoldersPage(
chatFolders={[]} chatFolders={[]}
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage} onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
onCreateChatFolder={action('onCreateChatFolder')} onCreateChatFolder={action('onCreateChatFolder')}
onDeleteChatFolder={action('onDeletChatFolder')}
onUpdateChatFoldersPositions={action('onUpdateChatFoldersPositions')}
/> />
); );
} }
@ -297,10 +300,14 @@ function renderPreferencesEditChatFolderPage(
<PreferencesEditChatFolderPage <PreferencesEditChatFolderPage
i18n={i18n} i18n={i18n}
theme={ThemeType.light} theme={ThemeType.light}
onBack={props.onBack} previousLocation={{
tab: NavTab.Settings,
details: { page: SettingsPage.ChatFolders },
}}
settingsPaneRef={props.settingsPaneRef} settingsPaneRef={props.settingsPaneRef}
existingChatFolderId={props.existingChatFolderId} existingChatFolderId={props.existingChatFolderId}
initChatFolderParams={CHAT_FOLDER_DEFAULTS} initChatFolderParams={CHAT_FOLDER_DEFAULTS}
changeLocation={action('changeLocation')}
onCreateChatFolder={action('onCreateChatFolder')} onCreateChatFolder={action('onCreateChatFolder')}
onUpdateChatFolder={action('onUpdateChatFolder')} onUpdateChatFolder={action('onUpdateChatFolder')}
onDeleteChatFolder={action('onDeleteChatFolder')} onDeleteChatFolder={action('onDeleteChatFolder')}
@ -403,9 +410,12 @@ export default {
otherTabsUnreadStats: { otherTabsUnreadStats: {
unreadCount: 0, unreadCount: 0,
unreadMentionsCount: 0, unreadMentionsCount: 0,
markedUnread: false, readChatsMarkedUnreadCount: 0,
}, },
settingsLocation: {
page: SettingsPage.Profile, page: SettingsPage.Profile,
state: ProfileEditorPage.None,
},
preferredSystemLocales: ['en'], preferredSystemLocales: ['en'],
preferredWidthFromStorage: 300, preferredWidthFromStorage: 300,
resolvedLocale: 'en', resolvedLocale: 'en',
@ -424,17 +434,17 @@ export default {
renderDonationsPane: ({ renderDonationsPane: ({
contentsRef, contentsRef,
page, settingsLocation,
setPage, setSettingsLocation,
}: { }: {
contentsRef: MutableRefObject<HTMLDivElement | null>; contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage; settingsLocation: SettingsLocation;
setPage: (page: SettingsPage) => void; setSettingsLocation: (settingsLocation: SettingsLocation) => void;
}) => }) =>
renderDonationsPane({ renderDonationsPane({
contentsRef, contentsRef,
page, settingsLocation,
setPage, setSettingsLocation,
me, me,
donationReceipts: [], donationReceipts: [],
saveAttachmentToDisk: async () => { saveAttachmentToDisk: async () => {
@ -534,7 +544,7 @@ export default {
setGlobalDefaultConversationColor: action( setGlobalDefaultConversationColor: action(
'setGlobalDefaultConversationColor' 'setGlobalDefaultConversationColor'
), ),
setPage: action('setPage'), setSettingsLocation: action('setSettingsLocation'),
showToast: action('showToast'), showToast: action('showToast'),
validateBackup: async () => { validateBackup: async () => {
return { return {
@ -559,18 +569,17 @@ export default {
// eslint-disable-next-line react/function-component-definition // eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = args => { const Template: StoryFn<PropsType> = args => {
const [page, setPage] = useState(args.page); const [settingsLocation, setSettingsLocation] = useState(
args.settingsLocation
);
return ( return (
<Preferences <Preferences
{...args} {...args}
page={page} settingsLocation={settingsLocation}
setPage={( setSettingsLocation={(newSettingsLocation: SettingsLocation) => {
newPage: SettingsPage,
profilePage: ProfileEditorPage | undefined
) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('setPage:', newPage, profilePage); console.log('setSettingsLocation:', newSettingsLocation);
setPage(newPage); setSettingsLocation(newSettingsLocation);
}} }}
/> />
); );
@ -580,62 +589,69 @@ export const _Preferences = Template.bind({});
export const General = Template.bind({}); export const General = Template.bind({});
General.args = { General.args = {
page: SettingsPage.General, settingsLocation: { page: SettingsPage.General },
}; };
export const Appearance = Template.bind({}); export const Appearance = Template.bind({});
Appearance.args = { Appearance.args = {
page: SettingsPage.Appearance, settingsLocation: { page: SettingsPage.Appearance },
}; };
export const Chats = Template.bind({}); export const Chats = Template.bind({});
Chats.args = { Chats.args = {
page: SettingsPage.Chats, settingsLocation: { page: SettingsPage.Chats },
}; };
export const ChatFolders = Template.bind({}); export const ChatFolders = Template.bind({});
ChatFolders.args = { ChatFolders.args = {
page: SettingsPage.ChatFolders, settingsLocation: { page: SettingsPage.ChatFolders },
}; };
export const EditChatFolder = Template.bind({}); export const EditChatFolder = Template.bind({});
EditChatFolder.args = { EditChatFolder.args = {
settingsLocation: {
page: SettingsPage.EditChatFolder, page: SettingsPage.EditChatFolder,
chatFolderId: null,
previousLocation: {
tab: NavTab.Settings,
details: { page: SettingsPage.ChatFolders },
},
},
}; };
export const Calls = Template.bind({}); export const Calls = Template.bind({});
Calls.args = { Calls.args = {
page: SettingsPage.Calls, settingsLocation: { page: SettingsPage.Calls },
}; };
export const Notifications = Template.bind({}); export const Notifications = Template.bind({});
Notifications.args = { Notifications.args = {
page: SettingsPage.Notifications, settingsLocation: { page: SettingsPage.Notifications },
}; };
export const Privacy = Template.bind({}); export const Privacy = Template.bind({});
Privacy.args = { Privacy.args = {
page: SettingsPage.Privacy, settingsLocation: { page: SettingsPage.Privacy },
}; };
export const DataUsage = Template.bind({}); export const DataUsage = Template.bind({});
DataUsage.args = { DataUsage.args = {
page: SettingsPage.DataUsage, settingsLocation: { page: SettingsPage.DataUsage },
}; };
export const Donations = Template.bind({}); export const Donations = Template.bind({});
Donations.args = { Donations.args = {
donationsFeatureEnabled: true, donationsFeatureEnabled: true,
page: SettingsPage.Donations, settingsLocation: { page: SettingsPage.Donations },
}; };
export const DonationsDonateFlow = Template.bind({}); export const DonationsDonateFlow = Template.bind({});
DonationsDonateFlow.args = { DonationsDonateFlow.args = {
donationsFeatureEnabled: true, donationsFeatureEnabled: true,
page: SettingsPage.DonationsDonateFlow, settingsLocation: { page: SettingsPage.DonationsDonateFlow },
renderDonationsPane: ({ renderDonationsPane: ({
contentsRef, contentsRef,
}: { }: {
contentsRef: MutableRefObject<HTMLDivElement | null>; contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage; settingsLocation: SettingsLocation;
setPage: (page: SettingsPage) => void; setSettingsLocation: (settingsLocation: SettingsLocation) => void;
}) => }) =>
renderDonationsPane({ renderDonationsPane({
contentsRef, contentsRef,
me, me,
donationReceipts: [], donationReceipts: [],
page: SettingsPage.DonationsDonateFlow, settingsLocation: { page: SettingsPage.DonationsDonateFlow },
setPage: action('setPage'), setSettingsLocation: action('setSettingsLocation'),
saveAttachmentToDisk: async () => { saveAttachmentToDisk: async () => {
action('saveAttachmentToDisk')(); action('saveAttachmentToDisk')();
return { fullPath: '/mock/path/to/file.png', name: 'file.png' }; return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
@ -650,13 +666,13 @@ DonationsDonateFlow.args = {
export const DonationReceipts = Template.bind({}); export const DonationReceipts = Template.bind({});
DonationReceipts.args = { DonationReceipts.args = {
donationsFeatureEnabled: true, donationsFeatureEnabled: true,
page: SettingsPage.DonationsDonateFlow, settingsLocation: { page: SettingsPage.DonationsDonateFlow },
renderDonationsPane: ({ renderDonationsPane: ({
contentsRef, contentsRef,
}: { }: {
contentsRef: MutableRefObject<HTMLDivElement | null>; contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage; settingsLocation: SettingsLocation;
setPage: (page: SettingsPage) => void; setSettingsLocation: (settingsLocation: SettingsLocation) => void;
}) => }) =>
renderDonationsPane({ renderDonationsPane({
contentsRef, contentsRef,
@ -675,8 +691,8 @@ DonationReceipts.args = {
timestamp: 1753995255509, timestamp: 1753995255509,
}, },
], ],
page: SettingsPage.DonationsReceiptList, settingsLocation: { page: SettingsPage.DonationsReceiptList },
setPage: action('setPage'), setSettingsLocation: action('setSettingsLocation'),
saveAttachmentToDisk: async () => { saveAttachmentToDisk: async () => {
action('saveAttachmentToDisk')(); action('saveAttachmentToDisk')();
return { fullPath: '/mock/path/to/file.png', name: 'file.png' }; return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
@ -691,7 +707,7 @@ DonationReceipts.args = {
export const DonationsHomeWithInProgressDonation = Template.bind({}); export const DonationsHomeWithInProgressDonation = Template.bind({});
DonationsHomeWithInProgressDonation.args = { DonationsHomeWithInProgressDonation.args = {
donationsFeatureEnabled: true, donationsFeatureEnabled: true,
page: SettingsPage.Donations, settingsLocation: { page: SettingsPage.Donations },
renderDonationsPane: ({ renderDonationsPane: ({
contentsRef, contentsRef,
}: { }: {
@ -701,8 +717,8 @@ DonationsHomeWithInProgressDonation.args = {
contentsRef, contentsRef,
me, me,
donationReceipts: [], donationReceipts: [],
page: SettingsPage.Donations, settingsLocation: { page: SettingsPage.Donations },
setPage: action('setPage'), setSettingsLocation: action('setSettingsLocation'),
saveAttachmentToDisk: async () => { saveAttachmentToDisk: async () => {
action('saveAttachmentToDisk')(); action('saveAttachmentToDisk')();
return { fullPath: '/mock/path/to/file.png', name: 'file.png' }; return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
@ -727,45 +743,45 @@ DonationsHomeWithInProgressDonation.args = {
}; };
export const Internal = Template.bind({}); export const Internal = Template.bind({});
Internal.args = { Internal.args = {
page: SettingsPage.Internal, settingsLocation: { page: SettingsPage.Internal },
isInternalUser: true, isInternalUser: true,
}; };
export const Blocked1 = Template.bind({}); export const Blocked1 = Template.bind({});
Blocked1.args = { Blocked1.args = {
blockedCount: 1, blockedCount: 1,
page: SettingsPage.Privacy, settingsLocation: { page: SettingsPage.Privacy },
}; };
export const BlockedMany = Template.bind({}); export const BlockedMany = Template.bind({});
BlockedMany.args = { BlockedMany.args = {
blockedCount: 55, blockedCount: 55,
page: SettingsPage.Privacy, settingsLocation: { page: SettingsPage.Privacy },
}; };
export const CustomUniversalExpireTimer = Template.bind({}); export const CustomUniversalExpireTimer = Template.bind({});
CustomUniversalExpireTimer.args = { CustomUniversalExpireTimer.args = {
universalExpireTimer: DurationInSeconds.fromSeconds(9000), universalExpireTimer: DurationInSeconds.fromSeconds(9000),
page: SettingsPage.Privacy, settingsLocation: { page: SettingsPage.Privacy },
}; };
export const PNPSharingDisabled = Template.bind({}); export const PNPSharingDisabled = Template.bind({});
PNPSharingDisabled.args = { PNPSharingDisabled.args = {
whoCanSeeMe: PhoneNumberSharingMode.Nobody, whoCanSeeMe: PhoneNumberSharingMode.Nobody,
whoCanFindMe: PhoneNumberDiscoverability.Discoverable, whoCanFindMe: PhoneNumberDiscoverability.Discoverable,
page: SettingsPage.PNP, settingsLocation: { page: SettingsPage.PNP },
}; };
export const PNPDiscoverabilityDisabled = Template.bind({}); export const PNPDiscoverabilityDisabled = Template.bind({});
PNPDiscoverabilityDisabled.args = { PNPDiscoverabilityDisabled.args = {
whoCanSeeMe: PhoneNumberSharingMode.Nobody, whoCanSeeMe: PhoneNumberSharingMode.Nobody,
whoCanFindMe: PhoneNumberDiscoverability.NotDiscoverable, whoCanFindMe: PhoneNumberDiscoverability.NotDiscoverable,
page: SettingsPage.PNP, settingsLocation: { page: SettingsPage.PNP },
}; };
export const BackupsMediaDownloadActive = Template.bind({}); export const BackupsMediaDownloadActive = Template.bind({});
BackupsMediaDownloadActive.args = { BackupsMediaDownloadActive.args = {
page: SettingsPage.BackupsDetails, settingsLocation: { page: SettingsPage.BackupsDetails },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
cloudBackupStatus: { cloudBackupStatus: {
@ -789,7 +805,7 @@ BackupsMediaDownloadActive.args = {
}; };
export const BackupsMediaDownloadPaused = Template.bind({}); export const BackupsMediaDownloadPaused = Template.bind({});
BackupsMediaDownloadPaused.args = { BackupsMediaDownloadPaused.args = {
page: SettingsPage.BackupsDetails, settingsLocation: { page: SettingsPage.BackupsDetails },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
cloudBackupStatus: { cloudBackupStatus: {
@ -814,7 +830,7 @@ BackupsMediaDownloadPaused.args = {
export const BackupsPaidActive = Template.bind({}); export const BackupsPaidActive = Template.bind({});
BackupsPaidActive.args = { BackupsPaidActive.args = {
page: SettingsPage.Backups, settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
cloudBackupStatus: { cloudBackupStatus: {
@ -833,7 +849,7 @@ BackupsPaidActive.args = {
export const BackupsPaidCanceled = Template.bind({}); export const BackupsPaidCanceled = Template.bind({});
BackupsPaidCanceled.args = { BackupsPaidCanceled.args = {
page: SettingsPage.Backups, settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
cloudBackupStatus: { cloudBackupStatus: {
@ -852,7 +868,7 @@ BackupsPaidCanceled.args = {
export const BackupsFree = Template.bind({}); export const BackupsFree = Template.bind({});
BackupsFree.args = { BackupsFree.args = {
page: SettingsPage.Backups, settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
backupSubscriptionStatus: { backupSubscriptionStatus: {
@ -862,7 +878,7 @@ BackupsFree.args = {
}; };
export const BackupsFreeNoLocal = Template.bind({}); export const BackupsFreeNoLocal = Template.bind({});
BackupsFreeNoLocal.args = { BackupsFreeNoLocal.args = {
page: SettingsPage.Backups, settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: false, backupLocalBackupsEnabled: false,
backupSubscriptionStatus: { backupSubscriptionStatus: {
@ -873,28 +889,28 @@ BackupsFreeNoLocal.args = {
export const BackupsOff = Template.bind({}); export const BackupsOff = Template.bind({});
BackupsOff.args = { BackupsOff.args = {
page: SettingsPage.Backups, settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
}; };
export const BackupsLocalBackups = Template.bind({}); export const BackupsLocalBackups = Template.bind({});
BackupsLocalBackups.args = { BackupsLocalBackups.args = {
page: SettingsPage.Backups, settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
}; };
export const BackupsRemoteEnabledLocalDisabled = Template.bind({}); export const BackupsRemoteEnabledLocalDisabled = Template.bind({});
BackupsRemoteEnabledLocalDisabled.args = { BackupsRemoteEnabledLocalDisabled.args = {
page: SettingsPage.Backups, settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: false, backupLocalBackupsEnabled: false,
}; };
export const BackupsSubscriptionNotFound = Template.bind({}); export const BackupsSubscriptionNotFound = Template.bind({});
BackupsSubscriptionNotFound.args = { BackupsSubscriptionNotFound.args = {
page: SettingsPage.Backups, settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
backupSubscriptionStatus: { backupSubscriptionStatus: {
@ -908,7 +924,7 @@ BackupsSubscriptionNotFound.args = {
export const BackupsSubscriptionExpired = Template.bind({}); export const BackupsSubscriptionExpired = Template.bind({});
BackupsSubscriptionExpired.args = { BackupsSubscriptionExpired.args = {
page: SettingsPage.Backups, settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
backupSubscriptionStatus: { backupSubscriptionStatus: {
@ -918,7 +934,7 @@ BackupsSubscriptionExpired.args = {
export const LocalBackups = Template.bind({}); export const LocalBackups = Template.bind({});
LocalBackups.args = { LocalBackups.args = {
page: SettingsPage.LocalBackups, settingsLocation: { page: SettingsPage.LocalBackups },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
backupKeyViewed: true, backupKeyViewed: true,
@ -927,14 +943,14 @@ LocalBackups.args = {
export const LocalBackupsSetupChooseFolder = Template.bind({}); export const LocalBackupsSetupChooseFolder = Template.bind({});
LocalBackupsSetupChooseFolder.args = { LocalBackupsSetupChooseFolder.args = {
page: SettingsPage.LocalBackupsSetupFolder, settingsLocation: { page: SettingsPage.LocalBackupsSetupFolder },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
}; };
export const LocalBackupsSetupViewBackupKey = Template.bind({}); export const LocalBackupsSetupViewBackupKey = Template.bind({});
LocalBackupsSetupViewBackupKey.args = { LocalBackupsSetupViewBackupKey.args = {
page: SettingsPage.LocalBackupsSetupKey, settingsLocation: { page: SettingsPage.LocalBackupsSetupKey },
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,
localBackupFolder: '/home/signaluser/Signal Backups/', localBackupFolder: '/home/signaluser/Signal Backups/',
@ -957,7 +973,7 @@ NavTabsCollapsedWithBadges.args = {
otherTabsUnreadStats: { otherTabsUnreadStats: {
unreadCount: 1, unreadCount: 1,
unreadMentionsCount: 2, unreadMentionsCount: 2,
markedUnread: false, readChatsMarkedUnreadCount: 0,
}, },
}; };
@ -968,7 +984,7 @@ NavTabsCollapsedWithExclamation.args = {
otherTabsUnreadStats: { otherTabsUnreadStats: {
unreadCount: 1, unreadCount: 1,
unreadMentionsCount: 2, unreadMentionsCount: 2,
markedUnread: true, readChatsMarkedUnreadCount: 0,
}, },
}; };

View file

@ -54,7 +54,8 @@ import { PreferencesInternal } from './PreferencesInternal.js';
import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider.js'; import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider.js';
import { Avatar, AvatarSize } from './Avatar.js'; import { Avatar, AvatarSize } from './Avatar.js';
import { NavSidebar } from './NavSidebar.js'; import { NavSidebar } from './NavSidebar.js';
import { SettingsPage, ProfileEditorPage } from '../types/Nav.js'; import type { SettingsLocation } from '../types/Nav.js';
import { SettingsPage, ProfileEditorPage, NavTab } from '../types/Nav.js';
import type { MediaDeviceSettings } from '../types/Calling.js'; import type { MediaDeviceSettings } from '../types/Calling.js';
import type { ValidationResultType as BackupValidationResultType } from '../services/backups/index.js'; import type { ValidationResultType as BackupValidationResultType } from '../services/backups/index.js';
@ -149,7 +150,7 @@ export type PropsDataType = {
hasTextFormatting: boolean; hasTextFormatting: boolean;
hasTypingIndicators: boolean; hasTypingIndicators: boolean;
hasKeepMutedChatsArchived: boolean; hasKeepMutedChatsArchived: boolean;
page: SettingsPage; settingsLocation: SettingsLocation;
lastSyncTime?: number; lastSyncTime?: number;
notificationContent: NotificationSettingType; notificationContent: NotificationSettingType;
phoneNumber: string | undefined; phoneNumber: string | undefined;
@ -204,8 +205,8 @@ type PropsFunctionType = {
// Render props // Render props
renderDonationsPane: (options: { renderDonationsPane: (options: {
contentsRef: MutableRefObject<HTMLDivElement | null>; contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage; settingsLocation: SettingsLocation;
setPage: (page: SettingsPage, profilePage?: ProfileEditorPage) => void; setSettingsLocation: (settingsLocation: SettingsLocation) => void;
}) => JSX.Element; }) => JSX.Element;
renderProfileEditor: (options: { renderProfileEditor: (options: {
contentsRef: MutableRefObject<HTMLDivElement | null>; contentsRef: MutableRefObject<HTMLDivElement | null>;
@ -255,7 +256,7 @@ type PropsFunctionType = {
value: CustomColorType; value: CustomColorType;
} }
) => unknown; ) => unknown;
setPage: (page: SettingsPage, editState?: ProfileEditorPage) => unknown; setSettingsLocation: (settingsLocation: SettingsLocation) => unknown;
showToast: (toast: AnyToast) => unknown; showToast: (toast: AnyToast) => unknown;
validateBackup: () => Promise<BackupValidationResultType>; validateBackup: () => Promise<BackupValidationResultType>;
@ -472,7 +473,7 @@ export function Preferences({
onWhoCanFindMeChange, onWhoCanFindMeChange,
onZoomFactorChange, onZoomFactorChange,
otherTabsUnreadStats, otherTabsUnreadStats,
page, settingsLocation,
phoneNumber = '', phoneNumber = '',
pickLocalBackupFolder, pickLocalBackupFolder,
preferredSystemLocales, preferredSystemLocales,
@ -497,7 +498,7 @@ export function Preferences({
selectedSpeaker, selectedSpeaker,
sentMediaQualitySetting, sentMediaQualitySetting,
setGlobalDefaultConversationColor, setGlobalDefaultConversationColor,
setPage, setSettingsLocation,
shouldShowUpdateDialog, shouldShowUpdateDialog,
showToast, showToast,
localeOverride, localeOverride,
@ -537,22 +538,20 @@ export function Preferences({
const [confirmPnpNotDiscoverable, setConfirmPnpNoDiscoverable] = const [confirmPnpNotDiscoverable, setConfirmPnpNoDiscoverable] =
useState(false); useState(false);
const [editChatFolderPageId, setEditChatFolderPageId] =
useState<ChatFolderId | null>(null);
const handleOpenEditChatFoldersPage = useCallback( const handleOpenEditChatFoldersPage = useCallback(
(chatFolderId: ChatFolderId | null) => { (chatFolderId: ChatFolderId | null) => {
setPage(SettingsPage.EditChatFolder); setSettingsLocation({
setEditChatFolderPageId(chatFolderId); page: SettingsPage.EditChatFolder,
chatFolderId,
previousLocation: {
tab: NavTab.Settings,
details: settingsLocation,
}, },
[setPage] });
},
[setSettingsLocation, settingsLocation]
); );
const handleCloseEditChatFoldersPage = useCallback(() => {
setPage(SettingsPage.ChatFolders);
setEditChatFolderPageId(null);
}, [setPage]);
function closeLanguageDialog() { function closeLanguageDialog() {
setLanguageDialog(null); setLanguageDialog(null);
setSelectedLanguageLocale(localeOverride); setSelectedLanguageLocale(localeOverride);
@ -560,14 +559,17 @@ export function Preferences({
const shouldShowBackupsPage = const shouldShowBackupsPage =
backupFeatureEnabled || backupLocalBackupsEnabled; backupFeatureEnabled || backupLocalBackupsEnabled;
if (page === SettingsPage.Backups && !shouldShowBackupsPage) { if (
setPage(SettingsPage.General); settingsLocation.page === SettingsPage.Backups &&
!shouldShowBackupsPage
) {
setSettingsLocation({ page: SettingsPage.General });
} }
if (isDonationsPage(page) && !donationsFeatureEnabled) { if (isDonationsPage(settingsLocation.page) && !donationsFeatureEnabled) {
setPage(SettingsPage.General); setSettingsLocation({ page: SettingsPage.General });
} }
if (page === SettingsPage.Internal && !isInternalUser) { if (settingsLocation.page === SettingsPage.Internal && !isInternalUser) {
setPage(SettingsPage.General); setSettingsLocation({ page: SettingsPage.General });
} }
let maybeUpdateDialog: JSX.Element | undefined; let maybeUpdateDialog: JSX.Element | undefined;
@ -625,7 +627,7 @@ export function Preferences({
return; return;
} }
elements[0]?.focus(); elements[0]?.focus();
}, [page]); }, [settingsLocation.page]);
const onAudioOutputSelectChange = useCallback( const onAudioOutputSelectChange = useCallback(
(value: string) => { (value: string) => {
@ -729,11 +731,11 @@ export function Preferences({
let content: JSX.Element | undefined; let content: JSX.Element | undefined;
if (page === SettingsPage.Profile) { if (settingsLocation.page === SettingsPage.Profile) {
content = renderProfileEditor({ content = renderProfileEditor({
contentsRef: settingsPaneRef, contentsRef: settingsPaneRef,
}); });
} else if (page === SettingsPage.General) { } else if (settingsLocation.page === SettingsPage.General) {
const pageContents = ( const pageContents = (
<> <>
<SettingsRow> <SettingsRow>
@ -861,13 +863,13 @@ export function Preferences({
title={i18n('icu:Preferences__button--general')} title={i18n('icu:Preferences__button--general')}
/> />
); );
} else if (isDonationsPage(page)) { } else if (isDonationsPage(settingsLocation.page)) {
content = renderDonationsPane({ content = renderDonationsPane({
contentsRef: settingsPaneRef, contentsRef: settingsPaneRef,
page, settingsLocation,
setPage, setSettingsLocation,
}); });
} else if (page === SettingsPage.Appearance) { } else if (settingsLocation.page === SettingsPage.Appearance) {
let zoomFactors = DEFAULT_ZOOM_FACTORS; let zoomFactors = DEFAULT_ZOOM_FACTORS;
if ( if (
@ -1048,7 +1050,7 @@ export function Preferences({
icon icon
left={i18n('icu:showChatColorEditor')} left={i18n('icu:showChatColorEditor')}
onClick={() => { onClick={() => {
setPage(SettingsPage.ChatColor); setSettingsLocation({ page: SettingsPage.ChatColor });
}} }}
right={ right={
<div <div
@ -1087,7 +1089,7 @@ export function Preferences({
title={i18n('icu:Preferences__button--appearance')} title={i18n('icu:Preferences__button--appearance')}
/> />
); );
} else if (page === SettingsPage.Chats) { } else if (settingsLocation.page === SettingsPage.Chats) {
let spellCheckDirtyText: string | undefined; let spellCheckDirtyText: string | undefined;
if ( if (
hasSpellCheck !== undefined && hasSpellCheck !== undefined &&
@ -1188,7 +1190,9 @@ export function Preferences({
</> </>
} }
right={null} right={null}
onClick={() => setPage(SettingsPage.ChatFolders)} onClick={() =>
setSettingsLocation({ page: SettingsPage.ChatFolders })
}
/> />
</SettingsRow> </SettingsRow>
)} )}
@ -1255,7 +1259,7 @@ export function Preferences({
title={i18n('icu:Preferences__button--chats')} title={i18n('icu:Preferences__button--chats')}
/> />
); );
} else if (page === SettingsPage.Calls) { } else if (settingsLocation.page === SettingsPage.Calls) {
const pageContents = ( const pageContents = (
<> <>
<SettingsRow title={i18n('icu:calling')}> <SettingsRow title={i18n('icu:calling')}>
@ -1404,7 +1408,7 @@ export function Preferences({
title={i18n('icu:Preferences__button--calls')} title={i18n('icu:Preferences__button--calls')}
/> />
); );
} else if (page === SettingsPage.Notifications) { } else if (settingsLocation.page === SettingsPage.Notifications) {
const pageContents = ( const pageContents = (
<> <>
<SettingsRow> <SettingsRow>
@ -1492,7 +1496,7 @@ export function Preferences({
title={i18n('icu:Preferences__button--notifications')} title={i18n('icu:Preferences__button--notifications')}
/> />
); );
} else if (page === SettingsPage.Privacy) { } else if (settingsLocation.page === SettingsPage.Privacy) {
const isCustomDisappearingMessageValue = const isCustomDisappearingMessageValue =
!DEFAULT_DURATIONS_SET.has(universalExpireTimer); !DEFAULT_DURATIONS_SET.has(universalExpireTimer);
const pageContents = ( const pageContents = (
@ -1519,7 +1523,7 @@ export function Preferences({
)} )}
> >
<Button <Button
onClick={() => setPage(SettingsPage.PNP)} onClick={() => setSettingsLocation({ page: SettingsPage.PNP })}
variant={ButtonVariant.Secondary} variant={ButtonVariant.Secondary}
> >
{i18n('icu:Preferences__pnp__row--button')} {i18n('icu:Preferences__pnp__row--button')}
@ -1770,7 +1774,7 @@ export function Preferences({
title={i18n('icu:Preferences__button--privacy')} title={i18n('icu:Preferences__button--privacy')}
/> />
); );
} else if (page === SettingsPage.DataUsage) { } else if (settingsLocation.page === SettingsPage.DataUsage) {
const pageContents = ( const pageContents = (
<> <>
<SettingsRow title={i18n('icu:Preferences__media-auto-download')}> <SettingsRow title={i18n('icu:Preferences__media-auto-download')}>
@ -1882,12 +1886,12 @@ export function Preferences({
title={i18n('icu:Preferences__button--data-usage')} title={i18n('icu:Preferences__button--data-usage')}
/> />
); );
} else if (page === SettingsPage.ChatColor) { } else if (settingsLocation.page === SettingsPage.ChatColor) {
const backButton = ( const backButton = (
<button <button
aria-label={i18n('icu:goBack')} aria-label={i18n('icu:goBack')}
className="Preferences__back-icon" className="Preferences__back-icon"
onClick={() => setPage(SettingsPage.Appearance)} onClick={() => setSettingsLocation({ page: SettingsPage.Appearance })}
type="button" type="button"
/> />
); );
@ -1918,19 +1922,19 @@ export function Preferences({
title={i18n('icu:ChatColorPicker__menu-title')} title={i18n('icu:ChatColorPicker__menu-title')}
/> />
); );
} else if (page === SettingsPage.ChatFolders) { } else if (settingsLocation.page === SettingsPage.ChatFolders) {
content = renderPreferencesChatFoldersPage({ content = renderPreferencesChatFoldersPage({
onBack: () => setPage(SettingsPage.Chats), onBack: () => setSettingsLocation({ page: SettingsPage.Chats }),
onOpenEditChatFoldersPage: handleOpenEditChatFoldersPage, onOpenEditChatFoldersPage: handleOpenEditChatFoldersPage,
settingsPaneRef, settingsPaneRef,
}); });
} else if (page === SettingsPage.EditChatFolder) { } else if (settingsLocation.page === SettingsPage.EditChatFolder) {
content = renderPreferencesEditChatFolderPage({ content = renderPreferencesEditChatFolderPage({
onBack: handleCloseEditChatFoldersPage, previousLocation: settingsLocation.previousLocation,
settingsPaneRef, settingsPaneRef,
existingChatFolderId: editChatFolderPageId, existingChatFolderId: settingsLocation.chatFolderId,
}); });
} else if (page === SettingsPage.PNP) { } else if (settingsLocation.page === SettingsPage.PNP) {
let sharingDescription: string; let sharingDescription: string;
if (whoCanSeeMe === PhoneNumberSharingMode.Everybody) { if (whoCanSeeMe === PhoneNumberSharingMode.Everybody) {
@ -1951,7 +1955,7 @@ export function Preferences({
<button <button
aria-label={i18n('icu:goBack')} aria-label={i18n('icu:goBack')}
className="Preferences__back-icon" className="Preferences__back-icon"
onClick={() => setPage(SettingsPage.Privacy)} onClick={() => setSettingsLocation({ page: SettingsPage.Privacy })}
type="button" type="button"
/> />
); );
@ -2071,19 +2075,22 @@ export function Preferences({
title={i18n('icu:Preferences__pnp--page-title')} title={i18n('icu:Preferences__pnp--page-title')}
/> />
); );
} else if (isBackupPage(page)) { } else if (isBackupPage(settingsLocation.page)) {
let pageTitle: string | undefined; let pageTitle: string | undefined;
if (page === SettingsPage.Backups || page === SettingsPage.BackupsDetails) { if (
settingsLocation.page === SettingsPage.Backups ||
settingsLocation.page === SettingsPage.BackupsDetails
) {
pageTitle = i18n('icu:Preferences__button--backups'); pageTitle = i18n('icu:Preferences__button--backups');
} else if (page === SettingsPage.LocalBackups) { } else if (settingsLocation.page === SettingsPage.LocalBackups) {
pageTitle = i18n('icu:Preferences__local-backups'); pageTitle = i18n('icu:Preferences__local-backups');
} }
// Local backups setup page titles intentionally left blank // Local backups setup page titles intentionally left blank
let backPage: PreferencesBackupPage | undefined; let backPage: PreferencesBackupPage | undefined;
if (page === SettingsPage.LocalBackupsKeyReference) { if (settingsLocation.page === SettingsPage.LocalBackupsKeyReference) {
backPage = SettingsPage.LocalBackups; backPage = SettingsPage.LocalBackups;
} else if (page !== SettingsPage.Backups) { } else if (settingsLocation.page !== SettingsPage.Backups) {
backPage = SettingsPage.Backups; backPage = SettingsPage.Backups;
} }
let backButton: JSX.Element | undefined; let backButton: JSX.Element | undefined;
@ -2092,7 +2099,7 @@ export function Preferences({
<button <button
aria-label={i18n('icu:goBack')} aria-label={i18n('icu:goBack')}
className="Preferences__back-icon" className="Preferences__back-icon"
onClick={() => setPage(backPage)} onClick={() => setSettingsLocation({ page: backPage })}
type="button" type="button"
/> />
); );
@ -2114,11 +2121,11 @@ export function Preferences({
localBackupFolder={localBackupFolder} localBackupFolder={localBackupFolder}
onBackupKeyViewedChange={onBackupKeyViewedChange} onBackupKeyViewedChange={onBackupKeyViewedChange}
pickLocalBackupFolder={pickLocalBackupFolder} pickLocalBackupFolder={pickLocalBackupFolder}
page={page} settingsLocation={settingsLocation}
promptOSAuth={promptOSAuth} promptOSAuth={promptOSAuth}
refreshCloudBackupStatus={refreshCloudBackupStatus} refreshCloudBackupStatus={refreshCloudBackupStatus}
refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus} refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
setPage={setPage} setSettingsLocation={setSettingsLocation}
showToast={showToast} showToast={showToast}
/> />
); );
@ -2130,7 +2137,7 @@ export function Preferences({
title={pageTitle} title={pageTitle}
/> />
); );
} else if (page === SettingsPage.Internal) { } else if (settingsLocation.page === SettingsPage.Internal) {
content = ( content = (
<PreferencesContent <PreferencesContent
contents={ contents={
@ -2185,7 +2192,7 @@ export function Preferences({
className={classNames({ className={classNames({
'Preferences__profile-chip': true, 'Preferences__profile-chip': true,
'Preferences__profile-chip--selected': 'Preferences__profile-chip--selected':
page === SettingsPage.Profile, settingsLocation.page === SettingsPage.Profile,
})} })}
> >
<div className="Preferences__profile-chip__avatar"> <div className="Preferences__profile-chip__avatar">
@ -2224,7 +2231,10 @@ export function Preferences({
className="Preferences__profile-chip__button" className="Preferences__profile-chip__button"
aria-label={i18n('icu:ProfileEditor__open')} aria-label={i18n('icu:ProfileEditor__open')}
onClick={() => { onClick={() => {
setPage(SettingsPage.Profile); setSettingsLocation({
page: SettingsPage.Profile,
state: ProfileEditorPage.None,
});
}} }}
> >
<span className="Preferences__profile-chip__screenreader-only"> <span className="Preferences__profile-chip__screenreader-only">
@ -2236,10 +2246,10 @@ export function Preferences({
className="Preferences__profile-chip__qr-icon-button" className="Preferences__profile-chip__qr-icon-button"
aria-label={i18n('icu:ProfileEditor__username-link__open')} aria-label={i18n('icu:ProfileEditor__username-link__open')}
onClick={() => { onClick={() => {
setPage( setSettingsLocation({
SettingsPage.Profile, page: SettingsPage.Profile,
ProfileEditorPage.UsernameLink state: ProfileEditorPage.UsernameLink,
); });
}} }}
> >
<div className="Preferences__profile-chip__qr-icon" /> <div className="Preferences__profile-chip__qr-icon" />
@ -2251,9 +2261,11 @@ export function Preferences({
Preferences__button: true, Preferences__button: true,
'Preferences__button--general': true, 'Preferences__button--general': true,
'Preferences__button--selected': 'Preferences__button--selected':
page === SettingsPage.General, settingsLocation.page === SettingsPage.General,
})} })}
onClick={() => setPage(SettingsPage.General)} onClick={() =>
setSettingsLocation({ page: SettingsPage.General })
}
> >
{i18n('icu:Preferences__button--general')} {i18n('icu:Preferences__button--general')}
</button> </button>
@ -2263,10 +2275,12 @@ export function Preferences({
Preferences__button: true, Preferences__button: true,
'Preferences__button--appearance': true, 'Preferences__button--appearance': true,
'Preferences__button--selected': 'Preferences__button--selected':
page === SettingsPage.Appearance || settingsLocation.page === SettingsPage.Appearance ||
page === SettingsPage.ChatColor, settingsLocation.page === SettingsPage.ChatColor,
})} })}
onClick={() => setPage(SettingsPage.Appearance)} onClick={() =>
setSettingsLocation({ page: SettingsPage.Appearance })
}
> >
{i18n('icu:Preferences__button--appearance')} {i18n('icu:Preferences__button--appearance')}
</button> </button>
@ -2275,9 +2289,12 @@ export function Preferences({
className={classNames({ className={classNames({
Preferences__button: true, Preferences__button: true,
'Preferences__button--chats': true, 'Preferences__button--chats': true,
'Preferences__button--selected': page === SettingsPage.Chats, 'Preferences__button--selected':
settingsLocation.page === SettingsPage.Chats,
})} })}
onClick={() => setPage(SettingsPage.Chats)} onClick={() =>
setSettingsLocation({ page: SettingsPage.Chats })
}
> >
{i18n('icu:Preferences__button--chats')} {i18n('icu:Preferences__button--chats')}
</button> </button>
@ -2286,9 +2303,12 @@ export function Preferences({
className={classNames({ className={classNames({
Preferences__button: true, Preferences__button: true,
'Preferences__button--calls': true, 'Preferences__button--calls': true,
'Preferences__button--selected': page === SettingsPage.Calls, 'Preferences__button--selected':
settingsLocation.page === SettingsPage.Calls,
})} })}
onClick={() => setPage(SettingsPage.Calls)} onClick={() =>
setSettingsLocation({ page: SettingsPage.Calls })
}
> >
{i18n('icu:Preferences__button--calls')} {i18n('icu:Preferences__button--calls')}
</button> </button>
@ -2298,9 +2318,11 @@ export function Preferences({
Preferences__button: true, Preferences__button: true,
'Preferences__button--notifications': true, 'Preferences__button--notifications': true,
'Preferences__button--selected': 'Preferences__button--selected':
page === SettingsPage.Notifications, settingsLocation.page === SettingsPage.Notifications,
})} })}
onClick={() => setPage(SettingsPage.Notifications)} onClick={() =>
setSettingsLocation({ page: SettingsPage.Notifications })
}
> >
{i18n('icu:Preferences__button--notifications')} {i18n('icu:Preferences__button--notifications')}
</button> </button>
@ -2310,9 +2332,12 @@ export function Preferences({
Preferences__button: true, Preferences__button: true,
'Preferences__button--privacy': true, 'Preferences__button--privacy': true,
'Preferences__button--selected': 'Preferences__button--selected':
page === SettingsPage.Privacy || page === SettingsPage.PNP, settingsLocation.page === SettingsPage.Privacy ||
settingsLocation.page === SettingsPage.PNP,
})} })}
onClick={() => setPage(SettingsPage.Privacy)} onClick={() =>
setSettingsLocation({ page: SettingsPage.Privacy })
}
> >
{i18n('icu:Preferences__button--privacy')} {i18n('icu:Preferences__button--privacy')}
</button> </button>
@ -2322,9 +2347,11 @@ export function Preferences({
Preferences__button: true, Preferences__button: true,
'Preferences__button--data-usage': true, 'Preferences__button--data-usage': true,
'Preferences__button--selected': 'Preferences__button--selected':
page === SettingsPage.DataUsage, settingsLocation.page === SettingsPage.DataUsage,
})} })}
onClick={() => setPage(SettingsPage.DataUsage)} onClick={() =>
setSettingsLocation({ page: SettingsPage.DataUsage })
}
> >
{i18n('icu:Preferences__button--data-usage')} {i18n('icu:Preferences__button--data-usage')}
</button> </button>
@ -2334,9 +2361,13 @@ export function Preferences({
className={classNames({ className={classNames({
Preferences__button: true, Preferences__button: true,
'Preferences__button--backups': true, 'Preferences__button--backups': true,
'Preferences__button--selected': isBackupPage(page), 'Preferences__button--selected': isBackupPage(
settingsLocation.page
),
})} })}
onClick={() => setPage(SettingsPage.Backups)} onClick={() =>
setSettingsLocation({ page: SettingsPage.Backups })
}
> >
{i18n('icu:Preferences__button--backups')} {i18n('icu:Preferences__button--backups')}
</button> </button>
@ -2347,9 +2378,13 @@ export function Preferences({
className={classNames({ className={classNames({
Preferences__button: true, Preferences__button: true,
'Preferences__button--donations': true, 'Preferences__button--donations': true,
'Preferences__button--selected': isDonationsPage(page), 'Preferences__button--selected': isDonationsPage(
settingsLocation.page
),
})} })}
onClick={() => setPage(SettingsPage.Donations)} onClick={() =>
setSettingsLocation({ page: SettingsPage.Donations })
}
> >
{i18n('icu:Preferences__button--donate')} {i18n('icu:Preferences__button--donate')}
</button> </button>
@ -2361,9 +2396,11 @@ export function Preferences({
Preferences__button: true, Preferences__button: true,
'Preferences__button--internal': true, 'Preferences__button--internal': true,
'Preferences__button--selected': 'Preferences__button--selected':
page === SettingsPage.Internal, settingsLocation.page === SettingsPage.Internal,
})} })}
onClick={() => setPage(SettingsPage.Internal)} onClick={() =>
setSettingsLocation({ page: SettingsPage.Internal })
}
> >
{i18n('icu:Preferences__button--internal')} {i18n('icu:Preferences__button--internal')}
</button> </button>

View file

@ -19,7 +19,7 @@ import {
} from './PreferencesUtil.js'; } from './PreferencesUtil.js';
import { missingCaseError } from '../util/missingCaseError.js'; import { missingCaseError } from '../util/missingCaseError.js';
import { Button, ButtonVariant } from './Button.js'; import { Button, ButtonVariant } from './Button.js';
import type { PreferencesBackupPage } from '../types/PreferencesBackupPage.js'; import type { SettingsLocation } from '../types/Nav.js';
import { SettingsPage } from '../types/Nav.js'; import { SettingsPage } from '../types/Nav.js';
import { I18n } from './I18n.js'; import { I18n } from './I18n.js';
import { PreferencesLocalBackups } from './PreferencesLocalBackups.js'; import { PreferencesLocalBackups } from './PreferencesLocalBackups.js';
@ -64,11 +64,11 @@ export function PreferencesBackups({
cancelBackupMediaDownload, cancelBackupMediaDownload,
pauseBackupMediaDownload, pauseBackupMediaDownload,
resumeBackupMediaDownload, resumeBackupMediaDownload,
page, settingsLocation,
promptOSAuth, promptOSAuth,
refreshCloudBackupStatus, refreshCloudBackupStatus,
refreshBackupSubscriptionStatus, refreshBackupSubscriptionStatus,
setPage, setSettingsLocation,
showToast, showToast,
}: { }: {
accountEntropyPool: string | undefined; accountEntropyPool: string | undefined;
@ -81,7 +81,7 @@ export function PreferencesBackups({
isRemoteBackupsEnabled: boolean; isRemoteBackupsEnabled: boolean;
locale: string; locale: string;
onBackupKeyViewedChange: (keyViewed: boolean) => void; onBackupKeyViewedChange: (keyViewed: boolean) => void;
page: PreferencesBackupPage; settingsLocation: SettingsLocation;
backupMediaDownloadStatus: BackupMediaDownloadStatusType | undefined; backupMediaDownloadStatus: BackupMediaDownloadStatusType | undefined;
cancelBackupMediaDownload: () => void; cancelBackupMediaDownload: () => void;
pauseBackupMediaDownload: () => void; pauseBackupMediaDownload: () => void;
@ -92,7 +92,7 @@ export function PreferencesBackups({
) => Promise<PromptOSAuthResultType>; ) => Promise<PromptOSAuthResultType>;
refreshCloudBackupStatus: () => void; refreshCloudBackupStatus: () => void;
refreshBackupSubscriptionStatus: () => void; refreshBackupSubscriptionStatus: () => void;
setPage: (page: PreferencesBackupPage) => void; setSettingsLocation: (settingsLocation: SettingsLocation) => void;
showToast: ShowToastAction; showToast: ShowToastAction;
}): JSX.Element | null { }): JSX.Element | null {
const [authError, setAuthError] = const [authError, setAuthError] =
@ -100,27 +100,31 @@ export function PreferencesBackups({
const [isAuthPending, setIsAuthPending] = useState<boolean>(false); const [isAuthPending, setIsAuthPending] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (page === SettingsPage.Backups) { if (settingsLocation.page === SettingsPage.Backups) {
refreshBackupSubscriptionStatus(); refreshBackupSubscriptionStatus();
} else if (page === SettingsPage.BackupsDetails) { } else if (settingsLocation.page === SettingsPage.BackupsDetails) {
refreshBackupSubscriptionStatus(); refreshBackupSubscriptionStatus();
refreshCloudBackupStatus(); refreshCloudBackupStatus();
} }
}, [page, refreshBackupSubscriptionStatus, refreshCloudBackupStatus]); }, [
settingsLocation.page,
refreshBackupSubscriptionStatus,
refreshCloudBackupStatus,
]);
if (!isRemoteBackupsEnabled && isRemoteBackupsPage(page)) { if (!isRemoteBackupsEnabled && isRemoteBackupsPage(settingsLocation.page)) {
setPage(SettingsPage.Backups); setSettingsLocation({ page: SettingsPage.Backups });
return null; return null;
} }
if (!isLocalBackupsEnabled && isLocalBackupsPage(page)) { if (!isLocalBackupsEnabled && isLocalBackupsPage(settingsLocation.page)) {
setPage(SettingsPage.Backups); setSettingsLocation({ page: SettingsPage.Backups });
return null; return null;
} }
if (page === SettingsPage.BackupsDetails) { if (settingsLocation.page === SettingsPage.BackupsDetails) {
if (backupSubscriptionStatus.status === 'off') { if (backupSubscriptionStatus.status === 'off') {
setPage(SettingsPage.Backups); setSettingsLocation({ page: SettingsPage.Backups });
return null; return null;
} }
return ( return (
@ -137,7 +141,7 @@ export function PreferencesBackups({
); );
} }
if (isLocalBackupsPage(page)) { if (isLocalBackupsPage(settingsLocation.page)) {
return ( return (
<PreferencesLocalBackups <PreferencesLocalBackups
accountEntropyPool={accountEntropyPool} accountEntropyPool={accountEntropyPool}
@ -145,10 +149,10 @@ export function PreferencesBackups({
i18n={i18n} i18n={i18n}
localBackupFolder={localBackupFolder} localBackupFolder={localBackupFolder}
onBackupKeyViewedChange={onBackupKeyViewedChange} onBackupKeyViewedChange={onBackupKeyViewedChange}
page={page} settingsLocation={settingsLocation}
pickLocalBackupFolder={pickLocalBackupFolder} pickLocalBackupFolder={pickLocalBackupFolder}
promptOSAuth={promptOSAuth} promptOSAuth={promptOSAuth}
setPage={setPage} setSettingsLocation={setSettingsLocation}
showToast={showToast} showToast={showToast}
/> />
); );
@ -211,7 +215,9 @@ export function PreferencesBackups({
)} )}
> >
<Button <Button
onClick={() => setPage(SettingsPage.BackupsDetails)} onClick={() =>
setSettingsLocation({ page: SettingsPage.BackupsDetails })
}
variant={ButtonVariant.Secondary} variant={ButtonVariant.Secondary}
> >
{i18n('icu:Preferences__button--manage')} {i18n('icu:Preferences__button--manage')}
@ -270,7 +276,7 @@ export function PreferencesBackups({
} }
} }
setPage(SettingsPage.LocalBackups); setSettingsLocation({ page: SettingsPage.LocalBackups });
}} }}
variant={ButtonVariant.Secondary} variant={ButtonVariant.Secondary}
> >

View file

@ -10,6 +10,7 @@ import { getDateTimeFormatter } from '../util/formatTimestamp.js';
import type { LocalizerType, ThemeType } from '../types/Util.js'; import type { LocalizerType, ThemeType } from '../types/Util.js';
import { PreferencesContent } from './Preferences.js'; import { PreferencesContent } from './Preferences.js';
import type { SettingsLocation } from '../types/Nav.js';
import { SettingsPage } from '../types/Nav.js'; import { SettingsPage } from '../types/Nav.js';
import { PreferencesDonateFlow } from './PreferencesDonateFlow.js'; import { PreferencesDonateFlow } from './PreferencesDonateFlow.js';
import type { import type {
@ -67,7 +68,7 @@ export type PropsDataType = {
i18n: LocalizerType; i18n: LocalizerType;
initialCurrency: string; initialCurrency: string;
isOnline: boolean; isOnline: boolean;
page: SettingsPage; settingsLocation: SettingsLocation;
didResumeWorkflowAtStartup: boolean; didResumeWorkflowAtStartup: boolean;
lastError: DonationErrorType | undefined; lastError: DonationErrorType | undefined;
workflow: DonationWorkflow | undefined; workflow: DonationWorkflow | undefined;
@ -106,7 +107,7 @@ type PropsActionType = {
}) => void; }) => void;
clearWorkflow: () => void; clearWorkflow: () => void;
resumeWorkflow: () => void; resumeWorkflow: () => void;
setPage: (page: SettingsPage) => void; setSettingsLocation: (settingsLocation: SettingsLocation) => void;
showToast: (toast: AnyToast) => void; showToast: (toast: AnyToast) => void;
submitDonation: (payload: SubmitDonationType) => void; submitDonation: (payload: SubmitDonationType) => void;
updateLastError: (error: DonationErrorType | undefined) => void; updateLastError: (error: DonationErrorType | undefined) => void;
@ -123,12 +124,11 @@ type PreferencesHomeProps = Pick<
PropsType, PropsType,
| 'contentsRef' | 'contentsRef'
| 'i18n' | 'i18n'
| 'setPage' | 'setSettingsLocation'
| 'isOnline' | 'isOnline'
| 'donationReceipts' | 'donationReceipts'
| 'workflow' | 'workflow'
> & { > & {
navigateToPage: (newPage: SettingsPage) => void;
renderDonationHero: () => JSX.Element; renderDonationHero: () => JSX.Element;
}; };
@ -205,8 +205,7 @@ function DonationHero({
function DonationsHome({ function DonationsHome({
i18n, i18n,
renderDonationHero, renderDonationHero,
navigateToPage, setSettingsLocation,
setPage,
isOnline, isOnline,
donationReceipts, donationReceipts,
workflow, workflow,
@ -224,9 +223,9 @@ function DonationsHome({
if (inProgressDonationAmount) { if (inProgressDonationAmount) {
setIsInProgressVisible(true); setIsInProgressVisible(true);
} else { } else {
setPage(SettingsPage.DonationsDonateFlow); setSettingsLocation({ page: SettingsPage.DonationsDonateFlow });
} }
}, [inProgressDonationAmount, setPage]); }, [inProgressDonationAmount, setSettingsLocation]);
const handleInProgressDonationClicked = useCallback(() => { const handleInProgressDonationClicked = useCallback(() => {
setIsInProgressVisible(true); setIsInProgressVisible(true);
@ -299,7 +298,7 @@ function DonationsHome({
<ListBoxItem <ListBoxItem
className="PreferencesDonations__list-item" className="PreferencesDonations__list-item"
onAction={() => { onAction={() => {
navigateToPage(SettingsPage.DonationsReceiptList); setSettingsLocation({ page: SettingsPage.DonationsReceiptList });
}} }}
> >
<span className="PreferencesDonations__list-item__icon PreferencesDonations__list-item__icon--receipts" /> <span className="PreferencesDonations__list-item__icon PreferencesDonations__list-item__icon--receipts" />
@ -542,14 +541,14 @@ export function PreferencesDonations({
i18n, i18n,
initialCurrency, initialCurrency,
isOnline, isOnline,
page, settingsLocation,
workflow, workflow,
didResumeWorkflowAtStartup, didResumeWorkflowAtStartup,
lastError, lastError,
applyDonationBadge, applyDonationBadge,
clearWorkflow, clearWorkflow,
resumeWorkflow, resumeWorkflow,
setPage, setSettingsLocation,
submitDonation, submitDonation,
badge, badge,
color, color,
@ -575,19 +574,12 @@ export function PreferencesDonations({
useEffect(() => { useEffect(() => {
if ( if (
workflow?.type === donationStateSchema.Enum.DONE && workflow?.type === donationStateSchema.Enum.DONE &&
page === SettingsPage.Donations && settingsLocation.page === SettingsPage.Donations &&
!donationBadge !donationBadge
) { ) {
drop(fetchBadgeData()); drop(fetchBadgeData());
} }
}, [workflow, page, donationBadge, fetchBadgeData]); }, [workflow, settingsLocation.page, donationBadge, fetchBadgeData]);
const navigateToPage = useCallback(
(newPage: SettingsPage) => {
setPage(newPage);
},
[setPage]
);
useEffect(() => { useEffect(() => {
if (lastError) { if (lastError) {
@ -618,7 +610,7 @@ export function PreferencesDonations({
[badge, color, firstName, i18n, profileAvatarUrl, theme] [badge, color, firstName, i18n, profileAvatarUrl, theme]
); );
if (!isDonationPage(page)) { if (!isDonationPage(settingsLocation.page)) {
return null; return null;
} }
@ -649,7 +641,7 @@ export function PreferencesDonations({
i18n={i18n} i18n={i18n}
onCancelDonation={() => { onCancelDonation={() => {
clearWorkflow(); clearWorkflow();
setPage(SettingsPage.Donations); setSettingsLocation({ page: SettingsPage.Donations });
showToast({ toastType: ToastType.DonationCanceled }); showToast({ toastType: ToastType.DonationCanceled });
}} }}
onRetryDonation={() => { onRetryDonation={() => {
@ -663,7 +655,7 @@ export function PreferencesDonations({
i18n={i18n} i18n={i18n}
onCancelDonation={() => { onCancelDonation={() => {
clearWorkflow(); clearWorkflow();
setPage(SettingsPage.Donations); setSettingsLocation({ page: SettingsPage.Donations });
showToast({ toastType: ToastType.DonationCanceled }); showToast({ toastType: ToastType.DonationCanceled });
}} }}
onOpenBrowser={() => { onOpenBrowser={() => {
@ -672,7 +664,7 @@ export function PreferencesDonations({
onTimedOut={() => { onTimedOut={() => {
clearWorkflow(); clearWorkflow();
updateLastError(donationErrorTypeSchema.Enum.TimedOut); updateLastError(donationErrorTypeSchema.Enum.TimedOut);
setPage(SettingsPage.Donations); setSettingsLocation({ page: SettingsPage.Donations });
}} }}
/> />
); );
@ -695,7 +687,7 @@ export function PreferencesDonations({
/> />
); );
} else if ( } else if (
page === SettingsPage.DonationsDonateFlow && settingsLocation.page === SettingsPage.DonationsDonateFlow &&
(isSubmitted || (isSubmitted ||
workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED || workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED ||
workflow?.type === donationStateSchema.Enum.RECEIPT) workflow?.type === donationStateSchema.Enum.RECEIPT)
@ -711,7 +703,7 @@ export function PreferencesDonations({
<DonationStillProcessingModal <DonationStillProcessingModal
i18n={i18n} i18n={i18n}
onClose={() => { onClose={() => {
setPage(SettingsPage.Donations); setSettingsLocation({ page: SettingsPage.Donations });
// We need to delay until we've transitioned away from this page, or we'll // We need to delay until we've transitioned away from this page, or we'll
// go back to showing the spinner. // go back to showing the spinner.
setTimeout(() => setHasProcessingExpired(false), 500); setTimeout(() => setHasProcessingExpired(false), 500);
@ -736,7 +728,7 @@ export function PreferencesDonations({
) : null; ) : null;
let content; let content;
if (page === SettingsPage.DonationsDonateFlow) { if (settingsLocation.page === SettingsPage.DonationsDonateFlow) {
// DonateFlow has to control Back button to switch between CC form and Amount picker // DonateFlow has to control Back button to switch between CC form and Amount picker
return ( return (
<> <>
@ -758,25 +750,24 @@ export function PreferencesDonations({
submitDonation(details); submitDonation(details);
}} }}
showPrivacyModal={() => setIsPrivacyModalVisible(true)} showPrivacyModal={() => setIsPrivacyModalVisible(true)}
onBack={() => setPage(SettingsPage.Donations)} onBack={() => setSettingsLocation({ page: SettingsPage.Donations })}
/> />
</> </>
); );
} }
if (page === SettingsPage.Donations) { if (settingsLocation.page === SettingsPage.Donations) {
content = ( content = (
<DonationsHome <DonationsHome
contentsRef={contentsRef} contentsRef={contentsRef}
i18n={i18n} i18n={i18n}
isOnline={isOnline} isOnline={isOnline}
navigateToPage={navigateToPage}
donationReceipts={donationReceipts} donationReceipts={donationReceipts}
renderDonationHero={renderDonationHero} renderDonationHero={renderDonationHero}
setPage={setPage} setSettingsLocation={setSettingsLocation}
workflow={workflow} workflow={workflow}
/> />
); );
} else if (page === SettingsPage.DonationsReceiptList) { } else if (settingsLocation.page === SettingsPage.DonationsReceiptList) {
content = ( content = (
<PreferencesReceiptList <PreferencesReceiptList
i18n={i18n} i18n={i18n}
@ -790,15 +781,15 @@ export function PreferencesDonations({
let title: string | undefined; let title: string | undefined;
let backButton: JSX.Element | undefined; let backButton: JSX.Element | undefined;
if (page === SettingsPage.Donations) { if (settingsLocation.page === SettingsPage.Donations) {
title = i18n('icu:Preferences__DonateTitle'); title = i18n('icu:Preferences__DonateTitle');
} else if (page === SettingsPage.DonationsReceiptList) { } else if (settingsLocation.page === SettingsPage.DonationsReceiptList) {
title = i18n('icu:PreferencesDonations__receipts'); title = i18n('icu:PreferencesDonations__receipts');
backButton = ( backButton = (
<button <button
aria-label={i18n('icu:goBack')} aria-label={i18n('icu:goBack')}
className="Preferences__back-icon" className="Preferences__back-icon"
onClick={() => setPage(SettingsPage.Donations)} onClick={() => setSettingsLocation({ page: SettingsPage.Donations })}
type="button" type="button"
/> />
); );

View file

@ -23,7 +23,7 @@ import {
SIGNAL_BACKUPS_LEARN_MORE_URL, SIGNAL_BACKUPS_LEARN_MORE_URL,
} from './PreferencesBackups.js'; } from './PreferencesBackups.js';
import { I18n } from './I18n.js'; import { I18n } from './I18n.js';
import type { PreferencesBackupPage } from '../types/PreferencesBackupPage.js'; import type { SettingsLocation } from '../types/Nav.js';
import { SettingsPage } from '../types/Nav.js'; import { SettingsPage } from '../types/Nav.js';
import { ToastType } from '../types/Toast.js'; import { ToastType } from '../types/Toast.js';
import type { ShowToastAction } from '../state/ducks/toast.js'; import type { ShowToastAction } from '../state/ducks/toast.js';
@ -43,10 +43,10 @@ export function PreferencesLocalBackups({
i18n, i18n,
localBackupFolder, localBackupFolder,
onBackupKeyViewedChange, onBackupKeyViewedChange,
page, settingsLocation,
pickLocalBackupFolder, pickLocalBackupFolder,
promptOSAuth, promptOSAuth,
setPage, setSettingsLocation,
showToast, showToast,
}: { }: {
accountEntropyPool: string | undefined; accountEntropyPool: string | undefined;
@ -54,12 +54,12 @@ export function PreferencesLocalBackups({
i18n: LocalizerType; i18n: LocalizerType;
localBackupFolder: string | undefined; localBackupFolder: string | undefined;
onBackupKeyViewedChange: (keyViewed: boolean) => void; onBackupKeyViewedChange: (keyViewed: boolean) => void;
page: PreferencesBackupPage; settingsLocation: SettingsLocation;
pickLocalBackupFolder: () => Promise<string | undefined>; pickLocalBackupFolder: () => Promise<string | undefined>;
promptOSAuth: ( promptOSAuth: (
reason: PromptOSAuthReasonType reason: PromptOSAuthReasonType
) => Promise<PromptOSAuthResultType>; ) => Promise<PromptOSAuthResultType>;
setPage: (page: PreferencesBackupPage) => void; setSettingsLocation: (settingsLocation: SettingsLocation) => void;
showToast: ShowToastAction; showToast: ShowToastAction;
}): JSX.Element { }): JSX.Element {
const [authError, setAuthError] = const [authError, setAuthError] =
@ -75,7 +75,8 @@ export function PreferencesLocalBackups({
); );
} }
const isReferencingBackupKey = page === SettingsPage.LocalBackupsKeyReference; const isReferencingBackupKey =
settingsLocation.page === SettingsPage.LocalBackupsKeyReference;
if (!backupKeyViewed || isReferencingBackupKey) { if (!backupKeyViewed || isReferencingBackupKey) {
strictAssert(accountEntropyPool, 'AEP is required for backup key viewer'); strictAssert(accountEntropyPool, 'AEP is required for backup key viewer');
@ -86,7 +87,9 @@ export function PreferencesLocalBackups({
isReferencing={isReferencingBackupKey} isReferencing={isReferencingBackupKey}
onBackupKeyViewed={() => { onBackupKeyViewed={() => {
if (backupKeyViewed) { if (backupKeyViewed) {
setPage(SettingsPage.LocalBackups); setSettingsLocation({
page: SettingsPage.LocalBackups,
});
} else { } else {
onBackupKeyViewedChange(true); onBackupKeyViewedChange(true);
} }
@ -160,7 +163,9 @@ export function PreferencesLocalBackups({
setIsAuthPending(true); setIsAuthPending(true);
const result = await promptOSAuth('view-aep'); const result = await promptOSAuth('view-aep');
if (result === 'success' || result === 'unsupported') { if (result === 'success' || result === 'unsupported') {
setPage(SettingsPage.LocalBackupsKeyReference); setSettingsLocation({
page: SettingsPage.LocalBackupsKeyReference,
});
} else { } else {
setAuthError(result); setAuthError(result);
} }

View file

@ -38,8 +38,8 @@ import {
MessageRequestState, MessageRequestState,
} from './MessageRequestActionsConfirmation.js'; } from './MessageRequestActionsConfirmation.js';
import type { MinimalConversation } from '../../hooks/useMinimalConversation.js'; import type { MinimalConversation } from '../../hooks/useMinimalConversation.js';
import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal.js';
import { InAnotherCallTooltip } from './InAnotherCallTooltip.js'; import { InAnotherCallTooltip } from './InAnotherCallTooltip.js';
import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.js';
function HeaderInfoTitle({ function HeaderInfoTitle({
name, name,
@ -1003,50 +1003,3 @@ function CannotLeaveGroupBecauseYouAreLastAdminAlert({
/> />
); );
} }
function DeleteMessagesConfirmationDialog({
i18n,
localDeleteWarningShown,
onDestroyMessages,
onClose,
setLocalDeleteWarningShown,
}: {
i18n: LocalizerType;
localDeleteWarningShown: boolean;
onDestroyMessages: () => void;
onClose: () => void;
setLocalDeleteWarningShown: () => void;
}) {
if (!localDeleteWarningShown) {
return (
<LocalDeleteWarningModal
i18n={i18n}
onClose={setLocalDeleteWarningShown}
/>
);
}
const dialogBody = i18n(
'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync'
);
return (
<ConfirmationDialog
dialogName="ConversationHeader.destroyMessages"
title={i18n(
'icu:ConversationHeader__DeleteConversationConfirmation__title'
)}
actions={[
{
action: onDestroyMessages,
style: 'negative',
text: i18n('icu:delete'),
},
]}
i18n={i18n}
onClose={onClose}
>
{dialogBody}
</ConfirmationDialog>
);
}

View file

@ -209,13 +209,13 @@ export function PollMessageContents({
{totalVotes > 0 ? ( {totalVotes > 0 ? (
<div className={tw('mt-4 flex justify-center scheme-light')}> <div className={tw('mt-4 flex justify-center scheme-light')}>
<AxoButton <AxoButton.Root
size="medium" size="medium"
variant="floating-secondary" variant="floating-secondary"
onClick={() => setShowVotesModal(true)} onClick={() => setShowVotesModal(true)}
> >
{i18n('icu:PollMessage__ViewVotesButton')} {i18n('icu:PollMessage__ViewVotesButton')}
</AxoButton> </AxoButton.Root>
</div> </div>
) : ( ) : (
<div <div

View file

@ -34,6 +34,11 @@ const CHECKBOX_CONTAINER_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox--container`;
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`; const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
export const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`; export const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`;
export type RenderConversationListItemContextMenuProps = Readonly<{
conversationId: string;
children: ReactNode;
}>;
type PropsType = { type PropsType = {
buttonAriaLabel?: string; buttonAriaLabel?: string;
checked?: boolean; checked?: boolean;
@ -58,6 +63,9 @@ type PropsType = {
unreadMentionsCount?: number; unreadMentionsCount?: number;
avatarSize?: AvatarSize; avatarSize?: AvatarSize;
testId?: string; testId?: string;
renderConversationListItemContextMenu?: (
props: RenderConversationListItemContextMenuProps
) => JSX.Element;
} & Pick< } & Pick<
ConversationType, ConversationType,
| 'avatarPlaceholderGradient' | 'avatarPlaceholderGradient'
@ -114,6 +122,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
unreadCount, unreadCount,
unreadMentionsCount, unreadMentionsCount,
serviceId, serviceId,
renderConversationListItemContextMenu,
} = props; } = props;
const identifier = id ? cleanId(id) : undefined; const identifier = id ? cleanId(id) : undefined;
@ -275,8 +284,10 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
); );
} }
let wrapper: JSX.Element;
if (onClick) { if (onClick) {
return ( wrapper = (
<button <button
aria-label={ aria-label={
buttonAriaLabel || buttonAriaLabel ||
@ -298,9 +309,8 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
{contents} {contents}
</button> </button>
); );
} } else {
wrapper = (
return (
<div <div
className={commonClassNames} className={commonClassNames}
data-id={identifier} data-id={identifier}
@ -309,6 +319,16 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
{contents} {contents}
</div> </div>
); );
}
if (renderConversationListItemContextMenu != null && id != null) {
return renderConversationListItemContextMenu({
conversationId: id,
children: wrapper,
});
}
return wrapper;
}); });
function Timestamp({ function Timestamp({

View file

@ -5,6 +5,7 @@ import type { FunctionComponent, ReactNode } from 'react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { RenderConversationListItemContextMenuProps } from './BaseConversationListItem.js';
import { import {
BaseConversationListItem, BaseConversationListItem,
HEADER_NAME_CLASS_NAME, HEADER_NAME_CLASS_NAME,
@ -77,6 +78,9 @@ type PropsHousekeeping = {
onClick: (id: string) => void; onClick: (id: string) => void;
onMouseDown: (id: string) => void; onMouseDown: (id: string) => void;
theme: ThemeType; theme: ThemeType;
renderConversationListItemContextMenu?: (
props: RenderConversationListItemContextMenuProps
) => JSX.Element;
}; };
export type Props = PropsData & PropsHousekeeping; export type Props = PropsData & PropsHousekeeping;
@ -115,6 +119,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
unreadCount, unreadCount,
unreadMentionsCount, unreadMentionsCount,
serviceId, serviceId,
renderConversationListItemContextMenu,
}) { }) {
const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt); const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt);
const isSomeoneTyping = const isSomeoneTyping =
@ -243,6 +248,9 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
unreadCount={unreadCount} unreadCount={unreadCount}
unreadMentionsCount={unreadMentionsCount} unreadMentionsCount={unreadMentionsCount}
serviceId={serviceId} serviceId={serviceId}
renderConversationListItemContextMenu={
renderConversationListItemContextMenu
}
/> />
); );
} }

View file

@ -0,0 +1,390 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
useCallback,
useMemo,
type FocusEvent,
type ReactNode,
} from 'react';
import {
ChatFolderType,
type ChatFolder,
type ChatFolderId,
} from '../../types/ChatFolder.js';
import type { LocalizerType } from '../../types/I18N.js';
import { ExperimentalAxoSegmentedControl } from '../../axo/AxoSegmentedControl.js';
import { tw } from '../../axo/tw.js';
import type {
AllChatFoldersUnreadStats,
UnreadStats,
} from '../../util/countUnreadStats.js';
import { WidthBreakpoint } from '../_util.js';
import { AxoSelect } from '../../axo/AxoSelect.js';
import { AxoContextMenu } from '../../axo/AxoContextMenu.js';
import { getMuteValuesOptions } from '../../util/getMuteOptions.js';
import type {
AllChatFoldersMutedStats,
MutedStats,
} from '../../util/countMutedStats.js';
import type { AxoSymbol } from '../../axo/AxoSymbol.js';
export type LeftPaneChatFoldersProps = Readonly<{
i18n: LocalizerType;
navSidebarWidthBreakpoint: WidthBreakpoint | null;
sortedChatFolders: ReadonlyArray<ChatFolder>;
allChatFoldersUnreadStats: AllChatFoldersUnreadStats;
allChatFoldersMutedStats: AllChatFoldersMutedStats;
selectedChatFolder: ChatFolder | null;
onSelectedChatFolderIdChange: (newValue: ChatFolderId) => void;
onChatFolderMarkRead: (chatFolderId: ChatFolderId) => void;
onChatFolderUpdateMute: (chatFolderId: ChatFolderId, value: number) => void;
onChatFolderOpenSettings: (chatFolderId: ChatFolderId) => void;
}>;
function getBadgeValue(
unreadStats: UnreadStats | null
): ExperimentalAxoSegmentedControl.ExperimentalItemBadgeProps['value'] | null {
if (unreadStats == null) {
return null;
}
if (unreadStats.unreadCount > 0) {
return unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount;
}
if (unreadStats.readChatsMarkedUnreadCount > 0) {
return unreadStats.readChatsMarkedUnreadCount;
}
return null;
}
function getChatFolderLabel(
i18n: LocalizerType,
chatFolder: ChatFolder,
preferShort: boolean
): string {
if (chatFolder.folderType === ChatFolderType.ALL) {
if (preferShort) {
return i18n('icu:LeftPaneChatFolders__ItemLabel--All--Short');
}
return i18n('icu:LeftPaneChatFolders__ItemLabel--All');
}
if (chatFolder.folderType === ChatFolderType.CUSTOM) {
return chatFolder.name;
}
return '';
}
function getChatFolderIconName(
chatFolder: ChatFolder | null
): AxoSymbol.IconName {
if (chatFolder == null) {
return 'message';
}
return chatFolder.folderType === ChatFolderType.ALL ? 'message' : 'folder';
}
export function LeftPaneChatFolders(
props: LeftPaneChatFoldersProps
): JSX.Element | null {
const { i18n, onSelectedChatFolderIdChange } = props;
const handleValueChange = useCallback(
(newValue: string | null) => {
if (newValue != null) {
onSelectedChatFolderIdChange(newValue as ChatFolderId);
}
},
[onSelectedChatFolderIdChange]
);
const handleFocus = useCallback((event: FocusEvent<HTMLDivElement>) => {
event.target.scrollIntoView({
behavior: 'smooth',
inline: 'nearest',
});
}, []);
if (props.sortedChatFolders.length < 2) {
return null;
}
if (props.navSidebarWidthBreakpoint === WidthBreakpoint.Narrow) {
return (
<div className={tw('px-2')}>
<AxoSelect.Root
value={props.selectedChatFolder?.id ?? null}
onValueChange={handleValueChange}
>
<AxoSelect.Trigger
variant="floating"
width="full"
placeholder=""
chevron="on-hover"
/>
<AxoSelect.Content position="dropdown">
{props.sortedChatFolders.map(chatFolder => {
const unreadStats =
props.allChatFoldersUnreadStats.get(chatFolder.id) ?? null;
return (
<ChatFolderSelectItem
key={chatFolder.id}
i18n={i18n}
chatFolder={chatFolder}
unreadStats={unreadStats}
/>
);
})}
</AxoSelect.Content>
</AxoSelect.Root>
</div>
);
}
return (
<div
className={tw(
'scroll-px-[20%] overflow-x-auto overflow-y-clip px-4 py-2 [scrollbar-width:none]'
)}
onFocus={handleFocus}
>
<ExperimentalAxoSegmentedControl.Root
variant="no-track"
width="full"
itemWidth="fit"
value={props.selectedChatFolder?.id ?? null}
onValueChange={handleValueChange}
>
{props.sortedChatFolders.map(chatFolder => {
const unreadStats =
props.allChatFoldersUnreadStats.get(chatFolder.id) ?? null;
const mutedStats =
props.allChatFoldersMutedStats.get(chatFolder.id) ?? null;
return (
<ChatFolderSegmentedControlItem
key={chatFolder.id}
i18n={i18n}
chatFolder={chatFolder}
unreadStats={unreadStats}
mutedStats={mutedStats}
onChatFolderMarkRead={props.onChatFolderMarkRead}
onChatFolderUpdateMute={props.onChatFolderUpdateMute}
onChatFolderOpenSettings={props.onChatFolderOpenSettings}
/>
);
})}
</ExperimentalAxoSegmentedControl.Root>
</div>
);
}
const UNREAD_BADGE_MAX_COUNT = 999;
function ChatFolderSelectItem(props: {
i18n: LocalizerType;
chatFolder: ChatFolder;
unreadStats: UnreadStats | null;
}): JSX.Element {
const { i18n, unreadStats } = props;
const badgeValue = useMemo(() => {
return getBadgeValue(unreadStats);
}, [unreadStats]);
return (
<AxoSelect.Item
key={props.chatFolder.id}
value={props.chatFolder.id}
symbol={getChatFolderIconName(props.chatFolder)}
>
<AxoSelect.ItemText>
{getChatFolderLabel(i18n, props.chatFolder, true)}
</AxoSelect.ItemText>
{badgeValue != null && (
<AxoSelect.ExperimentalItemBadge
value={badgeValue}
max={UNREAD_BADGE_MAX_COUNT}
maxDisplay={i18n(
'icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount',
{
maxCount: UNREAD_BADGE_MAX_COUNT,
}
)}
aria-label={null}
/>
)}
</AxoSelect.Item>
);
}
function ChatFolderSegmentedControlItem(props: {
i18n: LocalizerType;
chatFolder: ChatFolder;
unreadStats: UnreadStats | null;
mutedStats: MutedStats | null;
onChatFolderMarkRead: (chatFolderId: ChatFolderId) => void;
onChatFolderUpdateMute: (chatFolderId: ChatFolderId, value: number) => void;
onChatFolderOpenSettings: (chatFolderId: ChatFolderId) => void;
}): JSX.Element {
const { i18n, unreadStats } = props;
const badgeValue = useMemo(() => {
return getBadgeValue(unreadStats);
}, [unreadStats]);
return (
<ChatFolderSegmentedControlItemContextMenu
i18n={i18n}
chatFolder={props.chatFolder}
unreadStats={props.unreadStats}
mutedStats={props.mutedStats}
onChatFolderMarkRead={props.onChatFolderMarkRead}
onChatFolderUpdateMute={props.onChatFolderUpdateMute}
onChatFolderOpenSettings={props.onChatFolderOpenSettings}
>
<ExperimentalAxoSegmentedControl.Item value={props.chatFolder.id}>
<ExperimentalAxoSegmentedControl.ItemText maxWidth="12ch">
{getChatFolderLabel(i18n, props.chatFolder, false)}
</ExperimentalAxoSegmentedControl.ItemText>
{badgeValue != null && (
<ExperimentalAxoSegmentedControl.ExperimentalItemBadge
value={badgeValue}
max={UNREAD_BADGE_MAX_COUNT}
maxDisplay={i18n(
'icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount',
{ maxCount: UNREAD_BADGE_MAX_COUNT }
)}
aria-label={null}
/>
)}
</ExperimentalAxoSegmentedControl.Item>
</ChatFolderSegmentedControlItemContextMenu>
);
}
function ChatFolderSegmentedControlItemContextMenu(props: {
i18n: LocalizerType;
chatFolder: ChatFolder;
unreadStats: UnreadStats | null;
mutedStats: MutedStats | null;
onChatFolderMarkRead: (chatFolderId: ChatFolderId) => void;
onChatFolderUpdateMute: (chatFolderId: ChatFolderId, value: number) => void;
onChatFolderOpenSettings: (chatFolderId: ChatFolderId) => void;
children: ReactNode;
}) {
const {
i18n,
onChatFolderMarkRead,
onChatFolderUpdateMute,
onChatFolderOpenSettings,
} = props;
const chatFolderId = props.chatFolder.id;
const muteValuesOptions = useMemo(() => {
return getMuteValuesOptions(i18n);
}, [i18n]);
const someChatsUnread =
(props.unreadStats?.unreadCount ?? 0) > 0 ||
(props.unreadStats?.readChatsMarkedUnreadCount ?? 0) > 0;
const someChatsMuted = (props.mutedStats?.chatsMutedCount ?? 0) > 0;
const someChatsUnmuted = (props.mutedStats?.chatsUnmutedCount ?? 0) > 0;
const showOnlyUnmuteAll = someChatsMuted && !someChatsUnmuted;
const handleChatFolderMarkRead = useCallback(() => {
onChatFolderMarkRead(chatFolderId);
}, [chatFolderId, onChatFolderMarkRead]);
const handleChatFolderUpdateMute = useCallback(
(value: number) => {
onChatFolderUpdateMute(chatFolderId, value);
},
[chatFolderId, onChatFolderUpdateMute]
);
const handleChatFolderOpenSettings = useCallback(() => {
onChatFolderOpenSettings(chatFolderId);
}, [chatFolderId, onChatFolderOpenSettings]);
return (
<AxoContextMenu.Root>
<AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
<AxoContextMenu.Content>
{someChatsUnread && (
<AxoContextMenu.Item
symbol="message-check"
onSelect={handleChatFolderMarkRead}
>
{i18n('icu:LeftPaneChatFolders__Item__ContextMenu__MarkAllRead')}
</AxoContextMenu.Item>
)}
{!showOnlyUnmuteAll && (
<AxoContextMenu.Sub>
<AxoContextMenu.SubTrigger symbol="bell-slash">
{i18n(
'icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications'
)}
</AxoContextMenu.SubTrigger>
<AxoContextMenu.SubContent>
{someChatsMuted && (
<ContextMenuMuteNotificationsItem
value={0}
onSelect={handleChatFolderUpdateMute}
>
{i18n(
'icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll'
)}
</ContextMenuMuteNotificationsItem>
)}
{muteValuesOptions.map(option => {
return (
<ContextMenuMuteNotificationsItem
key={option.value}
value={option.value}
onSelect={handleChatFolderUpdateMute}
>
{option.name}
</ContextMenuMuteNotificationsItem>
);
})}
</AxoContextMenu.SubContent>
</AxoContextMenu.Sub>
)}
{showOnlyUnmuteAll && (
<ContextMenuMuteNotificationsItem
symbol="bell"
value={0}
onSelect={handleChatFolderUpdateMute}
>
{i18n('icu:LeftPaneChatFolders__Item__ContextMenu__UnmuteAll')}
</ContextMenuMuteNotificationsItem>
)}
{props.chatFolder.folderType === ChatFolderType.CUSTOM && (
<AxoContextMenu.Item
symbol="pencil"
onSelect={handleChatFolderOpenSettings}
>
{i18n('icu:LeftPaneChatFolders__Item__ContextMenu__EditFolder')}
</AxoContextMenu.Item>
)}
</AxoContextMenu.Content>
</AxoContextMenu.Root>
);
}
function ContextMenuMuteNotificationsItem(props: {
symbol?: AxoSymbol.IconName;
value: number;
onSelect: (value: number) => void;
children: ReactNode;
}): JSX.Element {
const { value, onSelect } = props;
const handleSelect = useCallback(() => {
onSelect(value);
}, [onSelect, value]);
return (
<AxoContextMenu.Item symbol={props.symbol} onSelect={handleSelect}>
{props.children}
</AxoContextMenu.Item>
);
}

View file

@ -0,0 +1,275 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC, ReactNode } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { AxoContextMenu } from '../../axo/AxoContextMenu.js';
import type { LocalizerType } from '../../types/I18N.js';
import type { ConversationType } from '../../state/ducks/conversations.js';
import { isConversationUnread } from '../../util/isConversationUnread.js';
import {
Environment,
getEnvironment,
isMockEnvironment,
} from '../../environment.js';
import { isAlpha } from '../../util/version.js';
import { drop } from '../../util/drop.js';
import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.js';
import { getMuteOptions } from '../../util/getMuteOptions.js';
function isEnabled() {
const env = getEnvironment();
if (
env === Environment.Development ||
env === Environment.Test ||
isMockEnvironment()
) {
return true;
}
const version = window.getVersion?.();
if (version != null) {
if (isAlpha(version)) {
return true;
}
}
return false;
}
export type LeftPaneConversationListItemContextMenuProps = Readonly<{
i18n: LocalizerType;
conversation: ConversationType;
onMarkUnread: (conversationId: string) => void;
onMarkRead: (conversationId: string) => void;
onPin: (conversationId: string) => void;
onUnpin: (conversationId: string) => void;
onUpdateMute: (conversationId: string, muteExpiresAt: number) => void;
onArchive: (conversationId: string) => void;
onUnarchive: (conversationId: string) => void;
onDelete: (conversationId: string) => void;
localDeleteWarningShown: boolean;
setLocalDeleteWarningShown: () => void;
children: ReactNode;
}>;
export const LeftPaneConversationListItemContextMenu: FC<LeftPaneConversationListItemContextMenuProps> =
memo(function ConversationListItemContextMenu(props) {
const {
i18n,
conversation,
onMarkUnread,
onMarkRead,
onPin,
onUnpin,
onUpdateMute,
onArchive,
onUnarchive,
onDelete,
} = props;
const { id: conversationId, muteExpiresAt } = conversation;
const muteOptions = useMemo(() => {
return getMuteOptions(muteExpiresAt, i18n);
}, [muteExpiresAt, i18n]);
const [showConfirmDeleteDialog, setShowConfirmDeleteDialog] =
useState(false);
const handleOpenConfirmDeleteDialog = useCallback(() => {
setShowConfirmDeleteDialog(true);
}, []);
const handleCloseConfirmDeleteDialog = useCallback(() => {
setShowConfirmDeleteDialog(false);
}, []);
const isUnread = useMemo(() => {
return isConversationUnread(conversation);
}, [conversation]);
const handleMarkUnread = useCallback(() => {
onMarkUnread(conversationId);
}, [onMarkUnread, conversationId]);
const handleMarkRead = useCallback(() => {
onMarkRead(conversationId);
}, [onMarkRead, conversationId]);
const handlePin = useCallback(() => {
onPin(conversationId);
}, [onPin, conversationId]);
const handleUnpin = useCallback(() => {
onUnpin(conversationId);
}, [onUnpin, conversationId]);
const handleUpdateMute = useCallback(
(value: number) => {
onUpdateMute(conversationId, value);
},
[onUpdateMute, conversationId]
);
const handleArchive = useCallback(() => {
onArchive(conversationId);
}, [onArchive, conversationId]);
const handleUnarchive = useCallback(() => {
onUnarchive(conversationId);
}, [onUnarchive, conversationId]);
const handleDelete = useCallback(() => {
onDelete(conversationId);
}, [onDelete, conversationId]);
return (
<>
<AxoContextMenu.Root>
<AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
<AxoContextMenu.Content>
{isUnread && (
<AxoContextMenu.Item
symbol="message-check"
onSelect={handleMarkRead}
>
{i18n('icu:markRead')}
</AxoContextMenu.Item>
)}
{!isUnread && !conversation.markedUnread && (
<AxoContextMenu.Item
symbol="message-badge"
onSelect={handleMarkUnread}
>
{i18n('icu:markUnread')}
</AxoContextMenu.Item>
)}
{!conversation.isPinned && (
<AxoContextMenu.Item symbol="pin" onSelect={handlePin}>
{i18n('icu:pinConversation')}
</AxoContextMenu.Item>
)}
{conversation.isPinned && (
<AxoContextMenu.Item symbol="pin-slash" onSelect={handleUnpin}>
{i18n('icu:unpinConversation')}
</AxoContextMenu.Item>
)}
<AxoContextMenu.Sub>
<AxoContextMenu.SubTrigger symbol="bell-slash">
{i18n('icu:muteNotificationsTitle')}
</AxoContextMenu.SubTrigger>
<AxoContextMenu.SubContent>
{muteOptions.map(muteOption => {
return (
<ContextMenuMuteNotificationsItem
key={muteOption.value}
value={muteOption.value}
disabled={muteOption.disabled}
onSelect={handleUpdateMute}
>
{muteOption.name}
</ContextMenuMuteNotificationsItem>
);
})}
</AxoContextMenu.SubContent>
</AxoContextMenu.Sub>
{!conversation.isArchived && (
<AxoContextMenu.Item symbol="archive" onSelect={handleArchive}>
{i18n('icu:archiveConversation')}
</AxoContextMenu.Item>
)}
{conversation.isArchived && (
<AxoContextMenu.Item
symbol="archive-up"
onSelect={handleUnarchive}
>
{i18n('icu:moveConversationToInbox')}
</AxoContextMenu.Item>
)}
<AxoContextMenu.Item
symbol="trash"
onSelect={handleOpenConfirmDeleteDialog}
>
{i18n('icu:deleteConversation')}
</AxoContextMenu.Item>
{isEnabled() && (
<>
<AxoContextMenu.Separator />
<AxoContextMenu.Group>
<AxoContextMenu.Label>Internal</AxoContextMenu.Label>
<ContextMenuCopyTextItem value={conversation.id}>
Copy Conversation ID
</ContextMenuCopyTextItem>
{conversation.serviceId != null && (
<ContextMenuCopyTextItem value={conversation.serviceId}>
Copy Service ID
</ContextMenuCopyTextItem>
)}
{conversation.pni != null && (
<ContextMenuCopyTextItem value={conversation.pni}>
Copy PNI
</ContextMenuCopyTextItem>
)}
{conversation.groupId != null && (
<ContextMenuCopyTextItem value={conversation.groupId}>
Copy Group ID
</ContextMenuCopyTextItem>
)}
{conversation.e164 != null && (
<ContextMenuCopyTextItem value={conversation.e164}>
Copy E164
</ContextMenuCopyTextItem>
)}
</AxoContextMenu.Group>
</>
)}
</AxoContextMenu.Content>
</AxoContextMenu.Root>
{showConfirmDeleteDialog && (
<DeleteMessagesConfirmationDialog
i18n={i18n}
localDeleteWarningShown={props.localDeleteWarningShown}
onDestroyMessages={handleDelete}
onClose={handleCloseConfirmDeleteDialog}
setLocalDeleteWarningShown={props.setLocalDeleteWarningShown}
/>
)}
</>
);
});
function ContextMenuMuteNotificationsItem(props: {
disabled?: boolean;
value: number;
onSelect: (value: number) => void;
children: ReactNode;
}): JSX.Element {
const { value, onSelect } = props;
const handleSelect = useCallback(() => {
onSelect(value);
}, [onSelect, value]);
return (
<AxoContextMenu.Item disabled={props.disabled} onSelect={handleSelect}>
{props.children}
</AxoContextMenu.Item>
);
}
function ContextMenuCopyTextItem(props: {
value: string;
children: ReactNode;
}): JSX.Element {
const { value } = props;
const handleSelect = useCallback((): void => {
drop(window.navigator.clipboard.writeText(value));
}, [value]);
return (
<AxoContextMenu.Item symbol="copy" onSelect={handleSelect}>
{props.children}
</AxoContextMenu.Item>
);
}

View file

@ -88,6 +88,7 @@ export abstract class LeftPaneHelper<T> {
createGroup: () => unknown; createGroup: () => unknown;
i18n: LocalizerType; i18n: LocalizerType;
removeSelectedContact: (_: string) => unknown; removeSelectedContact: (_: string) => unknown;
renderLeftPaneChatFolders: () => JSX.Element;
setComposeGroupAvatar: (_: undefined | Uint8Array) => unknown; setComposeGroupAvatar: (_: undefined | Uint8Array) => unknown;
setComposeGroupExpireTimer: (_: DurationInSeconds) => void; setComposeGroupExpireTimer: (_: DurationInSeconds) => void;
setComposeGroupName: (_: string) => unknown; setComposeGroupName: (_: string) => unknown;

View file

@ -131,6 +131,14 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
); );
} }
override getPreRowsNode({
renderLeftPaneChatFolders,
}: Readonly<{
renderLeftPaneChatFolders: () => JSX.Element;
}>): ReactChild {
return renderLeftPaneChatFolders();
}
override getBackgroundNode({ override getBackgroundNode({
i18n, i18n,
}: Readonly<{ }: Readonly<{

View file

@ -0,0 +1,36 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../../../types/I18N.js';
import { ConfirmationDialog } from '../../ConfirmationDialog.js';
export function DeleteChatFolderDialog(props: {
i18n: LocalizerType;
title: string;
description: string;
cancelText: string;
deleteText: string;
onConfirm: () => void;
onClose: () => void;
}): JSX.Element {
const { i18n } = props;
return (
<ConfirmationDialog
i18n={i18n}
dialogName="Preferences__DeleteChatFolderDialog"
title={props.title}
cancelText={props.cancelText}
actions={[
{
text: props.deleteText,
style: 'affirmative',
action: props.onConfirm,
},
]}
onClose={props.onClose}
>
{props.description}
</ConfirmationDialog>
);
}

View file

@ -1,8 +1,10 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { MutableRefObject } from 'react'; import type { MutableRefObject, ReactNode } from 'react';
import { ListBox, ListBoxItem, useDragAndDrop } from 'react-aria-components';
import { partition } from 'lodash';
import type { LocalizerType } from '../../../types/I18N.js'; import type { LocalizerType } from '../../../types/I18N.js';
import { PreferencesContent } from '../../Preferences.js'; import { PreferencesContent } from '../../Preferences.js';
import { SettingsRow } from '../../PreferencesUtil.js'; import { SettingsRow } from '../../PreferencesUtil.js';
@ -18,30 +20,128 @@ import type {
ChatFolder, ChatFolder,
} from '../../../types/ChatFolder.js'; } from '../../../types/ChatFolder.js';
import { Button, ButtonVariant } from '../../Button.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 { showToast } from '../../state/ducks/toast';
function moveChatFolders(
chatFolders: ReadonlyArray<ChatFolder>,
target: ChatFolderId,
moving: Set<ChatFolderId>,
position: 'before' | 'after'
) {
const [toSplice, toInsert] = partition(chatFolders, chatFolder => {
return !moving.has(chatFolder.id);
});
const targetIndex = toSplice.findIndex(chatFolder => {
return chatFolder.id === target;
});
if (targetIndex === -1) {
return chatFolders;
}
const spliceIndex = position === 'before' ? targetIndex : targetIndex + 1;
return toSplice.toSpliced(spliceIndex, 0, ...toInsert);
}
export type PreferencesChatFoldersPageProps = Readonly<{ export type PreferencesChatFoldersPageProps = Readonly<{
i18n: LocalizerType; i18n: LocalizerType;
onBack: () => void; onBack: () => void;
onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void; onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void;
chatFolders: ReadonlyArray<ChatFolder>; chatFolders: ReadonlyArray<ChatFolder>;
onCreateChatFolder: (params: ChatFolderParams) => void; onCreateChatFolder: (params: ChatFolderParams) => void;
onDeleteChatFolder: (chatFolderId: ChatFolderId) => void;
onUpdateChatFoldersPositions: (
chatFolderIds: ReadonlyArray<ChatFolderId>
) => void;
settingsPaneRef: MutableRefObject<HTMLDivElement | null>; settingsPaneRef: MutableRefObject<HTMLDivElement | null>;
}>; }>;
export function PreferencesChatFoldersPage( export function PreferencesChatFoldersPage(
props: PreferencesChatFoldersPageProps props: PreferencesChatFoldersPageProps
): JSX.Element { ): JSX.Element {
const { i18n, onOpenEditChatFoldersPage, chatFolders } = props; const {
i18n,
onOpenEditChatFoldersPage,
onDeleteChatFolder,
onUpdateChatFoldersPositions,
chatFolders,
} = props;
const [confirmDeleteChatFolder, setConfirmDeleteChatFolder] =
useState<ChatFolder | null>(null);
// showToast( // showToast(
// i18n("icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast") // i18n("icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast")
// ) // )
const handleOpenEditChatFoldersPageForNew = useCallback(() => { const handleChatFolderCreate = useCallback(() => {
onOpenEditChatFoldersPage(null); onOpenEditChatFoldersPage(null);
}, [onOpenEditChatFoldersPage]); }, [onOpenEditChatFoldersPage]);
const handleChatFolderEdit = useCallback(
(chatFolder: ChatFolder) => {
onOpenEditChatFoldersPage(chatFolder.id);
},
[onOpenEditChatFoldersPage]
);
const handleChatFolderDeleteInit = useCallback((chatFolder: ChatFolder) => {
setConfirmDeleteChatFolder(chatFolder);
}, []);
const handleChatFolderDeleteCancel = useCallback(() => {
setConfirmDeleteChatFolder(null);
}, []);
const handleChatFolderDeleteConfirm = useCallback(() => {
strictAssert(confirmDeleteChatFolder, 'Missing chat folder to delete');
onDeleteChatFolder(confirmDeleteChatFolder.id);
}, [confirmDeleteChatFolder, onDeleteChatFolder]);
const [chatFoldersReordered, setChatFoldersReordered] = useState(chatFolders);
useEffect(() => {
setChatFoldersReordered(chatFolders);
}, [chatFolders]);
const { dragAndDropHooks } = useDragAndDrop({
getItems: () => {
return chatFolders.map(chatFolder => {
return { 'signal-chat-folder-id': chatFolder.id.slice(-3) };
});
},
acceptedDragTypes: ['signal-chat-folder-id'],
getDropOperation: () => 'move',
onDragEnd: () => {
onUpdateChatFoldersPositions(
chatFoldersReordered.map(chatFolder => {
return chatFolder.id;
})
);
},
onReorder: event => {
const target = event.target.key as ChatFolderId;
const moving = event.keys as Set<ChatFolderId>;
const position = event.target.dropPosition;
if (position !== 'before' && position !== 'after') {
return;
}
setChatFoldersReordered(prevChatFolders => {
return moveChatFolders(prevChatFolders, target, moving, position);
});
},
renderDropIndicator: () => {
return <div className={tw('h-12')} />;
},
});
const presetItemsConfigs = useMemo(() => { const presetItemsConfigs = useMemo(() => {
const initial: ReadonlyArray<ChatFolderPresetItemConfig> = [ const initial: ReadonlyArray<ChatFolderPresetItemConfig> = [
{ {
@ -86,6 +186,7 @@ export function PreferencesChatFoldersPage(
}, [i18n, chatFolders]); }, [i18n, chatFolders]);
return ( return (
<>
<PreferencesContent <PreferencesContent
backButton={ backButton={
<button <button
@ -104,13 +205,12 @@ export function PreferencesChatFoldersPage(
title={i18n( title={i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__Title' 'icu:Preferences__ChatFoldersPage__FoldersSection__Title'
)} )}
className={tw('mt-4')}
> >
<ul data-testid="ChatFoldersList">
<li>
<button <button
type="button" type="button"
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button" className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
onClick={handleOpenEditChatFoldersPageForNew} onClick={handleChatFolderCreate}
> >
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" /> <span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle"> <span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
@ -119,18 +219,23 @@ export function PreferencesChatFoldersPage(
)} )}
</span> </span>
</button> </button>
</li> <ListBox
{props.chatFolders.map(chatFolder => { selectionMode="single"
data-testid="ChatFoldersList"
items={chatFoldersReordered}
dragAndDropHooks={dragAndDropHooks}
>
{chatFolder => {
return ( return (
<ChatFolderListItem <ChatFolderListItem
key={chatFolder.id}
i18n={i18n} i18n={i18n}
chatFolder={chatFolder} chatFolder={chatFolder}
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage} onChatFolderEdit={handleChatFolderEdit}
onChatFolderDelete={handleChatFolderDeleteInit}
/> />
); );
})} }}
</ul> </ListBox>
</SettingsRow> </SettingsRow>
{presetItemsConfigs.length > 0 && ( {presetItemsConfigs.length > 0 && (
<SettingsRow <SettingsRow
@ -145,6 +250,7 @@ export function PreferencesChatFoldersPage(
{presetItemsConfigs.map(presetItemConfig => { {presetItemsConfigs.map(presetItemConfig => {
return ( return (
<ChatFolderPresetItem <ChatFolderPresetItem
key={presetItemConfig.id}
i18n={i18n} i18n={i18n}
config={presetItemConfig} config={presetItemConfig}
onCreateChatFolder={props.onCreateChatFolder} onCreateChatFolder={props.onCreateChatFolder}
@ -159,6 +265,27 @@ export function PreferencesChatFoldersPage(
contentsRef={props.settingsPaneRef} contentsRef={props.settingsPaneRef}
title={i18n('icu:Preferences__ChatFoldersPage__Title')} title={i18n('icu:Preferences__ChatFoldersPage__Title')}
/> />
{confirmDeleteChatFolder != null && (
<DeleteChatFolderDialog
i18n={i18n}
title={i18n(
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title'
)}
description={i18n(
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description',
{ chatFolderTitle: confirmDeleteChatFolder.name }
)}
deleteText={i18n(
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__DeleteButton'
)}
cancelText={i18n(
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton'
)}
onClose={handleChatFolderDeleteCancel}
onConfirm={handleChatFolderDeleteConfirm}
/>
)}
</>
); );
} }
@ -214,87 +341,96 @@ function ChatFolderPresetItem(props: ChatFolderPresetItemProps) {
function ChatFolderListItem(props: { function ChatFolderListItem(props: {
i18n: LocalizerType; i18n: LocalizerType;
chatFolder: ChatFolder; chatFolder: ChatFolder;
onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId) => void; onChatFolderEdit: (chatFolder: ChatFolder) => void;
onChatFolderDelete: (chatFolder: ChatFolder) => void;
}): JSX.Element { }): JSX.Element {
const { i18n, chatFolder, onOpenEditChatFoldersPage } = props; const { i18n, chatFolder, onChatFolderEdit } = props;
const handleAction = useCallback(() => { const handleClickChatFolder = useCallback(() => {
onOpenEditChatFoldersPage(chatFolder.id); onChatFolderEdit(chatFolder);
}, [chatFolder, onOpenEditChatFoldersPage]); }, [chatFolder, onChatFolderEdit]);
return ( return (
<li> <>
<button {props.chatFolder.folderType === ChatFolderType.ALL && (
type="button" <ListBoxItem
id={chatFolder.id}
data-testid={`ChatFolder--${chatFolder.id}`} data-testid={`ChatFolder--${chatFolder.id}`}
onClick={handleAction} className="Preferences__ChatFolders__ChatSelection__Item"
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
> >
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" /> <span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle"> {i18n(
{props.chatFolder.folderType === ChatFolderType.ALL &&
i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title' 'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
)} )}
{props.chatFolder.folderType === ChatFolderType.CUSTOM && ( </ListBoxItem>
<>{props.chatFolder.name}</>
)} )}
</span>
</button> {props.chatFolder.folderType === ChatFolderType.CUSTOM && (
</li> <ListBoxItem
id={chatFolder.id}
data-testid={`ChatFolder--${chatFolder.id}`}
textValue={props.chatFolder.name}
onAction={handleClickChatFolder}
>
<ChatFolderListItemContextMenu
i18n={i18n}
chatFolder={props.chatFolder}
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>
</ChatFolderListItemContextMenu>
</ListBoxItem>
)}
</>
); );
} }
// function ChatFolderContextMenu(props: { function ChatFolderListItemContextMenu(props: {
// i18n: LocalizerType; i18n: LocalizerType;
// children: ReactNode; chatFolder: ChatFolder;
// }) { onChatFolderEdit: (chatFolder: ChatFolder) => void;
// const { i18n } = props; onChatFolderDelete: (chatFolder: ChatFolder) => void;
// return ( children: ReactNode;
// <AxoContextMenu.Root> }) {
// <AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger> const { i18n, chatFolder, onChatFolderEdit, onChatFolderDelete } = props;
// <AxoContextMenu.Content>
// <AxoContextMenu.Item>
// {i18n(
// eslint-disable-next-line max-len
// 'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder'
// )}
// </AxoContextMenu.Item>
// <AxoContextMenu.Item>
// {i18n(
// eslint-disable-next-line max-len
// 'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__DeleteFolder'
// )}
// </AxoContextMenu.Item>
// </AxoContextMenu.Content>
// </AxoContextMenu.Root>
// );
// }
// function DeleteChatFolderDialog(props: { i18n: LocalizerType }): JSX.Element { const handleSelectChatFolderEdit = useCallback(() => {
// const { i18n } = props; onChatFolderEdit(chatFolder);
// return ( }, [chatFolder, onChatFolderEdit]);
// <ConfirmationDialog
// i18n={i18n} const handleSelectChatFolderDelete = useCallback(() => {
// dialogName="Preferences__ChatsPage__DeleteChatFolderDialog" onChatFolderDelete(chatFolder);
// title={i18n('icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title')} }, [chatFolder, onChatFolderDelete]);
// cancelText={i18n(
// 'icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton' if (chatFolder.folderType !== ChatFolderType.CUSTOM) {
// )} return <>{props.children}</>;
// actions={[ }
// {
// text: i18n( return (
// 'icu:Preferences__ChatsPage__DeleteChatFolderDialog__DeleteButton' <AxoContextMenu.Root>
// ), <AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
// style: 'affirmative', <AxoContextMenu.Content>
// action: () => null, <AxoContextMenu.Item
// }, symbol="pencil"
// ]} onSelect={handleSelectChatFolderEdit}
// onClose={() => null} >
// > {i18n(
// {i18n('icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description', { 'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder'
// chatFolderTitle: '', )}
// })} </AxoContextMenu.Item>
// </ConfirmationDialog> <AxoContextMenu.Item
// ); symbol="trash"
// } onSelect={handleSelectChatFolderDelete}
>
{i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__DeleteFolder'
)}
</AxoContextMenu.Item>
</AxoContextMenu.Content>
</AxoContextMenu.Root>
);
}

View file

@ -1,7 +1,7 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { ConversationType } from '../../../state/ducks/conversations.js'; import type { ConversationType } from '../../../state/ducks/conversations.js';
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js'; import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js';
import type { LocalizerType } from '../../../types/I18N.js'; import type { LocalizerType } from '../../../types/I18N.js';
@ -28,12 +28,17 @@ import type {
import type { GetConversationByIdType } from '../../../state/selectors/conversations.js'; import type { GetConversationByIdType } from '../../../state/selectors/conversations.js';
import { strictAssert } from '../../../util/assert.js'; import { strictAssert } from '../../../util/assert.js';
import { parseStrict } from '../../../util/schemas.js'; import { parseStrict } from '../../../util/schemas.js';
import { BeforeNavigateResponse } from '../../../services/BeforeNavigate.js';
import { type Location } from '../../../types/Nav.js';
import { useNavBlocker } from '../../../hooks/useNavBlocker.js';
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
export type PreferencesEditChatFolderPageProps = Readonly<{ export type PreferencesEditChatFolderPageProps = Readonly<{
i18n: LocalizerType; i18n: LocalizerType;
previousLocation: Location;
existingChatFolderId: ChatFolderId | null; existingChatFolderId: ChatFolderId | null;
initChatFolderParams: ChatFolderParams; initChatFolderParams: ChatFolderParams;
onBack: () => void; changeLocation: (location: Location) => void;
conversations: ReadonlyArray<ConversationType>; conversations: ReadonlyArray<ConversationType>;
preferredBadgeSelector: PreferredBadgeSelectorType; preferredBadgeSelector: PreferredBadgeSelectorType;
theme: ThemeType; theme: ThemeType;
@ -52,12 +57,13 @@ export function PreferencesEditChatFolderPage(
): JSX.Element { ): JSX.Element {
const { const {
i18n, i18n,
previousLocation,
initChatFolderParams, initChatFolderParams,
existingChatFolderId, existingChatFolderId,
onCreateChatFolder, onCreateChatFolder,
onUpdateChatFolder, onUpdateChatFolder,
onDeleteChatFolder, onDeleteChatFolder,
onBack, changeLocation,
conversationSelector, conversationSelector,
} = props; } = props;
@ -67,7 +73,6 @@ export function PreferencesEditChatFolderPage(
const [showInclusionsDialog, setShowInclusionsDialog] = useState(false); const [showInclusionsDialog, setShowInclusionsDialog] = useState(false);
const [showExclusionsDialog, setShowExclusionsDialog] = useState(false); const [showExclusionsDialog, setShowExclusionsDialog] = useState(false);
const [showDeleteFolderDialog, setShowDeleteFolderDialog] = useState(false); const [showDeleteFolderDialog, setShowDeleteFolderDialog] = useState(false);
const [showSaveChangesDialog, setShowSaveChangesDialog] = useState(false);
const normalizedChatFolderParams = useMemo(() => { const normalizedChatFolderParams = useMemo(() => {
return parseStrict(ChatFolderParamsSchema, chatFolderParams); return parseStrict(ChatFolderParamsSchema, chatFolderParams);
@ -80,6 +85,12 @@ export function PreferencesEditChatFolderPage(
); );
}, [initChatFolderParams, normalizedChatFolderParams]); }, [initChatFolderParams, normalizedChatFolderParams]);
const didSaveOrDiscardChangesRef = useRef(false);
const blocker = useNavBlocker('PreferencesEditChatFoldersPage', () => {
return isChanged && !didSaveOrDiscardChangesRef.current;
});
const isValid = useMemo(() => { const isValid = useMemo(() => {
return validateChatFolderParams(normalizedChatFolderParams); return validateChatFolderParams(normalizedChatFolderParams);
}, [normalizedChatFolderParams]); }, [normalizedChatFolderParams]);
@ -102,23 +113,16 @@ export function PreferencesEditChatFolderPage(
}); });
}, []); }, []);
const handleBackInit = useCallback(() => { const handleBack = useCallback(() => {
if (!isChanged) { changeLocation(previousLocation);
onBack(); }, [changeLocation, previousLocation]);
} else {
setShowSaveChangesDialog(true);
}
}, [isChanged, onBack]);
const handleDiscard = useCallback(() => { const handleDiscardAndBack = useCallback(() => {
onBack(); didSaveOrDiscardChangesRef.current = true;
}, [onBack]); handleBack();
}, [handleBack]);
const handleSaveClose = useCallback(() => { const handleSaveChanges = useCallback(() => {
setShowSaveChangesDialog(false);
}, []);
const handleSave = useCallback(() => {
strictAssert(isChanged, 'tried saving when unchanged'); strictAssert(isChanged, 'tried saving when unchanged');
strictAssert(isValid, 'tried saving when invalid'); strictAssert(isValid, 'tried saving when invalid');
@ -127,9 +131,9 @@ export function PreferencesEditChatFolderPage(
} else { } else {
onCreateChatFolder(normalizedChatFolderParams); onCreateChatFolder(normalizedChatFolderParams);
} }
onBack();
didSaveOrDiscardChangesRef.current = true;
}, [ }, [
onBack,
existingChatFolderId, existingChatFolderId,
isChanged, isChanged,
isValid, isValid,
@ -138,6 +142,24 @@ export function PreferencesEditChatFolderPage(
onUpdateChatFolder, onUpdateChatFolder,
]); ]);
const handleSaveChangesAndBack = useCallback(() => {
handleSaveChanges();
handleBack();
}, [handleSaveChanges, handleBack]);
const handleBlockerCancelNavigation = useCallback(() => {
blocker.respond?.(BeforeNavigateResponse.CancelNavigation);
}, [blocker]);
const handleBlockerSaveChanges = useCallback(() => {
handleSaveChanges();
blocker.respond?.(BeforeNavigateResponse.WaitedForUser);
}, [handleSaveChanges, blocker]);
const handleBlockerDiscardChanges = useCallback(() => {
blocker.respond?.(BeforeNavigateResponse.WaitedForUser);
}, [blocker]);
const handleDeleteInit = useCallback(() => { const handleDeleteInit = useCallback(() => {
setShowDeleteFolderDialog(true); setShowDeleteFolderDialog(true);
}, []); }, []);
@ -145,8 +167,8 @@ export function PreferencesEditChatFolderPage(
strictAssert(existingChatFolderId, 'Missing existing chat folder id'); strictAssert(existingChatFolderId, 'Missing existing chat folder id');
onDeleteChatFolder(existingChatFolderId); onDeleteChatFolder(existingChatFolderId);
setShowDeleteFolderDialog(false); setShowDeleteFolderDialog(false);
onBack(); handleBack();
}, [existingChatFolderId, onDeleteChatFolder, onBack]); }, [existingChatFolderId, onDeleteChatFolder, handleBack]);
const handleDeleteClose = useCallback(() => { const handleDeleteClose = useCallback(() => {
setShowDeleteFolderDialog(false); setShowDeleteFolderDialog(false);
}, []); }, []);
@ -193,7 +215,7 @@ export function PreferencesEditChatFolderPage(
<button <button
aria-label={i18n('icu:goBack')} aria-label={i18n('icu:goBack')}
className="Preferences__back-icon" className="Preferences__back-icon"
onClick={handleBackInit} onClick={handleBack}
type="button" type="button"
/> />
} }
@ -415,16 +437,28 @@ export function PreferencesEditChatFolderPage(
{showDeleteFolderDialog && ( {showDeleteFolderDialog && (
<DeleteChatFolderDialog <DeleteChatFolderDialog
i18n={i18n} i18n={i18n}
title={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Title'
)}
description={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Description'
)}
cancelText={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__CancelButton'
)}
deleteText={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__DeleteButton'
)}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
onClose={handleDeleteClose} onClose={handleDeleteClose}
/> />
)} )}
{showSaveChangesDialog && ( {blocker.state === 'blocked' && (
<SaveChangesFolderDialog <SaveChangesFolderDialog
i18n={i18n} i18n={i18n}
onSave={handleSave} onSave={handleBlockerSaveChanges}
onCancel={handleDiscard} onDiscard={handleBlockerDiscardChanges}
onClose={handleSaveClose} onClose={handleBlockerCancelNavigation}
/> />
)} )}
</> </>
@ -433,12 +467,15 @@ export function PreferencesEditChatFolderPage(
title={i18n('icu:Preferences__EditChatFolderPage__Title')} title={i18n('icu:Preferences__EditChatFolderPage__Title')}
actions={ actions={
<> <>
<Button variant={ButtonVariant.Secondary} onClick={handleDiscard}> <Button
variant={ButtonVariant.Secondary}
onClick={handleDiscardAndBack}
>
{i18n('icu:Preferences__EditChatFolderPage__CancelButton')} {i18n('icu:Preferences__EditChatFolderPage__CancelButton')}
</Button> </Button>
<Button <Button
variant={ButtonVariant.Primary} variant={ButtonVariant.Primary}
onClick={handleSave} onClick={handleSaveChangesAndBack}
disabled={!(isChanged && isValid)} disabled={!(isChanged && isValid)}
> >
{i18n('icu:Preferences__EditChatFolderPage__SaveButton')} {i18n('icu:Preferences__EditChatFolderPage__SaveButton')}
@ -449,44 +486,10 @@ export function PreferencesEditChatFolderPage(
); );
} }
function DeleteChatFolderDialog(props: {
i18n: LocalizerType;
onConfirm: () => void;
onClose: () => void;
}) {
const { i18n } = props;
return (
<ConfirmationDialog
i18n={i18n}
dialogName="Preferences__EditChatFolderPage__DeleteChatFolderDialog"
title={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Title'
)}
cancelText={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__CancelButton'
)}
actions={[
{
text: i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__DeleteButton'
),
style: 'affirmative',
action: props.onConfirm,
},
]}
onClose={props.onClose}
>
{i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Description'
)}
</ConfirmationDialog>
);
}
function SaveChangesFolderDialog(props: { function SaveChangesFolderDialog(props: {
i18n: LocalizerType; i18n: LocalizerType;
onSave: () => void; onSave: () => void;
onCancel: () => void; onDiscard: () => void;
onClose: () => void; onClose: () => void;
}) { }) {
const { i18n } = props; const { i18n } = props;
@ -510,7 +513,7 @@ function SaveChangesFolderDialog(props: {
action: props.onSave, action: props.onSave,
}, },
]} ]}
onCancel={props.onCancel} onCancel={props.onDiscard}
onClose={props.onClose} onClose={props.onClose}
> >
{i18n( {i18n(

96
ts/hooks/useNavBlocker.ts Normal file
View file

@ -0,0 +1,96 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { useEffect, useRef, useState } from 'react';
import { BeforeNavigateResponse } from '../services/BeforeNavigate.js';
import type {
BeforeNavigateCallback,
BeforeNavigateTransitionDetails,
} from '../services/BeforeNavigate.js';
type NavBlockerBlocked = Readonly<{
state: 'blocked';
respond: (response: BeforeNavigateResponse) => void;
}>;
type NavBlockerUnblocked = Readonly<{
state: 'unblocked';
respond?: never;
}>;
export type NavBlocker = NavBlockerBlocked | NavBlockerUnblocked;
export type NavBlockerFunction = (
details: BeforeNavigateTransitionDetails
) => boolean;
export type ShouldBlock = boolean | NavBlockerFunction;
function checkShouldBlock(
shouldBlock: ShouldBlock,
details: BeforeNavigateTransitionDetails
): boolean {
if (typeof shouldBlock === 'function') {
return shouldBlock(details);
}
return shouldBlock;
}
export function useNavBlocker(
name: string,
shouldBlock: ShouldBlock
): NavBlocker {
const nameRef = useRef(name);
useEffect(() => {
nameRef.current = name;
}, [name]);
const shouldBlockRef = useRef(shouldBlock);
useEffect(() => {
shouldBlockRef.current = shouldBlock;
}, [shouldBlock]);
const [blocker, setBlocker] = useState<NavBlocker>(() => {
return { state: 'unblocked' };
});
useEffect(() => {
const nameValue = nameRef.current;
const callback: BeforeNavigateCallback = async details => {
const shouldBlockNav = checkShouldBlock(shouldBlockRef.current, details);
if (!shouldBlockNav) {
return BeforeNavigateResponse.Noop;
}
const { promise, resolve } =
Promise.withResolvers<BeforeNavigateResponse>();
function respond(response: BeforeNavigateResponse) {
setBlocker({ state: 'unblocked' });
resolve(response);
}
setBlocker({
state: 'blocked',
respond,
});
return promise;
};
window.Signal.Services.beforeNavigate.registerCallback({
callback,
name: nameValue,
});
return () => {
window.Signal.Services.beforeNavigate.unregisterCallback({
callback,
name: nameValue,
});
};
}, []);
return blocker;
}

3
ts/model-types.d.ts vendored
View file

@ -41,6 +41,7 @@ import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
import MemberRoleEnum = Proto.Member.Role; import MemberRoleEnum = Proto.Member.Role;
import type { MessageRequestResponseEvent } from './types/MessageRequestResponseEvent.js'; import type { MessageRequestResponseEvent } from './types/MessageRequestResponseEvent.js';
import type { QuotedMessageForComposerType } from './state/ducks/composer.js'; import type { QuotedMessageForComposerType } from './state/ducks/composer.js';
import type { SEALED_SENDER } from './types/SealedSender.js';
export type LastMessageStatus = export type LastMessageStatus =
| 'paused' | 'paused'
@ -399,7 +400,7 @@ export type ConversationAttributesType = {
* TODO: Rename this key to be specific to the accessKey on the conversation * TODO: Rename this key to be specific to the accessKey on the conversation
* It's not used for group endorsements. * It's not used for group endorsements.
*/ */
sealedSender?: unknown; sealedSender?: SEALED_SENDER;
sentMessageCount?: number; sentMessageCount?: number;
sharedGroupNames?: ReadonlyArray<string>; sharedGroupNames?: ReadonlyArray<string>;
voiceNotePlaybackRate?: number; voiceNotePlaybackRate?: number;

View file

@ -17,10 +17,16 @@ export enum BeforeNavigateResponse {
CancelNavigation = 'CancelNavigation', CancelNavigation = 'CancelNavigation',
TimedOut = 'TimedOut', TimedOut = 'TimedOut',
} }
export type BeforeNavigateCallback = (options: {
existingLocation?: Location; export type BeforeNavigateTransitionDetails = Readonly<{
existingLocation: Location;
newLocation: Location; newLocation: Location;
}) => Promise<BeforeNavigateResponse>; }>;
export type BeforeNavigateCallback = (
details: BeforeNavigateTransitionDetails
) => Promise<BeforeNavigateResponse>;
export type BeforeNavigateEntry = { export type BeforeNavigateEntry = {
name: string; name: string;
callback: BeforeNavigateCallback; callback: BeforeNavigateCallback;

View file

@ -88,7 +88,11 @@ import { isDone as isRegistrationDone } from '../util/registration.js';
import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue.js'; import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue.js';
import { isMockEnvironment } from '../environment.js'; import { isMockEnvironment } from '../environment.js';
import { validateConversation } from '../util/validateConversation.js'; import { validateConversation } from '../util/validateConversation.js';
import type { ChatFolder } from '../types/ChatFolder.js'; import {
ChatFolderType,
toCurrentChatFolders,
type ChatFolder,
} from '../types/ChatFolder.js';
const { debounce, isNumber, chunk } = lodash; const { debounce, isNumber, chunk } = lodash;
@ -1658,6 +1662,22 @@ async function processManifest(
storageVersion: null, storageVersion: null,
}); });
}); });
const chatFoldersHasAllChatsFolder = chatFolders.some(chatFolder => {
return (
chatFolder.folderType === ChatFolderType.ALL &&
chatFolder.deletedAtTimestampMs === 0
);
});
if (!chatFoldersHasAllChatsFolder) {
log.info(`process(${version}): creating all chats chat folder`);
await DataWriter.createAllChatsChatFolder();
const currentChatFolders = await DataReader.getCurrentChatFolders();
window.reduxActions.chatFolders.replaceAllChatFolderRecords(
toCurrentChatFolders(currentChatFolders)
);
}
} }
log.info(`process(${version}): done`); log.info(`process(${version}): done`);

View file

@ -1246,6 +1246,7 @@ type WritableInterface = {
createDonationReceipt(profile: DonationReceipt): void; createDonationReceipt(profile: DonationReceipt): void;
createChatFolder: (chatFolder: ChatFolder) => void; createChatFolder: (chatFolder: ChatFolder) => void;
createAllChatsChatFolder: () => ChatFolder;
updateChatFolder: (chatFolder: ChatFolder) => void; updateChatFolder: (chatFolder: ChatFolder) => void;
updateChatFolderPositions: (chatFolders: ReadonlyArray<ChatFolder>) => void; updateChatFolderPositions: (chatFolders: ReadonlyArray<ChatFolder>) => void;
updateChatFolderDeletedAtTimestampMsFromSync: ( updateChatFolderDeletedAtTimestampMsFromSync: (

View file

@ -239,6 +239,7 @@ import {
getCurrentChatFolders, getCurrentChatFolders,
getChatFolder, getChatFolder,
createChatFolder, createChatFolder,
createAllChatsChatFolder,
updateChatFolder, updateChatFolder,
markChatFolderDeleted, markChatFolderDeleted,
getOldestDeletedChatFolder, getOldestDeletedChatFolder,
@ -700,6 +701,7 @@ export const DataWriter: ServerWritableInterface = {
createDonationReceipt, createDonationReceipt,
createChatFolder, createChatFolder,
createAllChatsChatFolder,
updateChatFolder, updateChatFolder,
markChatFolderDeleted, markChatFolderDeleted,
deleteExpiredChatFolders, deleteExpiredChatFolders,
@ -7655,8 +7657,8 @@ function hydrateNotificationProfile(
allowAllMentions: Boolean(profile.allowAllMentions), allowAllMentions: Boolean(profile.allowAllMentions),
scheduleEnabled: Boolean(profile.scheduleEnabled), scheduleEnabled: Boolean(profile.scheduleEnabled),
allowedMembers: profile.allowedMembersJson allowedMembers: profile.allowedMembersJson
? new Set(JSON.parse(profile.allowedMembersJson)) ? new Set<string>(JSON.parse(profile.allowedMembersJson))
: new Set(), : new Set<string>(),
scheduleStartTime: profile.scheduleStartTime || undefined, scheduleStartTime: profile.scheduleStartTime || undefined,
scheduleEndTime: profile.scheduleEndTime || undefined, scheduleEndTime: profile.scheduleEndTime || undefined,
scheduleDaysEnabled: profile.scheduleDaysEnabledJson scheduleDaysEnabled: profile.scheduleDaysEnabledJson

View file

@ -1,9 +1,12 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { v4 as generateUuid } from 'uuid';
import { import {
type ChatFolderId, type ChatFolderId,
type ChatFolder, type ChatFolder,
CHAT_FOLDER_DELETED_POSITION, CHAT_FOLDER_DELETED_POSITION,
CHAT_FOLDER_DEFAULTS,
ChatFolderType,
} from '../../types/ChatFolder.js'; } from '../../types/ChatFolder.js';
import type { ReadableDB, WritableDB } from '../Interface.js'; import type { ReadableDB, WritableDB } from '../Interface.js';
import { sql } from '../util.js'; import { sql } from '../util.js';
@ -97,8 +100,7 @@ export function getChatFolder(
})(); })();
} }
export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void { function _insertChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
return db.transaction(() => {
const chatFolderRow = chatFolderToRow(chatFolder); const chatFolderRow = chatFolderToRow(chatFolder);
const [chatFolderQuery, chatFolderParams] = sql` const [chatFolderQuery, chatFolderParams] = sql`
INSERT INTO chatFolders ( INSERT INTO chatFolders (
@ -136,6 +138,33 @@ export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
) )
`; `;
db.prepare(chatFolderQuery).run(chatFolderParams); db.prepare(chatFolderQuery).run(chatFolderParams);
}
export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
return db.transaction(() => {
_insertChatFolder(db, chatFolder);
})();
}
export function createAllChatsChatFolder(db: WritableDB): ChatFolder {
return db.transaction(() => {
const allChatsChatFolder: ChatFolder = {
...CHAT_FOLDER_DEFAULTS,
id: generateUuid() as ChatFolderId,
folderType: ChatFolderType.ALL,
position: 0,
deletedAtTimestampMs: 0,
storageID: null,
storageVersion: null,
storageUnknownFields: null,
storageNeedsSync: true,
};
// shift all positions over 1
_resetAllChatFolderPositions(db, 1);
_insertChatFolder(db, allChatsChatFolder);
return allChatsChatFolder;
})(); })();
} }
@ -187,11 +216,11 @@ export function markChatFolderDeleted(
WHERE id = ${id} WHERE id = ${id}
`; `;
db.prepare(query).run(params); db.prepare(query).run(params);
_resetAllChatFolderPositions(db); _resetAllChatFolderPositions(db, 0);
})(); })();
} }
function _resetAllChatFolderPositions(db: WritableDB) { function _resetAllChatFolderPositions(db: WritableDB, offset: number) {
const [query, params] = sql` const [query, params] = sql`
SELECT id FROM chatFolders SELECT id FROM chatFolders
WHERE deletedAtTimestampMs IS 0 WHERE deletedAtTimestampMs IS 0
@ -204,7 +233,7 @@ function _resetAllChatFolderPositions(db: WritableDB) {
const [update, updateParams] = sql` const [update, updateParams] = sql`
UPDATE chatFolders UPDATE chatFolders
SET SET
position = ${index}, position = ${offset + index},
storageNeedsSync = 1 storageNeedsSync = 1
WHERE id = ${id} WHERE id = ${id}
`; `;

View file

@ -8,25 +8,43 @@ import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.j
import { useBoundActions } from '../../hooks/useBoundActions.js'; import { useBoundActions } from '../../hooks/useBoundActions.js';
import { import {
ChatFolderParamsSchema, ChatFolderParamsSchema,
lookupCurrentChatFolder,
toCurrentChatFolders,
getSortedCurrentChatFolders,
type ChatFolder, type ChatFolder,
type ChatFolderId, type ChatFolderId,
type ChatFolderParams, type ChatFolderParams,
type CurrentChatFolders,
} from '../../types/ChatFolder.js'; } from '../../types/ChatFolder.js';
import { getCurrentChatFolders } from '../selectors/chatFolders.js'; import { getCurrentChatFolders } from '../selectors/chatFolders.js';
import { DataWriter } from '../../sql/Client.js'; import { DataWriter } from '../../sql/Client.js';
import { strictAssert } from '../../util/assert.js';
import { storageServiceUploadJob } from '../../services/storage.js'; import { storageServiceUploadJob } from '../../services/storage.js';
import { parseStrict } from '../../util/schemas.js'; import { parseStrict } from '../../util/schemas.js';
import { chatFolderCleanupService } from '../../services/expiring/chatFolderCleanupService.js'; import { chatFolderCleanupService } from '../../services/expiring/chatFolderCleanupService.js';
import { drop } from '../../util/drop.js'; import { drop } from '../../util/drop.js';
import {
TARGETED_CONVERSATION_CHANGED,
type TargetedConversationChangedActionType,
} from './conversations.js';
export type ChatFoldersState = ReadonlyDeep<{ export type ChatFoldersState = ReadonlyDeep<{
currentChatFolders: ReadonlyArray<ChatFolder>; currentChatFolders: CurrentChatFolders;
selectedChatFolderId: ChatFolderId | null;
stableSelectedConversationIdInChatFolder: string | null;
}>; }>;
const CHAT_FOLDER_RECORD_REPLACE_ALL =
'chatFolders/CHAT_FOLDER_RECORD_REPLACE_ALL';
const CHAT_FOLDER_RECORD_ADD = 'chatFolders/RECORD_ADD'; const CHAT_FOLDER_RECORD_ADD = 'chatFolders/RECORD_ADD';
const CHAT_FOLDER_RECORD_REPLACE = 'chatFolders/RECORD_REPLACE'; const CHAT_FOLDER_RECORD_REPLACE = 'chatFolders/RECORD_REPLACE';
const CHAT_FOLDER_RECORD_REMOVE = 'chatFolders/RECORD_REMOVE'; const CHAT_FOLDER_RECORD_REMOVE = 'chatFolders/RECORD_REMOVE';
const CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID =
'chatFolders/CHANGE_SELECTED_CHAT_FOLDER_ID';
export type ChatFolderRecordReplaceAll = ReadonlyDeep<{
type: typeof CHAT_FOLDER_RECORD_REPLACE_ALL;
payload: CurrentChatFolders;
}>;
export type ChatFolderRecordAdd = ReadonlyDeep<{ export type ChatFolderRecordAdd = ReadonlyDeep<{
type: typeof CHAT_FOLDER_RECORD_ADD; type: typeof CHAT_FOLDER_RECORD_ADD;
@ -43,13 +61,36 @@ export type ChatFolderRecordRemove = ReadonlyDeep<{
payload: ChatFolderId; payload: ChatFolderId;
}>; }>;
export type ChatFolderChangeSelectedChatFolderId = ReadonlyDeep<{
type: typeof CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID;
payload: ChatFolderId | null;
}>;
export type ChatFolderAction = ReadonlyDeep< export type ChatFolderAction = ReadonlyDeep<
ChatFolderRecordAdd | ChatFolderRecordReplace | ChatFolderRecordRemove | ChatFolderRecordReplaceAll
| ChatFolderRecordAdd
| ChatFolderRecordReplace
| ChatFolderRecordRemove
| ChatFolderChangeSelectedChatFolderId
>; >;
export function getEmptyState(): ChatFoldersState { export function getEmptyState(): ChatFoldersState {
return { return {
currentChatFolders: [], currentChatFolders: {
order: [],
lookup: {},
},
selectedChatFolderId: null,
stableSelectedConversationIdInChatFolder: null,
};
}
function replaceAllChatFolderRecords(
currentChatFolders: CurrentChatFolders
): ChatFolderRecordReplaceAll {
return {
type: CHAT_FOLDER_RECORD_REPLACE_ALL,
payload: currentChatFolders,
}; };
} }
@ -87,7 +128,7 @@ function createChatFolder(
const chatFolder: ChatFolder = { const chatFolder: ChatFolder = {
...chatFolderParams, ...chatFolderParams,
id: generateUuid() as ChatFolderId, id: generateUuid() as ChatFolderId,
position: chatFolders.length, position: chatFolders.order.length,
deletedAtTimestampMs: 0, deletedAtTimestampMs: 0,
storageID: null, storageID: null,
storageVersion: null, storageVersion: null,
@ -106,12 +147,12 @@ function updateChatFolder(
chatFolderParams: ChatFolderParams chatFolderParams: ChatFolderParams
): ThunkAction<void, RootStateType, unknown, ChatFolderRecordReplace> { ): ThunkAction<void, RootStateType, unknown, ChatFolderRecordReplace> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const chatFolders = getCurrentChatFolders(getState()); const currentChatFolders = getCurrentChatFolders(getState());
const prevChatFolder = chatFolders.find(chatFolder => { const prevChatFolder = lookupCurrentChatFolder(
return chatFolder.id === chatFolderId; currentChatFolders,
}); chatFolderId
strictAssert(prevChatFolder != null, 'Missing chat folder'); );
const nextChatFolder: ChatFolder = { const nextChatFolder: ChatFolder = {
...prevChatFolder, ...prevChatFolder,
@ -136,58 +177,102 @@ function deleteChatFolder(
}; };
} }
function updateChatFoldersPositions(
chatFolderIds: ReadonlyArray<ChatFolderId>
): ThunkAction<void, RootStateType, unknown, ChatFolderRecordReplaceAll> {
return async (dispatch, getState) => {
const currentChatFolders = getCurrentChatFolders(getState());
const chatFolders = chatFolderIds.map((chatFolderId, index) => {
const chatFolder = lookupCurrentChatFolder(
currentChatFolders,
chatFolderId
);
return { ...chatFolder, position: index + 1 };
});
await DataWriter.updateChatFolderPositions(chatFolders);
storageServiceUploadJob({ reason: 'updateChatFoldersPositions' });
dispatch(replaceAllChatFolderRecords(toCurrentChatFolders(chatFolders)));
};
}
function updateSelectedChangeFolderId(
chatFolderId: ChatFolderId | null
): ChatFolderChangeSelectedChatFolderId {
return {
type: CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID,
payload: chatFolderId,
};
}
export const actions = { export const actions = {
replaceAllChatFolderRecords,
addChatFolderRecord, addChatFolderRecord,
replaceChatFolderRecord, replaceChatFolderRecord,
removeChatFolderRecord, removeChatFolderRecord,
createChatFolder, createChatFolder,
updateChatFolder, updateChatFolder,
deleteChatFolder, deleteChatFolder,
updateChatFoldersPositions,
updateSelectedChangeFolderId,
}; };
export const useChatFolderActions = (): BoundActionCreatorsMapObject< export const useChatFolderActions = (): BoundActionCreatorsMapObject<
typeof actions typeof actions
> => useBoundActions(actions); > => useBoundActions(actions);
function toSortedChatFolders(
chatFolders: ReadonlyArray<ChatFolder>
): ReadonlyArray<ChatFolder> {
return chatFolders.toSorted((a, b) => a.position - b.position);
}
export function reducer( export function reducer(
state: ChatFoldersState = getEmptyState(), state: ChatFoldersState = getEmptyState(),
action: ChatFolderAction action: ChatFolderAction | TargetedConversationChangedActionType
): ChatFoldersState { ): ChatFoldersState {
switch (action.type) { switch (action.type) {
case CHAT_FOLDER_RECORD_REPLACE_ALL:
return {
...state,
currentChatFolders: action.payload,
};
case CHAT_FOLDER_RECORD_ADD: case CHAT_FOLDER_RECORD_ADD:
return { return {
...state, ...state,
currentChatFolders: toSortedChatFolders([ currentChatFolders: toCurrentChatFolders([
...state.currentChatFolders, ...getSortedCurrentChatFolders(state.currentChatFolders),
action.payload, action.payload,
]), ]),
}; };
case CHAT_FOLDER_RECORD_REPLACE: case CHAT_FOLDER_RECORD_REPLACE:
return { return {
...state, ...state,
currentChatFolders: toSortedChatFolders( currentChatFolders: toCurrentChatFolders([
state.currentChatFolders.map(chatFolder => { ...getSortedCurrentChatFolders(state.currentChatFolders).filter(
return chatFolder.id === action.payload.id chatFolder => {
? action.payload return chatFolder.id !== action.payload.id;
: chatFolder; }
})
), ),
action.payload,
]),
}; };
case CHAT_FOLDER_RECORD_REMOVE: case CHAT_FOLDER_RECORD_REMOVE:
return { return {
...state, ...state,
currentChatFolders: toSortedChatFolders( currentChatFolders: toCurrentChatFolders(
state.currentChatFolders.filter(chatFolder => { getSortedCurrentChatFolders(state.currentChatFolders).filter(
chatFolder => {
return chatFolder.id !== action.payload; return chatFolder.id !== action.payload;
}) }
)
), ),
}; };
case CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID:
return {
...state,
selectedChatFolderId: action.payload,
stableSelectedConversationIdInChatFolder: null,
};
case TARGETED_CONVERSATION_CHANGED:
return {
...state,
stableSelectedConversationIdInChatFolder:
action.payload.conversationId ?? null,
};
default: default:
return state; return state;
} }

View file

@ -83,6 +83,7 @@ import {
getMe, getMe,
getMessagesByConversation, getMessagesByConversation,
getPendingAvatarDownloadSelector, getPendingAvatarDownloadSelector,
getAllConversations,
} from '../selectors/conversations.js'; } from '../selectors/conversations.js';
import { getIntl } from '../selectors/user.js'; import { getIntl } from '../selectors/user.js';
import type { import type {
@ -215,6 +216,13 @@ import { cleanupMessages } from '../../util/cleanup.js';
import type { ConversationModel } from '../../models/conversations.js'; import type { ConversationModel } from '../../models/conversations.js';
import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent.js'; import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent.js';
import { JobCancelReason } from '../../jobs/types.js'; import { JobCancelReason } from '../../jobs/types.js';
import type { ChatFolderId } from '../../types/ChatFolder.js';
import {
isConversationInChatFolder,
lookupCurrentChatFolder,
} from '../../types/ChatFolder.js';
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
import { isConversationUnread } from '../../util/isConversationUnread.js';
const { const {
chunk, chunk,
@ -1180,6 +1188,8 @@ export const actions = {
loadOlderMessages, loadOlderMessages,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
markMessageRead, markMessageRead,
markConversationRead,
markChatFolderRead,
markOpenConversationRead, markOpenConversationRead,
messageChanged, messageChanged,
messageDeleted, messageDeleted,
@ -1237,6 +1247,7 @@ export const actions = {
setMessageLoadingState, setMessageLoadingState,
setMessageToEdit, setMessageToEdit,
setMuteExpiration, setMuteExpiration,
setChatFolderMuteExpiration,
setPinned, setPinned,
setPreJoinConversation, setPreJoinConversation,
setProfileUpdateError, setProfileUpdateError,
@ -1448,6 +1459,57 @@ function loadOlderMessages(
}; };
} }
function _getAllConversationsInChatFolder(
state: RootStateType,
chatFolderId: ChatFolderId
) {
const currentChatFolders = getCurrentChatFolders(state);
const chatFolder = lookupCurrentChatFolder(currentChatFolders, chatFolderId);
const allConversations = getAllConversations(state);
return allConversations.filter(conversation => {
return isConversationInChatFolder(chatFolder, conversation);
});
}
function markChatFolderRead(
chatFolderId: ChatFolderId
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async (dispatch, getState) => {
const chatFolderConversations = _getAllConversationsInChatFolder(
getState(),
chatFolderId
);
const unreadChatFolderConversations = chatFolderConversations.filter(
conversation => {
return isConversationUnread(conversation);
}
);
for (const conversation of unreadChatFolderConversations) {
dispatch(markConversationRead(conversation.id));
}
};
}
function markConversationRead(
conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const model = window.ConversationController.get(conversationId);
strictAssert(model, 'Conversation must be found');
model.setMarkedUnread(false);
const lastMessage = await DataReader.getLastConversationMessage({
conversationId,
});
if (lastMessage == null) {
return;
}
dispatch(markMessageRead(conversationId, lastMessage.id));
};
}
function markMessageRead( function markMessageRead(
conversationId: string, conversationId: string,
messageId: string messageId: string
@ -1727,6 +1789,22 @@ function setDontNotifyForMentionsIfMuted(
}; };
} }
function setChatFolderMuteExpiration(
chatFolderId: ChatFolderId,
muteExpiresAt: number
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async (dispatch, getState) => {
const chatFolderConversations = _getAllConversationsInChatFolder(
getState(),
chatFolderId
);
for (const conversation of chatFolderConversations) {
dispatch(setMuteExpiration(conversation.id, muteExpiresAt));
}
};
}
function setMuteExpiration( function setMuteExpiration(
conversationId: string, conversationId: string,
muteExpiresAt = 0 muteExpiresAt = 0

View file

@ -47,10 +47,7 @@ import { createLogger } from '../../logging/log.js';
import { searchConversationTitles } from '../../util/searchConversationTitles.js'; import { searchConversationTitles } from '../../util/searchConversationTitles.js';
import { isDirectConversation } from '../../util/whatTypeOfConversation.js'; import { isDirectConversation } from '../../util/whatTypeOfConversation.js';
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly.js'; import { isConversationSMSOnly } from '../../util/isConversationSMSOnly.js';
import { import { isConversationUnread } from '../../util/countUnreadStats.js';
countConversationUnreadStats,
hasUnread,
} from '../../util/countUnreadStats.js';
const { debounce, omit, reject } = lodash; const { debounce, omit, reject } = lodash;
@ -336,9 +333,7 @@ function shouldRemoveConversationFromUnreadList(
conversation && conversation &&
(selectedConversationId == null || (selectedConversationId == null ||
selectedConversationId !== conversation.id) && selectedConversationId !== conversation.id) &&
!hasUnread( !isConversationUnread(conversation, { includeMuted: true })
countConversationUnreadStats(conversation, { includeMuted: true })
)
) { ) {
return true; return true;
} }
@ -503,11 +498,9 @@ const doSearch = debounce(
selectedConversationId && selectedConversationId &&
selectedConversation && selectedConversation &&
state.search.conversationIds.includes(selectedConversationId) && state.search.conversationIds.includes(selectedConversationId) &&
!hasUnread( !isConversationUnread(selectedConversation, {
countConversationUnreadStats(selectedConversation, {
includeMuted: true, includeMuted: true,
}) })
)
? selectedConversation ? selectedConversation
: undefined, : undefined,
}); });

View file

@ -40,6 +40,7 @@ import { getEmptyState as usernameEmptyState } from './ducks/username.js';
import OS from '../util/os/osMain.js'; import OS from '../util/os/osMain.js';
import { getInteractionMode } from '../services/InteractionMode.js'; import { getInteractionMode } from '../services/InteractionMode.js';
import { makeLookup } from '../util/makeLookup.js'; import { makeLookup } from '../util/makeLookup.js';
import { toCurrentChatFolders } from '../types/ChatFolder.js';
import type { StateType } from './reducer.js'; import type { StateType } from './reducer.js';
import type { MainWindowStatsType } from '../windows/context.js'; import type { MainWindowStatsType } from '../windows/context.js';
@ -91,7 +92,7 @@ export function getInitialState(
}, },
chatFolders: { chatFolders: {
...chatFoldersEmptyState(), ...chatFoldersEmptyState(),
currentChatFolders: chatFolders, currentChatFolders: toCurrentChatFolders(chatFolders),
}, },
donations, donations,
emojis: recentEmoji, emojis: recentEmoji,

View file

@ -9,11 +9,14 @@ import type { Store } from 'redux';
import { SmartApp } from '../smart/App.js'; import { SmartApp } from '../smart/App.js';
import { SmartVoiceNotesPlaybackProvider } from '../smart/VoiceNotesPlaybackProvider.js'; import { SmartVoiceNotesPlaybackProvider } from '../smart/VoiceNotesPlaybackProvider.js';
import { AxoProvider } from '../../axo/AxoProvider.js';
export const createApp = (store: Store): ReactElement => ( export const createApp = (store: Store): ReactElement => (
<AxoProvider dir={window.i18n.getLocaleDirection()}>
<Provider store={store}> <Provider store={store}>
<SmartVoiceNotesPlaybackProvider> <SmartVoiceNotesPlaybackProvider>
<SmartApp /> <SmartApp />
</SmartVoiceNotesPlaybackProvider> </SmartVoiceNotesPlaybackProvider>
</Provider> </Provider>
</AxoProvider>
); );

View file

@ -3,15 +3,44 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import type { StateType } from '../reducer.js'; import type { StateType } from '../reducer.js';
import type { StateSelector } from '../types.js';
import type { ChatFoldersState } from '../ducks/chatFolders.js'; import type { ChatFoldersState } from '../ducks/chatFolders.js';
import type { CurrentChatFolders, ChatFolder } from '../../types/ChatFolder.js';
import {
getSortedCurrentChatFolders,
lookupCurrentChatFolder,
} from '../../types/ChatFolder.js';
export function getChatFoldersState(state: StateType): ChatFoldersState { export function getChatFoldersState(state: StateType): ChatFoldersState {
return state.chatFolders; return state.chatFolders;
} }
export const getCurrentChatFolders = createSelector( export const getCurrentChatFolders: StateSelector<CurrentChatFolders> =
getChatFoldersState, createSelector(getChatFoldersState, state => {
state => {
return state.currentChatFolders; return state.currentChatFolders;
});
export const getSortedChatFolders: StateSelector<ReadonlyArray<ChatFolder>> =
createSelector(getCurrentChatFolders, currentChatFolders => {
return getSortedCurrentChatFolders(currentChatFolders);
});
export const getSelectedChatFolder: StateSelector<ChatFolder | null> =
createSelector(
getChatFoldersState,
getCurrentChatFolders,
(state, currentChatFolders) => {
const selectedChatFolderId =
state.selectedChatFolderId ?? currentChatFolders.order.at(0);
if (selectedChatFolderId == null) {
return null;
} }
); return lookupCurrentChatFolder(currentChatFolders, selectedChatFolderId);
}
);
export const getStableSelectedConversationIdInChatFolder: StateSelector<
string | null
> = createSelector(getChatFoldersState, state => {
return state.stableSelectedConversationIdInChatFolder;
});

View file

@ -4,9 +4,8 @@
import memoizee from 'memoizee'; import memoizee from 'memoizee';
import lodash from 'lodash'; import lodash from 'lodash';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import type { StateType } from '../reducer.js'; import type { StateType } from '../reducer.js';
import type { StateSelector } from '../types.js';
import type { import type {
ConversationLookupType, ConversationLookupType,
ConversationMessageType, ConversationMessageType,
@ -64,10 +63,25 @@ import type { HasStories } from '../../types/Stories.js';
import { getHasStoriesSelector } from './stories2.js'; import { getHasStoriesSelector } from './stories2.js';
import { canEditMessage } from '../../util/canEditMessage.js'; import { canEditMessage } from '../../util/canEditMessage.js';
import { isOutgoing } from '../../messages/helpers.js'; import { isOutgoing } from '../../messages/helpers.js';
import { import type {
countAllConversationsUnreadStats, AllChatFoldersUnreadStats,
type UnreadStats, UnreadStats,
} from '../../util/countUnreadStats.js'; } from '../../util/countUnreadStats.js';
import {
isConversationInChatFolder,
type ChatFolder,
} from '../../types/ChatFolder.js';
import {
getSelectedChatFolder,
getSortedChatFolders,
getStableSelectedConversationIdInChatFolder,
} from './chatFolders.js';
import {
countAllChatFoldersUnreadStats,
countAllConversationsUnreadStats,
} from '../../util/countUnreadStats.js';
import type { AllChatFoldersMutedStats } from '../../util/countMutedStats.js';
import { countAllChatFoldersMutedStats } from '../../util/countMutedStats.js';
const { isNumber, pick } = lodash; const { isNumber, pick } = lodash;
@ -364,21 +378,71 @@ type LeftPaneLists = Readonly<{
pinnedConversations: ReadonlyArray<ConversationType>; pinnedConversations: ReadonlyArray<ConversationType>;
}>; }>;
export const _getLeftPaneLists = ( function _shouldIncludeInChatFolder(
lookup: ConversationLookupType, conversation: ConversationType,
comparator: (left: ConversationType, right: ConversationType) => number, selectedChatFolder: ChatFolder | null,
selectedConversation?: string, stableSelectedConversationIdInChatFolder: string | null
pinnedConversationIds?: ReadonlyArray<string> ): boolean {
): LeftPaneLists => { if (selectedChatFolder == null) {
return true;
}
// This keeps conversation items from instantly disappearing from the left
// pane list when you open them and they get marked read
if (
stableSelectedConversationIdInChatFolder != null &&
conversation.id === stableSelectedConversationIdInChatFolder
) {
return true;
}
if (isConversationInChatFolder(selectedChatFolder, conversation)) {
return true;
}
return false;
}
type GetLeftPaneListsProps = Readonly<{
conversationLookup: ConversationLookupType;
conversationComparator: (
left: ConversationType,
right: ConversationType
) => number;
selectedConversationId: string | undefined;
pinnedConversationIds: ReadonlyArray<string> | null;
selectedChatFolder: ChatFolder | null;
stableSelectedConversationIdInChatFolder: string | null;
}>;
export const _getLeftPaneLists = ({
conversationLookup,
conversationComparator,
selectedConversationId,
pinnedConversationIds,
selectedChatFolder,
stableSelectedConversationIdInChatFolder,
}: GetLeftPaneListsProps): LeftPaneLists => {
const conversations: Array<ConversationType> = []; const conversations: Array<ConversationType> = [];
const archivedConversations: Array<ConversationType> = []; const archivedConversations: Array<ConversationType> = [];
const pinnedConversations: Array<ConversationType> = []; const pinnedConversations: Array<ConversationType> = [];
const values = Object.values(lookup); const values = Object.values(conversationLookup);
const max = values.length; const max = values.length;
for (let i = 0; i < max; i += 1) { for (let i = 0; i < max; i += 1) {
let conversation = values[i]; let conversation = values[i];
if (selectedConversation === conversation.id) {
if (
!_shouldIncludeInChatFolder(
conversation,
selectedChatFolder,
stableSelectedConversationIdInChatFolder
)
) {
continue;
}
if (selectedConversationId === conversation.id) {
conversation = { conversation = {
...conversation, ...conversation,
isSelected: true, isSelected: true,
@ -400,8 +464,8 @@ export const _getLeftPaneLists = (
} }
} }
conversations.sort(comparator); conversations.sort(conversationComparator);
archivedConversations.sort(comparator); archivedConversations.sort(conversationComparator);
pinnedConversations.sort( pinnedConversations.sort(
(a, b) => (a, b) =>
@ -417,7 +481,25 @@ export const getLeftPaneLists = createSelector(
getConversationComparator, getConversationComparator,
getSelectedConversationId, getSelectedConversationId,
getPinnedConversationIds, getPinnedConversationIds,
_getLeftPaneLists getSelectedChatFolder,
getStableSelectedConversationIdInChatFolder,
(
conversationLookup,
conversationComparator,
selectedConversationId,
pinnedConversationIds,
selectedChatFolder,
stableSelectedConversationIdInChatFolder
) => {
return _getLeftPaneLists({
conversationLookup,
conversationComparator,
selectedConversationId,
pinnedConversationIds,
selectedChatFolder,
stableSelectedConversationIdInChatFolder,
});
}
); );
export const getMaximumGroupSizeModalState = createSelector( export const getMaximumGroupSizeModalState = createSelector(
@ -615,6 +697,30 @@ export const getAllConversationsUnreadStats = createSelector(
} }
); );
export const getAllChatFoldersUnreadStats: StateSelector<AllChatFoldersUnreadStats> =
createSelector(
getSortedChatFolders,
getAllConversations,
(sortedChatFolders, allConversations) => {
return countAllChatFoldersUnreadStats(
sortedChatFolders,
allConversations,
{
includeMuted: false,
}
);
}
);
export const getAllChatFoldersMutedStats: StateSelector<AllChatFoldersMutedStats> =
createSelector(
getSortedChatFolders,
getAllConversations,
(sortedChatFolders, allConversations) => {
return countAllChatFoldersMutedStats(sortedChatFolders, allConversations);
}
);
/** /**
* getComposableContacts/getCandidateContactsForNewGroup both return contacts for the * getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
* composer and group members, a different list from your primary system contacts. * composer and group members, a different list from your primary system contacts.

View file

@ -36,12 +36,13 @@ export const getOtherTabsUnreadStats = createSelector(
): UnreadStats => { ): UnreadStats => {
let unreadCount = 0; let unreadCount = 0;
let unreadMentionsCount = 0; let unreadMentionsCount = 0;
let markedUnread = false; let readChatsMarkedUnreadCount = 0;
if (selectedNavTab !== NavTab.Chats) { if (selectedNavTab !== NavTab.Chats) {
unreadCount += conversationsUnreadStats.unreadCount; unreadCount += conversationsUnreadStats.unreadCount;
unreadMentionsCount += conversationsUnreadStats.unreadMentionsCount; unreadMentionsCount += conversationsUnreadStats.unreadMentionsCount;
markedUnread ||= conversationsUnreadStats.markedUnread; readChatsMarkedUnreadCount +=
conversationsUnreadStats.readChatsMarkedUnreadCount;
} }
// Note: Conversation unread stats includes the call history unread count. // Note: Conversation unread stats includes the call history unread count.
@ -56,7 +57,7 @@ export const getOtherTabsUnreadStats = createSelector(
return { return {
unreadCount, unreadCount,
unreadMentionsCount, unreadMentionsCount,
markedUnread, readChatsMarkedUnreadCount,
}; };
} }
); );

View file

@ -110,10 +110,18 @@ import {
resumeBackupMediaDownload, resumeBackupMediaDownload,
} from '../../util/backupMediaDownload.js'; } from '../../util/backupMediaDownload.js';
import { useNavActions } from '../ducks/nav.js'; import { useNavActions } from '../ducks/nav.js';
import { SmartLeftPaneChatFolders } from './LeftPaneChatFolders.js';
import { SmartLeftPaneConversationListItemContextMenu } from './LeftPaneConversationListItemContextMenu.js';
import type { RenderConversationListItemContextMenuProps } from '../../components/conversationList/BaseConversationListItem.js';
function renderMessageSearchResult(id: string): JSX.Element { function renderMessageSearchResult(id: string): JSX.Element {
return <SmartMessageSearchResult id={id} />; return <SmartMessageSearchResult id={id} />;
} }
function renderConversationListItemContextMenu(
props: RenderConversationListItemContextMenuProps
): JSX.Element {
return <SmartLeftPaneConversationListItemContextMenu {...props} />;
}
function renderNetworkStatus( function renderNetworkStatus(
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }> props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
): JSX.Element { ): JSX.Element {
@ -140,6 +148,9 @@ function renderExpiredBuildDialog(
): JSX.Element { ): JSX.Element {
return <DialogExpiredBuild {...props} />; return <DialogExpiredBuild {...props} />;
} }
function renderLeftPaneChatFolders(): JSX.Element {
return <SmartLeftPaneChatFolders />;
}
function renderUnsupportedOSDialog( function renderUnsupportedOSDialog(
props: Readonly<SmartUnsupportedOSDialogPropsType> props: Readonly<SmartUnsupportedOSDialogPropsType>
): JSX.Element { ): JSX.Element {
@ -420,7 +431,11 @@ export const SmartLeftPane = memo(function SmartLeftPane({
renderCaptchaDialog={renderCaptchaDialog} renderCaptchaDialog={renderCaptchaDialog}
renderCrashReportDialog={renderCrashReportDialog} renderCrashReportDialog={renderCrashReportDialog}
renderExpiredBuildDialog={renderExpiredBuildDialog} renderExpiredBuildDialog={renderExpiredBuildDialog}
renderLeftPaneChatFolders={renderLeftPaneChatFolders}
renderMessageSearchResult={renderMessageSearchResult} renderMessageSearchResult={renderMessageSearchResult}
renderConversationListItemContextMenu={
renderConversationListItemContextMenu
}
renderNetworkStatus={renderNetworkStatus} renderNetworkStatus={renderNetworkStatus}
renderRelinkDialog={renderRelinkDialog} renderRelinkDialog={renderRelinkDialog}
renderToastManager={renderToastManager} renderToastManager={renderToastManager}

View file

@ -0,0 +1,76 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback, useContext } from 'react';
import { useSelector } from 'react-redux';
import { LeftPaneChatFolders } from '../../components/leftPane/LeftPaneChatFolders.js';
import {
getSelectedChatFolder,
getSortedChatFolders,
} from '../selectors/chatFolders.js';
import { getIntl } from '../selectors/user.js';
import {
getAllChatFoldersMutedStats,
getAllChatFoldersUnreadStats,
} from '../selectors/conversations.js';
import { useChatFolderActions } from '../ducks/chatFolders.js';
import { NavSidebarWidthBreakpointContext } from '../../components/NavSidebar.js';
import { useNavActions } from '../ducks/nav.js';
import { NavTab, SettingsPage } from '../../types/Nav.js';
import {
isChatFoldersEnabled,
type ChatFolderId,
} from '../../types/ChatFolder.js';
import { getSelectedLocation } from '../selectors/nav.js';
import { useConversationsActions } from '../ducks/conversations.js';
export const SmartLeftPaneChatFolders = memo(
function SmartLeftPaneChatFolders() {
const i18n = useSelector(getIntl);
const sortedChatFolders = useSelector(getSortedChatFolders);
const allChatFoldersUnreadStats = useSelector(getAllChatFoldersUnreadStats);
const allChatFoldersMutedStats = useSelector(getAllChatFoldersMutedStats);
const selectedChatFolder = useSelector(getSelectedChatFolder);
const navSidebarWidthBreakpoint = useContext(
NavSidebarWidthBreakpointContext
);
const location = useSelector(getSelectedLocation);
const { updateSelectedChangeFolderId } = useChatFolderActions();
const { changeLocation } = useNavActions();
const { markChatFolderRead, setChatFolderMuteExpiration } =
useConversationsActions();
const handleChatFolderOpenSettings = useCallback(
(chatFolderId: ChatFolderId) => {
changeLocation({
tab: NavTab.Settings,
details: {
page: SettingsPage.EditChatFolder,
chatFolderId,
previousLocation: location,
},
});
},
[changeLocation, location]
);
if (!isChatFoldersEnabled()) {
return null;
}
return (
<LeftPaneChatFolders
i18n={i18n}
navSidebarWidthBreakpoint={navSidebarWidthBreakpoint}
sortedChatFolders={sortedChatFolders}
allChatFoldersUnreadStats={allChatFoldersUnreadStats}
allChatFoldersMutedStats={allChatFoldersMutedStats}
selectedChatFolder={selectedChatFolder}
onSelectedChatFolderIdChange={updateSelectedChangeFolderId}
onChatFolderMarkRead={markChatFolderRead}
onChatFolderUpdateMute={setChatFolderMuteExpiration}
onChatFolderOpenSettings={handleChatFolderOpenSettings}
/>
);
}
);

View file

@ -0,0 +1,71 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC } from 'react';
import React, { memo, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { getIntl } from '../selectors/user.js';
import { getConversationByIdSelector } from '../selectors/conversations.js';
import { LeftPaneConversationListItemContextMenu } from '../../components/leftPane/LeftPaneConversationListItemContextMenu.js';
import { strictAssert } from '../../util/assert.js';
import type { RenderConversationListItemContextMenuProps } from '../../components/conversationList/BaseConversationListItem.js';
import { useConversationsActions } from '../ducks/conversations.js';
import { getLocalDeleteWarningShown } from '../selectors/items.js';
import { useItemsActions } from '../ducks/items.js';
export const SmartLeftPaneConversationListItemContextMenu: FC<RenderConversationListItemContextMenuProps> =
memo(function SmartLeftPaneConversationListItemContextMenu(props) {
const i18n = useSelector(getIntl);
const conversationByIdSelector = useSelector(getConversationByIdSelector);
const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown);
const {
onMarkUnread,
markConversationRead,
setPinned,
onArchive,
onMoveToInbox,
deleteConversation,
setMuteExpiration,
} = useConversationsActions();
const { putItem } = useItemsActions();
const setLocalDeleteWarningShown = useCallback(() => {
putItem('localDeleteWarningShown', true);
}, [putItem]);
const conversation = conversationByIdSelector(props.conversationId);
strictAssert(conversation, 'Missing conversation');
const handlePin = useCallback(
(conversationId: string) => {
setPinned(conversationId, true);
},
[setPinned]
);
const handleUnpin = useCallback(
(conversationId: string) => {
setPinned(conversationId, false);
},
[setPinned]
);
return (
<LeftPaneConversationListItemContextMenu
i18n={i18n}
conversation={conversation}
onMarkUnread={onMarkUnread}
onMarkRead={markConversationRead}
onPin={handlePin}
onUnpin={handleUnpin}
onUpdateMute={setMuteExpiration}
onArchive={onArchive}
onUnarchive={onMoveToInbox}
onDelete={deleteConversation}
localDeleteWarningShown={localDeleteWarningShown}
setLocalDeleteWarningShown={setLocalDeleteWarningShown}
>
{props.children}
</LeftPaneConversationListItemContextMenu>
);
});

View file

@ -64,7 +64,8 @@ import {
import { getPreferredBadgeSelector } from '../selectors/badges.js'; import { getPreferredBadgeSelector } from '../selectors/badges.js';
import { SmartProfileEditor } from './ProfileEditor.js'; import { SmartProfileEditor } from './ProfileEditor.js';
import { useNavActions } from '../ducks/nav.js'; import { useNavActions } from '../ducks/nav.js';
import { NavTab, ProfileEditorPage, SettingsPage } from '../../types/Nav.js'; import type { SettingsLocation } from '../../types/Nav.js';
import { NavTab } from '../../types/Nav.js';
import { SmartToastManager } from './ToastManager.js'; import { SmartToastManager } from './ToastManager.js';
import { useToastActions } from '../ducks/toast.js'; import { useToastActions } from '../ducks/toast.js';
import { DataReader } from '../../sql/Client.js'; import { DataReader } from '../../sql/Client.js';
@ -127,19 +128,19 @@ function renderToastManager(props: {
function renderDonationsPane({ function renderDonationsPane({
contentsRef, contentsRef,
page, settingsLocation,
setPage, setSettingsLocation,
}: { }: {
contentsRef: MutableRefObject<HTMLDivElement | null>; contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage; settingsLocation: SettingsLocation;
setPage: (page: SettingsPage) => void; setSettingsLocation: (settingsLocation: SettingsLocation) => void;
}): JSX.Element { }): JSX.Element {
return ( return (
<DonationsErrorBoundary> <DonationsErrorBoundary>
<SmartPreferencesDonations <SmartPreferencesDonations
contentsRef={contentsRef} contentsRef={contentsRef}
page={page} settingsLocation={settingsLocation}
setPage={setPage} setSettingsLocation={setSettingsLocation}
/> />
</DonationsErrorBoundary> </DonationsErrorBoundary>
); );
@ -719,24 +720,12 @@ export function SmartPreferences(): JSX.Element | null {
return null; return null;
} }
const { page } = currentLocation.details; const settingsLocation = currentLocation.details;
const setPage = (newPage: SettingsPage, editState?: ProfileEditorPage) => {
if (newPage === SettingsPage.Profile) {
changeLocation({
tab: NavTab.Settings,
details: {
page: newPage,
state: editState || ProfileEditorPage.None,
},
});
return;
}
const setSettingsLocation = (location: SettingsLocation) => {
changeLocation({ changeLocation({
tab: NavTab.Settings, tab: NavTab.Settings,
details: { details: location,
page: newPage,
},
}); });
}; };
@ -873,7 +862,7 @@ export function SmartPreferences(): JSX.Element | null {
onWhoCanSeeMeChange={onWhoCanSeeMeChange} onWhoCanSeeMeChange={onWhoCanSeeMeChange}
onZoomFactorChange={onZoomFactorChange} onZoomFactorChange={onZoomFactorChange}
otherTabsUnreadStats={otherTabsUnreadStats} otherTabsUnreadStats={otherTabsUnreadStats}
page={page} settingsLocation={settingsLocation}
pickLocalBackupFolder={pickLocalBackupFolder} pickLocalBackupFolder={pickLocalBackupFolder}
preferredSystemLocales={preferredSystemLocales} preferredSystemLocales={preferredSystemLocales}
preferredWidthFromStorage={preferredWidthFromStorage} preferredWidthFromStorage={preferredWidthFromStorage}
@ -902,7 +891,7 @@ export function SmartPreferences(): JSX.Element | null {
selectedSpeaker={selectedSpeaker} selectedSpeaker={selectedSpeaker}
sentMediaQualitySetting={sentMediaQualitySetting} sentMediaQualitySetting={sentMediaQualitySetting}
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor} setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
setPage={setPage} setSettingsLocation={setSettingsLocation}
shouldShowUpdateDialog={shouldShowUpdateDialog} shouldShowUpdateDialog={shouldShowUpdateDialog}
showToast={showToast} showToast={showToast}
theme={theme} theme={theme}

View file

@ -5,7 +5,7 @@ import { useSelector } from 'react-redux';
import type { PreferencesChatFoldersPageProps } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js'; import type { PreferencesChatFoldersPageProps } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
import { PreferencesChatFoldersPage } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js'; import { PreferencesChatFoldersPage } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
import { getIntl } from '../selectors/user.js'; import { getIntl } from '../selectors/user.js';
import { getCurrentChatFolders } from '../selectors/chatFolders.js'; import { getSortedChatFolders } from '../selectors/chatFolders.js';
import type { ChatFolderId } from '../../types/ChatFolder.js'; import type { ChatFolderId } from '../../types/ChatFolder.js';
import { useChatFolderActions } from '../ducks/chatFolders.js'; import { useChatFolderActions } from '../ducks/chatFolders.js';
@ -19,8 +19,9 @@ export function SmartPreferencesChatFoldersPage(
props: SmartPreferencesChatFoldersPageProps props: SmartPreferencesChatFoldersPageProps
): JSX.Element { ): JSX.Element {
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const chatFolders = useSelector(getCurrentChatFolders); const chatFolders = useSelector(getSortedChatFolders);
const { createChatFolder } = useChatFolderActions(); const { createChatFolder, deleteChatFolder, updateChatFoldersPositions } =
useChatFolderActions();
return ( return (
<PreferencesChatFoldersPage <PreferencesChatFoldersPage
i18n={i18n} i18n={i18n}
@ -29,6 +30,8 @@ export function SmartPreferencesChatFoldersPage(
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage} onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
chatFolders={chatFolders} chatFolders={chatFolders}
onCreateChatFolder={createChatFolder} onCreateChatFolder={createChatFolder}
onDeleteChatFolder={deleteChatFolder}
onUpdateChatFoldersPositions={updateChatFoldersPositions}
/> />
); );
} }

View file

@ -9,7 +9,7 @@ import type { MutableRefObject } from 'react';
import { getIntl, getTheme, getUserNumber } from '../selectors/user.js'; import { getIntl, getTheme, getUserNumber } from '../selectors/user.js';
import { getMe } from '../selectors/conversations.js'; import { getMe } from '../selectors/conversations.js';
import { PreferencesDonations } from '../../components/PreferencesDonations.js'; import { PreferencesDonations } from '../../components/PreferencesDonations.js';
import type { SettingsPage } from '../../types/Nav.js'; import type { SettingsLocation } from '../../types/Nav.js';
import { useDonationsActions } from '../ducks/donations.js'; import { useDonationsActions } from '../ducks/donations.js';
import type { StateType } from '../reducer.js'; import type { StateType } from '../reducer.js';
import { useConversationsActions } from '../ducks/conversations.js'; import { useConversationsActions } from '../ducks/conversations.js';
@ -40,12 +40,12 @@ const log = createLogger('SmartPreferencesDonations');
export const SmartPreferencesDonations = memo( export const SmartPreferencesDonations = memo(
function SmartPreferencesDonations({ function SmartPreferencesDonations({
contentsRef, contentsRef,
page, settingsLocation,
setPage, setSettingsLocation,
}: { }: {
contentsRef: MutableRefObject<HTMLDivElement | null>; contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage; settingsLocation: SettingsLocation;
setPage: (page: SettingsPage) => void; setSettingsLocation: (settingsLocation: SettingsLocation) => void;
}) { }) {
const [validCurrencies, setValidCurrencies] = useState< const [validCurrencies, setValidCurrencies] = useState<
ReadonlyArray<string> ReadonlyArray<string>
@ -142,7 +142,7 @@ export const SmartPreferencesDonations = memo(
contentsRef={contentsRef} contentsRef={contentsRef}
initialCurrency={initialCurrency} initialCurrency={initialCurrency}
isOnline={isOnline} isOnline={isOnline}
page={page} settingsLocation={settingsLocation}
didResumeWorkflowAtStartup={donationsState.didResumeWorkflowAtStartup} didResumeWorkflowAtStartup={donationsState.didResumeWorkflowAtStartup}
lastError={donationsState.lastError} lastError={donationsState.lastError}
workflow={donationsState.currentWorkflow} workflow={donationsState.currentWorkflow}
@ -151,7 +151,7 @@ export const SmartPreferencesDonations = memo(
resumeWorkflow={resumeWorkflow} resumeWorkflow={resumeWorkflow}
updateLastError={updateLastError} updateLastError={updateLastError}
submitDonation={submitDonation} submitDonation={submitDonation}
setPage={setPage} setSettingsLocation={setSettingsLocation}
theme={theme} theme={theme}
donationBadge={badgesById[BOOST_ID] ?? undefined} donationBadge={badgesById[BOOST_ID] ?? undefined}
fetchBadgeData={fetchBadgeData} fetchBadgeData={fetchBadgeData}

View file

@ -12,11 +12,13 @@ import {
} from '../selectors/conversations.js'; } from '../selectors/conversations.js';
import { getPreferredBadgeSelector } from '../selectors/badges.js'; import { getPreferredBadgeSelector } from '../selectors/badges.js';
import { useChatFolderActions } from '../ducks/chatFolders.js'; import { useChatFolderActions } from '../ducks/chatFolders.js';
import { getCurrentChatFolders } from '../selectors/chatFolders.js'; import { getSortedChatFolders } from '../selectors/chatFolders.js';
import { strictAssert } from '../../util/assert.js'; import { strictAssert } from '../../util/assert.js';
import { useNavActions } from '../ducks/nav.js';
import type { Location } from '../../types/Nav.js';
export type SmartPreferencesEditChatFolderPageProps = Readonly<{ export type SmartPreferencesEditChatFolderPageProps = Readonly<{
onBack: () => void; previousLocation: Location;
existingChatFolderId: PreferencesEditChatFolderPageProps['existingChatFolderId']; existingChatFolderId: PreferencesEditChatFolderPageProps['existingChatFolderId'];
settingsPaneRef: PreferencesEditChatFolderPageProps['settingsPaneRef']; settingsPaneRef: PreferencesEditChatFolderPageProps['settingsPaneRef'];
}>; }>;
@ -31,9 +33,10 @@ export function SmartPreferencesEditChatFolderPage(
const conversations = useSelector(getAllComposableConversations); const conversations = useSelector(getAllComposableConversations);
const conversationSelector = useSelector(getConversationSelector); const conversationSelector = useSelector(getConversationSelector);
const preferredBadgeSelector = useSelector(getPreferredBadgeSelector); const preferredBadgeSelector = useSelector(getPreferredBadgeSelector);
const chatFolders = useSelector(getCurrentChatFolders); const chatFolders = useSelector(getSortedChatFolders);
const { createChatFolder, updateChatFolder, deleteChatFolder } = const { createChatFolder, updateChatFolder, deleteChatFolder } =
useChatFolderActions(); useChatFolderActions();
const { changeLocation } = useNavActions();
const initChatFolderParams = useMemo(() => { const initChatFolderParams = useMemo(() => {
if (existingChatFolderId == null) { if (existingChatFolderId == null) {
@ -49,9 +52,10 @@ export function SmartPreferencesEditChatFolderPage(
return ( return (
<PreferencesEditChatFolderPage <PreferencesEditChatFolderPage
i18n={i18n} i18n={i18n}
previousLocation={props.previousLocation}
existingChatFolderId={props.existingChatFolderId} existingChatFolderId={props.existingChatFolderId}
initChatFolderParams={initChatFolderParams} initChatFolderParams={initChatFolderParams}
onBack={props.onBack} changeLocation={changeLocation}
conversations={conversations} conversations={conversations}
preferredBadgeSelector={preferredBadgeSelector} preferredBadgeSelector={preferredBadgeSelector}
theme={theme} theme={theme}

View file

@ -1,6 +1,8 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { Selector } from 'reselect';
import type { StateType } from './reducer.js';
import type { actions as accounts } from './ducks/accounts.js'; import type { actions as accounts } from './ducks/accounts.js';
import type { actions as app } from './ducks/app.js'; import type { actions as app } from './ducks/app.js';
import type { actions as audioPlayer } from './ducks/audioPlayer.js'; import type { actions as audioPlayer } from './ducks/audioPlayer.js';
@ -72,3 +74,5 @@ export type ReduxActions = {
user: typeof user; user: typeof user;
username: typeof username; username: typeof username;
}; };
export type StateSelector<T> = Selector<StateType, T>;

View file

@ -72,7 +72,7 @@ describe('sql/notificationProfiles', () => {
allowAllCalls: false, allowAllCalls: false,
allowAllMentions: false, allowAllMentions: false,
allowedMembers: new Set(), allowedMembers: new Set<string>(),
scheduleEnabled: false, scheduleEnabled: false,
scheduleStartTime: undefined, scheduleStartTime: undefined,
@ -148,7 +148,7 @@ describe('sql/notificationProfiles', () => {
allowAllCalls: false, allowAllCalls: false,
allowAllMentions: false, allowAllMentions: false,
allowedMembers: new Set(), allowedMembers: new Set<string>(),
scheduleEnabled: false, scheduleEnabled: false,
scheduleStartTime: undefined, scheduleStartTime: undefined,
@ -220,7 +220,7 @@ describe('sql/notificationProfiles', () => {
allowAllCalls: false, allowAllCalls: false,
allowAllMentions: false, allowAllMentions: false,
allowedMembers: new Set(), allowedMembers: new Set<string>(),
scheduleEnabled: false, scheduleEnabled: false,
scheduleStartTime: undefined, scheduleStartTime: undefined,

View file

@ -224,9 +224,7 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
); );
const confirmDeleteBtn = window const confirmDeleteBtn = window
.getByTestId( .getByTestId('ConfirmationDialog.Preferences__DeleteChatFolderDialog')
'ConfirmationDialog.Preferences__EditChatFolderPage__DeleteChatFolderDialog'
)
.locator('button:has-text("Delete")'); .locator('button:has-text("Delete")');
let state = await phone.expectStorageState('initial state'); let state = await phone.expectStorageState('initial state');

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import createDebug from 'debug'; import createDebug from 'debug';
import { v4 as generateUuid } from 'uuid';
import type { import type {
Group, Group,
PrimaryDevice, PrimaryDevice,
@ -107,6 +108,22 @@ export async function initStorage(
}, },
}); });
state = state.addRecord({
type: IdentifierType.CHAT_FOLDER,
record: {
chatFolder: {
id: uuidToBytes(generateUuid()),
name: null,
position: 0,
showOnlyUnread: false,
showMutedChats: true,
includeAllIndividualChats: true,
includeAllGroupChats: true,
folderType: Proto.ChatFolderRecord.FolderType.ALL,
},
},
});
await phone.setStorageState(state); await phone.setStorageState(state);
// Link new device // Link new device

View file

@ -1149,7 +1149,7 @@ describe('both/state/selectors/conversations-extra', () => {
describe('#_getLeftPaneLists', () => { describe('#_getLeftPaneLists', () => {
it('sorts conversations based on timestamp then by intl-friendly title', () => { it('sorts conversations based on timestamp then by intl-friendly title', () => {
const data: ConversationLookupType = { const conversationLookup: ConversationLookupType = {
id1: getDefaultConversation({ id1: getDefaultConversation({
id: 'id1', id: 'id1',
e164: '+18005551111', e164: '+18005551111',
@ -1256,9 +1256,16 @@ describe('both/state/selectors/conversations-extra', () => {
acceptedMessageRequest: true, acceptedMessageRequest: true,
}), }),
}; };
const comparator = _getConversationComparator(); const conversationComparator = _getConversationComparator();
const { archivedConversations, conversations, pinnedConversations } = const { archivedConversations, conversations, pinnedConversations } =
_getLeftPaneLists(data, comparator); _getLeftPaneLists({
conversationLookup,
conversationComparator,
selectedConversationId: undefined,
pinnedConversationIds: null,
selectedChatFolder: null,
stableSelectedConversationIdInChatFolder: null,
});
assert.strictEqual(conversations[0].name, 'First!'); assert.strictEqual(conversations[0].name, 'First!');
assert.strictEqual(conversations[1].name, 'Á'); assert.strictEqual(conversations[1].name, 'Á');
@ -1274,7 +1281,7 @@ describe('both/state/selectors/conversations-extra', () => {
describe('given pinned conversations', () => { describe('given pinned conversations', () => {
it('sorts pinned conversations based on order in storage', () => { it('sorts pinned conversations based on order in storage', () => {
const data: ConversationLookupType = { const conversationLookup: ConversationLookupType = {
pin2: getDefaultConversation({ pin2: getDefaultConversation({
id: 'pin2', id: 'pin2',
e164: '+18005551111', e164: '+18005551111',
@ -1344,9 +1351,16 @@ describe('both/state/selectors/conversations-extra', () => {
}; };
const pinnedConversationIds = ['pin1', 'pin2', 'pin3']; const pinnedConversationIds = ['pin1', 'pin2', 'pin3'];
const comparator = _getConversationComparator(); const conversationComparator = _getConversationComparator();
const { archivedConversations, conversations, pinnedConversations } = const { archivedConversations, conversations, pinnedConversations } =
_getLeftPaneLists(data, comparator, undefined, pinnedConversationIds); _getLeftPaneLists({
conversationLookup,
conversationComparator,
selectedConversationId: undefined,
pinnedConversationIds,
selectedChatFolder: null,
stableSelectedConversationIdInChatFolder: null,
});
assert.strictEqual(pinnedConversations[0].name, 'Pin One'); assert.strictEqual(pinnedConversations[0].name, 'Pin One');
assert.strictEqual(pinnedConversations[1].name, 'Pin Two'); assert.strictEqual(pinnedConversations[1].name, 'Pin Two');
@ -1358,7 +1372,7 @@ describe('both/state/selectors/conversations-extra', () => {
}); });
it('includes archived and pinned conversations with no active_at', () => { it('includes archived and pinned conversations with no active_at', () => {
const data: ConversationLookupType = { const conversationLookup: ConversationLookupType = {
pin2: getDefaultConversation({ pin2: getDefaultConversation({
id: 'pin2', id: 'pin2',
e164: '+18005551111', e164: '+18005551111',
@ -1468,9 +1482,16 @@ describe('both/state/selectors/conversations-extra', () => {
}; };
const pinnedConversationIds = ['pin1', 'pin2', 'pin3']; const pinnedConversationIds = ['pin1', 'pin2', 'pin3'];
const comparator = _getConversationComparator(); const conversationComparator = _getConversationComparator();
const { archivedConversations, conversations, pinnedConversations } = const { archivedConversations, conversations, pinnedConversations } =
_getLeftPaneLists(data, comparator, undefined, pinnedConversationIds); _getLeftPaneLists({
conversationLookup,
conversationComparator,
selectedConversationId: undefined,
pinnedConversationIds,
selectedChatFolder: null,
stableSelectedConversationIdInChatFolder: null,
});
assert.strictEqual(pinnedConversations[0].name, 'Pin One'); assert.strictEqual(pinnedConversations[0].name, 'Pin One');
assert.strictEqual(pinnedConversations[1].name, 'Pin Two'); assert.strictEqual(pinnedConversations[1].name, 'Pin Two');

View file

@ -2,267 +2,198 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; import { assert } from 'chai';
import { v4 as generateUuid } from 'uuid';
import { countConversationUnreadStats } from '../../util/countUnreadStats.js'; import { countConversationUnreadStats } from '../../util/countUnreadStats.js';
import type {
UnreadStats,
ConversationPropsForUnreadStats,
} from '../../util/countUnreadStats.js';
describe('countConversationUnreadStats', () => { function getFutureMutedTimestamp() {
const mutedTimestamp = (): number => Date.now() + 12345; return Date.now() + 12345;
const oldMutedTimestamp = (): number => Date.now() - 1000; }
function getPastMutedTimestamp() {
return Date.now() - 1000;
}
function mockChat(
props: Partial<ConversationPropsForUnreadStats>
): ConversationPropsForUnreadStats {
return {
id: generateUuid(),
type: 'direct',
activeAt: Date.now(),
isArchived: false,
markedUnread: false,
unreadCount: 0,
unreadMentionsCount: 0,
muteExpiresAt: undefined,
left: false,
...props,
};
}
function mockStats(props: Partial<UnreadStats>): UnreadStats {
return {
unreadCount: 0,
unreadMentionsCount: 0,
readChatsMarkedUnreadCount: 0,
...props,
};
}
describe('countUnreadStats', () => {
describe('countConversationUnreadStats', () => {
it('returns 0 if the conversation is archived', () => { it('returns 0 if the conversation is archived', () => {
const isArchived = true;
const archivedConversations = [ const archivedConversations = [
{ mockChat({ isArchived, markedUnread: false, unreadCount: 0 }),
activeAt: Date.now(), mockChat({ isArchived, markedUnread: false, unreadCount: 123 }),
isArchived: true, mockChat({ isArchived, markedUnread: true, unreadCount: 0 }),
markedUnread: false, mockChat({ isArchived, markedUnread: true, unreadCount: undefined }),
unreadCount: 0, mockChat({ isArchived, markedUnread: undefined, unreadCount: 0 }),
},
{
activeAt: Date.now(),
isArchived: true,
markedUnread: false,
unreadCount: 123,
},
{
activeAt: Date.now(),
isArchived: true,
markedUnread: true,
unreadCount: 0,
},
{ activeAt: Date.now(), isArchived: true, markedUnread: true },
]; ];
for (const conversation of archivedConversations) { for (const conversation of archivedConversations) {
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: true }), countConversationUnreadStats(conversation, { includeMuted: true }),
{ mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
}
); );
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: false }), countConversationUnreadStats(conversation, { includeMuted: false }),
{ mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
}
); );
} }
}); });
it("returns 0 if the conversation is muted and the user doesn't want to include those in the result", () => { it("returns 0 if the conversation is muted and the user doesn't want to include those in the result", () => {
const muteExpiresAt = getFutureMutedTimestamp();
const mutedConversations = [ const mutedConversations = [
{ mockChat({ muteExpiresAt, markedUnread: false, unreadCount: 0 }),
activeAt: Date.now(), mockChat({ muteExpiresAt, markedUnread: false, unreadCount: 9 }),
muteExpiresAt: mutedTimestamp(), mockChat({ muteExpiresAt, markedUnread: true, unreadCount: 0 }),
markedUnread: false, mockChat({ muteExpiresAt, markedUnread: true, unreadCount: undefined }),
unreadCount: 0,
},
{
activeAt: Date.now(),
muteExpiresAt: mutedTimestamp(),
markedUnread: false,
unreadCount: 9,
},
{
activeAt: Date.now(),
muteExpiresAt: mutedTimestamp(),
markedUnread: true,
unreadCount: 0,
},
{
activeAt: Date.now(),
muteExpiresAt: mutedTimestamp(),
markedUnread: true,
},
]; ];
for (const conversation of mutedConversations) { for (const conversation of mutedConversations) {
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: false }), countConversationUnreadStats(conversation, { includeMuted: false }),
{ mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
}
); );
} }
}); });
it('returns the unread count if nonzero (and not archived)', () => { it('returns the unread count if nonzero (and not archived)', () => {
const conversationsWithUnreadCount = [ const conversationsWithUnreadCount = [
{ activeAt: Date.now(), unreadCount: 9, markedUnread: false }, mockChat({ unreadCount: 9, markedUnread: false }),
{ activeAt: Date.now(), unreadCount: 9, markedUnread: true }, mockChat({ unreadCount: 9, markedUnread: true }),
{ mockChat({ unreadCount: 9, muteExpiresAt: getPastMutedTimestamp() }),
activeAt: Date.now(),
unreadCount: 9,
markedUnread: false,
muteExpiresAt: oldMutedTimestamp(),
},
{
activeAt: Date.now(),
unreadCount: 9,
markedUnread: false,
isArchived: false,
},
]; ];
for (const conversation of conversationsWithUnreadCount) { for (const conversation of conversationsWithUnreadCount) {
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: false }), countConversationUnreadStats(conversation, { includeMuted: false }),
{ mockStats({ unreadCount: 9 })
unreadCount: 9,
unreadMentionsCount: 0,
markedUnread: conversation.markedUnread,
}
); );
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: true }), countConversationUnreadStats(conversation, { includeMuted: true }),
{ mockStats({ unreadCount: 9 })
unreadCount: 9,
unreadMentionsCount: 0,
markedUnread: conversation.markedUnread,
}
); );
} }
const mutedWithUnreads = { const mutedWithUnreads = mockChat({
activeAt: Date.now(),
unreadCount: 123, unreadCount: 123,
markedUnread: false, muteExpiresAt: getFutureMutedTimestamp(),
muteExpiresAt: mutedTimestamp(), });
};
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(mutedWithUnreads, { includeMuted: true }), countConversationUnreadStats(mutedWithUnreads, { includeMuted: true }),
{ mockStats({ unreadCount: 123 })
unreadCount: 123,
unreadMentionsCount: 0,
markedUnread: false,
}
); );
}); });
it('returns markedUnread:true if the conversation is marked unread', () => { it('returns markedUnread:true if the conversation is marked unread', () => {
const conversationsMarkedUnread = [ const conversationsMarkedUnread = [
{ activeAt: Date.now(), markedUnread: true }, mockChat({ markedUnread: true }),
{ activeAt: Date.now(), markedUnread: true, unreadCount: 0 }, mockChat({
{
activeAt: Date.now(),
markedUnread: true, markedUnread: true,
muteExpiresAt: oldMutedTimestamp(), muteExpiresAt: getPastMutedTimestamp(),
}, }),
{
activeAt: Date.now(),
markedUnread: true,
muteExpiresAt: oldMutedTimestamp(),
isArchived: false,
},
]; ];
for (const conversation of conversationsMarkedUnread) { for (const conversation of conversationsMarkedUnread) {
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: false }), countConversationUnreadStats(conversation, { includeMuted: false }),
{ mockStats({ readChatsMarkedUnreadCount: 1 })
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: true,
}
); );
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: true }), countConversationUnreadStats(conversation, { includeMuted: true }),
{ mockStats({ readChatsMarkedUnreadCount: 1 })
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: true,
}
); );
} }
const mutedConversationsMarkedUnread = [ const mutedConversationsMarkedUnread = [
{ mockChat({
activeAt: Date.now(),
markedUnread: true, markedUnread: true,
muteExpiresAt: mutedTimestamp(), muteExpiresAt: getFutureMutedTimestamp(),
}, }),
{ mockChat({
activeAt: Date.now(),
markedUnread: true, markedUnread: true,
muteExpiresAt: mutedTimestamp(), muteExpiresAt: getFutureMutedTimestamp(),
unreadCount: 0, unreadCount: 0,
}, }),
]; ];
for (const conversation of mutedConversationsMarkedUnread) { for (const conversation of mutedConversationsMarkedUnread) {
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: true }), countConversationUnreadStats(conversation, { includeMuted: true }),
{ mockStats({ readChatsMarkedUnreadCount: 1 })
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: true,
}
); );
} }
}); });
it('returns 0 if the conversation is read', () => { it('returns 0 if the conversation is read', () => {
const readConversations = [ const readConversations = [
{ activeAt: Date.now(), markedUnread: false }, mockChat({ markedUnread: false, unreadCount: undefined }),
{ activeAt: Date.now(), markedUnread: false, unreadCount: 0 }, mockChat({ markedUnread: false, unreadCount: 0 }),
{ mockChat({
activeAt: Date.now(),
markedUnread: false, markedUnread: false,
mutedTimestamp: mutedTimestamp(), muteExpiresAt: getFutureMutedTimestamp(),
}, }),
{ mockChat({
activeAt: Date.now(),
markedUnread: false, markedUnread: false,
mutedTimestamp: oldMutedTimestamp(), muteExpiresAt: getPastMutedTimestamp(),
}, }),
]; ];
for (const conversation of readConversations) { for (const conversation of readConversations) {
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: false }), countConversationUnreadStats(conversation, { includeMuted: false }),
{ mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
}
); );
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: true }), countConversationUnreadStats(conversation, { includeMuted: true }),
{ mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
}
); );
} }
}); });
it('returns 0 if the conversation has falsey activeAt', () => { it('returns 0 if the conversation has falsey activeAt', () => {
const readConversations = [ const readConversations = [
{ activeAt: undefined, markedUnread: false, unreadCount: 2 }, mockChat({ activeAt: undefined, unreadCount: 2 }),
{ mockChat({
activeAt: 0, activeAt: 0,
unreadCount: 2, unreadCount: 2,
markedUnread: false, muteExpiresAt: getPastMutedTimestamp(),
mutedTimestamp: oldMutedTimestamp(), }),
},
]; ];
for (const conversation of readConversations) { for (const conversation of readConversations) {
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: false }), countConversationUnreadStats(conversation, { includeMuted: false }),
{ mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
}
); );
assert.deepStrictEqual( assert.deepStrictEqual(
countConversationUnreadStats(conversation, { includeMuted: true }), countConversationUnreadStats(conversation, { includeMuted: true }),
{ mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
}
); );
} }
}); });
});
}); });

View file

@ -1,6 +1,7 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod'; import { z } from 'zod';
import type { Simplify } from 'type-fest';
import { import {
Environment, Environment,
getEnvironment, getEnvironment,
@ -9,6 +10,9 @@ import {
import * as grapheme from '../util/grapheme.js'; import * as grapheme from '../util/grapheme.js';
import * as RemoteConfig from '../RemoteConfig.js'; import * as RemoteConfig from '../RemoteConfig.js';
import { isAlpha, isBeta, isProduction } from '../util/version.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; export const CHAT_FOLDER_NAME_MAX_CHAR_LENGTH = 32;
@ -22,7 +26,8 @@ export enum ChatFolderType {
export type ChatFolderId = string & { ChatFolderId: never }; // uuid export type ChatFolderId = string & { ChatFolderId: never }; // uuid
export type ChatFolderPreset = Readonly<{ export type ChatFolderPreset = Simplify<
Readonly<{
folderType: ChatFolderType; folderType: ChatFolderType;
showOnlyUnread: boolean; showOnlyUnread: boolean;
showMutedChats: boolean; showMutedChats: boolean;
@ -30,15 +35,19 @@ export type ChatFolderPreset = Readonly<{
includeAllGroupChats: boolean; includeAllGroupChats: boolean;
includedConversationIds: ReadonlyArray<string>; includedConversationIds: ReadonlyArray<string>;
excludedConversationIds: ReadonlyArray<string>; excludedConversationIds: ReadonlyArray<string>;
}>; }>
>;
export type ChatFolderParams = Readonly< export type ChatFolderParams = Simplify<
Readonly<
ChatFolderPreset & { ChatFolderPreset & {
name: string; name: string;
} }
>
>; >;
export type ChatFolder = Readonly< export type ChatFolder = Simplify<
Readonly<
ChatFolderParams & { ChatFolderParams & {
id: ChatFolderId; id: ChatFolderId;
position: number; position: number;
@ -48,6 +57,7 @@ export type ChatFolder = Readonly<
storageUnknownFields: Uint8Array | null; storageUnknownFields: Uint8Array | null;
storageNeedsSync: boolean; storageNeedsSync: boolean;
} }
>
>; >;
export const ChatFolderPresetSchema = z.object({ export const ChatFolderPresetSchema = z.object({
@ -173,3 +183,91 @@ export function isChatFoldersEnabled(): boolean {
return false; return false;
} }
type ConversationPropsForChatFolder = Pick<
ConversationType,
'type' | 'id' | 'unreadCount' | 'markedUnread' | 'muteExpiresAt'
>;
function _isConversationIncludedInChatFolder(
chatFolder: ChatFolder,
conversation: ConversationPropsForChatFolder
): boolean {
if (chatFolder.includeAllIndividualChats && conversation.type === 'direct') {
return true; // is individual chat
}
if (chatFolder.includeAllGroupChats && conversation.type === 'group') {
return true; // is group chat
}
if (chatFolder.includedConversationIds.includes(conversation.id)) {
return true; // is included by id
}
return false;
}
function _isConversationExcludedFromChatFolder(
chatFolder: ChatFolder,
conversation: ConversationPropsForChatFolder
): boolean {
if (chatFolder.showOnlyUnread && !isConversationUnread(conversation)) {
return true; // not unread, only showing unread
}
if (!chatFolder.showMutedChats && (conversation.muteExpiresAt ?? 0) > 0) {
return true; // muted, not showing muted chats
}
if (chatFolder.excludedConversationIds.includes(conversation.id)) {
return true; // is excluded by id
}
return false;
}
export function isConversationInChatFolder(
chatFolder: ChatFolder,
conversation: ConversationPropsForChatFolder
): boolean {
if (chatFolder.folderType === ChatFolderType.ALL) {
return true;
}
return (
_isConversationIncludedInChatFolder(chatFolder, conversation) &&
!_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;
}

View file

@ -2,16 +2,30 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
import type { ChatFolderId } from './ChatFolder.js';
export type Location = ReadonlyDeep< export type SettingsLocation = ReadonlyDeep<
| {
tab: NavTab.Settings;
details:
| { | {
page: SettingsPage.Profile; page: SettingsPage.Profile;
state: ProfileEditorPage; state: ProfileEditorPage;
} }
| { page: Exclude<SettingsPage, SettingsPage.Profile> }; | {
page: SettingsPage.EditChatFolder;
chatFolderId: ChatFolderId | null;
previousLocation: Location;
}
| {
page: Exclude<
SettingsPage,
SettingsPage.Profile | SettingsPage.EditChatFolder
>;
}
>;
export type Location = ReadonlyDeep<
| {
tab: NavTab.Settings;
details: SettingsLocation;
} }
| { tab: Exclude<NavTab, NavTab.Settings> } | { tab: Exclude<NavTab, NavTab.Settings> }
>; >;

View file

@ -0,0 +1,60 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationType } from '../state/ducks/conversations.js';
import {
isConversationInChatFolder,
type ChatFolder,
type ChatFolderId,
} from '../types/ChatFolder.js';
import { isConversationMuted } from './isConversationMuted.js';
type MutableMutedStats = {
chatsMutedCount: number;
chatsUnmutedCount: number;
};
export type MutedStats = Readonly<MutableMutedStats>;
export type AllChatFoldersMutedStats = Map<ChatFolderId, MutedStats>;
function createMutedStats(): MutableMutedStats {
return {
chatsMutedCount: 0,
chatsUnmutedCount: 0,
};
}
export type ConversationPropsForMutedStats = Readonly<
Pick<ConversationType, 'id' | 'type' | 'activeAt' | 'muteExpiresAt'>
>;
export function countAllChatFoldersMutedStats(
sortedChatFolders: ReadonlyArray<ChatFolder>,
conversations: ReadonlyArray<ConversationPropsForMutedStats>
): AllChatFoldersMutedStats {
const results = new Map<ChatFolderId, MutableMutedStats>();
for (const conversation of conversations) {
const isMuted = isConversationMuted(conversation);
// check which chatFolders should count this conversation
for (const chatFolder of sortedChatFolders) {
if (isConversationInChatFolder(chatFolder, conversation)) {
let mutedStats = results.get(chatFolder.id);
if (mutedStats == null) {
mutedStats = createMutedStats();
results.set(chatFolder.id, mutedStats);
}
if (isMuted) {
mutedStats.chatsMutedCount += 1;
} else {
mutedStats.chatsUnmutedCount += 1;
}
}
}
}
return results;
}

View file

@ -2,23 +2,44 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationType } from '../state/ducks/conversations.js'; import type { ConversationType } from '../state/ducks/conversations.js';
import { isConversationInChatFolder } from '../types/ChatFolder.js';
import type { ChatFolder, ChatFolderId } from '../types/ChatFolder.js';
import { isConversationMuted } from './isConversationMuted.js'; import { isConversationMuted } from './isConversationMuted.js';
type MutableUnreadStats = {
/**
* Total of `conversation.unreadCount`
* in all countable conversations in the set.
*
* Note: `conversation.unreadCount` should always include the number of
* unread messages with mentions.
*/
unreadCount: number;
/**
* Total of `conversation.unreadMentionsCount`
* in all countable conversations in the set.
*/
unreadMentionsCount: number;
/**
* Total of `unreadCount === 0 && markedRead == true`
* in all countable conversations in the set.
*/
readChatsMarkedUnreadCount: number;
};
/** /**
* This can be used to describe unread counts of chats, stories, and calls, * This can be used to describe unread counts of chats, stories, and calls,
* individually or all of them together. * individually or all of them together.
*/ */
export type UnreadStats = Readonly<{ export type UnreadStats = Readonly<MutableUnreadStats>;
unreadCount: number;
unreadMentionsCount: number;
markedUnread: boolean;
}>;
function getEmptyUnreadStats(): UnreadStats { function createUnreadStats(): MutableUnreadStats {
return { return {
unreadCount: 0, unreadCount: 0,
unreadMentionsCount: 0, unreadMentionsCount: 0,
markedUnread: false, readChatsMarkedUnreadCount: 0,
}; };
} }
@ -29,6 +50,8 @@ export type UnreadStatsOptions = Readonly<{
export type ConversationPropsForUnreadStats = Readonly< export type ConversationPropsForUnreadStats = Readonly<
Pick< Pick<
ConversationType, ConversationType,
| 'id'
| 'type'
| 'activeAt' | 'activeAt'
| 'isArchived' | 'isArchived'
| 'markedUnread' | 'markedUnread'
@ -39,7 +62,9 @@ export type ConversationPropsForUnreadStats = Readonly<
> >
>; >;
function canCountConversation( export type AllChatFoldersUnreadStats = Map<ChatFolderId, UnreadStats>;
function _canCountConversation(
conversation: ConversationPropsForUnreadStats, conversation: ConversationPropsForUnreadStats,
options: UnreadStatsOptions options: UnreadStatsOptions
): boolean { ): boolean {
@ -58,39 +83,109 @@ function canCountConversation(
return true; return true;
} }
/** @private */
function _countConversation(
unreadStats: MutableUnreadStats,
conversation: ConversationPropsForUnreadStats
): void {
const mutable = unreadStats;
const {
unreadCount = 0,
unreadMentionsCount = 0,
markedUnread = false,
} = conversation;
const hasUnreadCount = unreadCount > 0;
if (hasUnreadCount) {
mutable.unreadCount += unreadCount;
mutable.unreadMentionsCount += unreadMentionsCount;
} else if (markedUnread) {
mutable.readChatsMarkedUnreadCount += 1;
}
}
export function isConversationUnread(
conversation: ConversationPropsForUnreadStats,
options: UnreadStatsOptions
): boolean {
if (!_canCountConversation(conversation, options)) {
return false;
}
// Note: Don't need to look at unreadMentionsCount
const { unreadCount, markedUnread } = conversation;
if (unreadCount != null && unreadCount !== 0) {
return true;
}
if (markedUnread) {
return true;
}
return false;
}
export function countConversationUnreadStats( export function countConversationUnreadStats(
conversation: ConversationPropsForUnreadStats, conversation: ConversationPropsForUnreadStats,
options: UnreadStatsOptions options: UnreadStatsOptions
): UnreadStats { ): UnreadStats {
if (canCountConversation(conversation, options)) { const unreadStats = createUnreadStats();
return { if (_canCountConversation(conversation, options)) {
unreadCount: conversation.unreadCount ?? 0, _countConversation(unreadStats, conversation);
unreadMentionsCount: conversation.unreadMentionsCount ?? 0,
markedUnread: conversation.markedUnread ?? false,
};
} }
return getEmptyUnreadStats(); return unreadStats;
} }
export function countAllConversationsUnreadStats( export function countAllConversationsUnreadStats(
conversations: ReadonlyArray<ConversationPropsForUnreadStats>, conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
options: UnreadStatsOptions options: UnreadStatsOptions
): UnreadStats { ): UnreadStats {
return conversations.reduce<UnreadStats>((total, conversation) => { const unreadStats = createUnreadStats();
const stats = countConversationUnreadStats(conversation, options);
return { for (const conversation of conversations) {
unreadCount: total.unreadCount + stats.unreadCount, if (_canCountConversation(conversation, options)) {
unreadMentionsCount: _countConversation(unreadStats, conversation);
total.unreadMentionsCount + stats.unreadMentionsCount, }
markedUnread: total.markedUnread || stats.markedUnread, }
};
}, getEmptyUnreadStats()); return unreadStats;
} }
export function hasUnread(unreadStats: UnreadStats): boolean { export function countAllChatFoldersUnreadStats(
return ( sortedChatFolders: ReadonlyArray<ChatFolder>,
unreadStats.unreadCount > 0 || conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
unreadStats.unreadMentionsCount > 0 || options: UnreadStatsOptions
unreadStats.markedUnread ): AllChatFoldersUnreadStats {
); const results = new Map<ChatFolderId, MutableUnreadStats>();
for (const conversation of conversations) {
// skip if we shouldn't count it
if (!_canCountConversation(conversation, options)) {
continue;
}
const {
unreadCount = 0,
unreadMentionsCount = 0,
markedUnread = false,
} = conversation;
// skip if we don't have any unreads
if (unreadCount === 0 && unreadMentionsCount === 0 && !markedUnread) {
continue;
}
// check which chatFolders should count this conversation
for (const chatFolder of sortedChatFolders) {
if (isConversationInChatFolder(chatFolder, conversation)) {
let unreadStats = results.get(chatFolder.id);
if (unreadStats == null) {
unreadStats = createUnreadStats();
results.set(chatFolder.id, unreadStats);
}
_countConversation(unreadStats, conversation);
}
}
}
return results;
} }

View file

@ -6,7 +6,7 @@ import type { ConversationType } from '../state/ducks/conversations.js';
import { parseAndFormatPhoneNumber } from './libphonenumberInstance.js'; import { parseAndFormatPhoneNumber } from './libphonenumberInstance.js';
import { WEEK } from './durations/index.js'; import { WEEK } from './durations/index.js';
import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse.js'; import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse.js';
import { countConversationUnreadStats, hasUnread } from './countUnreadStats.js'; import { isConversationUnread } from './countUnreadStats.js';
import { getE164 } from './getE164.js'; import { getE164 } from './getE164.js';
import { removeDiacritics } from './removeDiacritics.js'; import { removeDiacritics } from './removeDiacritics.js';
import { isAciString } from './isAciString.js'; import { isAciString } from './isAciString.js';
@ -69,9 +69,7 @@ function filterConversationsByUnread(
includeMuted: boolean includeMuted: boolean
): Array<ConversationType> { ): Array<ConversationType> {
return conversations.filter(conversation => { return conversations.filter(conversation => {
return hasUnread( return isConversationUnread(conversation, { includeMuted });
countConversationUnreadStats(conversation, { includeMuted })
);
}); });
} }

View file

@ -12,24 +12,10 @@ export type MuteOption = {
value: number; value: number;
}; };
export function getMuteOptions( export function getMuteValuesOptions(
muteExpiresAt: null | undefined | number,
i18n: LocalizerType i18n: LocalizerType
): Array<MuteOption> { ): ReadonlyArray<MuteOption> {
return [ return [
...(muteExpiresAt && isConversationMuted({ muteExpiresAt })
? [
{
name: getMutedUntilText(muteExpiresAt, i18n),
disabled: true,
value: -1,
},
{
name: i18n('icu:unmute'),
value: 0,
},
]
: []),
{ {
name: i18n('icu:muteHour'), name: i18n('icu:muteHour'),
value: durations.HOUR, value: durations.HOUR,
@ -52,3 +38,25 @@ export function getMuteOptions(
}, },
]; ];
} }
export function getMuteOptions(
muteExpiresAt: null | undefined | number,
i18n: LocalizerType
): Array<MuteOption> {
return [
...(muteExpiresAt && isConversationMuted({ muteExpiresAt })
? [
{
name: getMutedUntilText(muteExpiresAt, i18n),
disabled: true,
value: -1,
},
{
name: i18n('icu:unmute'),
value: 0,
},
]
: []),
...getMuteValuesOptions(i18n),
];
}

View file

@ -2083,6 +2083,13 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2025-02-19T20:14:46.879Z" "updated": "2025-02-19T20:14:46.879Z"
}, },
{
"rule": "React-useRef",
"path": "ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx",
"line": " const didSaveOrDiscardChangesRef = useRef(false);",
"reasonCategory": "usageTrusted",
"updated": "2025-09-24T17:08:10.620Z"
},
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/preferences/donations/DonateInputAmount.tsx", "path": "ts/components/preferences/donations/DonateInputAmount.tsx",
@ -2141,6 +2148,20 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2023-10-04T20:50:45.297Z" "updated": "2023-10-04T20:50:45.297Z"
}, },
{
"rule": "React-useRef",
"path": "ts/hooks/useNavBlocker.ts",
"line": " const nameRef = useRef(name);",
"reasonCategory": "usageTrusted",
"updated": "2025-09-24T17:08:10.620Z"
},
{
"rule": "React-useRef",
"path": "ts/hooks/useNavBlocker.ts",
"line": " const shouldBlockRef = useRef(shouldBlock);",
"reasonCategory": "usageTrusted",
"updated": "2025-09-24T17:08:10.620Z"
},
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/hooks/usePrevious.ts", "path": "ts/hooks/usePrevious.ts",

View file

@ -27,14 +27,12 @@
*/ */
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"target": "ES2021", "target": "ES2023",
/* Specify a set of bundled library declaration files that describe the target runtime environment. */ /* Specify a set of bundled library declaration files that describe the target runtime environment. */
"lib": [ "lib": [
"DOM", // Required to access `window` "DOM", // Required to access `window`
"DOM.Iterable", "DOM.Iterable",
"ES2022", "ESNext"
"ES2023.Array",
"ESNext.Disposable" // For `playwright`
], ],
/* Specify what JSX code is generated. */ /* Specify what JSX code is generated. */
"jsx": "react", "jsx": "react",