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: {

View file

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

View file

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

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
// time this runs
return {
id: conversation.get('id'),
type: conversation.get('type') === 'private' ? 'direct' : 'group',
activeAt: conversation.get('active_at') ?? undefined,
isArchived: conversation.get('isArchived'),
markedUnread: conversation.get('markedUnread'),
@ -383,15 +385,16 @@ export class ConversationController {
drop(window.storage.put('unreadCount', unreadStats.unreadCount));
if (unreadStats.unreadCount > 0) {
window.IPC.setBadge(unreadStats.unreadCount);
window.IPC.updateTrayIcon(unreadStats.unreadCount);
window.document.title = `${window.getTitle()} (${
unreadStats.unreadCount
})`;
} else if (unreadStats.markedUnread) {
window.IPC.setBadge('marked-unread');
window.IPC.updateTrayIcon(1);
window.document.title = `${window.getTitle()} (1)`;
const total =
unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount;
window.IPC.setBadge(total);
window.IPC.updateTrayIcon(total);
window.document.title = `${window.getTitle()} (${total})`;
} else if (unreadStats.readChatsMarkedUnreadCount > 0) {
const total = unreadStats.readChatsMarkedUnreadCount;
window.IPC.setBadge(total);
window.IPC.updateTrayIcon(total);
window.document.title = `${window.getTitle()} (${total})`;
} else {
window.IPC.setBadge(0);
window.IPC.updateTrayIcon(0);

View file

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

View file

@ -27,7 +27,7 @@ const Namespace = 'AriaClickable';
* <AriaClickable.HiddenTrigger aria-labelledby="see-more-1"/>
* </p>
* <AriaClickable.SubWidget>
* <AxoButton>Delete</AxoButton>
* <AxoButton.Root>Delete</AxoButton.Root>
* </AriaClickable.SubWidget>
* <AriaClickable.SubWidget>
* <AxoLink>Edit</AxoLink>
@ -36,7 +36,6 @@ const Namespace = 'AriaClickable';
* );
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AriaClickable {
type TriggerState = Readonly<{
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 => {
return (
<div key={variant} className={tw('flex gap-1')}>
<AxoButton
<AxoButton.Root
variant={variant}
size={size}
onClick={action('click')}
>
{variant}
</AxoButton>
</AxoButton.Root>
<AxoButton
<AxoButton.Root
variant={variant}
size={size}
onClick={action('click')}
disabled
>
Disabled
</AxoButton>
</AxoButton.Root>
<AxoButton
<AxoButton.Root
symbol="info"
variant={variant}
size={size}
onClick={action('click')}
>
Icon
</AxoButton>
</AxoButton.Root>
<AxoButton
<AxoButton.Root
symbol="info"
variant={variant}
size={size}
@ -60,18 +60,18 @@ export function Basic(): JSX.Element {
disabled
>
Disabled
</AxoButton>
</AxoButton.Root>
<AxoButton
<AxoButton.Root
arrow
variant={variant}
size={size}
onClick={action('click')}
>
Arrow
</AxoButton>
</AxoButton.Root>
<AxoButton
<AxoButton.Root
arrow
variant={variant}
size={size}
@ -79,7 +79,7 @@ export function Basic(): JSX.Element {
disabled
>
Disabled
</AxoButton>
</AxoButton.Root>
</div>
);
})}

View file

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

View file

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

View file

@ -7,51 +7,46 @@ import { tw } from './tw.js';
const Namespace = 'AxoCheckbox';
type AxoCheckboxProps = Readonly<{
id?: string;
checked: boolean;
onCheckedChange: (nextChecked: boolean) => void;
disabled?: boolean;
required?: boolean;
}>;
export namespace AxoCheckbox {
export type RootProps = Readonly<{
id?: string;
checked: boolean;
onCheckedChange: (nextChecked: boolean) => void;
disabled?: boolean;
required?: boolean;
}>;
// eslint-disable-next-line import/export
export const AxoCheckbox = memo((props: AxoCheckboxProps) => {
return (
<Checkbox.Root
id={props.id}
checked={props.checked}
onCheckedChange={props.onCheckedChange}
disabled={props.disabled}
required={props.required}
className={tw(
'flex size-5 items-center justify-center rounded-full',
'border border-border-primary inset-shadow-on-color',
'data-[state=unchecked]:bg-fill-primary',
'data-[state=unchecked]:pressed:bg-fill-primary-pressed',
'data-[state=checked]:bg-color-fill-primary',
'data-[state=checked]:pressed:bg-color-fill-primary-pressed',
'data-[disabled]:border-border-secondary',
'outline-0 outline-border-focused focused:outline-[2.5px]',
'overflow-hidden'
)}
>
<Checkbox.Indicator
export const Root = memo((props: RootProps) => {
return (
<Checkbox.Root
id={props.id}
checked={props.checked}
onCheckedChange={props.onCheckedChange}
disabled={props.disabled}
required={props.required}
className={tw(
'data-[state=checked]:text-label-primary-on-color',
'data-[state=checked]:data-[disabled]:text-label-disabled-on-color'
'flex size-5 items-center justify-center rounded-full',
'border border-border-primary inset-shadow-on-color',
'data-[state=unchecked]:bg-fill-primary',
'data-[state=unchecked]:pressed:bg-fill-primary-pressed',
'data-[state=checked]:bg-color-fill-primary',
'data-[state=checked]:pressed:bg-color-fill-primary-pressed',
'data-[disabled]:border-border-secondary',
'outline-0 outline-border-focused focused:outline-[2.5px]',
'overflow-hidden'
)}
>
<AxoSymbol.Icon symbol="check" size={14} label={null} />
</Checkbox.Indicator>
</Checkbox.Root>
);
});
<Checkbox.Indicator
className={tw(
'data-[state=checked]:text-label-primary-on-color',
'data-[state=checked]:data-[disabled]:text-label-disabled-on-color'
)}
>
<AxoSymbol.Icon symbol="check" size={14} label={null} />
</Checkbox.Indicator>
</Checkbox.Root>
);
});
AxoCheckbox.displayName = `${Namespace}`;
// 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;
Root.displayName = `${Namespace}.Root`;
}

View file

@ -48,7 +48,6 @@ const Namespace = 'AxoContextMenu';
* )
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoContextMenu {
/**
* Component: <AxoContextMenu.Root>
@ -71,7 +70,7 @@ export namespace AxoContextMenu {
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
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`;
@ -247,7 +246,7 @@ export namespace AxoContextMenu {
export const RadioGroup: FC<RadioGroupProps> = memo(props => {
return (
<ContextMenu.RadioGroup
value={props.value}
value={props.value ?? undefined}
onValueChange={props.onValueChange}
className={AxoBaseMenu.menuRadioGroupStyles}
>
@ -283,7 +282,11 @@ export namespace AxoContextMenu {
</AxoBaseMenu.ItemCheckPlaceholder>
</AxoBaseMenu.ItemLeadingSlot>
<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>
{props.keyboardShortcut && (
<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')}>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoButton variant="secondary" size="medium">
<AxoButton.Root variant="secondary" size="medium">
Open Dropdown Menu
</AxoButton>
</AxoButton.Root>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item

View file

@ -51,7 +51,6 @@ const Namespace = 'AxoDropdownMenu';
* )
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoDropdownMenu {
/**
* Component: <AxoDropdownMenu.Root>
@ -261,7 +260,7 @@ export namespace AxoDropdownMenu {
export const RadioGroup: FC<RadioGroupProps> = memo(props => {
return (
<DropdownMenu.RadioGroup
value={props.value}
value={props.value ?? undefined}
onValueChange={props.onValueChange}
className={AxoBaseMenu.menuRadioGroupStyles}
>
@ -297,7 +296,11 @@ export namespace AxoDropdownMenu {
</AxoBaseMenu.ItemCheckPlaceholder>
</AxoBaseMenu.ItemLeadingSlot>
<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>
{props.keyboardShortcut && (
<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
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useState } from 'react';
import type { Meta } from '@storybook/react';
import { AxoSelect } from './AxoSelect.js';
@ -9,6 +10,18 @@ export default {
title: 'Axo/AxoSelect',
} 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: {
disabled?: boolean;
triggerWidth?: AxoSelect.TriggerWidth;
@ -29,29 +42,29 @@ function Template(props: {
<AxoSelect.Content>
<AxoSelect.Group>
<AxoSelect.Label>Fruits</AxoSelect.Label>
<AxoSelect.Item value="apple">Apple</AxoSelect.Item>
<AxoSelect.Item value="banana">Banana</AxoSelect.Item>
<AxoSelect.Item value="blueberry">Blueberry</AxoSelect.Item>
<AxoSelect.Item value="grapes">Grapes</AxoSelect.Item>
<AxoSelect.Item value="pineapple">Pineapple</AxoSelect.Item>
<TemplateItem value="apple">Apple</TemplateItem>
<TemplateItem value="banana">Banana</TemplateItem>
<TemplateItem value="blueberry">Blueberry</TemplateItem>
<TemplateItem value="grapes">Grapes</TemplateItem>
<TemplateItem value="pineapple">Pineapple</TemplateItem>
</AxoSelect.Group>
<AxoSelect.Separator />
<AxoSelect.Group>
<AxoSelect.Label>Vegetables</AxoSelect.Label>
<AxoSelect.Item value="aubergine">Aubergine</AxoSelect.Item>
<AxoSelect.Item value="broccoli">Broccoli</AxoSelect.Item>
<AxoSelect.Item value="carrot" disabled>
<TemplateItem value="aubergine">Aubergine</TemplateItem>
<TemplateItem value="broccoli">Broccoli</TemplateItem>
<TemplateItem value="carrot" disabled>
Carrot
</AxoSelect.Item>
<AxoSelect.Item value="leek">Leek</AxoSelect.Item>
</TemplateItem>
<TemplateItem value="leek">Leek</TemplateItem>
</AxoSelect.Group>
<AxoSelect.Separator />
<AxoSelect.Group>
<AxoSelect.Label>Meat</AxoSelect.Label>
<AxoSelect.Item value="beef">Beef</AxoSelect.Item>
<AxoSelect.Item value="chicken">Chicken</AxoSelect.Item>
<AxoSelect.Item value="lamb">Lamb</AxoSelect.Item>
<AxoSelect.Item value="pork">Pork</AxoSelect.Item>
<TemplateItem value="beef">Beef</TemplateItem>
<TemplateItem value="chicken">Chicken</TemplateItem>
<TemplateItem value="lamb">Lamb</TemplateItem>
<TemplateItem value="pork">Pork</TemplateItem>
</AxoSelect.Group>
</AxoSelect.Content>
</AxoSelect.Root>

View file

@ -7,6 +7,7 @@ import { AxoBaseMenu } from './_internal/AxoBaseMenu.js';
import { AxoSymbol } from './AxoSymbol.js';
import type { TailwindStyles } from './tw.js';
import { tw } from './tw.js';
import { ExperimentalAxoBadge } from './AxoBadge.js';
const Namespace = 'AxoSelect';
@ -19,18 +20,22 @@ const Namespace = 'AxoSelect';
* <AxoSelect.Root>
* <AxoSelect.Trigger/>
* <AxoSelect.Content>
* <AxoSelect.Item/>
* <AxoSelect.Item>
* <AxoSelect.ItemText/>
* <AxoSelect.ItemBadge/>
* </AxoSelect.Item>
* <AxoSelect.Separator/>
* <AxoSelect.Group>
* <AxoSelect.Label/>
* <AxoSelect.Item/>
* <AxoSelect.Item>
* <AxoSelect.ItemText/>
* </AxoSelect.Item>
* </AxoSelect.Group>
* </AxoSelect.Content>
* </AxoSelect.Root>
* );
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoSelect {
/**
* 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(
'flex',
'rounded-full py-[5px] ps-3 pe-2.5 type-body-medium text-label-primary',
'group relative flex items-center',
'rounded-full text-start type-body-medium text-label-primary',
'disabled:text-label-disabled',
'outline-0 outline-border-focused focused:outline-[2.5px]',
'forced-colors:border'
);
const TriggerVariants = {
const TriggerVariants: Record<TriggerVariant, TailwindStyles> = {
default: tw(
baseTriggerStyles,
'bg-fill-secondary',
@ -104,19 +113,52 @@ export namespace AxoSelect {
'hovered:bg-fill-secondary',
'pressed:bg-fill-secondary-pressed'
),
} as const satisfies Record<string, TailwindStyles>;
};
const TriggerWidths = {
const TriggerWidths: Record<TriggerWidth, TailwindStyles> = {
hug: tw(),
full: tw('w-full'),
};
export type TriggerVariant = keyof typeof TriggerVariants;
export type TriggerWidth = keyof typeof TriggerWidths;
type TriggerChevronConfig = {
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<{
variant?: TriggerVariant;
width?: TriggerWidth;
chevron?: TriggerChevron;
placeholder: string;
children?: ReactNode;
}>;
@ -129,16 +171,20 @@ export namespace AxoSelect {
export const Trigger: FC<TriggerProps> = memo(props => {
const variant = props.variant ?? 'default';
const width = props.width ?? 'hug';
const chevron = props.chevron ?? 'always';
const variantStyles = TriggerVariants[variant];
const widthStyles = TriggerWidths[width];
const chevronConfig = TriggerChevrons[chevron];
return (
<Select.Trigger className={tw(variantStyles, widthStyles)}>
<AxoBaseMenu.ItemText>
<Select.Value placeholder={props.placeholder}>
{props.children}
</Select.Value>
</AxoBaseMenu.ItemText>
<Select.Icon className={tw('ms-2')}>
<div className={chevronConfig.contentStyles}>
<AxoBaseMenu.ItemText>
<Select.Value placeholder={props.placeholder}>
{props.children}
</Select.Value>
</AxoBaseMenu.ItemText>
</div>
<Select.Icon className={chevronConfig.chevronStyles}>
<AxoSymbol.Icon symbol="chevron-down" size={14} label={null} />
</Select.Icon>
</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<{
position?: ContentPosition;
children: ReactNode;
}>;
@ -161,9 +229,17 @@ export namespace AxoSelect {
* Uses a portal to render the content part into the `body`.
*/
export const Content: FC<ContentProps> = memo(props => {
const position = props.position ?? 'item-aligned';
const positionConfig = ContentPositions[position];
return (
<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
className={tw(
'flex items-center justify-center p-1 text-label-primary'
@ -197,6 +273,7 @@ export namespace AxoSelect {
value: string;
disabled?: boolean;
textValue?: string;
symbol?: AxoSymbol.IconName;
children: ReactNode;
}>;
@ -219,9 +296,12 @@ export namespace AxoSelect {
</AxoBaseMenu.ItemCheckPlaceholder>
</AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemContentSlot>
<AxoBaseMenu.ItemText>
<Select.ItemText>{props.children}</Select.ItemText>
</AxoBaseMenu.ItemText>
{props.symbol && (
<span className={tw('me-2')}>
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
</span>
)}
{props.children}
</AxoBaseMenu.ItemContentSlot>
</Select.Item>
);
@ -229,9 +309,55 @@ export namespace AxoSelect {
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>
* ---------------------------
* ----------------------------
*/
export type GroupProps = Readonly<{

View file

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

View file

@ -7,77 +7,72 @@ import { AxoSymbol } from './AxoSymbol.js';
const Namespace = 'AxoSwitch';
type AxoSwitchProps = Readonly<{
checked: boolean;
onCheckedChange: (nextChecked: boolean) => void;
disabled?: boolean;
required?: boolean;
}>;
export namespace AxoSwitch {
export type RootProps = Readonly<{
checked: boolean;
onCheckedChange: (nextChecked: boolean) => void;
disabled?: boolean;
required?: boolean;
}>;
// eslint-disable-next-line import/export
export const AxoSwitch = memo((props: AxoSwitchProps) => {
return (
<Switch.Root
checked={props.checked}
onCheckedChange={props.onCheckedChange}
disabled={props.disabled}
required={props.required}
className={tw(
'group relative z-0 flex h-[18px] w-8 items-center rounded-full',
'border border-border-secondary inset-shadow-on-color',
'bg-fill-secondary',
'data-[disabled]:bg-fill-primary',
'pressed:bg-fill-secondary-pressed',
'outline-0 outline-border-focused focused:outline-[2.5px]',
'overflow-hidden'
)}
>
<span
export const Root = memo((props: RootProps) => {
return (
<Switch.Root
checked={props.checked}
onCheckedChange={props.onCheckedChange}
disabled={props.disabled}
required={props.required}
className={tw(
'absolute top-0 bottom-0',
'w-5.5 rounded-s-full',
'group-data-[disabled]:w-7.5 group-data-[disabled]:rounded-full',
'opacity-0 group-data-[state=checked]:opacity-100',
'-translate-x-3.5 group-data-[state=checked]:translate-x-0 rtl:translate-x-3.5',
'bg-color-fill-primary group-pressed:bg-color-fill-primary-pressed',
'transition-all duration-200 ease-out-cubic',
'forced-colors:bg-[AccentColor]',
'forced-colors:group-data-[disabled]:bg-[GrayText]'
)}
/>
<span
className={tw(
'invisible forced-colors:visible',
'absolute start-0.5 z-0 text-[12px]',
'forced-color-adjust-none',
'forced-colors:text-[AccentColorText]'
'group relative z-0 flex h-[18px] w-8 items-center rounded-full',
'border border-border-secondary inset-shadow-on-color',
'bg-fill-secondary',
'data-[disabled]:bg-fill-primary',
'pressed:bg-fill-secondary-pressed',
'outline-0 outline-border-focused focused:outline-[2.5px]',
'overflow-hidden'
)}
>
<AxoSymbol.InlineGlyph symbol="check" label={null} />
</span>
<Switch.Thumb
className={tw(
'z-10 block size-4 rounded-full',
// eslint-disable-next-line better-tailwindcss/no-restricted-classes
'shadow-[#000]/12',
'shadow-[0.5px_0_0.5px_0.5px,-0.5px_0_0.5px_0.5px]',
'bg-label-primary-on-color',
'data-[disabled]:bg-label-disabled-on-color',
'transition-all duration-200 ease-out-cubic',
'data-[state=checked]:translate-x-3.5',
'rtl:data-[state=checked]:-translate-x-3.5',
'forced-colors:border',
'forced-colors:data-[disabled]:bg-[ButtonFace]'
)}
/>
</Switch.Root>
);
});
<span
className={tw(
'absolute top-0 bottom-0',
'w-5.5 rounded-s-full',
'group-data-[disabled]:w-7.5 group-data-[disabled]:rounded-full',
'opacity-0 group-data-[state=checked]:opacity-100',
'-translate-x-3.5 group-data-[state=checked]:translate-x-0 rtl:translate-x-3.5',
'bg-color-fill-primary group-pressed:bg-color-fill-primary-pressed',
'transition-all duration-200 ease-out-cubic',
'forced-colors:bg-[AccentColor]',
'forced-colors:group-data-[disabled]:bg-[GrayText]'
)}
/>
<span
className={tw(
'invisible forced-colors:visible',
'absolute start-0.5 z-0 text-[12px]',
'forced-color-adjust-none',
'forced-colors:text-[AccentColorText]'
)}
>
<AxoSymbol.InlineGlyph symbol="check" label={null} />
</span>
<Switch.Thumb
className={tw(
'z-10 block size-4 rounded-full',
// eslint-disable-next-line better-tailwindcss/no-restricted-classes
'shadow-[#000]/12',
'shadow-[0.5px_0_0.5px_0.5px,-0.5px_0_0.5px_0.5px]',
'bg-label-primary-on-color',
'data-[disabled]:bg-label-disabled-on-color',
'transition-all duration-200 ease-out-cubic',
'data-[state=checked]:translate-x-3.5',
'rtl:data-[state=checked]:-translate-x-3.5',
'forced-colors:border',
'forced-colors:data-[disabled]:bg-[ButtonFace]'
)}
/>
</Switch.Root>
);
});
AxoSwitch.displayName = `${Namespace}`;
// 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;
Root.displayName = `${Namespace}.Root`;
}

View file

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

View file

@ -5,7 +5,6 @@ import type { ReactNode } from 'react';
import { tw } from '../tw.js';
import { AxoSymbol } from '../AxoSymbol.js';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoBaseMenu {
// <Content/SubContent>
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<{
children: ReactNode;
@ -283,7 +282,7 @@ export namespace AxoBaseMenu {
/**
* The value of the selected item in the group.
*/
value: string;
value: string | null;
/**
* 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;
}
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: {
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
readChatsMarkedUnreadCount: 0,
},
isStaging: false,
hasPendingUpdate: false,

View file

@ -373,17 +373,19 @@ export const ConversationMessageRequest = (): JSX.Element =>
export function ConversationsUnreadCount(): JSX.Element {
return (
<Wrapper
rows={[4, 10, 34, 250, 2048].map(unreadCount => ({
type: RowType.Conversation,
conversation: createConversation({
lastMessage: {
text: 'Hey there!',
status: 'delivered',
deletedForEveryone: false,
},
unreadCount,
}),
}))}
rows={[4, 10, 34, 250, 2048, Number.MAX_SAFE_INTEGER].map(
unreadCount => ({
type: RowType.Conversation,
conversation: createConversation({
lastMessage: {
text: 'Hey there!',
status: 'delivered',
deletedForEveryone: false,
},
unreadCount,
}),
})
)}
/>
);
}

View file

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

View file

@ -34,6 +34,8 @@ import {
} from '../test-helpers/fakeLookupConversationWithoutServiceId.js';
import type { GroupListItemConversationType } from './conversationList/GroupListItem.js';
import { ServerAlert } from '../util/handleServerAlerts.js';
import { LeftPaneChatFolders } from './leftPane/LeftPaneChatFolders.js';
import { LeftPaneConversationListItemContextMenu } from './leftPane/LeftPaneConversationListItemContextMenu.js';
const { i18n } = window.SignalContext;
@ -144,7 +146,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
otherTabsUnreadStats: {
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
readChatsMarkedUnreadCount: 0,
},
backupMediaDownloadProgress: {
isBackupMediaEnabled: true,
@ -298,6 +300,38 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
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,
targetedMessageId: undefined,
openUsernameReservationModal: action('openUsernameReservationModal'),

View file

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

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
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 { useMove } from 'react-aria';
import { NavTabsToggle } from './NavTabs.js';
@ -16,6 +16,9 @@ import {
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util.js';
import type { UnreadStats } from '../util/countUnreadStats.js';
export const NavSidebarWidthBreakpointContext =
createContext<WidthBreakpoint | null>(null);
type NavSidebarActionButtonProps = {
icon: ReactNode;
label: ReactNode;
@ -158,76 +161,79 @@ export function NavSidebar({
}, [dragState]);
return (
<div
role="navigation"
className={classNames('NavSidebar', {
'NavSidebar--narrow': widthBreakpoint === WidthBreakpoint.Narrow,
})}
style={{ width }}
>
{!hideHeader && (
<div className="NavSidebar__Header">
{onBack == null && navTabsCollapsed && (
<NavTabsToggle
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
otherTabsUnreadStats={otherTabsUnreadStats}
/>
)}
<div
className={classNames('NavSidebar__HeaderContent', {
'NavSidebar__HeaderContent--navTabsCollapsed': navTabsCollapsed,
'NavSidebar__HeaderContent--withBackButton': onBack != null,
})}
>
{onBack != null && (
<button
type="button"
role="link"
onClick={onBack}
className="NavSidebar__BackButton"
>
<span className="NavSidebar__BackButtonLabel">
{i18n('icu:NavSidebar__BackButtonLabel')}
</span>
</button>
)}
<h1
className={classNames('NavSidebar__HeaderTitle', {
'NavSidebar__HeaderTitle--withBackButton': onBack != null,
})}
aria-live="assertive"
>
{title}
</h1>
{actions && (
<div className="NavSidebar__HeaderActions">{actions}</div>
)}
</div>
</div>
)}
<div className="NavSidebar__Content">{children}</div>
<NavSidebarWidthBreakpointContext.Provider value={widthBreakpoint}>
<div
className={classNames('NavSidebar__DragHandle', {
'NavSidebar__DragHandle--dragging': dragState === DragState.DRAGGING,
role="navigation"
className={classNames('NavSidebar', {
'NavSidebar--narrow': widthBreakpoint === WidthBreakpoint.Narrow,
})}
role="separator"
aria-orientation="vertical"
aria-valuemin={MIN_WIDTH}
aria-valuemax={preferredLeftPaneWidth}
aria-valuenow={MAX_WIDTH}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/separator_role#focusable_separator
tabIndex={0}
{...moveProps}
/>
style={{ width }}
>
{!hideHeader && (
<div className="NavSidebar__Header">
{onBack == null && navTabsCollapsed && (
<NavTabsToggle
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
otherTabsUnreadStats={otherTabsUnreadStats}
/>
)}
<div
className={classNames('NavSidebar__HeaderContent', {
'NavSidebar__HeaderContent--navTabsCollapsed': navTabsCollapsed,
'NavSidebar__HeaderContent--withBackButton': onBack != null,
})}
>
{onBack != null && (
<button
type="button"
role="link"
onClick={onBack}
className="NavSidebar__BackButton"
>
<span className="NavSidebar__BackButtonLabel">
{i18n('icu:NavSidebar__BackButtonLabel')}
</span>
</button>
)}
<h1
className={classNames('NavSidebar__HeaderTitle', {
'NavSidebar__HeaderTitle--withBackButton': onBack != null,
})}
aria-live="assertive"
>
{title}
</h1>
{actions && (
<div className="NavSidebar__HeaderActions">{actions}</div>
)}
</div>
</div>
)}
{renderToastManager({ containerWidthBreakpoint: widthBreakpoint })}
</div>
<div className="NavSidebar__Content">{children}</div>
<div
className={classNames('NavSidebar__DragHandle', {
'NavSidebar__DragHandle--dragging':
dragState === DragState.DRAGGING,
})}
role="separator"
aria-orientation="vertical"
aria-valuemin={MIN_WIDTH}
aria-valuemax={preferredLeftPaneWidth}
aria-valuenow={MAX_WIDTH}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/separator_role#focusable_separator
tabIndex={0}
{...moveProps}
/>
{renderToastManager({ containerWidthBreakpoint: widthBreakpoint })}
</div>
</NavSidebarWidthBreakpointContext.Provider>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -38,8 +38,8 @@ import {
MessageRequestState,
} from './MessageRequestActionsConfirmation.js';
import type { MinimalConversation } from '../../hooks/useMinimalConversation.js';
import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal.js';
import { InAnotherCallTooltip } from './InAnotherCallTooltip.js';
import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.js';
function HeaderInfoTitle({
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 ? (
<div className={tw('mt-4 flex justify-center scheme-light')}>
<AxoButton
<AxoButton.Root
size="medium"
variant="floating-secondary"
onClick={() => setShowVotesModal(true)}
>
{i18n('icu:PollMessage__ViewVotesButton')}
</AxoButton>
</AxoButton.Root>
</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`;
export const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`;
export type RenderConversationListItemContextMenuProps = Readonly<{
conversationId: string;
children: ReactNode;
}>;
type PropsType = {
buttonAriaLabel?: string;
checked?: boolean;
@ -58,6 +63,9 @@ type PropsType = {
unreadMentionsCount?: number;
avatarSize?: AvatarSize;
testId?: string;
renderConversationListItemContextMenu?: (
props: RenderConversationListItemContextMenuProps
) => JSX.Element;
} & Pick<
ConversationType,
| 'avatarPlaceholderGradient'
@ -114,6 +122,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
unreadCount,
unreadMentionsCount,
serviceId,
renderConversationListItemContextMenu,
} = props;
const identifier = id ? cleanId(id) : undefined;
@ -275,8 +284,10 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
);
}
let wrapper: JSX.Element;
if (onClick) {
return (
wrapper = (
<button
aria-label={
buttonAriaLabel ||
@ -298,17 +309,26 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
{contents}
</button>
);
} else {
wrapper = (
<div
className={commonClassNames}
data-id={identifier}
data-testid={testId}
>
{contents}
</div>
);
}
return (
<div
className={commonClassNames}
data-id={identifier}
data-testid={testId}
>
{contents}
</div>
);
if (renderConversationListItemContextMenu != null && id != null) {
return renderConversationListItemContextMenu({
conversationId: id,
children: wrapper,
});
}
return wrapper;
});
function Timestamp({

View file

@ -5,6 +5,7 @@ import type { FunctionComponent, ReactNode } from 'react';
import React, { useCallback } from 'react';
import classNames from 'classnames';
import type { RenderConversationListItemContextMenuProps } from './BaseConversationListItem.js';
import {
BaseConversationListItem,
HEADER_NAME_CLASS_NAME,
@ -77,6 +78,9 @@ type PropsHousekeeping = {
onClick: (id: string) => void;
onMouseDown: (id: string) => void;
theme: ThemeType;
renderConversationListItemContextMenu?: (
props: RenderConversationListItemContextMenuProps
) => JSX.Element;
};
export type Props = PropsData & PropsHousekeeping;
@ -115,6 +119,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
unreadCount,
unreadMentionsCount,
serviceId,
renderConversationListItemContextMenu,
}) {
const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt);
const isSomeoneTyping =
@ -243,6 +248,9 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
unreadCount={unreadCount}
unreadMentionsCount={unreadMentionsCount}
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;
i18n: LocalizerType;
removeSelectedContact: (_: string) => unknown;
renderLeftPaneChatFolders: () => JSX.Element;
setComposeGroupAvatar: (_: undefined | Uint8Array) => unknown;
setComposeGroupExpireTimer: (_: DurationInSeconds) => void;
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({
i18n,
}: 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
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useMemo } from 'react';
import type { MutableRefObject } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { MutableRefObject, ReactNode } from 'react';
import { ListBox, ListBoxItem, useDragAndDrop } from 'react-aria-components';
import { partition } from 'lodash';
import type { LocalizerType } from '../../../types/I18N.js';
import { PreferencesContent } from '../../Preferences.js';
import { SettingsRow } from '../../PreferencesUtil.js';
@ -18,30 +20,128 @@ import type {
ChatFolder,
} from '../../../types/ChatFolder.js';
import { Button, ButtonVariant } from '../../Button.js';
import { AxoContextMenu } from '../../../axo/AxoContextMenu.js';
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
import { strictAssert } from '../../../util/assert.js';
import { tw } from '../../../axo/tw.js';
// import { showToast } from '../../state/ducks/toast';
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<{
i18n: LocalizerType;
onBack: () => void;
onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void;
chatFolders: ReadonlyArray<ChatFolder>;
onCreateChatFolder: (params: ChatFolderParams) => void;
onDeleteChatFolder: (chatFolderId: ChatFolderId) => void;
onUpdateChatFoldersPositions: (
chatFolderIds: ReadonlyArray<ChatFolderId>
) => void;
settingsPaneRef: MutableRefObject<HTMLDivElement | null>;
}>;
export function PreferencesChatFoldersPage(
props: PreferencesChatFoldersPageProps
): JSX.Element {
const { i18n, onOpenEditChatFoldersPage, chatFolders } = props;
const {
i18n,
onOpenEditChatFoldersPage,
onDeleteChatFolder,
onUpdateChatFoldersPositions,
chatFolders,
} = props;
const [confirmDeleteChatFolder, setConfirmDeleteChatFolder] =
useState<ChatFolder | null>(null);
// showToast(
// i18n("icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast")
// )
const handleOpenEditChatFoldersPageForNew = useCallback(() => {
const handleChatFolderCreate = useCallback(() => {
onOpenEditChatFoldersPage(null);
}, [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 initial: ReadonlyArray<ChatFolderPresetItemConfig> = [
{
@ -86,79 +186,106 @@ export function PreferencesChatFoldersPage(
}, [i18n, chatFolders]);
return (
<PreferencesContent
backButton={
<button
type="button"
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={props.onBack}
/>
}
contents={
<>
<p className="Preferences__description Preferences__padding">
{i18n('icu:Preferences__ChatFoldersPage__Description')}
</p>
<SettingsRow
title={i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__Title'
)}
>
<ul data-testid="ChatFoldersList">
<li>
<button
type="button"
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
onClick={handleOpenEditChatFoldersPageForNew}
>
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__CreateAFolderButton'
)}
</span>
</button>
</li>
{props.chatFolders.map(chatFolder => {
return (
<ChatFolderListItem
key={chatFolder.id}
i18n={i18n}
chatFolder={chatFolder}
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
/>
);
})}
</ul>
</SettingsRow>
{presetItemsConfigs.length > 0 && (
<>
<PreferencesContent
backButton={
<button
type="button"
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={props.onBack}
/>
}
contents={
<>
<p className="Preferences__description Preferences__padding">
{i18n('icu:Preferences__ChatFoldersPage__Description')}
</p>
<SettingsRow
title={i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__Title'
'icu:Preferences__ChatFoldersPage__FoldersSection__Title'
)}
className={tw('mt-4')}
>
<ul
data-testid="ChatFoldersPresets"
className="Preferences__ChatFolders__ChatSelection__List"
<button
type="button"
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
onClick={handleChatFolderCreate}
>
{presetItemsConfigs.map(presetItemConfig => {
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
{i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__CreateAFolderButton'
)}
</span>
</button>
<ListBox
selectionMode="single"
data-testid="ChatFoldersList"
items={chatFoldersReordered}
dragAndDropHooks={dragAndDropHooks}
>
{chatFolder => {
return (
<ChatFolderPresetItem
<ChatFolderListItem
i18n={i18n}
config={presetItemConfig}
onCreateChatFolder={props.onCreateChatFolder}
chatFolder={chatFolder}
onChatFolderEdit={handleChatFolderEdit}
onChatFolderDelete={handleChatFolderDeleteInit}
/>
);
})}
</ul>
}}
</ListBox>
</SettingsRow>
{presetItemsConfigs.length > 0 && (
<SettingsRow
title={i18n(
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__Title'
)}
>
<ul
data-testid="ChatFoldersPresets"
className="Preferences__ChatFolders__ChatSelection__List"
>
{presetItemsConfigs.map(presetItemConfig => {
return (
<ChatFolderPresetItem
key={presetItemConfig.id}
i18n={i18n}
config={presetItemConfig}
onCreateChatFolder={props.onCreateChatFolder}
/>
);
})}
</ul>
</SettingsRow>
)}
</>
}
contentsRef={props.settingsPaneRef}
title={i18n('icu:Preferences__ChatFoldersPage__Title')}
/>
{confirmDeleteChatFolder != null && (
<DeleteChatFolderDialog
i18n={i18n}
title={i18n(
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title'
)}
</>
}
contentsRef={props.settingsPaneRef}
title={i18n('icu:Preferences__ChatFoldersPage__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: {
i18n: LocalizerType;
chatFolder: ChatFolder;
onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId) => void;
onChatFolderEdit: (chatFolder: ChatFolder) => void;
onChatFolderDelete: (chatFolder: ChatFolder) => void;
}): JSX.Element {
const { i18n, chatFolder, onOpenEditChatFoldersPage } = props;
const { i18n, chatFolder, onChatFolderEdit } = props;
const handleAction = useCallback(() => {
onOpenEditChatFoldersPage(chatFolder.id);
}, [chatFolder, onOpenEditChatFoldersPage]);
const handleClickChatFolder = useCallback(() => {
onChatFolderEdit(chatFolder);
}, [chatFolder, onChatFolderEdit]);
return (
<li>
<button
type="button"
data-testid={`ChatFolder--${chatFolder.id}`}
onClick={handleAction}
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__ItemTitle">
{props.chatFolder.folderType === ChatFolderType.ALL &&
i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
)}
{props.chatFolder.folderType === ChatFolderType.CUSTOM && (
<>{props.chatFolder.name}</>
<>
{props.chatFolder.folderType === ChatFolderType.ALL && (
<ListBoxItem
id={chatFolder.id}
data-testid={`ChatFolder--${chatFolder.id}`}
className="Preferences__ChatFolders__ChatSelection__Item"
>
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
{i18n(
'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
)}
</span>
</button>
</li>
</ListBoxItem>
)}
{props.chatFolder.folderType === ChatFolderType.CUSTOM && (
<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: {
// i18n: LocalizerType;
// children: ReactNode;
// }) {
// const { i18n } = props;
// return (
// <AxoContextMenu.Root>
// <AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
// <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 ChatFolderListItemContextMenu(props: {
i18n: LocalizerType;
chatFolder: ChatFolder;
onChatFolderEdit: (chatFolder: ChatFolder) => void;
onChatFolderDelete: (chatFolder: ChatFolder) => void;
children: ReactNode;
}) {
const { i18n, chatFolder, onChatFolderEdit, onChatFolderDelete } = props;
// function DeleteChatFolderDialog(props: { i18n: LocalizerType }): JSX.Element {
// const { i18n } = props;
// return (
// <ConfirmationDialog
// i18n={i18n}
// dialogName="Preferences__ChatsPage__DeleteChatFolderDialog"
// title={i18n('icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title')}
// cancelText={i18n(
// 'icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton'
// )}
// actions={[
// {
// text: i18n(
// 'icu:Preferences__ChatsPage__DeleteChatFolderDialog__DeleteButton'
// ),
// style: 'affirmative',
// action: () => null,
// },
// ]}
// onClose={() => null}
// >
// {i18n('icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description', {
// chatFolderTitle: '',
// })}
// </ConfirmationDialog>
// );
// }
const handleSelectChatFolderEdit = useCallback(() => {
onChatFolderEdit(chatFolder);
}, [chatFolder, onChatFolderEdit]);
const handleSelectChatFolderDelete = useCallback(() => {
onChatFolderDelete(chatFolder);
}, [chatFolder, onChatFolderDelete]);
if (chatFolder.folderType !== ChatFolderType.CUSTOM) {
return <>{props.children}</>;
}
return (
<AxoContextMenu.Root>
<AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
<AxoContextMenu.Content>
<AxoContextMenu.Item
symbol="pencil"
onSelect={handleSelectChatFolderEdit}
>
{i18n(
'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder'
)}
</AxoContextMenu.Item>
<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
// SPDX-License-Identifier: AGPL-3.0-only
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 { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js';
import type { LocalizerType } from '../../../types/I18N.js';
@ -28,12 +28,17 @@ import type {
import type { GetConversationByIdType } from '../../../state/selectors/conversations.js';
import { strictAssert } from '../../../util/assert.js';
import { parseStrict } from '../../../util/schemas.js';
import { BeforeNavigateResponse } from '../../../services/BeforeNavigate.js';
import { type Location } from '../../../types/Nav.js';
import { useNavBlocker } from '../../../hooks/useNavBlocker.js';
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
export type PreferencesEditChatFolderPageProps = Readonly<{
i18n: LocalizerType;
previousLocation: Location;
existingChatFolderId: ChatFolderId | null;
initChatFolderParams: ChatFolderParams;
onBack: () => void;
changeLocation: (location: Location) => void;
conversations: ReadonlyArray<ConversationType>;
preferredBadgeSelector: PreferredBadgeSelectorType;
theme: ThemeType;
@ -52,12 +57,13 @@ export function PreferencesEditChatFolderPage(
): JSX.Element {
const {
i18n,
previousLocation,
initChatFolderParams,
existingChatFolderId,
onCreateChatFolder,
onUpdateChatFolder,
onDeleteChatFolder,
onBack,
changeLocation,
conversationSelector,
} = props;
@ -67,7 +73,6 @@ export function PreferencesEditChatFolderPage(
const [showInclusionsDialog, setShowInclusionsDialog] = useState(false);
const [showExclusionsDialog, setShowExclusionsDialog] = useState(false);
const [showDeleteFolderDialog, setShowDeleteFolderDialog] = useState(false);
const [showSaveChangesDialog, setShowSaveChangesDialog] = useState(false);
const normalizedChatFolderParams = useMemo(() => {
return parseStrict(ChatFolderParamsSchema, chatFolderParams);
@ -80,6 +85,12 @@ export function PreferencesEditChatFolderPage(
);
}, [initChatFolderParams, normalizedChatFolderParams]);
const didSaveOrDiscardChangesRef = useRef(false);
const blocker = useNavBlocker('PreferencesEditChatFoldersPage', () => {
return isChanged && !didSaveOrDiscardChangesRef.current;
});
const isValid = useMemo(() => {
return validateChatFolderParams(normalizedChatFolderParams);
}, [normalizedChatFolderParams]);
@ -102,23 +113,16 @@ export function PreferencesEditChatFolderPage(
});
}, []);
const handleBackInit = useCallback(() => {
if (!isChanged) {
onBack();
} else {
setShowSaveChangesDialog(true);
}
}, [isChanged, onBack]);
const handleBack = useCallback(() => {
changeLocation(previousLocation);
}, [changeLocation, previousLocation]);
const handleDiscard = useCallback(() => {
onBack();
}, [onBack]);
const handleDiscardAndBack = useCallback(() => {
didSaveOrDiscardChangesRef.current = true;
handleBack();
}, [handleBack]);
const handleSaveClose = useCallback(() => {
setShowSaveChangesDialog(false);
}, []);
const handleSave = useCallback(() => {
const handleSaveChanges = useCallback(() => {
strictAssert(isChanged, 'tried saving when unchanged');
strictAssert(isValid, 'tried saving when invalid');
@ -127,9 +131,9 @@ export function PreferencesEditChatFolderPage(
} else {
onCreateChatFolder(normalizedChatFolderParams);
}
onBack();
didSaveOrDiscardChangesRef.current = true;
}, [
onBack,
existingChatFolderId,
isChanged,
isValid,
@ -138,6 +142,24 @@ export function PreferencesEditChatFolderPage(
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(() => {
setShowDeleteFolderDialog(true);
}, []);
@ -145,8 +167,8 @@ export function PreferencesEditChatFolderPage(
strictAssert(existingChatFolderId, 'Missing existing chat folder id');
onDeleteChatFolder(existingChatFolderId);
setShowDeleteFolderDialog(false);
onBack();
}, [existingChatFolderId, onDeleteChatFolder, onBack]);
handleBack();
}, [existingChatFolderId, onDeleteChatFolder, handleBack]);
const handleDeleteClose = useCallback(() => {
setShowDeleteFolderDialog(false);
}, []);
@ -193,7 +215,7 @@ export function PreferencesEditChatFolderPage(
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={handleBackInit}
onClick={handleBack}
type="button"
/>
}
@ -415,16 +437,28 @@ export function PreferencesEditChatFolderPage(
{showDeleteFolderDialog && (
<DeleteChatFolderDialog
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}
onClose={handleDeleteClose}
/>
)}
{showSaveChangesDialog && (
{blocker.state === 'blocked' && (
<SaveChangesFolderDialog
i18n={i18n}
onSave={handleSave}
onCancel={handleDiscard}
onClose={handleSaveClose}
onSave={handleBlockerSaveChanges}
onDiscard={handleBlockerDiscardChanges}
onClose={handleBlockerCancelNavigation}
/>
)}
</>
@ -433,12 +467,15 @@ export function PreferencesEditChatFolderPage(
title={i18n('icu:Preferences__EditChatFolderPage__Title')}
actions={
<>
<Button variant={ButtonVariant.Secondary} onClick={handleDiscard}>
<Button
variant={ButtonVariant.Secondary}
onClick={handleDiscardAndBack}
>
{i18n('icu:Preferences__EditChatFolderPage__CancelButton')}
</Button>
<Button
variant={ButtonVariant.Primary}
onClick={handleSave}
onClick={handleSaveChangesAndBack}
disabled={!(isChanged && isValid)}
>
{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: {
i18n: LocalizerType;
onSave: () => void;
onCancel: () => void;
onDiscard: () => void;
onClose: () => void;
}) {
const { i18n } = props;
@ -510,7 +513,7 @@ function SaveChangesFolderDialog(props: {
action: props.onSave,
},
]}
onCancel={props.onCancel}
onCancel={props.onDiscard}
onClose={props.onClose}
>
{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 type { MessageRequestResponseEvent } from './types/MessageRequestResponseEvent.js';
import type { QuotedMessageForComposerType } from './state/ducks/composer.js';
import type { SEALED_SENDER } from './types/SealedSender.js';
export type LastMessageStatus =
| 'paused'
@ -399,7 +400,7 @@ export type ConversationAttributesType = {
* TODO: Rename this key to be specific to the accessKey on the conversation
* It's not used for group endorsements.
*/
sealedSender?: unknown;
sealedSender?: SEALED_SENDER;
sentMessageCount?: number;
sharedGroupNames?: ReadonlyArray<string>;
voiceNotePlaybackRate?: number;

View file

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

View file

@ -88,7 +88,11 @@ import { isDone as isRegistrationDone } from '../util/registration.js';
import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue.js';
import { isMockEnvironment } from '../environment.js';
import { validateConversation } from '../util/validateConversation.js';
import type { ChatFolder } from '../types/ChatFolder.js';
import {
ChatFolderType,
toCurrentChatFolders,
type ChatFolder,
} from '../types/ChatFolder.js';
const { debounce, isNumber, chunk } = lodash;
@ -1658,6 +1662,22 @@ async function processManifest(
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`);

View file

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

View file

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

View file

@ -1,9 +1,12 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v4 as generateUuid } from 'uuid';
import {
type ChatFolderId,
type ChatFolder,
CHAT_FOLDER_DELETED_POSITION,
CHAT_FOLDER_DEFAULTS,
ChatFolderType,
} from '../../types/ChatFolder.js';
import type { ReadableDB, WritableDB } from '../Interface.js';
import { sql } from '../util.js';
@ -97,45 +100,71 @@ export function getChatFolder(
})();
}
function _insertChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
const chatFolderRow = chatFolderToRow(chatFolder);
const [chatFolderQuery, chatFolderParams] = sql`
INSERT INTO chatFolders (
id,
folderType,
name,
position,
showOnlyUnread,
showMutedChats,
includeAllIndividualChats,
includeAllGroupChats,
includedConversationIds,
excludedConversationIds,
deletedAtTimestampMs,
storageID,
storageVersion,
storageUnknownFields,
storageNeedsSync
) VALUES (
${chatFolderRow.id},
${chatFolderRow.folderType},
${chatFolderRow.name},
${chatFolderRow.position},
${chatFolderRow.showOnlyUnread},
${chatFolderRow.showMutedChats},
${chatFolderRow.includeAllIndividualChats},
${chatFolderRow.includeAllGroupChats},
${chatFolderRow.includedConversationIds},
${chatFolderRow.excludedConversationIds},
${chatFolderRow.deletedAtTimestampMs},
${chatFolderRow.storageID},
${chatFolderRow.storageVersion},
${chatFolderRow.storageUnknownFields},
${chatFolderRow.storageNeedsSync}
)
`;
db.prepare(chatFolderQuery).run(chatFolderParams);
}
export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
return db.transaction(() => {
const chatFolderRow = chatFolderToRow(chatFolder);
const [chatFolderQuery, chatFolderParams] = sql`
INSERT INTO chatFolders (
id,
folderType,
name,
position,
showOnlyUnread,
showMutedChats,
includeAllIndividualChats,
includeAllGroupChats,
includedConversationIds,
excludedConversationIds,
deletedAtTimestampMs,
storageID,
storageVersion,
storageUnknownFields,
storageNeedsSync
) VALUES (
${chatFolderRow.id},
${chatFolderRow.folderType},
${chatFolderRow.name},
${chatFolderRow.position},
${chatFolderRow.showOnlyUnread},
${chatFolderRow.showMutedChats},
${chatFolderRow.includeAllIndividualChats},
${chatFolderRow.includeAllGroupChats},
${chatFolderRow.includedConversationIds},
${chatFolderRow.excludedConversationIds},
${chatFolderRow.deletedAtTimestampMs},
${chatFolderRow.storageID},
${chatFolderRow.storageVersion},
${chatFolderRow.storageUnknownFields},
${chatFolderRow.storageNeedsSync}
)
`;
db.prepare(chatFolderQuery).run(chatFolderParams);
_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}
`;
db.prepare(query).run(params);
_resetAllChatFolderPositions(db);
_resetAllChatFolderPositions(db, 0);
})();
}
function _resetAllChatFolderPositions(db: WritableDB) {
function _resetAllChatFolderPositions(db: WritableDB, offset: number) {
const [query, params] = sql`
SELECT id FROM chatFolders
WHERE deletedAtTimestampMs IS 0
@ -204,7 +233,7 @@ function _resetAllChatFolderPositions(db: WritableDB) {
const [update, updateParams] = sql`
UPDATE chatFolders
SET
position = ${index},
position = ${offset + index},
storageNeedsSync = 1
WHERE id = ${id}
`;

View file

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

View file

@ -83,6 +83,7 @@ import {
getMe,
getMessagesByConversation,
getPendingAvatarDownloadSelector,
getAllConversations,
} from '../selectors/conversations.js';
import { getIntl } from '../selectors/user.js';
import type {
@ -215,6 +216,13 @@ import { cleanupMessages } from '../../util/cleanup.js';
import type { ConversationModel } from '../../models/conversations.js';
import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent.js';
import { JobCancelReason } from '../../jobs/types.js';
import type { ChatFolderId } from '../../types/ChatFolder.js';
import {
isConversationInChatFolder,
lookupCurrentChatFolder,
} from '../../types/ChatFolder.js';
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
import { isConversationUnread } from '../../util/isConversationUnread.js';
const {
chunk,
@ -1180,6 +1188,8 @@ export const actions = {
loadOlderMessages,
markAttachmentAsCorrupted,
markMessageRead,
markConversationRead,
markChatFolderRead,
markOpenConversationRead,
messageChanged,
messageDeleted,
@ -1237,6 +1247,7 @@ export const actions = {
setMessageLoadingState,
setMessageToEdit,
setMuteExpiration,
setChatFolderMuteExpiration,
setPinned,
setPreJoinConversation,
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(
conversationId: 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(
conversationId: string,
muteExpiresAt = 0

View file

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

View file

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

View file

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

View file

@ -3,15 +3,44 @@
import { createSelector } from 'reselect';
import type { StateType } from '../reducer.js';
import type { StateSelector } from '../types.js';
import type { ChatFoldersState } from '../ducks/chatFolders.js';
import type { CurrentChatFolders, ChatFolder } from '../../types/ChatFolder.js';
import {
getSortedCurrentChatFolders,
lookupCurrentChatFolder,
} from '../../types/ChatFolder.js';
export function getChatFoldersState(state: StateType): ChatFoldersState {
return state.chatFolders;
}
export const getCurrentChatFolders = createSelector(
getChatFoldersState,
state => {
export const getCurrentChatFolders: StateSelector<CurrentChatFolders> =
createSelector(getChatFoldersState, state => {
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 lodash from 'lodash';
import { createSelector } from 'reselect';
import type { StateType } from '../reducer.js';
import type { StateSelector } from '../types.js';
import type {
ConversationLookupType,
ConversationMessageType,
@ -64,10 +63,25 @@ import type { HasStories } from '../../types/Stories.js';
import { getHasStoriesSelector } from './stories2.js';
import { canEditMessage } from '../../util/canEditMessage.js';
import { isOutgoing } from '../../messages/helpers.js';
import {
countAllConversationsUnreadStats,
type UnreadStats,
import type {
AllChatFoldersUnreadStats,
UnreadStats,
} 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;
@ -364,21 +378,71 @@ type LeftPaneLists = Readonly<{
pinnedConversations: ReadonlyArray<ConversationType>;
}>;
export const _getLeftPaneLists = (
lookup: ConversationLookupType,
comparator: (left: ConversationType, right: ConversationType) => number,
selectedConversation?: string,
pinnedConversationIds?: ReadonlyArray<string>
): LeftPaneLists => {
function _shouldIncludeInChatFolder(
conversation: ConversationType,
selectedChatFolder: ChatFolder | null,
stableSelectedConversationIdInChatFolder: string | null
): boolean {
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 archivedConversations: Array<ConversationType> = [];
const pinnedConversations: Array<ConversationType> = [];
const values = Object.values(lookup);
const values = Object.values(conversationLookup);
const max = values.length;
for (let i = 0; i < max; i += 1) {
let conversation = values[i];
if (selectedConversation === conversation.id) {
if (
!_shouldIncludeInChatFolder(
conversation,
selectedChatFolder,
stableSelectedConversationIdInChatFolder
)
) {
continue;
}
if (selectedConversationId === conversation.id) {
conversation = {
...conversation,
isSelected: true,
@ -400,8 +464,8 @@ export const _getLeftPaneLists = (
}
}
conversations.sort(comparator);
archivedConversations.sort(comparator);
conversations.sort(conversationComparator);
archivedConversations.sort(conversationComparator);
pinnedConversations.sort(
(a, b) =>
@ -417,7 +481,25 @@ export const getLeftPaneLists = createSelector(
getConversationComparator,
getSelectedConversationId,
getPinnedConversationIds,
_getLeftPaneLists
getSelectedChatFolder,
getStableSelectedConversationIdInChatFolder,
(
conversationLookup,
conversationComparator,
selectedConversationId,
pinnedConversationIds,
selectedChatFolder,
stableSelectedConversationIdInChatFolder
) => {
return _getLeftPaneLists({
conversationLookup,
conversationComparator,
selectedConversationId,
pinnedConversationIds,
selectedChatFolder,
stableSelectedConversationIdInChatFolder,
});
}
);
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
* composer and group members, a different list from your primary system contacts.

View file

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

View file

@ -110,10 +110,18 @@ import {
resumeBackupMediaDownload,
} from '../../util/backupMediaDownload.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 {
return <SmartMessageSearchResult id={id} />;
}
function renderConversationListItemContextMenu(
props: RenderConversationListItemContextMenuProps
): JSX.Element {
return <SmartLeftPaneConversationListItemContextMenu {...props} />;
}
function renderNetworkStatus(
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
): JSX.Element {
@ -140,6 +148,9 @@ function renderExpiredBuildDialog(
): JSX.Element {
return <DialogExpiredBuild {...props} />;
}
function renderLeftPaneChatFolders(): JSX.Element {
return <SmartLeftPaneChatFolders />;
}
function renderUnsupportedOSDialog(
props: Readonly<SmartUnsupportedOSDialogPropsType>
): JSX.Element {
@ -420,7 +431,11 @@ export const SmartLeftPane = memo(function SmartLeftPane({
renderCaptchaDialog={renderCaptchaDialog}
renderCrashReportDialog={renderCrashReportDialog}
renderExpiredBuildDialog={renderExpiredBuildDialog}
renderLeftPaneChatFolders={renderLeftPaneChatFolders}
renderMessageSearchResult={renderMessageSearchResult}
renderConversationListItemContextMenu={
renderConversationListItemContextMenu
}
renderNetworkStatus={renderNetworkStatus}
renderRelinkDialog={renderRelinkDialog}
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 { SmartProfileEditor } from './ProfileEditor.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 { useToastActions } from '../ducks/toast.js';
import { DataReader } from '../../sql/Client.js';
@ -127,19 +128,19 @@ function renderToastManager(props: {
function renderDonationsPane({
contentsRef,
page,
setPage,
settingsLocation,
setSettingsLocation,
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage;
setPage: (page: SettingsPage) => void;
settingsLocation: SettingsLocation;
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
}): JSX.Element {
return (
<DonationsErrorBoundary>
<SmartPreferencesDonations
contentsRef={contentsRef}
page={page}
setPage={setPage}
settingsLocation={settingsLocation}
setSettingsLocation={setSettingsLocation}
/>
</DonationsErrorBoundary>
);
@ -719,24 +720,12 @@ export function SmartPreferences(): JSX.Element | null {
return null;
}
const { page } = 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 settingsLocation = currentLocation.details;
const setSettingsLocation = (location: SettingsLocation) => {
changeLocation({
tab: NavTab.Settings,
details: {
page: newPage,
},
details: location,
});
};
@ -873,7 +862,7 @@ export function SmartPreferences(): JSX.Element | null {
onWhoCanSeeMeChange={onWhoCanSeeMeChange}
onZoomFactorChange={onZoomFactorChange}
otherTabsUnreadStats={otherTabsUnreadStats}
page={page}
settingsLocation={settingsLocation}
pickLocalBackupFolder={pickLocalBackupFolder}
preferredSystemLocales={preferredSystemLocales}
preferredWidthFromStorage={preferredWidthFromStorage}
@ -902,7 +891,7 @@ export function SmartPreferences(): JSX.Element | null {
selectedSpeaker={selectedSpeaker}
sentMediaQualitySetting={sentMediaQualitySetting}
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
setPage={setPage}
setSettingsLocation={setSettingsLocation}
shouldShowUpdateDialog={shouldShowUpdateDialog}
showToast={showToast}
theme={theme}

View file

@ -5,7 +5,7 @@ import { useSelector } from 'react-redux';
import type { PreferencesChatFoldersPageProps } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
import { PreferencesChatFoldersPage } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
import { getIntl } from '../selectors/user.js';
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
import { getSortedChatFolders } from '../selectors/chatFolders.js';
import type { ChatFolderId } from '../../types/ChatFolder.js';
import { useChatFolderActions } from '../ducks/chatFolders.js';
@ -19,8 +19,9 @@ export function SmartPreferencesChatFoldersPage(
props: SmartPreferencesChatFoldersPageProps
): JSX.Element {
const i18n = useSelector(getIntl);
const chatFolders = useSelector(getCurrentChatFolders);
const { createChatFolder } = useChatFolderActions();
const chatFolders = useSelector(getSortedChatFolders);
const { createChatFolder, deleteChatFolder, updateChatFoldersPositions } =
useChatFolderActions();
return (
<PreferencesChatFoldersPage
i18n={i18n}
@ -29,6 +30,8 @@ export function SmartPreferencesChatFoldersPage(
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
chatFolders={chatFolders}
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 { getMe } from '../selectors/conversations.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 type { StateType } from '../reducer.js';
import { useConversationsActions } from '../ducks/conversations.js';
@ -40,12 +40,12 @@ const log = createLogger('SmartPreferencesDonations');
export const SmartPreferencesDonations = memo(
function SmartPreferencesDonations({
contentsRef,
page,
setPage,
settingsLocation,
setSettingsLocation,
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage;
setPage: (page: SettingsPage) => void;
settingsLocation: SettingsLocation;
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
}) {
const [validCurrencies, setValidCurrencies] = useState<
ReadonlyArray<string>
@ -142,7 +142,7 @@ export const SmartPreferencesDonations = memo(
contentsRef={contentsRef}
initialCurrency={initialCurrency}
isOnline={isOnline}
page={page}
settingsLocation={settingsLocation}
didResumeWorkflowAtStartup={donationsState.didResumeWorkflowAtStartup}
lastError={donationsState.lastError}
workflow={donationsState.currentWorkflow}
@ -151,7 +151,7 @@ export const SmartPreferencesDonations = memo(
resumeWorkflow={resumeWorkflow}
updateLastError={updateLastError}
submitDonation={submitDonation}
setPage={setPage}
setSettingsLocation={setSettingsLocation}
theme={theme}
donationBadge={badgesById[BOOST_ID] ?? undefined}
fetchBadgeData={fetchBadgeData}

View file

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

View file

@ -1,6 +1,8 @@
// Copyright 2021 Signal Messenger, LLC
// 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 app } from './ducks/app.js';
import type { actions as audioPlayer } from './ducks/audioPlayer.js';
@ -72,3 +74,5 @@ export type ReduxActions = {
user: typeof user;
username: typeof username;
};
export type StateSelector<T> = Selector<StateType, T>;

View file

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

View file

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

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import createDebug from 'debug';
import { v4 as generateUuid } from 'uuid';
import type {
Group,
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);
// Link new device

View file

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

View file

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

View file

@ -1,6 +1,7 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
import type { Simplify } from 'type-fest';
import {
Environment,
getEnvironment,
@ -9,6 +10,9 @@ import {
import * as grapheme from '../util/grapheme.js';
import * as RemoteConfig from '../RemoteConfig.js';
import { isAlpha, isBeta, isProduction } from '../util/version.js';
import type { ConversationType } from '../state/ducks/conversations.js';
import { strictAssert } from '../util/assert.js';
import { isConversationUnread } from '../util/isConversationUnread.js';
export const CHAT_FOLDER_NAME_MAX_CHAR_LENGTH = 32;
@ -22,32 +26,38 @@ export enum ChatFolderType {
export type ChatFolderId = string & { ChatFolderId: never }; // uuid
export type ChatFolderPreset = Readonly<{
folderType: ChatFolderType;
showOnlyUnread: boolean;
showMutedChats: boolean;
includeAllIndividualChats: boolean;
includeAllGroupChats: boolean;
includedConversationIds: ReadonlyArray<string>;
excludedConversationIds: ReadonlyArray<string>;
}>;
export type ChatFolderParams = Readonly<
ChatFolderPreset & {
name: string;
}
export type ChatFolderPreset = Simplify<
Readonly<{
folderType: ChatFolderType;
showOnlyUnread: boolean;
showMutedChats: boolean;
includeAllIndividualChats: boolean;
includeAllGroupChats: boolean;
includedConversationIds: ReadonlyArray<string>;
excludedConversationIds: ReadonlyArray<string>;
}>
>;
export type ChatFolder = Readonly<
ChatFolderParams & {
id: ChatFolderId;
position: number;
deletedAtTimestampMs: number;
storageID: string | null;
storageVersion: number | null;
storageUnknownFields: Uint8Array | null;
storageNeedsSync: boolean;
}
export type ChatFolderParams = Simplify<
Readonly<
ChatFolderPreset & {
name: string;
}
>
>;
export type ChatFolder = Simplify<
Readonly<
ChatFolderParams & {
id: ChatFolderId;
position: number;
deletedAtTimestampMs: number;
storageID: string | null;
storageVersion: number | null;
storageUnknownFields: Uint8Array | null;
storageNeedsSync: boolean;
}
>
>;
export const ChatFolderPresetSchema = z.object({
@ -173,3 +183,91 @@ export function isChatFoldersEnabled(): boolean {
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
import type { ReadonlyDeep } from 'type-fest';
import type { ChatFolderId } from './ChatFolder.js';
export type SettingsLocation = ReadonlyDeep<
| {
page: SettingsPage.Profile;
state: ProfileEditorPage;
}
| {
page: SettingsPage.EditChatFolder;
chatFolderId: ChatFolderId | null;
previousLocation: Location;
}
| {
page: Exclude<
SettingsPage,
SettingsPage.Profile | SettingsPage.EditChatFolder
>;
}
>;
export type Location = ReadonlyDeep<
| {
tab: NavTab.Settings;
details:
| {
page: SettingsPage.Profile;
state: ProfileEditorPage;
}
| { page: Exclude<SettingsPage, SettingsPage.Profile> };
details: SettingsLocation;
}
| { 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
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';
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,
* individually or all of them together.
*/
export type UnreadStats = Readonly<{
unreadCount: number;
unreadMentionsCount: number;
markedUnread: boolean;
}>;
export type UnreadStats = Readonly<MutableUnreadStats>;
function getEmptyUnreadStats(): UnreadStats {
function createUnreadStats(): MutableUnreadStats {
return {
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
readChatsMarkedUnreadCount: 0,
};
}
@ -29,6 +50,8 @@ export type UnreadStatsOptions = Readonly<{
export type ConversationPropsForUnreadStats = Readonly<
Pick<
ConversationType,
| 'id'
| 'type'
| 'activeAt'
| 'isArchived'
| 'markedUnread'
@ -39,7 +62,9 @@ export type ConversationPropsForUnreadStats = Readonly<
>
>;
function canCountConversation(
export type AllChatFoldersUnreadStats = Map<ChatFolderId, UnreadStats>;
function _canCountConversation(
conversation: ConversationPropsForUnreadStats,
options: UnreadStatsOptions
): boolean {
@ -58,39 +83,109 @@ function canCountConversation(
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(
conversation: ConversationPropsForUnreadStats,
options: UnreadStatsOptions
): UnreadStats {
if (canCountConversation(conversation, options)) {
return {
unreadCount: conversation.unreadCount ?? 0,
unreadMentionsCount: conversation.unreadMentionsCount ?? 0,
markedUnread: conversation.markedUnread ?? false,
};
const unreadStats = createUnreadStats();
if (_canCountConversation(conversation, options)) {
_countConversation(unreadStats, conversation);
}
return getEmptyUnreadStats();
return unreadStats;
}
export function countAllConversationsUnreadStats(
conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
options: UnreadStatsOptions
): UnreadStats {
return conversations.reduce<UnreadStats>((total, conversation) => {
const stats = countConversationUnreadStats(conversation, options);
return {
unreadCount: total.unreadCount + stats.unreadCount,
unreadMentionsCount:
total.unreadMentionsCount + stats.unreadMentionsCount,
markedUnread: total.markedUnread || stats.markedUnread,
};
}, getEmptyUnreadStats());
const unreadStats = createUnreadStats();
for (const conversation of conversations) {
if (_canCountConversation(conversation, options)) {
_countConversation(unreadStats, conversation);
}
}
return unreadStats;
}
export function hasUnread(unreadStats: UnreadStats): boolean {
return (
unreadStats.unreadCount > 0 ||
unreadStats.unreadMentionsCount > 0 ||
unreadStats.markedUnread
);
export function countAllChatFoldersUnreadStats(
sortedChatFolders: ReadonlyArray<ChatFolder>,
conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
options: UnreadStatsOptions
): 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 { WEEK } from './durations/index.js';
import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse.js';
import { countConversationUnreadStats, hasUnread } from './countUnreadStats.js';
import { isConversationUnread } from './countUnreadStats.js';
import { getE164 } from './getE164.js';
import { removeDiacritics } from './removeDiacritics.js';
import { isAciString } from './isAciString.js';
@ -69,9 +69,7 @@ function filterConversationsByUnread(
includeMuted: boolean
): Array<ConversationType> {
return conversations.filter(conversation => {
return hasUnread(
countConversationUnreadStats(conversation, { includeMuted })
);
return isConversationUnread(conversation, { includeMuted });
});
}

View file

@ -12,24 +12,10 @@ export type MuteOption = {
value: number;
};
export function getMuteOptions(
muteExpiresAt: null | undefined | number,
export function getMuteValuesOptions(
i18n: LocalizerType
): Array<MuteOption> {
): ReadonlyArray<MuteOption> {
return [
...(muteExpiresAt && isConversationMuted({ muteExpiresAt })
? [
{
name: getMutedUntilText(muteExpiresAt, i18n),
disabled: true,
value: -1,
},
{
name: i18n('icu:unmute'),
value: 0,
},
]
: []),
{
name: i18n('icu:muteHour'),
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",
"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",
"path": "ts/components/preferences/donations/DonateInputAmount.tsx",
@ -2141,6 +2148,20 @@
"reasonCategory": "usageTrusted",
"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",
"path": "ts/hooks/usePrevious.ts",

View file

@ -27,14 +27,12 @@
*/
/* 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. */
"lib": [
"DOM", // Required to access `window`
"DOM.Iterable",
"ES2022",
"ES2023.Array",
"ESNext.Disposable" // For `playwright`
"ESNext"
],
/* Specify what JSX code is generated. */
"jsx": "react",