Init Chat Folders UI
This commit is contained in:
parent
726234a27b
commit
ec7d07269d
88 changed files with 4082 additions and 1306 deletions
18
.eslintrc.js
18
.eslintrc.js
|
|
@ -436,6 +436,24 @@ module.exports = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['ts/axo/**/*.tsx'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-namespace': 'off',
|
||||||
|
'@typescript-eslint/no-redeclare': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
ignoreDeclarationMerge: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
allowHigherOrderFunctions: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,38 @@
|
||||||
"messageformat": "Enter a username followed by a dot and its set of numbers.",
|
"messageformat": "Enter a username followed by a dot and its set of numbers.",
|
||||||
"description": "Description displayed under search input in left pane when looking up someone by username"
|
"description": "Description displayed under search input in left pane when looking up someone by username"
|
||||||
},
|
},
|
||||||
|
"icu:LeftPaneChatFolders__ItemLabel--All--Short": {
|
||||||
|
"messageformat": "All",
|
||||||
|
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats (needs to fit in very small space)"
|
||||||
|
},
|
||||||
|
"icu:LeftPaneChatFolders__ItemLabel--All": {
|
||||||
|
"messageformat": "All chats",
|
||||||
|
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats"
|
||||||
|
},
|
||||||
|
"icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount": {
|
||||||
|
"messageformat": "{maxCount, number}+",
|
||||||
|
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Badge Count > When over the max count (Example: 1000 or more would be 999+)"
|
||||||
|
},
|
||||||
|
"icu:LeftPaneChatFolders__Item__ContextMenu__MarkAllRead": {
|
||||||
|
"messageformat": "Mark all read",
|
||||||
|
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mark all unread chats in chat folder as read"
|
||||||
|
},
|
||||||
|
"icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications": {
|
||||||
|
"messageformat": "Mute notifications",
|
||||||
|
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications"
|
||||||
|
},
|
||||||
|
"icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll": {
|
||||||
|
"messageformat": "Unmute all",
|
||||||
|
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications > Sub-Menu > Unmute all"
|
||||||
|
},
|
||||||
|
"icu:LeftPaneChatFolders__Item__ContextMenu__UnmuteAll": {
|
||||||
|
"messageformat": "Unmute all",
|
||||||
|
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Unmute all chats in chat folder"
|
||||||
|
},
|
||||||
|
"icu:LeftPaneChatFolders__Item__ContextMenu__EditFolder": {
|
||||||
|
"messageformat": "Edit folder",
|
||||||
|
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Open settings for current chat folder"
|
||||||
|
},
|
||||||
"icu:CountryCodeSelect__placeholder": {
|
"icu:CountryCodeSelect__placeholder": {
|
||||||
"messageformat": "Country code",
|
"messageformat": "Country code",
|
||||||
"description": "Placeholder displayed as default value of country code select element"
|
"description": "Placeholder displayed as default value of country code select element"
|
||||||
|
|
@ -447,6 +479,10 @@
|
||||||
"messageformat": "Mark as unread",
|
"messageformat": "Mark as unread",
|
||||||
"description": "Shown in menu for conversation, and marks conversation as unread"
|
"description": "Shown in menu for conversation, and marks conversation as unread"
|
||||||
},
|
},
|
||||||
|
"icu:markRead": {
|
||||||
|
"messageformat": "Mark read",
|
||||||
|
"description": "Shown in menu for conversation, and marks conversation read"
|
||||||
|
},
|
||||||
"icu:ConversationHeader__menu__selectMessages": {
|
"icu:ConversationHeader__menu__selectMessages": {
|
||||||
"messageformat": "Select messages",
|
"messageformat": "Select messages",
|
||||||
"description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation"
|
"description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation"
|
||||||
|
|
|
||||||
|
|
@ -1374,6 +1374,10 @@ $secondary-text-color: light-dark(
|
||||||
padding-block: 8px;
|
padding-block: 8px;
|
||||||
padding-inline: 24px;
|
padding-inline: 24px;
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
|
|
||||||
|
&[data-dragging='true'] {
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Preferences__ChatFolders__ChatSelection__ItemAvatar {
|
.Preferences__ChatFolders__ChatSelection__ItemAvatar {
|
||||||
|
|
|
||||||
|
|
@ -410,3 +410,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@property --axo-select-trigger-mask-start {
|
||||||
|
syntax: '<color>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: transparent;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,8 @@ export class ConversationController {
|
||||||
// because `conversation.format()` can return cached props by the
|
// because `conversation.format()` can return cached props by the
|
||||||
// time this runs
|
// time this runs
|
||||||
return {
|
return {
|
||||||
|
id: conversation.get('id'),
|
||||||
|
type: conversation.get('type') === 'private' ? 'direct' : 'group',
|
||||||
activeAt: conversation.get('active_at') ?? undefined,
|
activeAt: conversation.get('active_at') ?? undefined,
|
||||||
isArchived: conversation.get('isArchived'),
|
isArchived: conversation.get('isArchived'),
|
||||||
markedUnread: conversation.get('markedUnread'),
|
markedUnread: conversation.get('markedUnread'),
|
||||||
|
|
@ -383,15 +385,16 @@ export class ConversationController {
|
||||||
drop(window.storage.put('unreadCount', unreadStats.unreadCount));
|
drop(window.storage.put('unreadCount', unreadStats.unreadCount));
|
||||||
|
|
||||||
if (unreadStats.unreadCount > 0) {
|
if (unreadStats.unreadCount > 0) {
|
||||||
window.IPC.setBadge(unreadStats.unreadCount);
|
const total =
|
||||||
window.IPC.updateTrayIcon(unreadStats.unreadCount);
|
unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount;
|
||||||
window.document.title = `${window.getTitle()} (${
|
window.IPC.setBadge(total);
|
||||||
unreadStats.unreadCount
|
window.IPC.updateTrayIcon(total);
|
||||||
})`;
|
window.document.title = `${window.getTitle()} (${total})`;
|
||||||
} else if (unreadStats.markedUnread) {
|
} else if (unreadStats.readChatsMarkedUnreadCount > 0) {
|
||||||
window.IPC.setBadge('marked-unread');
|
const total = unreadStats.readChatsMarkedUnreadCount;
|
||||||
window.IPC.updateTrayIcon(1);
|
window.IPC.setBadge(total);
|
||||||
window.document.title = `${window.getTitle()} (1)`;
|
window.IPC.updateTrayIcon(total);
|
||||||
|
window.document.title = `${window.getTitle()} (${total})`;
|
||||||
} else {
|
} else {
|
||||||
window.IPC.setBadge(0);
|
window.IPC.setBadge(0);
|
||||||
window.IPC.updateTrayIcon(0);
|
window.IPC.updateTrayIcon(0);
|
||||||
|
|
|
||||||
|
|
@ -78,9 +78,13 @@ function CardButton(props: {
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AriaClickable.SubWidget>
|
<AriaClickable.SubWidget>
|
||||||
<AxoButton variant={props.variant} size="medium" onClick={props.onClick}>
|
<AxoButton.Root
|
||||||
|
variant={props.variant}
|
||||||
|
size="medium"
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</AxoButton>
|
</AxoButton.Root>
|
||||||
</AriaClickable.SubWidget>
|
</AriaClickable.SubWidget>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const Namespace = 'AriaClickable';
|
||||||
* <AriaClickable.HiddenTrigger aria-labelledby="see-more-1"/>
|
* <AriaClickable.HiddenTrigger aria-labelledby="see-more-1"/>
|
||||||
* </p>
|
* </p>
|
||||||
* <AriaClickable.SubWidget>
|
* <AriaClickable.SubWidget>
|
||||||
* <AxoButton>Delete</AxoButton>
|
* <AxoButton.Root>Delete</AxoButton.Root>
|
||||||
* </AriaClickable.SubWidget>
|
* </AriaClickable.SubWidget>
|
||||||
* <AriaClickable.SubWidget>
|
* <AriaClickable.SubWidget>
|
||||||
* <AxoLink>Edit</AxoLink>
|
* <AxoLink>Edit</AxoLink>
|
||||||
|
|
@ -36,7 +36,6 @@ const Namespace = 'AriaClickable';
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
||||||
export namespace AriaClickable {
|
export namespace AriaClickable {
|
||||||
type TriggerState = Readonly<{
|
type TriggerState = Readonly<{
|
||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
|
|
|
||||||
57
ts/axo/AxoBadge.stories.tsx
Normal file
57
ts/axo/AxoBadge.stories.tsx
Normal 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
125
ts/axo/AxoBadge.tsx
Normal 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`;
|
||||||
|
}
|
||||||
|
|
@ -26,33 +26,33 @@ export function Basic(): JSX.Element {
|
||||||
{variants.map(variant => {
|
{variants.map(variant => {
|
||||||
return (
|
return (
|
||||||
<div key={variant} className={tw('flex gap-1')}>
|
<div key={variant} className={tw('flex gap-1')}>
|
||||||
<AxoButton
|
<AxoButton.Root
|
||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
onClick={action('click')}
|
onClick={action('click')}
|
||||||
>
|
>
|
||||||
{variant}
|
{variant}
|
||||||
</AxoButton>
|
</AxoButton.Root>
|
||||||
|
|
||||||
<AxoButton
|
<AxoButton.Root
|
||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
onClick={action('click')}
|
onClick={action('click')}
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
Disabled
|
Disabled
|
||||||
</AxoButton>
|
</AxoButton.Root>
|
||||||
|
|
||||||
<AxoButton
|
<AxoButton.Root
|
||||||
symbol="info"
|
symbol="info"
|
||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
onClick={action('click')}
|
onClick={action('click')}
|
||||||
>
|
>
|
||||||
Icon
|
Icon
|
||||||
</AxoButton>
|
</AxoButton.Root>
|
||||||
|
|
||||||
<AxoButton
|
<AxoButton.Root
|
||||||
symbol="info"
|
symbol="info"
|
||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
|
|
@ -60,18 +60,18 @@ export function Basic(): JSX.Element {
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
Disabled
|
Disabled
|
||||||
</AxoButton>
|
</AxoButton.Root>
|
||||||
|
|
||||||
<AxoButton
|
<AxoButton.Root
|
||||||
arrow
|
arrow
|
||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
onClick={action('click')}
|
onClick={action('click')}
|
||||||
>
|
>
|
||||||
Arrow
|
Arrow
|
||||||
</AxoButton>
|
</AxoButton.Root>
|
||||||
|
|
||||||
<AxoButton
|
<AxoButton.Root
|
||||||
arrow
|
arrow
|
||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
|
|
@ -79,7 +79,7 @@ export function Basic(): JSX.Element {
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
Disabled
|
Disabled
|
||||||
</AxoButton>
|
</AxoButton.Root>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -139,15 +139,6 @@ type BaseButtonAttrs = Omit<
|
||||||
type AxoButtonVariant = keyof typeof AxoButtonVariants;
|
type AxoButtonVariant = keyof typeof AxoButtonVariants;
|
||||||
type AxoButtonSize = keyof typeof AxoButtonSizes;
|
type AxoButtonSize = keyof typeof AxoButtonSizes;
|
||||||
|
|
||||||
type AxoButtonProps = BaseButtonAttrs &
|
|
||||||
Readonly<{
|
|
||||||
variant: AxoButtonVariant;
|
|
||||||
size: AxoButtonSize;
|
|
||||||
symbol?: AxoSymbol.InlineGlyphName;
|
|
||||||
arrow?: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export function _getAllAxoButtonVariants(): ReadonlyArray<AxoButtonVariant> {
|
export function _getAllAxoButtonVariants(): ReadonlyArray<AxoButtonVariant> {
|
||||||
return Object.keys(AxoButtonVariants) as Array<AxoButtonVariant>;
|
return Object.keys(AxoButtonVariants) as Array<AxoButtonVariant>;
|
||||||
}
|
}
|
||||||
|
|
@ -156,8 +147,19 @@ export function _getAllAxoButtonSizes(): ReadonlyArray<AxoButtonSize> {
|
||||||
return Object.keys(AxoButtonSizes) as Array<AxoButtonSize>;
|
return Object.keys(AxoButtonSizes) as Array<AxoButtonSize>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line import/export
|
export namespace AxoButton {
|
||||||
export const AxoButton: FC<AxoButtonProps> = memo(
|
export type Variant = AxoButtonVariant;
|
||||||
|
export type Size = AxoButtonSize;
|
||||||
|
export type RootProps = BaseButtonAttrs &
|
||||||
|
Readonly<{
|
||||||
|
variant: AxoButtonVariant;
|
||||||
|
size: AxoButtonSize;
|
||||||
|
symbol?: AxoSymbol.InlineGlyphName;
|
||||||
|
arrow?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const Root: FC<RootProps> = memo(
|
||||||
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
|
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
|
||||||
const { variant, size, symbol, arrow, children, ...rest } = props;
|
const { variant, size, symbol, arrow, children, ...rest } = props;
|
||||||
const variantStyles = assert(
|
const variantStyles = assert(
|
||||||
|
|
@ -179,18 +181,13 @@ export const AxoButton: FC<AxoButtonProps> = memo(
|
||||||
<AxoSymbol.InlineGlyph symbol={symbol} label={null} />
|
<AxoSymbol.InlineGlyph symbol={symbol} label={null} />
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
{arrow && <AxoSymbol.InlineGlyph symbol="chevron-[end]" label={null} />}
|
{arrow && (
|
||||||
|
<AxoSymbol.InlineGlyph symbol="chevron-[end]" label={null} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
AxoButton.displayName = `${Namespace}`;
|
Root.displayName = `${Namespace}.Root`;
|
||||||
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export
|
|
||||||
export namespace AxoButton {
|
|
||||||
export type Variant = AxoButtonVariant;
|
|
||||||
export type Size = AxoButtonSize;
|
|
||||||
export type Props = AxoButtonProps;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ function Template(props: {
|
||||||
const [checked, setChecked] = useState(props.defaultChecked);
|
const [checked, setChecked] = useState(props.defaultChecked);
|
||||||
return (
|
return (
|
||||||
<label className={tw('my-2 flex items-center gap-2')}>
|
<label className={tw('my-2 flex items-center gap-2')}>
|
||||||
<AxoCheckbox
|
<AxoCheckbox.Root
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={setChecked}
|
onCheckedChange={setChecked}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,16 @@ import { tw } from './tw.js';
|
||||||
|
|
||||||
const Namespace = 'AxoCheckbox';
|
const Namespace = 'AxoCheckbox';
|
||||||
|
|
||||||
type AxoCheckboxProps = Readonly<{
|
export namespace AxoCheckbox {
|
||||||
|
export type RootProps = Readonly<{
|
||||||
id?: string;
|
id?: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onCheckedChange: (nextChecked: boolean) => void;
|
onCheckedChange: (nextChecked: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// eslint-disable-next-line import/export
|
export const Root = memo((props: RootProps) => {
|
||||||
export const AxoCheckbox = memo((props: AxoCheckboxProps) => {
|
|
||||||
return (
|
return (
|
||||||
<Checkbox.Root
|
<Checkbox.Root
|
||||||
id={props.id}
|
id={props.id}
|
||||||
|
|
@ -46,12 +46,7 @@ export const AxoCheckbox = memo((props: AxoCheckboxProps) => {
|
||||||
</Checkbox.Indicator>
|
</Checkbox.Indicator>
|
||||||
</Checkbox.Root>
|
</Checkbox.Root>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
AxoCheckbox.displayName = `${Namespace}`;
|
Root.displayName = `${Namespace}.Root`;
|
||||||
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export
|
|
||||||
export namespace AxoCheckbox {
|
|
||||||
export type Props = AxoCheckboxProps;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ const Namespace = 'AxoContextMenu';
|
||||||
* )
|
* )
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
||||||
export namespace AxoContextMenu {
|
export namespace AxoContextMenu {
|
||||||
/**
|
/**
|
||||||
* Component: <AxoContextMenu.Root>
|
* Component: <AxoContextMenu.Root>
|
||||||
|
|
@ -71,7 +70,7 @@ export namespace AxoContextMenu {
|
||||||
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
|
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
|
||||||
|
|
||||||
export const Trigger: FC<TriggerProps> = memo(props => {
|
export const Trigger: FC<TriggerProps> = memo(props => {
|
||||||
return <ContextMenu.Trigger>{props.children}</ContextMenu.Trigger>;
|
return <ContextMenu.Trigger asChild>{props.children}</ContextMenu.Trigger>;
|
||||||
});
|
});
|
||||||
|
|
||||||
Trigger.displayName = `${Namespace}.Trigger`;
|
Trigger.displayName = `${Namespace}.Trigger`;
|
||||||
|
|
@ -247,7 +246,7 @@ export namespace AxoContextMenu {
|
||||||
export const RadioGroup: FC<RadioGroupProps> = memo(props => {
|
export const RadioGroup: FC<RadioGroupProps> = memo(props => {
|
||||||
return (
|
return (
|
||||||
<ContextMenu.RadioGroup
|
<ContextMenu.RadioGroup
|
||||||
value={props.value}
|
value={props.value ?? undefined}
|
||||||
onValueChange={props.onValueChange}
|
onValueChange={props.onValueChange}
|
||||||
className={AxoBaseMenu.menuRadioGroupStyles}
|
className={AxoBaseMenu.menuRadioGroupStyles}
|
||||||
>
|
>
|
||||||
|
|
@ -283,7 +282,11 @@ export namespace AxoContextMenu {
|
||||||
</AxoBaseMenu.ItemCheckPlaceholder>
|
</AxoBaseMenu.ItemCheckPlaceholder>
|
||||||
</AxoBaseMenu.ItemLeadingSlot>
|
</AxoBaseMenu.ItemLeadingSlot>
|
||||||
<AxoBaseMenu.ItemContentSlot>
|
<AxoBaseMenu.ItemContentSlot>
|
||||||
{props.symbol && <AxoBaseMenu.ItemSymbol symbol={props.symbol} />}
|
{props.symbol && (
|
||||||
|
<span className={tw('me-2')}>
|
||||||
|
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
|
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
|
||||||
{props.keyboardShortcut && (
|
{props.keyboardShortcut && (
|
||||||
<AxoBaseMenu.ItemKeyboardShortcut
|
<AxoBaseMenu.ItemKeyboardShortcut
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,9 @@ export function Basic(): JSX.Element {
|
||||||
<div className={tw('flex h-96 w-full items-center justify-center')}>
|
<div className={tw('flex h-96 w-full items-center justify-center')}>
|
||||||
<AxoDropdownMenu.Root>
|
<AxoDropdownMenu.Root>
|
||||||
<AxoDropdownMenu.Trigger>
|
<AxoDropdownMenu.Trigger>
|
||||||
<AxoButton variant="secondary" size="medium">
|
<AxoButton.Root variant="secondary" size="medium">
|
||||||
Open Dropdown Menu
|
Open Dropdown Menu
|
||||||
</AxoButton>
|
</AxoButton.Root>
|
||||||
</AxoDropdownMenu.Trigger>
|
</AxoDropdownMenu.Trigger>
|
||||||
<AxoDropdownMenu.Content>
|
<AxoDropdownMenu.Content>
|
||||||
<AxoDropdownMenu.Item
|
<AxoDropdownMenu.Item
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ const Namespace = 'AxoDropdownMenu';
|
||||||
* )
|
* )
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
||||||
export namespace AxoDropdownMenu {
|
export namespace AxoDropdownMenu {
|
||||||
/**
|
/**
|
||||||
* Component: <AxoDropdownMenu.Root>
|
* Component: <AxoDropdownMenu.Root>
|
||||||
|
|
@ -261,7 +260,7 @@ export namespace AxoDropdownMenu {
|
||||||
export const RadioGroup: FC<RadioGroupProps> = memo(props => {
|
export const RadioGroup: FC<RadioGroupProps> = memo(props => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.RadioGroup
|
<DropdownMenu.RadioGroup
|
||||||
value={props.value}
|
value={props.value ?? undefined}
|
||||||
onValueChange={props.onValueChange}
|
onValueChange={props.onValueChange}
|
||||||
className={AxoBaseMenu.menuRadioGroupStyles}
|
className={AxoBaseMenu.menuRadioGroupStyles}
|
||||||
>
|
>
|
||||||
|
|
@ -297,7 +296,11 @@ export namespace AxoDropdownMenu {
|
||||||
</AxoBaseMenu.ItemCheckPlaceholder>
|
</AxoBaseMenu.ItemCheckPlaceholder>
|
||||||
</AxoBaseMenu.ItemLeadingSlot>
|
</AxoBaseMenu.ItemLeadingSlot>
|
||||||
<AxoBaseMenu.ItemContentSlot>
|
<AxoBaseMenu.ItemContentSlot>
|
||||||
{props.symbol && <AxoBaseMenu.ItemSymbol symbol={props.symbol} />}
|
{props.symbol && (
|
||||||
|
<span className={tw('me-2')}>
|
||||||
|
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
|
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
|
||||||
{props.keyboardShortcut && (
|
{props.keyboardShortcut && (
|
||||||
<AxoBaseMenu.ItemKeyboardShortcut
|
<AxoBaseMenu.ItemKeyboardShortcut
|
||||||
|
|
|
||||||
120
ts/axo/AxoSegmentedControl.stories.tsx
Normal file
120
ts/axo/AxoSegmentedControl.stories.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
130
ts/axo/AxoSegmentedControl.tsx
Normal file
130
ts/axo/AxoSegmentedControl.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { Meta } from '@storybook/react';
|
import type { Meta } from '@storybook/react';
|
||||||
import { AxoSelect } from './AxoSelect.js';
|
import { AxoSelect } from './AxoSelect.js';
|
||||||
|
|
@ -9,6 +10,18 @@ export default {
|
||||||
title: 'Axo/AxoSelect',
|
title: 'Axo/AxoSelect',
|
||||||
} satisfies Meta;
|
} satisfies Meta;
|
||||||
|
|
||||||
|
function TemplateItem(props: {
|
||||||
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<AxoSelect.Item value={props.value} disabled={props.disabled}>
|
||||||
|
<AxoSelect.ItemText>{props.children}</AxoSelect.ItemText>
|
||||||
|
</AxoSelect.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Template(props: {
|
function Template(props: {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
triggerWidth?: AxoSelect.TriggerWidth;
|
triggerWidth?: AxoSelect.TriggerWidth;
|
||||||
|
|
@ -29,29 +42,29 @@ function Template(props: {
|
||||||
<AxoSelect.Content>
|
<AxoSelect.Content>
|
||||||
<AxoSelect.Group>
|
<AxoSelect.Group>
|
||||||
<AxoSelect.Label>Fruits</AxoSelect.Label>
|
<AxoSelect.Label>Fruits</AxoSelect.Label>
|
||||||
<AxoSelect.Item value="apple">Apple</AxoSelect.Item>
|
<TemplateItem value="apple">Apple</TemplateItem>
|
||||||
<AxoSelect.Item value="banana">Banana</AxoSelect.Item>
|
<TemplateItem value="banana">Banana</TemplateItem>
|
||||||
<AxoSelect.Item value="blueberry">Blueberry</AxoSelect.Item>
|
<TemplateItem value="blueberry">Blueberry</TemplateItem>
|
||||||
<AxoSelect.Item value="grapes">Grapes</AxoSelect.Item>
|
<TemplateItem value="grapes">Grapes</TemplateItem>
|
||||||
<AxoSelect.Item value="pineapple">Pineapple</AxoSelect.Item>
|
<TemplateItem value="pineapple">Pineapple</TemplateItem>
|
||||||
</AxoSelect.Group>
|
</AxoSelect.Group>
|
||||||
<AxoSelect.Separator />
|
<AxoSelect.Separator />
|
||||||
<AxoSelect.Group>
|
<AxoSelect.Group>
|
||||||
<AxoSelect.Label>Vegetables</AxoSelect.Label>
|
<AxoSelect.Label>Vegetables</AxoSelect.Label>
|
||||||
<AxoSelect.Item value="aubergine">Aubergine</AxoSelect.Item>
|
<TemplateItem value="aubergine">Aubergine</TemplateItem>
|
||||||
<AxoSelect.Item value="broccoli">Broccoli</AxoSelect.Item>
|
<TemplateItem value="broccoli">Broccoli</TemplateItem>
|
||||||
<AxoSelect.Item value="carrot" disabled>
|
<TemplateItem value="carrot" disabled>
|
||||||
Carrot
|
Carrot
|
||||||
</AxoSelect.Item>
|
</TemplateItem>
|
||||||
<AxoSelect.Item value="leek">Leek</AxoSelect.Item>
|
<TemplateItem value="leek">Leek</TemplateItem>
|
||||||
</AxoSelect.Group>
|
</AxoSelect.Group>
|
||||||
<AxoSelect.Separator />
|
<AxoSelect.Separator />
|
||||||
<AxoSelect.Group>
|
<AxoSelect.Group>
|
||||||
<AxoSelect.Label>Meat</AxoSelect.Label>
|
<AxoSelect.Label>Meat</AxoSelect.Label>
|
||||||
<AxoSelect.Item value="beef">Beef</AxoSelect.Item>
|
<TemplateItem value="beef">Beef</TemplateItem>
|
||||||
<AxoSelect.Item value="chicken">Chicken</AxoSelect.Item>
|
<TemplateItem value="chicken">Chicken</TemplateItem>
|
||||||
<AxoSelect.Item value="lamb">Lamb</AxoSelect.Item>
|
<TemplateItem value="lamb">Lamb</TemplateItem>
|
||||||
<AxoSelect.Item value="pork">Pork</AxoSelect.Item>
|
<TemplateItem value="pork">Pork</TemplateItem>
|
||||||
</AxoSelect.Group>
|
</AxoSelect.Group>
|
||||||
</AxoSelect.Content>
|
</AxoSelect.Content>
|
||||||
</AxoSelect.Root>
|
</AxoSelect.Root>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { AxoBaseMenu } from './_internal/AxoBaseMenu.js';
|
||||||
import { AxoSymbol } from './AxoSymbol.js';
|
import { AxoSymbol } from './AxoSymbol.js';
|
||||||
import type { TailwindStyles } from './tw.js';
|
import type { TailwindStyles } from './tw.js';
|
||||||
import { tw } from './tw.js';
|
import { tw } from './tw.js';
|
||||||
|
import { ExperimentalAxoBadge } from './AxoBadge.js';
|
||||||
|
|
||||||
const Namespace = 'AxoSelect';
|
const Namespace = 'AxoSelect';
|
||||||
|
|
||||||
|
|
@ -19,18 +20,22 @@ const Namespace = 'AxoSelect';
|
||||||
* <AxoSelect.Root>
|
* <AxoSelect.Root>
|
||||||
* <AxoSelect.Trigger/>
|
* <AxoSelect.Trigger/>
|
||||||
* <AxoSelect.Content>
|
* <AxoSelect.Content>
|
||||||
* <AxoSelect.Item/>
|
* <AxoSelect.Item>
|
||||||
|
* <AxoSelect.ItemText/>
|
||||||
|
* <AxoSelect.ItemBadge/>
|
||||||
|
* </AxoSelect.Item>
|
||||||
* <AxoSelect.Separator/>
|
* <AxoSelect.Separator/>
|
||||||
* <AxoSelect.Group>
|
* <AxoSelect.Group>
|
||||||
* <AxoSelect.Label/>
|
* <AxoSelect.Label/>
|
||||||
* <AxoSelect.Item/>
|
* <AxoSelect.Item>
|
||||||
|
* <AxoSelect.ItemText/>
|
||||||
|
* </AxoSelect.Item>
|
||||||
* </AxoSelect.Group>
|
* </AxoSelect.Group>
|
||||||
* </AxoSelect.Content>
|
* </AxoSelect.Content>
|
||||||
* </AxoSelect.Root>
|
* </AxoSelect.Root>
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
||||||
export namespace AxoSelect {
|
export namespace AxoSelect {
|
||||||
/**
|
/**
|
||||||
* Component: <AxoSelect.Root>
|
* Component: <AxoSelect.Root>
|
||||||
|
|
@ -78,15 +83,19 @@ export namespace AxoSelect {
|
||||||
* ---------------------------
|
* ---------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type TriggerVariant = 'default' | 'floating' | 'borderless';
|
||||||
|
export type TriggerWidth = 'hug' | 'full';
|
||||||
|
export type TriggerChevron = 'always' | 'on-hover';
|
||||||
|
|
||||||
const baseTriggerStyles = tw(
|
const baseTriggerStyles = tw(
|
||||||
'flex',
|
'group relative flex items-center',
|
||||||
'rounded-full py-[5px] ps-3 pe-2.5 type-body-medium text-label-primary',
|
'rounded-full text-start type-body-medium text-label-primary',
|
||||||
'disabled:text-label-disabled',
|
'disabled:text-label-disabled',
|
||||||
'outline-0 outline-border-focused focused:outline-[2.5px]',
|
'outline-0 outline-border-focused focused:outline-[2.5px]',
|
||||||
'forced-colors:border'
|
'forced-colors:border'
|
||||||
);
|
);
|
||||||
|
|
||||||
const TriggerVariants = {
|
const TriggerVariants: Record<TriggerVariant, TailwindStyles> = {
|
||||||
default: tw(
|
default: tw(
|
||||||
baseTriggerStyles,
|
baseTriggerStyles,
|
||||||
'bg-fill-secondary',
|
'bg-fill-secondary',
|
||||||
|
|
@ -104,19 +113,52 @@ export namespace AxoSelect {
|
||||||
'hovered:bg-fill-secondary',
|
'hovered:bg-fill-secondary',
|
||||||
'pressed:bg-fill-secondary-pressed'
|
'pressed:bg-fill-secondary-pressed'
|
||||||
),
|
),
|
||||||
} as const satisfies Record<string, TailwindStyles>;
|
};
|
||||||
|
|
||||||
const TriggerWidths = {
|
const TriggerWidths: Record<TriggerWidth, TailwindStyles> = {
|
||||||
hug: tw(),
|
hug: tw(),
|
||||||
full: tw('w-full'),
|
full: tw('w-full'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TriggerVariant = keyof typeof TriggerVariants;
|
type TriggerChevronConfig = {
|
||||||
export type TriggerWidth = keyof typeof TriggerWidths;
|
chevronStyles: TailwindStyles;
|
||||||
|
contentStyles: TailwindStyles;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseContentStyles = tw('flex min-w-0 flex-1');
|
||||||
|
|
||||||
|
const TriggerChevrons: Record<TriggerChevron, TriggerChevronConfig> = {
|
||||||
|
always: {
|
||||||
|
chevronStyles: tw('ps-2 pe-2.5'),
|
||||||
|
contentStyles: tw(baseContentStyles, 'py-[5px] ps-3'),
|
||||||
|
},
|
||||||
|
'on-hover': {
|
||||||
|
chevronStyles: tw(
|
||||||
|
'absolute inset-y-0 end-0 w-9.5',
|
||||||
|
'flex items-center justify-end pe-2',
|
||||||
|
'opacity-0 group-focus:opacity-100 group-data-[state=open]:opacity-100 group-hovered:opacity-100',
|
||||||
|
'transition-opacity duration-150'
|
||||||
|
),
|
||||||
|
contentStyles: tw(
|
||||||
|
baseContentStyles,
|
||||||
|
'px-3 py-[5px]',
|
||||||
|
'[--axo-select-trigger-mask-start:black]',
|
||||||
|
'group-hovered:[--axo-select-trigger-mask-start:transparent]',
|
||||||
|
'group-focus:[--axo-select-trigger-mask-start:transparent]',
|
||||||
|
'group-data-[state=open]:[--axo-select-trigger-mask-start:transparent]',
|
||||||
|
'[mask-image:linear-gradient(to_left,var(--axo-select-trigger-mask-start)_19px,black_38px)]',
|
||||||
|
'rtl:[mask-image:linear-gradient(to_right,var(--axo-select-trigger-mask-start)_19px,black_38px)]',
|
||||||
|
'[mask-repeat:no-repeat]',
|
||||||
|
'[mask-position:right] rtl:[mask-position:left]',
|
||||||
|
'[transition-property:--axo-select-trigger-mask-start] duration-150'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export type TriggerProps = Readonly<{
|
export type TriggerProps = Readonly<{
|
||||||
variant?: TriggerVariant;
|
variant?: TriggerVariant;
|
||||||
width?: TriggerWidth;
|
width?: TriggerWidth;
|
||||||
|
chevron?: TriggerChevron;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}>;
|
}>;
|
||||||
|
|
@ -129,16 +171,20 @@ export namespace AxoSelect {
|
||||||
export const Trigger: FC<TriggerProps> = memo(props => {
|
export const Trigger: FC<TriggerProps> = memo(props => {
|
||||||
const variant = props.variant ?? 'default';
|
const variant = props.variant ?? 'default';
|
||||||
const width = props.width ?? 'hug';
|
const width = props.width ?? 'hug';
|
||||||
|
const chevron = props.chevron ?? 'always';
|
||||||
const variantStyles = TriggerVariants[variant];
|
const variantStyles = TriggerVariants[variant];
|
||||||
const widthStyles = TriggerWidths[width];
|
const widthStyles = TriggerWidths[width];
|
||||||
|
const chevronConfig = TriggerChevrons[chevron];
|
||||||
return (
|
return (
|
||||||
<Select.Trigger className={tw(variantStyles, widthStyles)}>
|
<Select.Trigger className={tw(variantStyles, widthStyles)}>
|
||||||
|
<div className={chevronConfig.contentStyles}>
|
||||||
<AxoBaseMenu.ItemText>
|
<AxoBaseMenu.ItemText>
|
||||||
<Select.Value placeholder={props.placeholder}>
|
<Select.Value placeholder={props.placeholder}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Select.Value>
|
</Select.Value>
|
||||||
</AxoBaseMenu.ItemText>
|
</AxoBaseMenu.ItemText>
|
||||||
<Select.Icon className={tw('ms-2')}>
|
</div>
|
||||||
|
<Select.Icon className={chevronConfig.chevronStyles}>
|
||||||
<AxoSymbol.Icon symbol="chevron-down" size={14} label={null} />
|
<AxoSymbol.Icon symbol="chevron-down" size={14} label={null} />
|
||||||
</Select.Icon>
|
</Select.Icon>
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
|
|
@ -152,7 +198,29 @@ export namespace AxoSelect {
|
||||||
* ------------------------------
|
* ------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type ContentPosition = 'item-aligned' | 'dropdown';
|
||||||
|
|
||||||
|
type ContentPositionConfig = {
|
||||||
|
position: Select.SelectContentProps['position'];
|
||||||
|
alignOffset?: Select.SelectContentProps['alignOffset'];
|
||||||
|
collisionPadding?: Select.SelectContentProps['collisionPadding'];
|
||||||
|
sideOffset?: Select.SelectContentProps['sideOffset'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContentPositions: Record<ContentPosition, ContentPositionConfig> = {
|
||||||
|
'item-aligned': {
|
||||||
|
position: 'item-aligned',
|
||||||
|
},
|
||||||
|
dropdown: {
|
||||||
|
position: 'popper',
|
||||||
|
alignOffset: 0,
|
||||||
|
collisionPadding: 6,
|
||||||
|
sideOffset: 8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export type ContentProps = Readonly<{
|
export type ContentProps = Readonly<{
|
||||||
|
position?: ContentPosition;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
@ -161,9 +229,17 @@ export namespace AxoSelect {
|
||||||
* Uses a portal to render the content part into the `body`.
|
* Uses a portal to render the content part into the `body`.
|
||||||
*/
|
*/
|
||||||
export const Content: FC<ContentProps> = memo(props => {
|
export const Content: FC<ContentProps> = memo(props => {
|
||||||
|
const position = props.position ?? 'item-aligned';
|
||||||
|
const positionConfig = ContentPositions[position];
|
||||||
return (
|
return (
|
||||||
<Select.Portal>
|
<Select.Portal>
|
||||||
<Select.Content className={AxoBaseMenu.selectContentStyles}>
|
<Select.Content
|
||||||
|
className={AxoBaseMenu.selectContentStyles}
|
||||||
|
position={positionConfig.position}
|
||||||
|
alignOffset={positionConfig.alignOffset}
|
||||||
|
collisionPadding={positionConfig.collisionPadding}
|
||||||
|
sideOffset={positionConfig.sideOffset}
|
||||||
|
>
|
||||||
<Select.ScrollUpButton
|
<Select.ScrollUpButton
|
||||||
className={tw(
|
className={tw(
|
||||||
'flex items-center justify-center p-1 text-label-primary'
|
'flex items-center justify-center p-1 text-label-primary'
|
||||||
|
|
@ -197,6 +273,7 @@ export namespace AxoSelect {
|
||||||
value: string;
|
value: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
textValue?: string;
|
textValue?: string;
|
||||||
|
symbol?: AxoSymbol.IconName;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
@ -219,9 +296,12 @@ export namespace AxoSelect {
|
||||||
</AxoBaseMenu.ItemCheckPlaceholder>
|
</AxoBaseMenu.ItemCheckPlaceholder>
|
||||||
</AxoBaseMenu.ItemLeadingSlot>
|
</AxoBaseMenu.ItemLeadingSlot>
|
||||||
<AxoBaseMenu.ItemContentSlot>
|
<AxoBaseMenu.ItemContentSlot>
|
||||||
<AxoBaseMenu.ItemText>
|
{props.symbol && (
|
||||||
<Select.ItemText>{props.children}</Select.ItemText>
|
<span className={tw('me-2')}>
|
||||||
</AxoBaseMenu.ItemText>
|
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{props.children}
|
||||||
</AxoBaseMenu.ItemContentSlot>
|
</AxoBaseMenu.ItemContentSlot>
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
);
|
);
|
||||||
|
|
@ -229,9 +309,55 @@ export namespace AxoSelect {
|
||||||
|
|
||||||
Item.displayName = `${Namespace}.Content`;
|
Item.displayName = `${Namespace}.Content`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component: <AxoSelect.ItemText>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ItemTextProps = Readonly<{
|
||||||
|
children: ReactNode;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const ItemText: FC<ItemTextProps> = memo(props => {
|
||||||
|
return (
|
||||||
|
<AxoBaseMenu.ItemText>
|
||||||
|
<Select.ItemText>{props.children}</Select.ItemText>
|
||||||
|
</AxoBaseMenu.ItemText>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ItemText.displayName = `${Namespace}.ItemText`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component: <AxoSelect.ItemBadge>
|
||||||
|
* --------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ExperimentalItemBadgeProps = Omit<
|
||||||
|
ExperimentalAxoBadge.RootProps,
|
||||||
|
'size'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ExperimentalItemBadge = memo(
|
||||||
|
(props: ExperimentalItemBadgeProps) => {
|
||||||
|
return (
|
||||||
|
<span className={tw('ms-[5px]')}>
|
||||||
|
<ExperimentalAxoBadge.Root
|
||||||
|
size="sm"
|
||||||
|
value={props.value}
|
||||||
|
max={props.max}
|
||||||
|
maxDisplay={props.maxDisplay}
|
||||||
|
aria-label={props['aria-label']}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ExperimentalItemBadge.displayName = `${Namespace}.ItemBadge`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component: <AxoSelect.Group>
|
* Component: <AxoSelect.Group>
|
||||||
* ---------------------------
|
* ----------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type GroupProps = Readonly<{
|
export type GroupProps = Readonly<{
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ function Template(props: {
|
||||||
const [checked, setChecked] = useState(props.defaultChecked);
|
const [checked, setChecked] = useState(props.defaultChecked);
|
||||||
return (
|
return (
|
||||||
<label className={tw('my-2 flex items-center gap-2')}>
|
<label className={tw('my-2 flex items-center gap-2')}>
|
||||||
<AxoSwitch
|
<AxoSwitch.Root
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={setChecked}
|
onCheckedChange={setChecked}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,15 @@ import { AxoSymbol } from './AxoSymbol.js';
|
||||||
|
|
||||||
const Namespace = 'AxoSwitch';
|
const Namespace = 'AxoSwitch';
|
||||||
|
|
||||||
type AxoSwitchProps = Readonly<{
|
export namespace AxoSwitch {
|
||||||
|
export type RootProps = Readonly<{
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onCheckedChange: (nextChecked: boolean) => void;
|
onCheckedChange: (nextChecked: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// eslint-disable-next-line import/export
|
export const Root = memo((props: RootProps) => {
|
||||||
export const AxoSwitch = memo((props: AxoSwitchProps) => {
|
|
||||||
return (
|
return (
|
||||||
<Switch.Root
|
<Switch.Root
|
||||||
checked={props.checked}
|
checked={props.checked}
|
||||||
|
|
@ -72,12 +72,7 @@ export const AxoSwitch = memo((props: AxoSwitchProps) => {
|
||||||
/>
|
/>
|
||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
AxoSwitch.displayName = `${Namespace}`;
|
Root.displayName = `${Namespace}.Root`;
|
||||||
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export
|
|
||||||
export namespace AxoSwitch {
|
|
||||||
export type Props = AxoSwitchProps;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ const { useDirection } = Direction;
|
||||||
|
|
||||||
const Namespace = 'AxoSymbol';
|
const Namespace = 'AxoSymbol';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
||||||
export namespace AxoSymbol {
|
export namespace AxoSymbol {
|
||||||
const symbolStyles = tw('font-symbols select-none');
|
const symbolStyles = tw('font-symbols select-none');
|
||||||
const labelStyles = tw('select-none');
|
const labelStyles = tw('select-none');
|
||||||
|
|
@ -83,7 +82,7 @@ export namespace AxoSymbol {
|
||||||
|
|
||||||
export type IconProps = Readonly<{
|
export type IconProps = Readonly<{
|
||||||
size: IconSize;
|
size: IconSize;
|
||||||
symbol: AxoSymbolIconName;
|
symbol: IconName;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
@ -93,7 +92,6 @@ export namespace AxoSymbol {
|
||||||
|
|
||||||
export const Icon: FC<IconProps> = memo(props => {
|
export const Icon: FC<IconProps> = memo(props => {
|
||||||
const config = IconSizes[props.size];
|
const config = IconSizes[props.size];
|
||||||
|
|
||||||
const direction = useDirection();
|
const direction = useDirection();
|
||||||
const glyph = getAxoSymbolIcon(props.symbol, direction);
|
const glyph = getAxoSymbolIcon(props.symbol, direction);
|
||||||
const content = useRenderSymbol(glyph, props.label);
|
const content = useRenderSymbol(glyph, props.label);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import type { ReactNode } from 'react';
|
||||||
import { tw } from '../tw.js';
|
import { tw } from '../tw.js';
|
||||||
import { AxoSymbol } from '../AxoSymbol.js';
|
import { AxoSymbol } from '../AxoSymbol.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
||||||
export namespace AxoBaseMenu {
|
export namespace AxoBaseMenu {
|
||||||
// <Content/SubContent>
|
// <Content/SubContent>
|
||||||
const baseContentStyles = tw(
|
const baseContentStyles = tw(
|
||||||
|
|
@ -114,7 +113,7 @@ export namespace AxoBaseMenu {
|
||||||
* -----------------------
|
* -----------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const itemTextStyles = tw('flex-1 truncate text-start');
|
export const itemTextStyles = tw('flex-auto grow-0 truncate text-start');
|
||||||
|
|
||||||
export type ItemTextProps = Readonly<{
|
export type ItemTextProps = Readonly<{
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
@ -283,7 +282,7 @@ export namespace AxoBaseMenu {
|
||||||
/**
|
/**
|
||||||
* The value of the selected item in the group.
|
* The value of the selected item in the group.
|
||||||
*/
|
*/
|
||||||
value: string;
|
value: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event handler called when the value changes.
|
* Event handler called when the value changes.
|
||||||
|
|
|
||||||
269
ts/axo/_internal/AxoBaseSegmentedControl.tsx
Normal file
269
ts/axo/_internal/AxoBaseSegmentedControl.tsx
Normal 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`;
|
||||||
|
}
|
||||||
|
|
@ -15,3 +15,9 @@ export function assert<T>(input: T, message?: string): NonNullable<T> {
|
||||||
}
|
}
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function unreachable(_value: never): never {
|
||||||
|
// eslint-disable-next-line no-debugger
|
||||||
|
debugger;
|
||||||
|
throw new AssertionError('unreachable');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export default {
|
||||||
otherTabsUnreadStats: {
|
otherTabsUnreadStats: {
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
unreadMentionsCount: 0,
|
unreadMentionsCount: 0,
|
||||||
markedUnread: false,
|
readChatsMarkedUnreadCount: 0,
|
||||||
},
|
},
|
||||||
isStaging: false,
|
isStaging: false,
|
||||||
hasPendingUpdate: false,
|
hasPendingUpdate: false,
|
||||||
|
|
|
||||||
|
|
@ -373,7 +373,8 @@ export const ConversationMessageRequest = (): JSX.Element =>
|
||||||
export function ConversationsUnreadCount(): JSX.Element {
|
export function ConversationsUnreadCount(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
rows={[4, 10, 34, 250, 2048].map(unreadCount => ({
|
rows={[4, 10, 34, 250, 2048, Number.MAX_SAFE_INTEGER].map(
|
||||||
|
unreadCount => ({
|
||||||
type: RowType.Conversation,
|
type: RowType.Conversation,
|
||||||
conversation: createConversation({
|
conversation: createConversation({
|
||||||
lastMessage: {
|
lastMessage: {
|
||||||
|
|
@ -383,7 +384,8 @@ export function ConversationsUnreadCount(): JSX.Element {
|
||||||
},
|
},
|
||||||
unreadCount,
|
unreadCount,
|
||||||
}),
|
}),
|
||||||
}))}
|
})
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { GroupListItem } from './conversationList/GroupListItem.js';
|
||||||
import { ListView } from './ListView.js';
|
import { ListView } from './ListView.js';
|
||||||
import { Button, ButtonVariant } from './Button.js';
|
import { Button, ButtonVariant } from './Button.js';
|
||||||
import { ListTile } from './ListTile.js';
|
import { ListTile } from './ListTile.js';
|
||||||
|
import type { RenderConversationListItemContextMenuProps } from './conversationList/BaseConversationListItem.js';
|
||||||
|
|
||||||
const { get, pick } = lodash;
|
const { get, pick } = lodash;
|
||||||
|
|
||||||
|
|
@ -238,6 +239,9 @@ export type PropsType = {
|
||||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||||
removeConversation: (conversationId: string) => void;
|
removeConversation: (conversationId: string) => void;
|
||||||
renderMessageSearchResult?: (id: string) => JSX.Element;
|
renderMessageSearchResult?: (id: string) => JSX.Element;
|
||||||
|
renderConversationListItemContextMenu?: (
|
||||||
|
props: RenderConversationListItemContextMenuProps
|
||||||
|
) => JSX.Element;
|
||||||
showChooseGroupMembers: () => void;
|
showChooseGroupMembers: () => void;
|
||||||
showFindByUsername: () => void;
|
showFindByUsername: () => void;
|
||||||
showFindByPhoneNumber: () => void;
|
showFindByPhoneNumber: () => void;
|
||||||
|
|
@ -264,6 +268,7 @@ export function ConversationList({
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
removeConversation,
|
removeConversation,
|
||||||
renderMessageSearchResult,
|
renderMessageSearchResult,
|
||||||
|
renderConversationListItemContextMenu,
|
||||||
rowCount,
|
rowCount,
|
||||||
scrollBehavior = ScrollBehavior.Default,
|
scrollBehavior = ScrollBehavior.Default,
|
||||||
scrollToRowIndex,
|
scrollToRowIndex,
|
||||||
|
|
@ -514,6 +519,9 @@ export function ConversationList({
|
||||||
onClick={onSelectConversation}
|
onClick={onSelectConversation}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
renderConversationListItemContextMenu={
|
||||||
|
renderConversationListItemContextMenu
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
@ -650,6 +658,7 @@ export function ConversationList({
|
||||||
onSelectConversation,
|
onSelectConversation,
|
||||||
removeConversation,
|
removeConversation,
|
||||||
renderMessageSearchResult,
|
renderMessageSearchResult,
|
||||||
|
renderConversationListItemContextMenu,
|
||||||
setIsFetchingUUID,
|
setIsFetchingUUID,
|
||||||
showChooseGroupMembers,
|
showChooseGroupMembers,
|
||||||
showFindByUsername,
|
showFindByUsername,
|
||||||
|
|
|
||||||
53
ts/components/DeleteMessagesConfirmationDialog.tsx
Normal file
53
ts/components/DeleteMessagesConfirmationDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -107,7 +107,7 @@ export function DisappearingTimerSelect(props: Props): JSX.Element {
|
||||||
{expirationTimerOptions.map(option => {
|
{expirationTimerOptions.map(option => {
|
||||||
return (
|
return (
|
||||||
<AxoSelect.Item key={option.value} value={String(option.value)}>
|
<AxoSelect.Item key={option.value} value={String(option.value)}>
|
||||||
{option.text}
|
<AxoSelect.ItemText>{option.text}</AxoSelect.ItemText>
|
||||||
</AxoSelect.Item>
|
</AxoSelect.Item>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ import {
|
||||||
} from '../test-helpers/fakeLookupConversationWithoutServiceId.js';
|
} from '../test-helpers/fakeLookupConversationWithoutServiceId.js';
|
||||||
import type { GroupListItemConversationType } from './conversationList/GroupListItem.js';
|
import type { GroupListItemConversationType } from './conversationList/GroupListItem.js';
|
||||||
import { ServerAlert } from '../util/handleServerAlerts.js';
|
import { ServerAlert } from '../util/handleServerAlerts.js';
|
||||||
|
import { LeftPaneChatFolders } from './leftPane/LeftPaneChatFolders.js';
|
||||||
|
import { LeftPaneConversationListItemContextMenu } from './leftPane/LeftPaneConversationListItemContextMenu.js';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
|
|
@ -144,7 +146,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||||
otherTabsUnreadStats: {
|
otherTabsUnreadStats: {
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
unreadMentionsCount: 0,
|
unreadMentionsCount: 0,
|
||||||
markedUnread: false,
|
readChatsMarkedUnreadCount: 0,
|
||||||
},
|
},
|
||||||
backupMediaDownloadProgress: {
|
backupMediaDownloadProgress: {
|
||||||
isBackupMediaEnabled: true,
|
isBackupMediaEnabled: true,
|
||||||
|
|
@ -298,6 +300,38 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||||
isInFullScreenCall={false}
|
isInFullScreenCall={false}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
renderLeftPaneChatFolders: () => (
|
||||||
|
<LeftPaneChatFolders
|
||||||
|
i18n={i18n}
|
||||||
|
navSidebarWidthBreakpoint={null}
|
||||||
|
sortedChatFolders={[]}
|
||||||
|
allChatFoldersUnreadStats={new Map()}
|
||||||
|
allChatFoldersMutedStats={new Map()}
|
||||||
|
selectedChatFolder={null}
|
||||||
|
onSelectedChatFolderIdChange={action('onSelectedChatFolderIdChange')}
|
||||||
|
onChatFolderMarkRead={action('onChatFolderMarkRead')}
|
||||||
|
onChatFolderUpdateMute={action('onChatFolderUpdateMute')}
|
||||||
|
onChatFolderOpenSettings={action('onChatFolderOpenSettings')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
renderConversationListItemContextMenu: props => (
|
||||||
|
<LeftPaneConversationListItemContextMenu
|
||||||
|
i18n={i18n}
|
||||||
|
conversation={getDefaultConversation()}
|
||||||
|
onMarkUnread={action('onMarkUnread')}
|
||||||
|
onMarkRead={action('onMarkRead')}
|
||||||
|
onPin={action('onPin')}
|
||||||
|
onUnpin={action('onUnpin')}
|
||||||
|
onUpdateMute={action('onUpdateMute')}
|
||||||
|
onArchive={action('onArchive')}
|
||||||
|
onUnarchive={action('onUnarchive')}
|
||||||
|
onDelete={action('onDelete')}
|
||||||
|
localDeleteWarningShown={false}
|
||||||
|
setLocalDeleteWarningShown={action('setLocalDeleteWarningShown')}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</LeftPaneConversationListItemContextMenu>
|
||||||
|
),
|
||||||
selectedConversationId: undefined,
|
selectedConversationId: undefined,
|
||||||
targetedMessageId: undefined,
|
targetedMessageId: undefined,
|
||||||
openUsernameReservationModal: action('openUsernameReservationModal'),
|
openUsernameReservationModal: action('openUsernameReservationModal'),
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ import type { ServerAlertsType } from '../util/handleServerAlerts.js';
|
||||||
import { getServerAlertDialog } from './ServerAlerts.js';
|
import { getServerAlertDialog } from './ServerAlerts.js';
|
||||||
import { NavTab, SettingsPage, ProfileEditorPage } from '../types/Nav.js';
|
import { NavTab, SettingsPage, ProfileEditorPage } from '../types/Nav.js';
|
||||||
import type { Location } from '../types/Nav.js';
|
import type { Location } from '../types/Nav.js';
|
||||||
|
import type { RenderConversationListItemContextMenuProps } from './conversationList/BaseConversationListItem.js';
|
||||||
|
|
||||||
const { isNumber } = lodash;
|
const { isNumber } = lodash;
|
||||||
|
|
||||||
|
|
@ -173,6 +174,9 @@ export type PropsType = {
|
||||||
|
|
||||||
// Render Props
|
// Render Props
|
||||||
renderMessageSearchResult: (id: string) => JSX.Element;
|
renderMessageSearchResult: (id: string) => JSX.Element;
|
||||||
|
renderConversationListItemContextMenu: (
|
||||||
|
props: RenderConversationListItemContextMenuProps
|
||||||
|
) => JSX.Element;
|
||||||
renderNetworkStatus: (
|
renderNetworkStatus: (
|
||||||
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||||
) => JSX.Element;
|
) => JSX.Element;
|
||||||
|
|
@ -188,6 +192,7 @@ export type PropsType = {
|
||||||
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
|
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
|
||||||
renderCrashReportDialog: () => JSX.Element;
|
renderCrashReportDialog: () => JSX.Element;
|
||||||
renderExpiredBuildDialog: (_: DialogExpiredBuildPropsType) => JSX.Element;
|
renderExpiredBuildDialog: (_: DialogExpiredBuildPropsType) => JSX.Element;
|
||||||
|
renderLeftPaneChatFolders: () => JSX.Element;
|
||||||
renderToastManager: (_: {
|
renderToastManager: (_: {
|
||||||
containerWidthBreakpoint: WidthBreakpoint;
|
containerWidthBreakpoint: WidthBreakpoint;
|
||||||
}) => JSX.Element;
|
}) => JSX.Element;
|
||||||
|
|
@ -237,7 +242,9 @@ export function LeftPane({
|
||||||
renderCaptchaDialog,
|
renderCaptchaDialog,
|
||||||
renderCrashReportDialog,
|
renderCrashReportDialog,
|
||||||
renderExpiredBuildDialog,
|
renderExpiredBuildDialog,
|
||||||
|
renderLeftPaneChatFolders,
|
||||||
renderMessageSearchResult,
|
renderMessageSearchResult,
|
||||||
|
renderConversationListItemContextMenu,
|
||||||
renderNetworkStatus,
|
renderNetworkStatus,
|
||||||
renderUnsupportedOSDialog,
|
renderUnsupportedOSDialog,
|
||||||
renderRelinkDialog,
|
renderRelinkDialog,
|
||||||
|
|
@ -519,6 +526,7 @@ export function LeftPane({
|
||||||
createGroup,
|
createGroup,
|
||||||
i18n,
|
i18n,
|
||||||
removeSelectedContact: toggleConversationInChooseMembers,
|
removeSelectedContact: toggleConversationInChooseMembers,
|
||||||
|
renderLeftPaneChatFolders,
|
||||||
setComposeGroupAvatar,
|
setComposeGroupAvatar,
|
||||||
setComposeGroupExpireTimer,
|
setComposeGroupExpireTimer,
|
||||||
setComposeGroupName,
|
setComposeGroupName,
|
||||||
|
|
@ -887,6 +895,9 @@ export function LeftPane({
|
||||||
}
|
}
|
||||||
removeConversation={removeConversation}
|
removeConversation={removeConversation}
|
||||||
renderMessageSearchResult={renderMessageSearchResult}
|
renderMessageSearchResult={renderMessageSearchResult}
|
||||||
|
renderConversationListItemContextMenu={
|
||||||
|
renderConversationListItemContextMenu
|
||||||
|
}
|
||||||
rowCount={helper.getRowCount()}
|
rowCount={helper.getRowCount()}
|
||||||
scrollBehavior={scrollBehavior}
|
scrollBehavior={scrollBehavior}
|
||||||
scrollToRowIndex={rowIndexToScrollTo}
|
scrollToRowIndex={rowIndexToScrollTo}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react';
|
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { createContext, useEffect, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useMove } from 'react-aria';
|
import { useMove } from 'react-aria';
|
||||||
import { NavTabsToggle } from './NavTabs.js';
|
import { NavTabsToggle } from './NavTabs.js';
|
||||||
|
|
@ -16,6 +16,9 @@ import {
|
||||||
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util.js';
|
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util.js';
|
||||||
import type { UnreadStats } from '../util/countUnreadStats.js';
|
import type { UnreadStats } from '../util/countUnreadStats.js';
|
||||||
|
|
||||||
|
export const NavSidebarWidthBreakpointContext =
|
||||||
|
createContext<WidthBreakpoint | null>(null);
|
||||||
|
|
||||||
type NavSidebarActionButtonProps = {
|
type NavSidebarActionButtonProps = {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
|
|
@ -158,6 +161,7 @@ export function NavSidebar({
|
||||||
}, [dragState]);
|
}, [dragState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<NavSidebarWidthBreakpointContext.Provider value={widthBreakpoint}>
|
||||||
<div
|
<div
|
||||||
role="navigation"
|
role="navigation"
|
||||||
className={classNames('NavSidebar', {
|
className={classNames('NavSidebar', {
|
||||||
|
|
@ -214,7 +218,8 @@ export function NavSidebar({
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={classNames('NavSidebar__DragHandle', {
|
className={classNames('NavSidebar__DragHandle', {
|
||||||
'NavSidebar__DragHandle--dragging': dragState === DragState.DRAGGING,
|
'NavSidebar__DragHandle--dragging':
|
||||||
|
dragState === DragState.DRAGGING,
|
||||||
})}
|
})}
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
|
|
@ -228,6 +233,7 @@ export function NavSidebar({
|
||||||
|
|
||||||
{renderToastManager({ containerWidthBreakpoint: widthBreakpoint })}
|
{renderToastManager({ containerWidthBreakpoint: widthBreakpoint })}
|
||||||
</div>
|
</div>
|
||||||
|
</NavSidebarWidthBreakpointContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ const createProps = (
|
||||||
unreadConversationsStats: overrideProps.unreadConversationsStats ?? {
|
unreadConversationsStats: overrideProps.unreadConversationsStats ?? {
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
unreadMentionsCount: 0,
|
unreadMentionsCount: 0,
|
||||||
markedUnread: false,
|
readChatsMarkedUnreadCount: 0,
|
||||||
},
|
},
|
||||||
unreadStoriesCount: overrideProps.unreadStoriesCount ?? 0,
|
unreadStoriesCount: overrideProps.unreadStoriesCount ?? 0,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -46,19 +46,21 @@ function NavTabsItemBadges({
|
||||||
|
|
||||||
if (unreadStats != null) {
|
if (unreadStats != null) {
|
||||||
if (unreadStats.unreadCount > 0) {
|
if (unreadStats.unreadCount > 0) {
|
||||||
|
const total =
|
||||||
|
unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount;
|
||||||
return (
|
return (
|
||||||
<span className="NavTabs__ItemUnreadBadge">
|
<span className="NavTabs__ItemUnreadBadge">
|
||||||
<span className="NavTabs__ItemIconLabel">
|
<span className="NavTabs__ItemIconLabel">
|
||||||
{i18n('icu:NavTabs__ItemIconLabel--UnreadCount', {
|
{i18n('icu:NavTabs__ItemIconLabel--UnreadCount', {
|
||||||
count: unreadStats.unreadCount,
|
count: total,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden>{unreadStats.unreadCount}</span>
|
<span aria-hidden>{total}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unreadStats.markedUnread) {
|
if (unreadStats.readChatsMarkedUnreadCount > 0) {
|
||||||
return (
|
return (
|
||||||
<span className="NavTabs__ItemUnreadBadge">
|
<span className="NavTabs__ItemUnreadBadge">
|
||||||
<span className="NavTabs__ItemIconLabel">
|
<span className="NavTabs__ItemIconLabel">
|
||||||
|
|
@ -307,7 +309,7 @@ export function NavTabs({
|
||||||
unreadStats={{
|
unreadStats={{
|
||||||
unreadCount: unreadCallsCount,
|
unreadCount: unreadCallsCount,
|
||||||
unreadMentionsCount: 0,
|
unreadMentionsCount: 0,
|
||||||
markedUnread: false,
|
readChatsMarkedUnreadCount: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{storiesEnabled && (
|
{storiesEnabled && (
|
||||||
|
|
@ -321,7 +323,7 @@ export function NavTabs({
|
||||||
unreadStats={{
|
unreadStats={{
|
||||||
unreadCount: unreadStoriesCount,
|
unreadCount: unreadStoriesCount,
|
||||||
unreadMentionsCount: 0,
|
unreadMentionsCount: 0,
|
||||||
markedUnread: false,
|
readChatsMarkedUnreadCount: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -331,11 +333,7 @@ export function NavTabs({
|
||||||
label={i18n('icu:NavTabs__ItemLabel--Settings')}
|
label={i18n('icu:NavTabs__ItemLabel--Settings')}
|
||||||
iconClassName="NavTabs__ItemIcon--Settings"
|
iconClassName="NavTabs__ItemIcon--Settings"
|
||||||
navTabClassName="NavTabs__Item--Settings"
|
navTabClassName="NavTabs__Item--Settings"
|
||||||
unreadStats={{
|
unreadStats={null}
|
||||||
unreadCount: 0,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: false,
|
|
||||||
}}
|
|
||||||
hasPendingUpdate={hasPendingUpdate}
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
/>
|
/>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ import {
|
||||||
UsernameEditState,
|
UsernameEditState,
|
||||||
UsernameLinkState,
|
UsernameLinkState,
|
||||||
} from '../state/ducks/usernameEnums.js';
|
} from '../state/ducks/usernameEnums.js';
|
||||||
import { ProfileEditorPage, SettingsPage } from '../types/Nav.js';
|
import type { SettingsLocation } from '../types/Nav.js';
|
||||||
|
import { NavTab, ProfileEditorPage, SettingsPage } from '../types/Nav.js';
|
||||||
import { PreferencesDonations } from './PreferencesDonations.js';
|
import { PreferencesDonations } from './PreferencesDonations.js';
|
||||||
import { strictAssert } from '../util/assert.js';
|
import { strictAssert } from '../util/assert.js';
|
||||||
|
|
||||||
|
|
@ -220,8 +221,8 @@ function renderProfileEditor({
|
||||||
|
|
||||||
function renderDonationsPane(props: {
|
function renderDonationsPane(props: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
page: SettingsPage;
|
settingsLocation: SettingsLocation;
|
||||||
setPage: (page: SettingsPage) => void;
|
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||||
me: typeof me;
|
me: typeof me;
|
||||||
donationReceipts: ReadonlyArray<DonationReceipt>;
|
donationReceipts: ReadonlyArray<DonationReceipt>;
|
||||||
saveAttachmentToDisk: (options: {
|
saveAttachmentToDisk: (options: {
|
||||||
|
|
@ -245,8 +246,8 @@ function renderDonationsPane(props: {
|
||||||
initialCurrency="usd"
|
initialCurrency="usd"
|
||||||
resumeWorkflow={action('resumeWorkflow')}
|
resumeWorkflow={action('resumeWorkflow')}
|
||||||
isOnline
|
isOnline
|
||||||
page={props.page}
|
settingsLocation={props.settingsLocation}
|
||||||
setPage={props.setPage}
|
setSettingsLocation={props.setSettingsLocation}
|
||||||
submitDonation={action('submitDonation')}
|
submitDonation={action('submitDonation')}
|
||||||
lastError={undefined}
|
lastError={undefined}
|
||||||
workflow={props.workflow}
|
workflow={props.workflow}
|
||||||
|
|
@ -286,6 +287,8 @@ function renderPreferencesChatFoldersPage(
|
||||||
chatFolders={[]}
|
chatFolders={[]}
|
||||||
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
|
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
|
||||||
onCreateChatFolder={action('onCreateChatFolder')}
|
onCreateChatFolder={action('onCreateChatFolder')}
|
||||||
|
onDeleteChatFolder={action('onDeletChatFolder')}
|
||||||
|
onUpdateChatFoldersPositions={action('onUpdateChatFoldersPositions')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -297,10 +300,14 @@ function renderPreferencesEditChatFolderPage(
|
||||||
<PreferencesEditChatFolderPage
|
<PreferencesEditChatFolderPage
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
theme={ThemeType.light}
|
theme={ThemeType.light}
|
||||||
onBack={props.onBack}
|
previousLocation={{
|
||||||
|
tab: NavTab.Settings,
|
||||||
|
details: { page: SettingsPage.ChatFolders },
|
||||||
|
}}
|
||||||
settingsPaneRef={props.settingsPaneRef}
|
settingsPaneRef={props.settingsPaneRef}
|
||||||
existingChatFolderId={props.existingChatFolderId}
|
existingChatFolderId={props.existingChatFolderId}
|
||||||
initChatFolderParams={CHAT_FOLDER_DEFAULTS}
|
initChatFolderParams={CHAT_FOLDER_DEFAULTS}
|
||||||
|
changeLocation={action('changeLocation')}
|
||||||
onCreateChatFolder={action('onCreateChatFolder')}
|
onCreateChatFolder={action('onCreateChatFolder')}
|
||||||
onUpdateChatFolder={action('onUpdateChatFolder')}
|
onUpdateChatFolder={action('onUpdateChatFolder')}
|
||||||
onDeleteChatFolder={action('onDeleteChatFolder')}
|
onDeleteChatFolder={action('onDeleteChatFolder')}
|
||||||
|
|
@ -403,9 +410,12 @@ export default {
|
||||||
otherTabsUnreadStats: {
|
otherTabsUnreadStats: {
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
unreadMentionsCount: 0,
|
unreadMentionsCount: 0,
|
||||||
markedUnread: false,
|
readChatsMarkedUnreadCount: 0,
|
||||||
},
|
},
|
||||||
|
settingsLocation: {
|
||||||
page: SettingsPage.Profile,
|
page: SettingsPage.Profile,
|
||||||
|
state: ProfileEditorPage.None,
|
||||||
|
},
|
||||||
preferredSystemLocales: ['en'],
|
preferredSystemLocales: ['en'],
|
||||||
preferredWidthFromStorage: 300,
|
preferredWidthFromStorage: 300,
|
||||||
resolvedLocale: 'en',
|
resolvedLocale: 'en',
|
||||||
|
|
@ -424,17 +434,17 @@ export default {
|
||||||
|
|
||||||
renderDonationsPane: ({
|
renderDonationsPane: ({
|
||||||
contentsRef,
|
contentsRef,
|
||||||
page,
|
settingsLocation,
|
||||||
setPage,
|
setSettingsLocation,
|
||||||
}: {
|
}: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
page: SettingsPage;
|
settingsLocation: SettingsLocation;
|
||||||
setPage: (page: SettingsPage) => void;
|
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||||
}) =>
|
}) =>
|
||||||
renderDonationsPane({
|
renderDonationsPane({
|
||||||
contentsRef,
|
contentsRef,
|
||||||
page,
|
settingsLocation,
|
||||||
setPage,
|
setSettingsLocation,
|
||||||
me,
|
me,
|
||||||
donationReceipts: [],
|
donationReceipts: [],
|
||||||
saveAttachmentToDisk: async () => {
|
saveAttachmentToDisk: async () => {
|
||||||
|
|
@ -534,7 +544,7 @@ export default {
|
||||||
setGlobalDefaultConversationColor: action(
|
setGlobalDefaultConversationColor: action(
|
||||||
'setGlobalDefaultConversationColor'
|
'setGlobalDefaultConversationColor'
|
||||||
),
|
),
|
||||||
setPage: action('setPage'),
|
setSettingsLocation: action('setSettingsLocation'),
|
||||||
showToast: action('showToast'),
|
showToast: action('showToast'),
|
||||||
validateBackup: async () => {
|
validateBackup: async () => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -559,18 +569,17 @@ export default {
|
||||||
|
|
||||||
// eslint-disable-next-line react/function-component-definition
|
// eslint-disable-next-line react/function-component-definition
|
||||||
const Template: StoryFn<PropsType> = args => {
|
const Template: StoryFn<PropsType> = args => {
|
||||||
const [page, setPage] = useState(args.page);
|
const [settingsLocation, setSettingsLocation] = useState(
|
||||||
|
args.settingsLocation
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Preferences
|
<Preferences
|
||||||
{...args}
|
{...args}
|
||||||
page={page}
|
settingsLocation={settingsLocation}
|
||||||
setPage={(
|
setSettingsLocation={(newSettingsLocation: SettingsLocation) => {
|
||||||
newPage: SettingsPage,
|
|
||||||
profilePage: ProfileEditorPage | undefined
|
|
||||||
) => {
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('setPage:', newPage, profilePage);
|
console.log('setSettingsLocation:', newSettingsLocation);
|
||||||
setPage(newPage);
|
setSettingsLocation(newSettingsLocation);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -580,62 +589,69 @@ export const _Preferences = Template.bind({});
|
||||||
|
|
||||||
export const General = Template.bind({});
|
export const General = Template.bind({});
|
||||||
General.args = {
|
General.args = {
|
||||||
page: SettingsPage.General,
|
settingsLocation: { page: SettingsPage.General },
|
||||||
};
|
};
|
||||||
export const Appearance = Template.bind({});
|
export const Appearance = Template.bind({});
|
||||||
Appearance.args = {
|
Appearance.args = {
|
||||||
page: SettingsPage.Appearance,
|
settingsLocation: { page: SettingsPage.Appearance },
|
||||||
};
|
};
|
||||||
export const Chats = Template.bind({});
|
export const Chats = Template.bind({});
|
||||||
Chats.args = {
|
Chats.args = {
|
||||||
page: SettingsPage.Chats,
|
settingsLocation: { page: SettingsPage.Chats },
|
||||||
};
|
};
|
||||||
export const ChatFolders = Template.bind({});
|
export const ChatFolders = Template.bind({});
|
||||||
ChatFolders.args = {
|
ChatFolders.args = {
|
||||||
page: SettingsPage.ChatFolders,
|
settingsLocation: { page: SettingsPage.ChatFolders },
|
||||||
};
|
};
|
||||||
export const EditChatFolder = Template.bind({});
|
export const EditChatFolder = Template.bind({});
|
||||||
EditChatFolder.args = {
|
EditChatFolder.args = {
|
||||||
|
settingsLocation: {
|
||||||
page: SettingsPage.EditChatFolder,
|
page: SettingsPage.EditChatFolder,
|
||||||
|
chatFolderId: null,
|
||||||
|
previousLocation: {
|
||||||
|
tab: NavTab.Settings,
|
||||||
|
details: { page: SettingsPage.ChatFolders },
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
export const Calls = Template.bind({});
|
export const Calls = Template.bind({});
|
||||||
Calls.args = {
|
Calls.args = {
|
||||||
page: SettingsPage.Calls,
|
settingsLocation: { page: SettingsPage.Calls },
|
||||||
};
|
};
|
||||||
export const Notifications = Template.bind({});
|
export const Notifications = Template.bind({});
|
||||||
Notifications.args = {
|
Notifications.args = {
|
||||||
page: SettingsPage.Notifications,
|
settingsLocation: { page: SettingsPage.Notifications },
|
||||||
};
|
};
|
||||||
export const Privacy = Template.bind({});
|
export const Privacy = Template.bind({});
|
||||||
Privacy.args = {
|
Privacy.args = {
|
||||||
page: SettingsPage.Privacy,
|
settingsLocation: { page: SettingsPage.Privacy },
|
||||||
};
|
};
|
||||||
export const DataUsage = Template.bind({});
|
export const DataUsage = Template.bind({});
|
||||||
DataUsage.args = {
|
DataUsage.args = {
|
||||||
page: SettingsPage.DataUsage,
|
settingsLocation: { page: SettingsPage.DataUsage },
|
||||||
};
|
};
|
||||||
export const Donations = Template.bind({});
|
export const Donations = Template.bind({});
|
||||||
Donations.args = {
|
Donations.args = {
|
||||||
donationsFeatureEnabled: true,
|
donationsFeatureEnabled: true,
|
||||||
page: SettingsPage.Donations,
|
settingsLocation: { page: SettingsPage.Donations },
|
||||||
};
|
};
|
||||||
export const DonationsDonateFlow = Template.bind({});
|
export const DonationsDonateFlow = Template.bind({});
|
||||||
DonationsDonateFlow.args = {
|
DonationsDonateFlow.args = {
|
||||||
donationsFeatureEnabled: true,
|
donationsFeatureEnabled: true,
|
||||||
page: SettingsPage.DonationsDonateFlow,
|
settingsLocation: { page: SettingsPage.DonationsDonateFlow },
|
||||||
renderDonationsPane: ({
|
renderDonationsPane: ({
|
||||||
contentsRef,
|
contentsRef,
|
||||||
}: {
|
}: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
page: SettingsPage;
|
settingsLocation: SettingsLocation;
|
||||||
setPage: (page: SettingsPage) => void;
|
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||||
}) =>
|
}) =>
|
||||||
renderDonationsPane({
|
renderDonationsPane({
|
||||||
contentsRef,
|
contentsRef,
|
||||||
me,
|
me,
|
||||||
donationReceipts: [],
|
donationReceipts: [],
|
||||||
page: SettingsPage.DonationsDonateFlow,
|
settingsLocation: { page: SettingsPage.DonationsDonateFlow },
|
||||||
setPage: action('setPage'),
|
setSettingsLocation: action('setSettingsLocation'),
|
||||||
saveAttachmentToDisk: async () => {
|
saveAttachmentToDisk: async () => {
|
||||||
action('saveAttachmentToDisk')();
|
action('saveAttachmentToDisk')();
|
||||||
return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
|
return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
|
||||||
|
|
@ -650,13 +666,13 @@ DonationsDonateFlow.args = {
|
||||||
export const DonationReceipts = Template.bind({});
|
export const DonationReceipts = Template.bind({});
|
||||||
DonationReceipts.args = {
|
DonationReceipts.args = {
|
||||||
donationsFeatureEnabled: true,
|
donationsFeatureEnabled: true,
|
||||||
page: SettingsPage.DonationsDonateFlow,
|
settingsLocation: { page: SettingsPage.DonationsDonateFlow },
|
||||||
renderDonationsPane: ({
|
renderDonationsPane: ({
|
||||||
contentsRef,
|
contentsRef,
|
||||||
}: {
|
}: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
page: SettingsPage;
|
settingsLocation: SettingsLocation;
|
||||||
setPage: (page: SettingsPage) => void;
|
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||||
}) =>
|
}) =>
|
||||||
renderDonationsPane({
|
renderDonationsPane({
|
||||||
contentsRef,
|
contentsRef,
|
||||||
|
|
@ -675,8 +691,8 @@ DonationReceipts.args = {
|
||||||
timestamp: 1753995255509,
|
timestamp: 1753995255509,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
page: SettingsPage.DonationsReceiptList,
|
settingsLocation: { page: SettingsPage.DonationsReceiptList },
|
||||||
setPage: action('setPage'),
|
setSettingsLocation: action('setSettingsLocation'),
|
||||||
saveAttachmentToDisk: async () => {
|
saveAttachmentToDisk: async () => {
|
||||||
action('saveAttachmentToDisk')();
|
action('saveAttachmentToDisk')();
|
||||||
return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
|
return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
|
||||||
|
|
@ -691,7 +707,7 @@ DonationReceipts.args = {
|
||||||
export const DonationsHomeWithInProgressDonation = Template.bind({});
|
export const DonationsHomeWithInProgressDonation = Template.bind({});
|
||||||
DonationsHomeWithInProgressDonation.args = {
|
DonationsHomeWithInProgressDonation.args = {
|
||||||
donationsFeatureEnabled: true,
|
donationsFeatureEnabled: true,
|
||||||
page: SettingsPage.Donations,
|
settingsLocation: { page: SettingsPage.Donations },
|
||||||
renderDonationsPane: ({
|
renderDonationsPane: ({
|
||||||
contentsRef,
|
contentsRef,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -701,8 +717,8 @@ DonationsHomeWithInProgressDonation.args = {
|
||||||
contentsRef,
|
contentsRef,
|
||||||
me,
|
me,
|
||||||
donationReceipts: [],
|
donationReceipts: [],
|
||||||
page: SettingsPage.Donations,
|
settingsLocation: { page: SettingsPage.Donations },
|
||||||
setPage: action('setPage'),
|
setSettingsLocation: action('setSettingsLocation'),
|
||||||
saveAttachmentToDisk: async () => {
|
saveAttachmentToDisk: async () => {
|
||||||
action('saveAttachmentToDisk')();
|
action('saveAttachmentToDisk')();
|
||||||
return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
|
return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
|
||||||
|
|
@ -727,45 +743,45 @@ DonationsHomeWithInProgressDonation.args = {
|
||||||
};
|
};
|
||||||
export const Internal = Template.bind({});
|
export const Internal = Template.bind({});
|
||||||
Internal.args = {
|
Internal.args = {
|
||||||
page: SettingsPage.Internal,
|
settingsLocation: { page: SettingsPage.Internal },
|
||||||
isInternalUser: true,
|
isInternalUser: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Blocked1 = Template.bind({});
|
export const Blocked1 = Template.bind({});
|
||||||
Blocked1.args = {
|
Blocked1.args = {
|
||||||
blockedCount: 1,
|
blockedCount: 1,
|
||||||
page: SettingsPage.Privacy,
|
settingsLocation: { page: SettingsPage.Privacy },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BlockedMany = Template.bind({});
|
export const BlockedMany = Template.bind({});
|
||||||
BlockedMany.args = {
|
BlockedMany.args = {
|
||||||
blockedCount: 55,
|
blockedCount: 55,
|
||||||
page: SettingsPage.Privacy,
|
settingsLocation: { page: SettingsPage.Privacy },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CustomUniversalExpireTimer = Template.bind({});
|
export const CustomUniversalExpireTimer = Template.bind({});
|
||||||
CustomUniversalExpireTimer.args = {
|
CustomUniversalExpireTimer.args = {
|
||||||
universalExpireTimer: DurationInSeconds.fromSeconds(9000),
|
universalExpireTimer: DurationInSeconds.fromSeconds(9000),
|
||||||
page: SettingsPage.Privacy,
|
settingsLocation: { page: SettingsPage.Privacy },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PNPSharingDisabled = Template.bind({});
|
export const PNPSharingDisabled = Template.bind({});
|
||||||
PNPSharingDisabled.args = {
|
PNPSharingDisabled.args = {
|
||||||
whoCanSeeMe: PhoneNumberSharingMode.Nobody,
|
whoCanSeeMe: PhoneNumberSharingMode.Nobody,
|
||||||
whoCanFindMe: PhoneNumberDiscoverability.Discoverable,
|
whoCanFindMe: PhoneNumberDiscoverability.Discoverable,
|
||||||
page: SettingsPage.PNP,
|
settingsLocation: { page: SettingsPage.PNP },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PNPDiscoverabilityDisabled = Template.bind({});
|
export const PNPDiscoverabilityDisabled = Template.bind({});
|
||||||
PNPDiscoverabilityDisabled.args = {
|
PNPDiscoverabilityDisabled.args = {
|
||||||
whoCanSeeMe: PhoneNumberSharingMode.Nobody,
|
whoCanSeeMe: PhoneNumberSharingMode.Nobody,
|
||||||
whoCanFindMe: PhoneNumberDiscoverability.NotDiscoverable,
|
whoCanFindMe: PhoneNumberDiscoverability.NotDiscoverable,
|
||||||
page: SettingsPage.PNP,
|
settingsLocation: { page: SettingsPage.PNP },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackupsMediaDownloadActive = Template.bind({});
|
export const BackupsMediaDownloadActive = Template.bind({});
|
||||||
BackupsMediaDownloadActive.args = {
|
BackupsMediaDownloadActive.args = {
|
||||||
page: SettingsPage.BackupsDetails,
|
settingsLocation: { page: SettingsPage.BackupsDetails },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
cloudBackupStatus: {
|
cloudBackupStatus: {
|
||||||
|
|
@ -789,7 +805,7 @@ BackupsMediaDownloadActive.args = {
|
||||||
};
|
};
|
||||||
export const BackupsMediaDownloadPaused = Template.bind({});
|
export const BackupsMediaDownloadPaused = Template.bind({});
|
||||||
BackupsMediaDownloadPaused.args = {
|
BackupsMediaDownloadPaused.args = {
|
||||||
page: SettingsPage.BackupsDetails,
|
settingsLocation: { page: SettingsPage.BackupsDetails },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
cloudBackupStatus: {
|
cloudBackupStatus: {
|
||||||
|
|
@ -814,7 +830,7 @@ BackupsMediaDownloadPaused.args = {
|
||||||
|
|
||||||
export const BackupsPaidActive = Template.bind({});
|
export const BackupsPaidActive = Template.bind({});
|
||||||
BackupsPaidActive.args = {
|
BackupsPaidActive.args = {
|
||||||
page: SettingsPage.Backups,
|
settingsLocation: { page: SettingsPage.Backups },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
cloudBackupStatus: {
|
cloudBackupStatus: {
|
||||||
|
|
@ -833,7 +849,7 @@ BackupsPaidActive.args = {
|
||||||
|
|
||||||
export const BackupsPaidCanceled = Template.bind({});
|
export const BackupsPaidCanceled = Template.bind({});
|
||||||
BackupsPaidCanceled.args = {
|
BackupsPaidCanceled.args = {
|
||||||
page: SettingsPage.Backups,
|
settingsLocation: { page: SettingsPage.Backups },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
cloudBackupStatus: {
|
cloudBackupStatus: {
|
||||||
|
|
@ -852,7 +868,7 @@ BackupsPaidCanceled.args = {
|
||||||
|
|
||||||
export const BackupsFree = Template.bind({});
|
export const BackupsFree = Template.bind({});
|
||||||
BackupsFree.args = {
|
BackupsFree.args = {
|
||||||
page: SettingsPage.Backups,
|
settingsLocation: { page: SettingsPage.Backups },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
backupSubscriptionStatus: {
|
backupSubscriptionStatus: {
|
||||||
|
|
@ -862,7 +878,7 @@ BackupsFree.args = {
|
||||||
};
|
};
|
||||||
export const BackupsFreeNoLocal = Template.bind({});
|
export const BackupsFreeNoLocal = Template.bind({});
|
||||||
BackupsFreeNoLocal.args = {
|
BackupsFreeNoLocal.args = {
|
||||||
page: SettingsPage.Backups,
|
settingsLocation: { page: SettingsPage.Backups },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: false,
|
backupLocalBackupsEnabled: false,
|
||||||
backupSubscriptionStatus: {
|
backupSubscriptionStatus: {
|
||||||
|
|
@ -873,28 +889,28 @@ BackupsFreeNoLocal.args = {
|
||||||
|
|
||||||
export const BackupsOff = Template.bind({});
|
export const BackupsOff = Template.bind({});
|
||||||
BackupsOff.args = {
|
BackupsOff.args = {
|
||||||
page: SettingsPage.Backups,
|
settingsLocation: { page: SettingsPage.Backups },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackupsLocalBackups = Template.bind({});
|
export const BackupsLocalBackups = Template.bind({});
|
||||||
BackupsLocalBackups.args = {
|
BackupsLocalBackups.args = {
|
||||||
page: SettingsPage.Backups,
|
settingsLocation: { page: SettingsPage.Backups },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackupsRemoteEnabledLocalDisabled = Template.bind({});
|
export const BackupsRemoteEnabledLocalDisabled = Template.bind({});
|
||||||
BackupsRemoteEnabledLocalDisabled.args = {
|
BackupsRemoteEnabledLocalDisabled.args = {
|
||||||
page: SettingsPage.Backups,
|
settingsLocation: { page: SettingsPage.Backups },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: false,
|
backupLocalBackupsEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackupsSubscriptionNotFound = Template.bind({});
|
export const BackupsSubscriptionNotFound = Template.bind({});
|
||||||
BackupsSubscriptionNotFound.args = {
|
BackupsSubscriptionNotFound.args = {
|
||||||
page: SettingsPage.Backups,
|
settingsLocation: { page: SettingsPage.Backups },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
backupSubscriptionStatus: {
|
backupSubscriptionStatus: {
|
||||||
|
|
@ -908,7 +924,7 @@ BackupsSubscriptionNotFound.args = {
|
||||||
|
|
||||||
export const BackupsSubscriptionExpired = Template.bind({});
|
export const BackupsSubscriptionExpired = Template.bind({});
|
||||||
BackupsSubscriptionExpired.args = {
|
BackupsSubscriptionExpired.args = {
|
||||||
page: SettingsPage.Backups,
|
settingsLocation: { page: SettingsPage.Backups },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
backupSubscriptionStatus: {
|
backupSubscriptionStatus: {
|
||||||
|
|
@ -918,7 +934,7 @@ BackupsSubscriptionExpired.args = {
|
||||||
|
|
||||||
export const LocalBackups = Template.bind({});
|
export const LocalBackups = Template.bind({});
|
||||||
LocalBackups.args = {
|
LocalBackups.args = {
|
||||||
page: SettingsPage.LocalBackups,
|
settingsLocation: { page: SettingsPage.LocalBackups },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
backupKeyViewed: true,
|
backupKeyViewed: true,
|
||||||
|
|
@ -927,14 +943,14 @@ LocalBackups.args = {
|
||||||
|
|
||||||
export const LocalBackupsSetupChooseFolder = Template.bind({});
|
export const LocalBackupsSetupChooseFolder = Template.bind({});
|
||||||
LocalBackupsSetupChooseFolder.args = {
|
LocalBackupsSetupChooseFolder.args = {
|
||||||
page: SettingsPage.LocalBackupsSetupFolder,
|
settingsLocation: { page: SettingsPage.LocalBackupsSetupFolder },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LocalBackupsSetupViewBackupKey = Template.bind({});
|
export const LocalBackupsSetupViewBackupKey = Template.bind({});
|
||||||
LocalBackupsSetupViewBackupKey.args = {
|
LocalBackupsSetupViewBackupKey.args = {
|
||||||
page: SettingsPage.LocalBackupsSetupKey,
|
settingsLocation: { page: SettingsPage.LocalBackupsSetupKey },
|
||||||
backupFeatureEnabled: true,
|
backupFeatureEnabled: true,
|
||||||
backupLocalBackupsEnabled: true,
|
backupLocalBackupsEnabled: true,
|
||||||
localBackupFolder: '/home/signaluser/Signal Backups/',
|
localBackupFolder: '/home/signaluser/Signal Backups/',
|
||||||
|
|
@ -957,7 +973,7 @@ NavTabsCollapsedWithBadges.args = {
|
||||||
otherTabsUnreadStats: {
|
otherTabsUnreadStats: {
|
||||||
unreadCount: 1,
|
unreadCount: 1,
|
||||||
unreadMentionsCount: 2,
|
unreadMentionsCount: 2,
|
||||||
markedUnread: false,
|
readChatsMarkedUnreadCount: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -968,7 +984,7 @@ NavTabsCollapsedWithExclamation.args = {
|
||||||
otherTabsUnreadStats: {
|
otherTabsUnreadStats: {
|
||||||
unreadCount: 1,
|
unreadCount: 1,
|
||||||
unreadMentionsCount: 2,
|
unreadMentionsCount: 2,
|
||||||
markedUnread: true,
|
readChatsMarkedUnreadCount: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ import { PreferencesInternal } from './PreferencesInternal.js';
|
||||||
import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider.js';
|
import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider.js';
|
||||||
import { Avatar, AvatarSize } from './Avatar.js';
|
import { Avatar, AvatarSize } from './Avatar.js';
|
||||||
import { NavSidebar } from './NavSidebar.js';
|
import { NavSidebar } from './NavSidebar.js';
|
||||||
import { SettingsPage, ProfileEditorPage } from '../types/Nav.js';
|
import type { SettingsLocation } from '../types/Nav.js';
|
||||||
|
import { SettingsPage, ProfileEditorPage, NavTab } from '../types/Nav.js';
|
||||||
|
|
||||||
import type { MediaDeviceSettings } from '../types/Calling.js';
|
import type { MediaDeviceSettings } from '../types/Calling.js';
|
||||||
import type { ValidationResultType as BackupValidationResultType } from '../services/backups/index.js';
|
import type { ValidationResultType as BackupValidationResultType } from '../services/backups/index.js';
|
||||||
|
|
@ -149,7 +150,7 @@ export type PropsDataType = {
|
||||||
hasTextFormatting: boolean;
|
hasTextFormatting: boolean;
|
||||||
hasTypingIndicators: boolean;
|
hasTypingIndicators: boolean;
|
||||||
hasKeepMutedChatsArchived: boolean;
|
hasKeepMutedChatsArchived: boolean;
|
||||||
page: SettingsPage;
|
settingsLocation: SettingsLocation;
|
||||||
lastSyncTime?: number;
|
lastSyncTime?: number;
|
||||||
notificationContent: NotificationSettingType;
|
notificationContent: NotificationSettingType;
|
||||||
phoneNumber: string | undefined;
|
phoneNumber: string | undefined;
|
||||||
|
|
@ -204,8 +205,8 @@ type PropsFunctionType = {
|
||||||
// Render props
|
// Render props
|
||||||
renderDonationsPane: (options: {
|
renderDonationsPane: (options: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
page: SettingsPage;
|
settingsLocation: SettingsLocation;
|
||||||
setPage: (page: SettingsPage, profilePage?: ProfileEditorPage) => void;
|
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||||
}) => JSX.Element;
|
}) => JSX.Element;
|
||||||
renderProfileEditor: (options: {
|
renderProfileEditor: (options: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
|
@ -255,7 +256,7 @@ type PropsFunctionType = {
|
||||||
value: CustomColorType;
|
value: CustomColorType;
|
||||||
}
|
}
|
||||||
) => unknown;
|
) => unknown;
|
||||||
setPage: (page: SettingsPage, editState?: ProfileEditorPage) => unknown;
|
setSettingsLocation: (settingsLocation: SettingsLocation) => unknown;
|
||||||
showToast: (toast: AnyToast) => unknown;
|
showToast: (toast: AnyToast) => unknown;
|
||||||
validateBackup: () => Promise<BackupValidationResultType>;
|
validateBackup: () => Promise<BackupValidationResultType>;
|
||||||
|
|
||||||
|
|
@ -472,7 +473,7 @@ export function Preferences({
|
||||||
onWhoCanFindMeChange,
|
onWhoCanFindMeChange,
|
||||||
onZoomFactorChange,
|
onZoomFactorChange,
|
||||||
otherTabsUnreadStats,
|
otherTabsUnreadStats,
|
||||||
page,
|
settingsLocation,
|
||||||
phoneNumber = '',
|
phoneNumber = '',
|
||||||
pickLocalBackupFolder,
|
pickLocalBackupFolder,
|
||||||
preferredSystemLocales,
|
preferredSystemLocales,
|
||||||
|
|
@ -497,7 +498,7 @@ export function Preferences({
|
||||||
selectedSpeaker,
|
selectedSpeaker,
|
||||||
sentMediaQualitySetting,
|
sentMediaQualitySetting,
|
||||||
setGlobalDefaultConversationColor,
|
setGlobalDefaultConversationColor,
|
||||||
setPage,
|
setSettingsLocation,
|
||||||
shouldShowUpdateDialog,
|
shouldShowUpdateDialog,
|
||||||
showToast,
|
showToast,
|
||||||
localeOverride,
|
localeOverride,
|
||||||
|
|
@ -537,22 +538,20 @@ export function Preferences({
|
||||||
const [confirmPnpNotDiscoverable, setConfirmPnpNoDiscoverable] =
|
const [confirmPnpNotDiscoverable, setConfirmPnpNoDiscoverable] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const [editChatFolderPageId, setEditChatFolderPageId] =
|
|
||||||
useState<ChatFolderId | null>(null);
|
|
||||||
|
|
||||||
const handleOpenEditChatFoldersPage = useCallback(
|
const handleOpenEditChatFoldersPage = useCallback(
|
||||||
(chatFolderId: ChatFolderId | null) => {
|
(chatFolderId: ChatFolderId | null) => {
|
||||||
setPage(SettingsPage.EditChatFolder);
|
setSettingsLocation({
|
||||||
setEditChatFolderPageId(chatFolderId);
|
page: SettingsPage.EditChatFolder,
|
||||||
|
chatFolderId,
|
||||||
|
previousLocation: {
|
||||||
|
tab: NavTab.Settings,
|
||||||
|
details: settingsLocation,
|
||||||
},
|
},
|
||||||
[setPage]
|
});
|
||||||
|
},
|
||||||
|
[setSettingsLocation, settingsLocation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseEditChatFoldersPage = useCallback(() => {
|
|
||||||
setPage(SettingsPage.ChatFolders);
|
|
||||||
setEditChatFolderPageId(null);
|
|
||||||
}, [setPage]);
|
|
||||||
|
|
||||||
function closeLanguageDialog() {
|
function closeLanguageDialog() {
|
||||||
setLanguageDialog(null);
|
setLanguageDialog(null);
|
||||||
setSelectedLanguageLocale(localeOverride);
|
setSelectedLanguageLocale(localeOverride);
|
||||||
|
|
@ -560,14 +559,17 @@ export function Preferences({
|
||||||
const shouldShowBackupsPage =
|
const shouldShowBackupsPage =
|
||||||
backupFeatureEnabled || backupLocalBackupsEnabled;
|
backupFeatureEnabled || backupLocalBackupsEnabled;
|
||||||
|
|
||||||
if (page === SettingsPage.Backups && !shouldShowBackupsPage) {
|
if (
|
||||||
setPage(SettingsPage.General);
|
settingsLocation.page === SettingsPage.Backups &&
|
||||||
|
!shouldShowBackupsPage
|
||||||
|
) {
|
||||||
|
setSettingsLocation({ page: SettingsPage.General });
|
||||||
}
|
}
|
||||||
if (isDonationsPage(page) && !donationsFeatureEnabled) {
|
if (isDonationsPage(settingsLocation.page) && !donationsFeatureEnabled) {
|
||||||
setPage(SettingsPage.General);
|
setSettingsLocation({ page: SettingsPage.General });
|
||||||
}
|
}
|
||||||
if (page === SettingsPage.Internal && !isInternalUser) {
|
if (settingsLocation.page === SettingsPage.Internal && !isInternalUser) {
|
||||||
setPage(SettingsPage.General);
|
setSettingsLocation({ page: SettingsPage.General });
|
||||||
}
|
}
|
||||||
|
|
||||||
let maybeUpdateDialog: JSX.Element | undefined;
|
let maybeUpdateDialog: JSX.Element | undefined;
|
||||||
|
|
@ -625,7 +627,7 @@ export function Preferences({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
elements[0]?.focus();
|
elements[0]?.focus();
|
||||||
}, [page]);
|
}, [settingsLocation.page]);
|
||||||
|
|
||||||
const onAudioOutputSelectChange = useCallback(
|
const onAudioOutputSelectChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
|
|
@ -729,11 +731,11 @@ export function Preferences({
|
||||||
|
|
||||||
let content: JSX.Element | undefined;
|
let content: JSX.Element | undefined;
|
||||||
|
|
||||||
if (page === SettingsPage.Profile) {
|
if (settingsLocation.page === SettingsPage.Profile) {
|
||||||
content = renderProfileEditor({
|
content = renderProfileEditor({
|
||||||
contentsRef: settingsPaneRef,
|
contentsRef: settingsPaneRef,
|
||||||
});
|
});
|
||||||
} else if (page === SettingsPage.General) {
|
} else if (settingsLocation.page === SettingsPage.General) {
|
||||||
const pageContents = (
|
const pageContents = (
|
||||||
<>
|
<>
|
||||||
<SettingsRow>
|
<SettingsRow>
|
||||||
|
|
@ -861,13 +863,13 @@ export function Preferences({
|
||||||
title={i18n('icu:Preferences__button--general')}
|
title={i18n('icu:Preferences__button--general')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (isDonationsPage(page)) {
|
} else if (isDonationsPage(settingsLocation.page)) {
|
||||||
content = renderDonationsPane({
|
content = renderDonationsPane({
|
||||||
contentsRef: settingsPaneRef,
|
contentsRef: settingsPaneRef,
|
||||||
page,
|
settingsLocation,
|
||||||
setPage,
|
setSettingsLocation,
|
||||||
});
|
});
|
||||||
} else if (page === SettingsPage.Appearance) {
|
} else if (settingsLocation.page === SettingsPage.Appearance) {
|
||||||
let zoomFactors = DEFAULT_ZOOM_FACTORS;
|
let zoomFactors = DEFAULT_ZOOM_FACTORS;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -1048,7 +1050,7 @@ export function Preferences({
|
||||||
icon
|
icon
|
||||||
left={i18n('icu:showChatColorEditor')}
|
left={i18n('icu:showChatColorEditor')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPage(SettingsPage.ChatColor);
|
setSettingsLocation({ page: SettingsPage.ChatColor });
|
||||||
}}
|
}}
|
||||||
right={
|
right={
|
||||||
<div
|
<div
|
||||||
|
|
@ -1087,7 +1089,7 @@ export function Preferences({
|
||||||
title={i18n('icu:Preferences__button--appearance')}
|
title={i18n('icu:Preferences__button--appearance')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === SettingsPage.Chats) {
|
} else if (settingsLocation.page === SettingsPage.Chats) {
|
||||||
let spellCheckDirtyText: string | undefined;
|
let spellCheckDirtyText: string | undefined;
|
||||||
if (
|
if (
|
||||||
hasSpellCheck !== undefined &&
|
hasSpellCheck !== undefined &&
|
||||||
|
|
@ -1188,7 +1190,9 @@ export function Preferences({
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
right={null}
|
right={null}
|
||||||
onClick={() => setPage(SettingsPage.ChatFolders)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.ChatFolders })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1255,7 +1259,7 @@ export function Preferences({
|
||||||
title={i18n('icu:Preferences__button--chats')}
|
title={i18n('icu:Preferences__button--chats')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === SettingsPage.Calls) {
|
} else if (settingsLocation.page === SettingsPage.Calls) {
|
||||||
const pageContents = (
|
const pageContents = (
|
||||||
<>
|
<>
|
||||||
<SettingsRow title={i18n('icu:calling')}>
|
<SettingsRow title={i18n('icu:calling')}>
|
||||||
|
|
@ -1404,7 +1408,7 @@ export function Preferences({
|
||||||
title={i18n('icu:Preferences__button--calls')}
|
title={i18n('icu:Preferences__button--calls')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === SettingsPage.Notifications) {
|
} else if (settingsLocation.page === SettingsPage.Notifications) {
|
||||||
const pageContents = (
|
const pageContents = (
|
||||||
<>
|
<>
|
||||||
<SettingsRow>
|
<SettingsRow>
|
||||||
|
|
@ -1492,7 +1496,7 @@ export function Preferences({
|
||||||
title={i18n('icu:Preferences__button--notifications')}
|
title={i18n('icu:Preferences__button--notifications')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === SettingsPage.Privacy) {
|
} else if (settingsLocation.page === SettingsPage.Privacy) {
|
||||||
const isCustomDisappearingMessageValue =
|
const isCustomDisappearingMessageValue =
|
||||||
!DEFAULT_DURATIONS_SET.has(universalExpireTimer);
|
!DEFAULT_DURATIONS_SET.has(universalExpireTimer);
|
||||||
const pageContents = (
|
const pageContents = (
|
||||||
|
|
@ -1519,7 +1523,7 @@ export function Preferences({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setPage(SettingsPage.PNP)}
|
onClick={() => setSettingsLocation({ page: SettingsPage.PNP })}
|
||||||
variant={ButtonVariant.Secondary}
|
variant={ButtonVariant.Secondary}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__pnp__row--button')}
|
{i18n('icu:Preferences__pnp__row--button')}
|
||||||
|
|
@ -1770,7 +1774,7 @@ export function Preferences({
|
||||||
title={i18n('icu:Preferences__button--privacy')}
|
title={i18n('icu:Preferences__button--privacy')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === SettingsPage.DataUsage) {
|
} else if (settingsLocation.page === SettingsPage.DataUsage) {
|
||||||
const pageContents = (
|
const pageContents = (
|
||||||
<>
|
<>
|
||||||
<SettingsRow title={i18n('icu:Preferences__media-auto-download')}>
|
<SettingsRow title={i18n('icu:Preferences__media-auto-download')}>
|
||||||
|
|
@ -1882,12 +1886,12 @@ export function Preferences({
|
||||||
title={i18n('icu:Preferences__button--data-usage')}
|
title={i18n('icu:Preferences__button--data-usage')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === SettingsPage.ChatColor) {
|
} else if (settingsLocation.page === SettingsPage.ChatColor) {
|
||||||
const backButton = (
|
const backButton = (
|
||||||
<button
|
<button
|
||||||
aria-label={i18n('icu:goBack')}
|
aria-label={i18n('icu:goBack')}
|
||||||
className="Preferences__back-icon"
|
className="Preferences__back-icon"
|
||||||
onClick={() => setPage(SettingsPage.Appearance)}
|
onClick={() => setSettingsLocation({ page: SettingsPage.Appearance })}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -1918,19 +1922,19 @@ export function Preferences({
|
||||||
title={i18n('icu:ChatColorPicker__menu-title')}
|
title={i18n('icu:ChatColorPicker__menu-title')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === SettingsPage.ChatFolders) {
|
} else if (settingsLocation.page === SettingsPage.ChatFolders) {
|
||||||
content = renderPreferencesChatFoldersPage({
|
content = renderPreferencesChatFoldersPage({
|
||||||
onBack: () => setPage(SettingsPage.Chats),
|
onBack: () => setSettingsLocation({ page: SettingsPage.Chats }),
|
||||||
onOpenEditChatFoldersPage: handleOpenEditChatFoldersPage,
|
onOpenEditChatFoldersPage: handleOpenEditChatFoldersPage,
|
||||||
settingsPaneRef,
|
settingsPaneRef,
|
||||||
});
|
});
|
||||||
} else if (page === SettingsPage.EditChatFolder) {
|
} else if (settingsLocation.page === SettingsPage.EditChatFolder) {
|
||||||
content = renderPreferencesEditChatFolderPage({
|
content = renderPreferencesEditChatFolderPage({
|
||||||
onBack: handleCloseEditChatFoldersPage,
|
previousLocation: settingsLocation.previousLocation,
|
||||||
settingsPaneRef,
|
settingsPaneRef,
|
||||||
existingChatFolderId: editChatFolderPageId,
|
existingChatFolderId: settingsLocation.chatFolderId,
|
||||||
});
|
});
|
||||||
} else if (page === SettingsPage.PNP) {
|
} else if (settingsLocation.page === SettingsPage.PNP) {
|
||||||
let sharingDescription: string;
|
let sharingDescription: string;
|
||||||
|
|
||||||
if (whoCanSeeMe === PhoneNumberSharingMode.Everybody) {
|
if (whoCanSeeMe === PhoneNumberSharingMode.Everybody) {
|
||||||
|
|
@ -1951,7 +1955,7 @@ export function Preferences({
|
||||||
<button
|
<button
|
||||||
aria-label={i18n('icu:goBack')}
|
aria-label={i18n('icu:goBack')}
|
||||||
className="Preferences__back-icon"
|
className="Preferences__back-icon"
|
||||||
onClick={() => setPage(SettingsPage.Privacy)}
|
onClick={() => setSettingsLocation({ page: SettingsPage.Privacy })}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -2071,19 +2075,22 @@ export function Preferences({
|
||||||
title={i18n('icu:Preferences__pnp--page-title')}
|
title={i18n('icu:Preferences__pnp--page-title')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (isBackupPage(page)) {
|
} else if (isBackupPage(settingsLocation.page)) {
|
||||||
let pageTitle: string | undefined;
|
let pageTitle: string | undefined;
|
||||||
if (page === SettingsPage.Backups || page === SettingsPage.BackupsDetails) {
|
if (
|
||||||
|
settingsLocation.page === SettingsPage.Backups ||
|
||||||
|
settingsLocation.page === SettingsPage.BackupsDetails
|
||||||
|
) {
|
||||||
pageTitle = i18n('icu:Preferences__button--backups');
|
pageTitle = i18n('icu:Preferences__button--backups');
|
||||||
} else if (page === SettingsPage.LocalBackups) {
|
} else if (settingsLocation.page === SettingsPage.LocalBackups) {
|
||||||
pageTitle = i18n('icu:Preferences__local-backups');
|
pageTitle = i18n('icu:Preferences__local-backups');
|
||||||
}
|
}
|
||||||
// Local backups setup page titles intentionally left blank
|
// Local backups setup page titles intentionally left blank
|
||||||
|
|
||||||
let backPage: PreferencesBackupPage | undefined;
|
let backPage: PreferencesBackupPage | undefined;
|
||||||
if (page === SettingsPage.LocalBackupsKeyReference) {
|
if (settingsLocation.page === SettingsPage.LocalBackupsKeyReference) {
|
||||||
backPage = SettingsPage.LocalBackups;
|
backPage = SettingsPage.LocalBackups;
|
||||||
} else if (page !== SettingsPage.Backups) {
|
} else if (settingsLocation.page !== SettingsPage.Backups) {
|
||||||
backPage = SettingsPage.Backups;
|
backPage = SettingsPage.Backups;
|
||||||
}
|
}
|
||||||
let backButton: JSX.Element | undefined;
|
let backButton: JSX.Element | undefined;
|
||||||
|
|
@ -2092,7 +2099,7 @@ export function Preferences({
|
||||||
<button
|
<button
|
||||||
aria-label={i18n('icu:goBack')}
|
aria-label={i18n('icu:goBack')}
|
||||||
className="Preferences__back-icon"
|
className="Preferences__back-icon"
|
||||||
onClick={() => setPage(backPage)}
|
onClick={() => setSettingsLocation({ page: backPage })}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -2114,11 +2121,11 @@ export function Preferences({
|
||||||
localBackupFolder={localBackupFolder}
|
localBackupFolder={localBackupFolder}
|
||||||
onBackupKeyViewedChange={onBackupKeyViewedChange}
|
onBackupKeyViewedChange={onBackupKeyViewedChange}
|
||||||
pickLocalBackupFolder={pickLocalBackupFolder}
|
pickLocalBackupFolder={pickLocalBackupFolder}
|
||||||
page={page}
|
settingsLocation={settingsLocation}
|
||||||
promptOSAuth={promptOSAuth}
|
promptOSAuth={promptOSAuth}
|
||||||
refreshCloudBackupStatus={refreshCloudBackupStatus}
|
refreshCloudBackupStatus={refreshCloudBackupStatus}
|
||||||
refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
|
refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
|
||||||
setPage={setPage}
|
setSettingsLocation={setSettingsLocation}
|
||||||
showToast={showToast}
|
showToast={showToast}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -2130,7 +2137,7 @@ export function Preferences({
|
||||||
title={pageTitle}
|
title={pageTitle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === SettingsPage.Internal) {
|
} else if (settingsLocation.page === SettingsPage.Internal) {
|
||||||
content = (
|
content = (
|
||||||
<PreferencesContent
|
<PreferencesContent
|
||||||
contents={
|
contents={
|
||||||
|
|
@ -2185,7 +2192,7 @@ export function Preferences({
|
||||||
className={classNames({
|
className={classNames({
|
||||||
'Preferences__profile-chip': true,
|
'Preferences__profile-chip': true,
|
||||||
'Preferences__profile-chip--selected':
|
'Preferences__profile-chip--selected':
|
||||||
page === SettingsPage.Profile,
|
settingsLocation.page === SettingsPage.Profile,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="Preferences__profile-chip__avatar">
|
<div className="Preferences__profile-chip__avatar">
|
||||||
|
|
@ -2224,7 +2231,10 @@ export function Preferences({
|
||||||
className="Preferences__profile-chip__button"
|
className="Preferences__profile-chip__button"
|
||||||
aria-label={i18n('icu:ProfileEditor__open')}
|
aria-label={i18n('icu:ProfileEditor__open')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPage(SettingsPage.Profile);
|
setSettingsLocation({
|
||||||
|
page: SettingsPage.Profile,
|
||||||
|
state: ProfileEditorPage.None,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="Preferences__profile-chip__screenreader-only">
|
<span className="Preferences__profile-chip__screenreader-only">
|
||||||
|
|
@ -2236,10 +2246,10 @@ export function Preferences({
|
||||||
className="Preferences__profile-chip__qr-icon-button"
|
className="Preferences__profile-chip__qr-icon-button"
|
||||||
aria-label={i18n('icu:ProfileEditor__username-link__open')}
|
aria-label={i18n('icu:ProfileEditor__username-link__open')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPage(
|
setSettingsLocation({
|
||||||
SettingsPage.Profile,
|
page: SettingsPage.Profile,
|
||||||
ProfileEditorPage.UsernameLink
|
state: ProfileEditorPage.UsernameLink,
|
||||||
);
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="Preferences__profile-chip__qr-icon" />
|
<div className="Preferences__profile-chip__qr-icon" />
|
||||||
|
|
@ -2251,9 +2261,11 @@ export function Preferences({
|
||||||
Preferences__button: true,
|
Preferences__button: true,
|
||||||
'Preferences__button--general': true,
|
'Preferences__button--general': true,
|
||||||
'Preferences__button--selected':
|
'Preferences__button--selected':
|
||||||
page === SettingsPage.General,
|
settingsLocation.page === SettingsPage.General,
|
||||||
})}
|
})}
|
||||||
onClick={() => setPage(SettingsPage.General)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.General })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__button--general')}
|
{i18n('icu:Preferences__button--general')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2263,10 +2275,12 @@ export function Preferences({
|
||||||
Preferences__button: true,
|
Preferences__button: true,
|
||||||
'Preferences__button--appearance': true,
|
'Preferences__button--appearance': true,
|
||||||
'Preferences__button--selected':
|
'Preferences__button--selected':
|
||||||
page === SettingsPage.Appearance ||
|
settingsLocation.page === SettingsPage.Appearance ||
|
||||||
page === SettingsPage.ChatColor,
|
settingsLocation.page === SettingsPage.ChatColor,
|
||||||
})}
|
})}
|
||||||
onClick={() => setPage(SettingsPage.Appearance)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.Appearance })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__button--appearance')}
|
{i18n('icu:Preferences__button--appearance')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2275,9 +2289,12 @@ export function Preferences({
|
||||||
className={classNames({
|
className={classNames({
|
||||||
Preferences__button: true,
|
Preferences__button: true,
|
||||||
'Preferences__button--chats': true,
|
'Preferences__button--chats': true,
|
||||||
'Preferences__button--selected': page === SettingsPage.Chats,
|
'Preferences__button--selected':
|
||||||
|
settingsLocation.page === SettingsPage.Chats,
|
||||||
})}
|
})}
|
||||||
onClick={() => setPage(SettingsPage.Chats)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.Chats })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__button--chats')}
|
{i18n('icu:Preferences__button--chats')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2286,9 +2303,12 @@ export function Preferences({
|
||||||
className={classNames({
|
className={classNames({
|
||||||
Preferences__button: true,
|
Preferences__button: true,
|
||||||
'Preferences__button--calls': true,
|
'Preferences__button--calls': true,
|
||||||
'Preferences__button--selected': page === SettingsPage.Calls,
|
'Preferences__button--selected':
|
||||||
|
settingsLocation.page === SettingsPage.Calls,
|
||||||
})}
|
})}
|
||||||
onClick={() => setPage(SettingsPage.Calls)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.Calls })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__button--calls')}
|
{i18n('icu:Preferences__button--calls')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2298,9 +2318,11 @@ export function Preferences({
|
||||||
Preferences__button: true,
|
Preferences__button: true,
|
||||||
'Preferences__button--notifications': true,
|
'Preferences__button--notifications': true,
|
||||||
'Preferences__button--selected':
|
'Preferences__button--selected':
|
||||||
page === SettingsPage.Notifications,
|
settingsLocation.page === SettingsPage.Notifications,
|
||||||
})}
|
})}
|
||||||
onClick={() => setPage(SettingsPage.Notifications)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.Notifications })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__button--notifications')}
|
{i18n('icu:Preferences__button--notifications')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2310,9 +2332,12 @@ export function Preferences({
|
||||||
Preferences__button: true,
|
Preferences__button: true,
|
||||||
'Preferences__button--privacy': true,
|
'Preferences__button--privacy': true,
|
||||||
'Preferences__button--selected':
|
'Preferences__button--selected':
|
||||||
page === SettingsPage.Privacy || page === SettingsPage.PNP,
|
settingsLocation.page === SettingsPage.Privacy ||
|
||||||
|
settingsLocation.page === SettingsPage.PNP,
|
||||||
})}
|
})}
|
||||||
onClick={() => setPage(SettingsPage.Privacy)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.Privacy })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__button--privacy')}
|
{i18n('icu:Preferences__button--privacy')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2322,9 +2347,11 @@ export function Preferences({
|
||||||
Preferences__button: true,
|
Preferences__button: true,
|
||||||
'Preferences__button--data-usage': true,
|
'Preferences__button--data-usage': true,
|
||||||
'Preferences__button--selected':
|
'Preferences__button--selected':
|
||||||
page === SettingsPage.DataUsage,
|
settingsLocation.page === SettingsPage.DataUsage,
|
||||||
})}
|
})}
|
||||||
onClick={() => setPage(SettingsPage.DataUsage)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.DataUsage })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__button--data-usage')}
|
{i18n('icu:Preferences__button--data-usage')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2334,9 +2361,13 @@ export function Preferences({
|
||||||
className={classNames({
|
className={classNames({
|
||||||
Preferences__button: true,
|
Preferences__button: true,
|
||||||
'Preferences__button--backups': true,
|
'Preferences__button--backups': true,
|
||||||
'Preferences__button--selected': isBackupPage(page),
|
'Preferences__button--selected': isBackupPage(
|
||||||
|
settingsLocation.page
|
||||||
|
),
|
||||||
})}
|
})}
|
||||||
onClick={() => setPage(SettingsPage.Backups)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.Backups })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__button--backups')}
|
{i18n('icu:Preferences__button--backups')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2347,9 +2378,13 @@ export function Preferences({
|
||||||
className={classNames({
|
className={classNames({
|
||||||
Preferences__button: true,
|
Preferences__button: true,
|
||||||
'Preferences__button--donations': true,
|
'Preferences__button--donations': true,
|
||||||
'Preferences__button--selected': isDonationsPage(page),
|
'Preferences__button--selected': isDonationsPage(
|
||||||
|
settingsLocation.page
|
||||||
|
),
|
||||||
})}
|
})}
|
||||||
onClick={() => setPage(SettingsPage.Donations)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.Donations })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__button--donate')}
|
{i18n('icu:Preferences__button--donate')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2361,9 +2396,11 @@ export function Preferences({
|
||||||
Preferences__button: true,
|
Preferences__button: true,
|
||||||
'Preferences__button--internal': true,
|
'Preferences__button--internal': true,
|
||||||
'Preferences__button--selected':
|
'Preferences__button--selected':
|
||||||
page === SettingsPage.Internal,
|
settingsLocation.page === SettingsPage.Internal,
|
||||||
})}
|
})}
|
||||||
onClick={() => setPage(SettingsPage.Internal)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.Internal })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__button--internal')}
|
{i18n('icu:Preferences__button--internal')}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
} from './PreferencesUtil.js';
|
} from './PreferencesUtil.js';
|
||||||
import { missingCaseError } from '../util/missingCaseError.js';
|
import { missingCaseError } from '../util/missingCaseError.js';
|
||||||
import { Button, ButtonVariant } from './Button.js';
|
import { Button, ButtonVariant } from './Button.js';
|
||||||
import type { PreferencesBackupPage } from '../types/PreferencesBackupPage.js';
|
import type { SettingsLocation } from '../types/Nav.js';
|
||||||
import { SettingsPage } from '../types/Nav.js';
|
import { SettingsPage } from '../types/Nav.js';
|
||||||
import { I18n } from './I18n.js';
|
import { I18n } from './I18n.js';
|
||||||
import { PreferencesLocalBackups } from './PreferencesLocalBackups.js';
|
import { PreferencesLocalBackups } from './PreferencesLocalBackups.js';
|
||||||
|
|
@ -64,11 +64,11 @@ export function PreferencesBackups({
|
||||||
cancelBackupMediaDownload,
|
cancelBackupMediaDownload,
|
||||||
pauseBackupMediaDownload,
|
pauseBackupMediaDownload,
|
||||||
resumeBackupMediaDownload,
|
resumeBackupMediaDownload,
|
||||||
page,
|
settingsLocation,
|
||||||
promptOSAuth,
|
promptOSAuth,
|
||||||
refreshCloudBackupStatus,
|
refreshCloudBackupStatus,
|
||||||
refreshBackupSubscriptionStatus,
|
refreshBackupSubscriptionStatus,
|
||||||
setPage,
|
setSettingsLocation,
|
||||||
showToast,
|
showToast,
|
||||||
}: {
|
}: {
|
||||||
accountEntropyPool: string | undefined;
|
accountEntropyPool: string | undefined;
|
||||||
|
|
@ -81,7 +81,7 @@ export function PreferencesBackups({
|
||||||
isRemoteBackupsEnabled: boolean;
|
isRemoteBackupsEnabled: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
onBackupKeyViewedChange: (keyViewed: boolean) => void;
|
onBackupKeyViewedChange: (keyViewed: boolean) => void;
|
||||||
page: PreferencesBackupPage;
|
settingsLocation: SettingsLocation;
|
||||||
backupMediaDownloadStatus: BackupMediaDownloadStatusType | undefined;
|
backupMediaDownloadStatus: BackupMediaDownloadStatusType | undefined;
|
||||||
cancelBackupMediaDownload: () => void;
|
cancelBackupMediaDownload: () => void;
|
||||||
pauseBackupMediaDownload: () => void;
|
pauseBackupMediaDownload: () => void;
|
||||||
|
|
@ -92,7 +92,7 @@ export function PreferencesBackups({
|
||||||
) => Promise<PromptOSAuthResultType>;
|
) => Promise<PromptOSAuthResultType>;
|
||||||
refreshCloudBackupStatus: () => void;
|
refreshCloudBackupStatus: () => void;
|
||||||
refreshBackupSubscriptionStatus: () => void;
|
refreshBackupSubscriptionStatus: () => void;
|
||||||
setPage: (page: PreferencesBackupPage) => void;
|
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||||
showToast: ShowToastAction;
|
showToast: ShowToastAction;
|
||||||
}): JSX.Element | null {
|
}): JSX.Element | null {
|
||||||
const [authError, setAuthError] =
|
const [authError, setAuthError] =
|
||||||
|
|
@ -100,27 +100,31 @@ export function PreferencesBackups({
|
||||||
const [isAuthPending, setIsAuthPending] = useState<boolean>(false);
|
const [isAuthPending, setIsAuthPending] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page === SettingsPage.Backups) {
|
if (settingsLocation.page === SettingsPage.Backups) {
|
||||||
refreshBackupSubscriptionStatus();
|
refreshBackupSubscriptionStatus();
|
||||||
} else if (page === SettingsPage.BackupsDetails) {
|
} else if (settingsLocation.page === SettingsPage.BackupsDetails) {
|
||||||
refreshBackupSubscriptionStatus();
|
refreshBackupSubscriptionStatus();
|
||||||
refreshCloudBackupStatus();
|
refreshCloudBackupStatus();
|
||||||
}
|
}
|
||||||
}, [page, refreshBackupSubscriptionStatus, refreshCloudBackupStatus]);
|
}, [
|
||||||
|
settingsLocation.page,
|
||||||
|
refreshBackupSubscriptionStatus,
|
||||||
|
refreshCloudBackupStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!isRemoteBackupsEnabled && isRemoteBackupsPage(page)) {
|
if (!isRemoteBackupsEnabled && isRemoteBackupsPage(settingsLocation.page)) {
|
||||||
setPage(SettingsPage.Backups);
|
setSettingsLocation({ page: SettingsPage.Backups });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLocalBackupsEnabled && isLocalBackupsPage(page)) {
|
if (!isLocalBackupsEnabled && isLocalBackupsPage(settingsLocation.page)) {
|
||||||
setPage(SettingsPage.Backups);
|
setSettingsLocation({ page: SettingsPage.Backups });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page === SettingsPage.BackupsDetails) {
|
if (settingsLocation.page === SettingsPage.BackupsDetails) {
|
||||||
if (backupSubscriptionStatus.status === 'off') {
|
if (backupSubscriptionStatus.status === 'off') {
|
||||||
setPage(SettingsPage.Backups);
|
setSettingsLocation({ page: SettingsPage.Backups });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|
@ -137,7 +141,7 @@ export function PreferencesBackups({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLocalBackupsPage(page)) {
|
if (isLocalBackupsPage(settingsLocation.page)) {
|
||||||
return (
|
return (
|
||||||
<PreferencesLocalBackups
|
<PreferencesLocalBackups
|
||||||
accountEntropyPool={accountEntropyPool}
|
accountEntropyPool={accountEntropyPool}
|
||||||
|
|
@ -145,10 +149,10 @@ export function PreferencesBackups({
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
localBackupFolder={localBackupFolder}
|
localBackupFolder={localBackupFolder}
|
||||||
onBackupKeyViewedChange={onBackupKeyViewedChange}
|
onBackupKeyViewedChange={onBackupKeyViewedChange}
|
||||||
page={page}
|
settingsLocation={settingsLocation}
|
||||||
pickLocalBackupFolder={pickLocalBackupFolder}
|
pickLocalBackupFolder={pickLocalBackupFolder}
|
||||||
promptOSAuth={promptOSAuth}
|
promptOSAuth={promptOSAuth}
|
||||||
setPage={setPage}
|
setSettingsLocation={setSettingsLocation}
|
||||||
showToast={showToast}
|
showToast={showToast}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -211,7 +215,9 @@ export function PreferencesBackups({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setPage(SettingsPage.BackupsDetails)}
|
onClick={() =>
|
||||||
|
setSettingsLocation({ page: SettingsPage.BackupsDetails })
|
||||||
|
}
|
||||||
variant={ButtonVariant.Secondary}
|
variant={ButtonVariant.Secondary}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__button--manage')}
|
{i18n('icu:Preferences__button--manage')}
|
||||||
|
|
@ -270,7 +276,7 @@ export function PreferencesBackups({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setPage(SettingsPage.LocalBackups);
|
setSettingsLocation({ page: SettingsPage.LocalBackups });
|
||||||
}}
|
}}
|
||||||
variant={ButtonVariant.Secondary}
|
variant={ButtonVariant.Secondary}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { getDateTimeFormatter } from '../util/formatTimestamp.js';
|
||||||
|
|
||||||
import type { LocalizerType, ThemeType } from '../types/Util.js';
|
import type { LocalizerType, ThemeType } from '../types/Util.js';
|
||||||
import { PreferencesContent } from './Preferences.js';
|
import { PreferencesContent } from './Preferences.js';
|
||||||
|
import type { SettingsLocation } from '../types/Nav.js';
|
||||||
import { SettingsPage } from '../types/Nav.js';
|
import { SettingsPage } from '../types/Nav.js';
|
||||||
import { PreferencesDonateFlow } from './PreferencesDonateFlow.js';
|
import { PreferencesDonateFlow } from './PreferencesDonateFlow.js';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -67,7 +68,7 @@ export type PropsDataType = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
initialCurrency: string;
|
initialCurrency: string;
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
page: SettingsPage;
|
settingsLocation: SettingsLocation;
|
||||||
didResumeWorkflowAtStartup: boolean;
|
didResumeWorkflowAtStartup: boolean;
|
||||||
lastError: DonationErrorType | undefined;
|
lastError: DonationErrorType | undefined;
|
||||||
workflow: DonationWorkflow | undefined;
|
workflow: DonationWorkflow | undefined;
|
||||||
|
|
@ -106,7 +107,7 @@ type PropsActionType = {
|
||||||
}) => void;
|
}) => void;
|
||||||
clearWorkflow: () => void;
|
clearWorkflow: () => void;
|
||||||
resumeWorkflow: () => void;
|
resumeWorkflow: () => void;
|
||||||
setPage: (page: SettingsPage) => void;
|
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||||
showToast: (toast: AnyToast) => void;
|
showToast: (toast: AnyToast) => void;
|
||||||
submitDonation: (payload: SubmitDonationType) => void;
|
submitDonation: (payload: SubmitDonationType) => void;
|
||||||
updateLastError: (error: DonationErrorType | undefined) => void;
|
updateLastError: (error: DonationErrorType | undefined) => void;
|
||||||
|
|
@ -123,12 +124,11 @@ type PreferencesHomeProps = Pick<
|
||||||
PropsType,
|
PropsType,
|
||||||
| 'contentsRef'
|
| 'contentsRef'
|
||||||
| 'i18n'
|
| 'i18n'
|
||||||
| 'setPage'
|
| 'setSettingsLocation'
|
||||||
| 'isOnline'
|
| 'isOnline'
|
||||||
| 'donationReceipts'
|
| 'donationReceipts'
|
||||||
| 'workflow'
|
| 'workflow'
|
||||||
> & {
|
> & {
|
||||||
navigateToPage: (newPage: SettingsPage) => void;
|
|
||||||
renderDonationHero: () => JSX.Element;
|
renderDonationHero: () => JSX.Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -205,8 +205,7 @@ function DonationHero({
|
||||||
function DonationsHome({
|
function DonationsHome({
|
||||||
i18n,
|
i18n,
|
||||||
renderDonationHero,
|
renderDonationHero,
|
||||||
navigateToPage,
|
setSettingsLocation,
|
||||||
setPage,
|
|
||||||
isOnline,
|
isOnline,
|
||||||
donationReceipts,
|
donationReceipts,
|
||||||
workflow,
|
workflow,
|
||||||
|
|
@ -224,9 +223,9 @@ function DonationsHome({
|
||||||
if (inProgressDonationAmount) {
|
if (inProgressDonationAmount) {
|
||||||
setIsInProgressVisible(true);
|
setIsInProgressVisible(true);
|
||||||
} else {
|
} else {
|
||||||
setPage(SettingsPage.DonationsDonateFlow);
|
setSettingsLocation({ page: SettingsPage.DonationsDonateFlow });
|
||||||
}
|
}
|
||||||
}, [inProgressDonationAmount, setPage]);
|
}, [inProgressDonationAmount, setSettingsLocation]);
|
||||||
|
|
||||||
const handleInProgressDonationClicked = useCallback(() => {
|
const handleInProgressDonationClicked = useCallback(() => {
|
||||||
setIsInProgressVisible(true);
|
setIsInProgressVisible(true);
|
||||||
|
|
@ -299,7 +298,7 @@ function DonationsHome({
|
||||||
<ListBoxItem
|
<ListBoxItem
|
||||||
className="PreferencesDonations__list-item"
|
className="PreferencesDonations__list-item"
|
||||||
onAction={() => {
|
onAction={() => {
|
||||||
navigateToPage(SettingsPage.DonationsReceiptList);
|
setSettingsLocation({ page: SettingsPage.DonationsReceiptList });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="PreferencesDonations__list-item__icon PreferencesDonations__list-item__icon--receipts" />
|
<span className="PreferencesDonations__list-item__icon PreferencesDonations__list-item__icon--receipts" />
|
||||||
|
|
@ -542,14 +541,14 @@ export function PreferencesDonations({
|
||||||
i18n,
|
i18n,
|
||||||
initialCurrency,
|
initialCurrency,
|
||||||
isOnline,
|
isOnline,
|
||||||
page,
|
settingsLocation,
|
||||||
workflow,
|
workflow,
|
||||||
didResumeWorkflowAtStartup,
|
didResumeWorkflowAtStartup,
|
||||||
lastError,
|
lastError,
|
||||||
applyDonationBadge,
|
applyDonationBadge,
|
||||||
clearWorkflow,
|
clearWorkflow,
|
||||||
resumeWorkflow,
|
resumeWorkflow,
|
||||||
setPage,
|
setSettingsLocation,
|
||||||
submitDonation,
|
submitDonation,
|
||||||
badge,
|
badge,
|
||||||
color,
|
color,
|
||||||
|
|
@ -575,19 +574,12 @@ export function PreferencesDonations({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
workflow?.type === donationStateSchema.Enum.DONE &&
|
workflow?.type === donationStateSchema.Enum.DONE &&
|
||||||
page === SettingsPage.Donations &&
|
settingsLocation.page === SettingsPage.Donations &&
|
||||||
!donationBadge
|
!donationBadge
|
||||||
) {
|
) {
|
||||||
drop(fetchBadgeData());
|
drop(fetchBadgeData());
|
||||||
}
|
}
|
||||||
}, [workflow, page, donationBadge, fetchBadgeData]);
|
}, [workflow, settingsLocation.page, donationBadge, fetchBadgeData]);
|
||||||
|
|
||||||
const navigateToPage = useCallback(
|
|
||||||
(newPage: SettingsPage) => {
|
|
||||||
setPage(newPage);
|
|
||||||
},
|
|
||||||
[setPage]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastError) {
|
if (lastError) {
|
||||||
|
|
@ -618,7 +610,7 @@ export function PreferencesDonations({
|
||||||
[badge, color, firstName, i18n, profileAvatarUrl, theme]
|
[badge, color, firstName, i18n, profileAvatarUrl, theme]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isDonationPage(page)) {
|
if (!isDonationPage(settingsLocation.page)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -649,7 +641,7 @@ export function PreferencesDonations({
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onCancelDonation={() => {
|
onCancelDonation={() => {
|
||||||
clearWorkflow();
|
clearWorkflow();
|
||||||
setPage(SettingsPage.Donations);
|
setSettingsLocation({ page: SettingsPage.Donations });
|
||||||
showToast({ toastType: ToastType.DonationCanceled });
|
showToast({ toastType: ToastType.DonationCanceled });
|
||||||
}}
|
}}
|
||||||
onRetryDonation={() => {
|
onRetryDonation={() => {
|
||||||
|
|
@ -663,7 +655,7 @@ export function PreferencesDonations({
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onCancelDonation={() => {
|
onCancelDonation={() => {
|
||||||
clearWorkflow();
|
clearWorkflow();
|
||||||
setPage(SettingsPage.Donations);
|
setSettingsLocation({ page: SettingsPage.Donations });
|
||||||
showToast({ toastType: ToastType.DonationCanceled });
|
showToast({ toastType: ToastType.DonationCanceled });
|
||||||
}}
|
}}
|
||||||
onOpenBrowser={() => {
|
onOpenBrowser={() => {
|
||||||
|
|
@ -672,7 +664,7 @@ export function PreferencesDonations({
|
||||||
onTimedOut={() => {
|
onTimedOut={() => {
|
||||||
clearWorkflow();
|
clearWorkflow();
|
||||||
updateLastError(donationErrorTypeSchema.Enum.TimedOut);
|
updateLastError(donationErrorTypeSchema.Enum.TimedOut);
|
||||||
setPage(SettingsPage.Donations);
|
setSettingsLocation({ page: SettingsPage.Donations });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -695,7 +687,7 @@ export function PreferencesDonations({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
page === SettingsPage.DonationsDonateFlow &&
|
settingsLocation.page === SettingsPage.DonationsDonateFlow &&
|
||||||
(isSubmitted ||
|
(isSubmitted ||
|
||||||
workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED ||
|
workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED ||
|
||||||
workflow?.type === donationStateSchema.Enum.RECEIPT)
|
workflow?.type === donationStateSchema.Enum.RECEIPT)
|
||||||
|
|
@ -711,7 +703,7 @@ export function PreferencesDonations({
|
||||||
<DonationStillProcessingModal
|
<DonationStillProcessingModal
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setPage(SettingsPage.Donations);
|
setSettingsLocation({ page: SettingsPage.Donations });
|
||||||
// We need to delay until we've transitioned away from this page, or we'll
|
// We need to delay until we've transitioned away from this page, or we'll
|
||||||
// go back to showing the spinner.
|
// go back to showing the spinner.
|
||||||
setTimeout(() => setHasProcessingExpired(false), 500);
|
setTimeout(() => setHasProcessingExpired(false), 500);
|
||||||
|
|
@ -736,7 +728,7 @@ export function PreferencesDonations({
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
if (page === SettingsPage.DonationsDonateFlow) {
|
if (settingsLocation.page === SettingsPage.DonationsDonateFlow) {
|
||||||
// DonateFlow has to control Back button to switch between CC form and Amount picker
|
// DonateFlow has to control Back button to switch between CC form and Amount picker
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -758,25 +750,24 @@ export function PreferencesDonations({
|
||||||
submitDonation(details);
|
submitDonation(details);
|
||||||
}}
|
}}
|
||||||
showPrivacyModal={() => setIsPrivacyModalVisible(true)}
|
showPrivacyModal={() => setIsPrivacyModalVisible(true)}
|
||||||
onBack={() => setPage(SettingsPage.Donations)}
|
onBack={() => setSettingsLocation({ page: SettingsPage.Donations })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (page === SettingsPage.Donations) {
|
if (settingsLocation.page === SettingsPage.Donations) {
|
||||||
content = (
|
content = (
|
||||||
<DonationsHome
|
<DonationsHome
|
||||||
contentsRef={contentsRef}
|
contentsRef={contentsRef}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
isOnline={isOnline}
|
isOnline={isOnline}
|
||||||
navigateToPage={navigateToPage}
|
|
||||||
donationReceipts={donationReceipts}
|
donationReceipts={donationReceipts}
|
||||||
renderDonationHero={renderDonationHero}
|
renderDonationHero={renderDonationHero}
|
||||||
setPage={setPage}
|
setSettingsLocation={setSettingsLocation}
|
||||||
workflow={workflow}
|
workflow={workflow}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === SettingsPage.DonationsReceiptList) {
|
} else if (settingsLocation.page === SettingsPage.DonationsReceiptList) {
|
||||||
content = (
|
content = (
|
||||||
<PreferencesReceiptList
|
<PreferencesReceiptList
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
|
@ -790,15 +781,15 @@ export function PreferencesDonations({
|
||||||
|
|
||||||
let title: string | undefined;
|
let title: string | undefined;
|
||||||
let backButton: JSX.Element | undefined;
|
let backButton: JSX.Element | undefined;
|
||||||
if (page === SettingsPage.Donations) {
|
if (settingsLocation.page === SettingsPage.Donations) {
|
||||||
title = i18n('icu:Preferences__DonateTitle');
|
title = i18n('icu:Preferences__DonateTitle');
|
||||||
} else if (page === SettingsPage.DonationsReceiptList) {
|
} else if (settingsLocation.page === SettingsPage.DonationsReceiptList) {
|
||||||
title = i18n('icu:PreferencesDonations__receipts');
|
title = i18n('icu:PreferencesDonations__receipts');
|
||||||
backButton = (
|
backButton = (
|
||||||
<button
|
<button
|
||||||
aria-label={i18n('icu:goBack')}
|
aria-label={i18n('icu:goBack')}
|
||||||
className="Preferences__back-icon"
|
className="Preferences__back-icon"
|
||||||
onClick={() => setPage(SettingsPage.Donations)}
|
onClick={() => setSettingsLocation({ page: SettingsPage.Donations })}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
SIGNAL_BACKUPS_LEARN_MORE_URL,
|
SIGNAL_BACKUPS_LEARN_MORE_URL,
|
||||||
} from './PreferencesBackups.js';
|
} from './PreferencesBackups.js';
|
||||||
import { I18n } from './I18n.js';
|
import { I18n } from './I18n.js';
|
||||||
import type { PreferencesBackupPage } from '../types/PreferencesBackupPage.js';
|
import type { SettingsLocation } from '../types/Nav.js';
|
||||||
import { SettingsPage } from '../types/Nav.js';
|
import { SettingsPage } from '../types/Nav.js';
|
||||||
import { ToastType } from '../types/Toast.js';
|
import { ToastType } from '../types/Toast.js';
|
||||||
import type { ShowToastAction } from '../state/ducks/toast.js';
|
import type { ShowToastAction } from '../state/ducks/toast.js';
|
||||||
|
|
@ -43,10 +43,10 @@ export function PreferencesLocalBackups({
|
||||||
i18n,
|
i18n,
|
||||||
localBackupFolder,
|
localBackupFolder,
|
||||||
onBackupKeyViewedChange,
|
onBackupKeyViewedChange,
|
||||||
page,
|
settingsLocation,
|
||||||
pickLocalBackupFolder,
|
pickLocalBackupFolder,
|
||||||
promptOSAuth,
|
promptOSAuth,
|
||||||
setPage,
|
setSettingsLocation,
|
||||||
showToast,
|
showToast,
|
||||||
}: {
|
}: {
|
||||||
accountEntropyPool: string | undefined;
|
accountEntropyPool: string | undefined;
|
||||||
|
|
@ -54,12 +54,12 @@ export function PreferencesLocalBackups({
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
localBackupFolder: string | undefined;
|
localBackupFolder: string | undefined;
|
||||||
onBackupKeyViewedChange: (keyViewed: boolean) => void;
|
onBackupKeyViewedChange: (keyViewed: boolean) => void;
|
||||||
page: PreferencesBackupPage;
|
settingsLocation: SettingsLocation;
|
||||||
pickLocalBackupFolder: () => Promise<string | undefined>;
|
pickLocalBackupFolder: () => Promise<string | undefined>;
|
||||||
promptOSAuth: (
|
promptOSAuth: (
|
||||||
reason: PromptOSAuthReasonType
|
reason: PromptOSAuthReasonType
|
||||||
) => Promise<PromptOSAuthResultType>;
|
) => Promise<PromptOSAuthResultType>;
|
||||||
setPage: (page: PreferencesBackupPage) => void;
|
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||||
showToast: ShowToastAction;
|
showToast: ShowToastAction;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const [authError, setAuthError] =
|
const [authError, setAuthError] =
|
||||||
|
|
@ -75,7 +75,8 @@ export function PreferencesLocalBackups({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isReferencingBackupKey = page === SettingsPage.LocalBackupsKeyReference;
|
const isReferencingBackupKey =
|
||||||
|
settingsLocation.page === SettingsPage.LocalBackupsKeyReference;
|
||||||
if (!backupKeyViewed || isReferencingBackupKey) {
|
if (!backupKeyViewed || isReferencingBackupKey) {
|
||||||
strictAssert(accountEntropyPool, 'AEP is required for backup key viewer');
|
strictAssert(accountEntropyPool, 'AEP is required for backup key viewer');
|
||||||
|
|
||||||
|
|
@ -86,7 +87,9 @@ export function PreferencesLocalBackups({
|
||||||
isReferencing={isReferencingBackupKey}
|
isReferencing={isReferencingBackupKey}
|
||||||
onBackupKeyViewed={() => {
|
onBackupKeyViewed={() => {
|
||||||
if (backupKeyViewed) {
|
if (backupKeyViewed) {
|
||||||
setPage(SettingsPage.LocalBackups);
|
setSettingsLocation({
|
||||||
|
page: SettingsPage.LocalBackups,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
onBackupKeyViewedChange(true);
|
onBackupKeyViewedChange(true);
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +163,9 @@ export function PreferencesLocalBackups({
|
||||||
setIsAuthPending(true);
|
setIsAuthPending(true);
|
||||||
const result = await promptOSAuth('view-aep');
|
const result = await promptOSAuth('view-aep');
|
||||||
if (result === 'success' || result === 'unsupported') {
|
if (result === 'success' || result === 'unsupported') {
|
||||||
setPage(SettingsPage.LocalBackupsKeyReference);
|
setSettingsLocation({
|
||||||
|
page: SettingsPage.LocalBackupsKeyReference,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setAuthError(result);
|
setAuthError(result);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ import {
|
||||||
MessageRequestState,
|
MessageRequestState,
|
||||||
} from './MessageRequestActionsConfirmation.js';
|
} from './MessageRequestActionsConfirmation.js';
|
||||||
import type { MinimalConversation } from '../../hooks/useMinimalConversation.js';
|
import type { MinimalConversation } from '../../hooks/useMinimalConversation.js';
|
||||||
import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal.js';
|
|
||||||
import { InAnotherCallTooltip } from './InAnotherCallTooltip.js';
|
import { InAnotherCallTooltip } from './InAnotherCallTooltip.js';
|
||||||
|
import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.js';
|
||||||
|
|
||||||
function HeaderInfoTitle({
|
function HeaderInfoTitle({
|
||||||
name,
|
name,
|
||||||
|
|
@ -1003,50 +1003,3 @@ function CannotLeaveGroupBecauseYouAreLastAdminAlert({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeleteMessagesConfirmationDialog({
|
|
||||||
i18n,
|
|
||||||
localDeleteWarningShown,
|
|
||||||
onDestroyMessages,
|
|
||||||
onClose,
|
|
||||||
setLocalDeleteWarningShown,
|
|
||||||
}: {
|
|
||||||
i18n: LocalizerType;
|
|
||||||
localDeleteWarningShown: boolean;
|
|
||||||
onDestroyMessages: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
setLocalDeleteWarningShown: () => void;
|
|
||||||
}) {
|
|
||||||
if (!localDeleteWarningShown) {
|
|
||||||
return (
|
|
||||||
<LocalDeleteWarningModal
|
|
||||||
i18n={i18n}
|
|
||||||
onClose={setLocalDeleteWarningShown}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dialogBody = i18n(
|
|
||||||
'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync'
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConfirmationDialog
|
|
||||||
dialogName="ConversationHeader.destroyMessages"
|
|
||||||
title={i18n(
|
|
||||||
'icu:ConversationHeader__DeleteConversationConfirmation__title'
|
|
||||||
)}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
action: onDestroyMessages,
|
|
||||||
style: 'negative',
|
|
||||||
text: i18n('icu:delete'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
i18n={i18n}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
{dialogBody}
|
|
||||||
</ConfirmationDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -209,13 +209,13 @@ export function PollMessageContents({
|
||||||
|
|
||||||
{totalVotes > 0 ? (
|
{totalVotes > 0 ? (
|
||||||
<div className={tw('mt-4 flex justify-center scheme-light')}>
|
<div className={tw('mt-4 flex justify-center scheme-light')}>
|
||||||
<AxoButton
|
<AxoButton.Root
|
||||||
size="medium"
|
size="medium"
|
||||||
variant="floating-secondary"
|
variant="floating-secondary"
|
||||||
onClick={() => setShowVotesModal(true)}
|
onClick={() => setShowVotesModal(true)}
|
||||||
>
|
>
|
||||||
{i18n('icu:PollMessage__ViewVotesButton')}
|
{i18n('icu:PollMessage__ViewVotesButton')}
|
||||||
</AxoButton>
|
</AxoButton.Root>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ const CHECKBOX_CONTAINER_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox--container`;
|
||||||
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
|
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
|
||||||
export const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`;
|
export const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`;
|
||||||
|
|
||||||
|
export type RenderConversationListItemContextMenuProps = Readonly<{
|
||||||
|
conversationId: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}>;
|
||||||
|
|
||||||
type PropsType = {
|
type PropsType = {
|
||||||
buttonAriaLabel?: string;
|
buttonAriaLabel?: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
|
|
@ -58,6 +63,9 @@ type PropsType = {
|
||||||
unreadMentionsCount?: number;
|
unreadMentionsCount?: number;
|
||||||
avatarSize?: AvatarSize;
|
avatarSize?: AvatarSize;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
|
renderConversationListItemContextMenu?: (
|
||||||
|
props: RenderConversationListItemContextMenuProps
|
||||||
|
) => JSX.Element;
|
||||||
} & Pick<
|
} & Pick<
|
||||||
ConversationType,
|
ConversationType,
|
||||||
| 'avatarPlaceholderGradient'
|
| 'avatarPlaceholderGradient'
|
||||||
|
|
@ -114,6 +122,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
unreadCount,
|
unreadCount,
|
||||||
unreadMentionsCount,
|
unreadMentionsCount,
|
||||||
serviceId,
|
serviceId,
|
||||||
|
renderConversationListItemContextMenu,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const identifier = id ? cleanId(id) : undefined;
|
const identifier = id ? cleanId(id) : undefined;
|
||||||
|
|
@ -275,8 +284,10 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let wrapper: JSX.Element;
|
||||||
|
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
return (
|
wrapper = (
|
||||||
<button
|
<button
|
||||||
aria-label={
|
aria-label={
|
||||||
buttonAriaLabel ||
|
buttonAriaLabel ||
|
||||||
|
|
@ -298,9 +309,8 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
{contents}
|
{contents}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
wrapper = (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
className={commonClassNames}
|
className={commonClassNames}
|
||||||
data-id={identifier}
|
data-id={identifier}
|
||||||
|
|
@ -309,6 +319,16 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
{contents}
|
{contents}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderConversationListItemContextMenu != null && id != null) {
|
||||||
|
return renderConversationListItemContextMenu({
|
||||||
|
conversationId: id,
|
||||||
|
children: wrapper,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
});
|
});
|
||||||
|
|
||||||
function Timestamp({
|
function Timestamp({
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { FunctionComponent, ReactNode } from 'react';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { RenderConversationListItemContextMenuProps } from './BaseConversationListItem.js';
|
||||||
import {
|
import {
|
||||||
BaseConversationListItem,
|
BaseConversationListItem,
|
||||||
HEADER_NAME_CLASS_NAME,
|
HEADER_NAME_CLASS_NAME,
|
||||||
|
|
@ -77,6 +78,9 @@ type PropsHousekeeping = {
|
||||||
onClick: (id: string) => void;
|
onClick: (id: string) => void;
|
||||||
onMouseDown: (id: string) => void;
|
onMouseDown: (id: string) => void;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
|
renderConversationListItemContextMenu?: (
|
||||||
|
props: RenderConversationListItemContextMenuProps
|
||||||
|
) => JSX.Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = PropsData & PropsHousekeeping;
|
export type Props = PropsData & PropsHousekeeping;
|
||||||
|
|
@ -115,6 +119,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
unreadCount,
|
unreadCount,
|
||||||
unreadMentionsCount,
|
unreadMentionsCount,
|
||||||
serviceId,
|
serviceId,
|
||||||
|
renderConversationListItemContextMenu,
|
||||||
}) {
|
}) {
|
||||||
const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt);
|
const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt);
|
||||||
const isSomeoneTyping =
|
const isSomeoneTyping =
|
||||||
|
|
@ -243,6 +248,9 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
unreadCount={unreadCount}
|
unreadCount={unreadCount}
|
||||||
unreadMentionsCount={unreadMentionsCount}
|
unreadMentionsCount={unreadMentionsCount}
|
||||||
serviceId={serviceId}
|
serviceId={serviceId}
|
||||||
|
renderConversationListItemContextMenu={
|
||||||
|
renderConversationListItemContextMenu
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
390
ts/components/leftPane/LeftPaneChatFolders.tsx
Normal file
390
ts/components/leftPane/LeftPaneChatFolders.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,7 @@ export abstract class LeftPaneHelper<T> {
|
||||||
createGroup: () => unknown;
|
createGroup: () => unknown;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
removeSelectedContact: (_: string) => unknown;
|
removeSelectedContact: (_: string) => unknown;
|
||||||
|
renderLeftPaneChatFolders: () => JSX.Element;
|
||||||
setComposeGroupAvatar: (_: undefined | Uint8Array) => unknown;
|
setComposeGroupAvatar: (_: undefined | Uint8Array) => unknown;
|
||||||
setComposeGroupExpireTimer: (_: DurationInSeconds) => void;
|
setComposeGroupExpireTimer: (_: DurationInSeconds) => void;
|
||||||
setComposeGroupName: (_: string) => unknown;
|
setComposeGroupName: (_: string) => unknown;
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,14 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override getPreRowsNode({
|
||||||
|
renderLeftPaneChatFolders,
|
||||||
|
}: Readonly<{
|
||||||
|
renderLeftPaneChatFolders: () => JSX.Element;
|
||||||
|
}>): ReactChild {
|
||||||
|
return renderLeftPaneChatFolders();
|
||||||
|
}
|
||||||
|
|
||||||
override getBackgroundNode({
|
override getBackgroundNode({
|
||||||
i18n,
|
i18n,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import type { MutableRefObject } from 'react';
|
import type { MutableRefObject, ReactNode } from 'react';
|
||||||
|
import { ListBox, ListBoxItem, useDragAndDrop } from 'react-aria-components';
|
||||||
|
import { partition } from 'lodash';
|
||||||
import type { LocalizerType } from '../../../types/I18N.js';
|
import type { LocalizerType } from '../../../types/I18N.js';
|
||||||
import { PreferencesContent } from '../../Preferences.js';
|
import { PreferencesContent } from '../../Preferences.js';
|
||||||
import { SettingsRow } from '../../PreferencesUtil.js';
|
import { SettingsRow } from '../../PreferencesUtil.js';
|
||||||
|
|
@ -18,30 +20,128 @@ import type {
|
||||||
ChatFolder,
|
ChatFolder,
|
||||||
} from '../../../types/ChatFolder.js';
|
} from '../../../types/ChatFolder.js';
|
||||||
import { Button, ButtonVariant } from '../../Button.js';
|
import { Button, ButtonVariant } from '../../Button.js';
|
||||||
|
import { AxoContextMenu } from '../../../axo/AxoContextMenu.js';
|
||||||
|
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
|
||||||
|
import { strictAssert } from '../../../util/assert.js';
|
||||||
|
import { tw } from '../../../axo/tw.js';
|
||||||
// import { showToast } from '../../state/ducks/toast';
|
// import { showToast } from '../../state/ducks/toast';
|
||||||
|
|
||||||
|
function moveChatFolders(
|
||||||
|
chatFolders: ReadonlyArray<ChatFolder>,
|
||||||
|
target: ChatFolderId,
|
||||||
|
moving: Set<ChatFolderId>,
|
||||||
|
position: 'before' | 'after'
|
||||||
|
) {
|
||||||
|
const [toSplice, toInsert] = partition(chatFolders, chatFolder => {
|
||||||
|
return !moving.has(chatFolder.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetIndex = toSplice.findIndex(chatFolder => {
|
||||||
|
return chatFolder.id === target;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetIndex === -1) {
|
||||||
|
return chatFolders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spliceIndex = position === 'before' ? targetIndex : targetIndex + 1;
|
||||||
|
|
||||||
|
return toSplice.toSpliced(spliceIndex, 0, ...toInsert);
|
||||||
|
}
|
||||||
|
|
||||||
export type PreferencesChatFoldersPageProps = Readonly<{
|
export type PreferencesChatFoldersPageProps = Readonly<{
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void;
|
onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void;
|
||||||
chatFolders: ReadonlyArray<ChatFolder>;
|
chatFolders: ReadonlyArray<ChatFolder>;
|
||||||
onCreateChatFolder: (params: ChatFolderParams) => void;
|
onCreateChatFolder: (params: ChatFolderParams) => void;
|
||||||
|
onDeleteChatFolder: (chatFolderId: ChatFolderId) => void;
|
||||||
|
onUpdateChatFoldersPositions: (
|
||||||
|
chatFolderIds: ReadonlyArray<ChatFolderId>
|
||||||
|
) => void;
|
||||||
settingsPaneRef: MutableRefObject<HTMLDivElement | null>;
|
settingsPaneRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function PreferencesChatFoldersPage(
|
export function PreferencesChatFoldersPage(
|
||||||
props: PreferencesChatFoldersPageProps
|
props: PreferencesChatFoldersPageProps
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
const { i18n, onOpenEditChatFoldersPage, chatFolders } = props;
|
const {
|
||||||
|
i18n,
|
||||||
|
onOpenEditChatFoldersPage,
|
||||||
|
onDeleteChatFolder,
|
||||||
|
onUpdateChatFoldersPositions,
|
||||||
|
chatFolders,
|
||||||
|
} = props;
|
||||||
|
const [confirmDeleteChatFolder, setConfirmDeleteChatFolder] =
|
||||||
|
useState<ChatFolder | null>(null);
|
||||||
|
|
||||||
// showToast(
|
// showToast(
|
||||||
// i18n("icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast")
|
// i18n("icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast")
|
||||||
// )
|
// )
|
||||||
|
|
||||||
const handleOpenEditChatFoldersPageForNew = useCallback(() => {
|
const handleChatFolderCreate = useCallback(() => {
|
||||||
onOpenEditChatFoldersPage(null);
|
onOpenEditChatFoldersPage(null);
|
||||||
}, [onOpenEditChatFoldersPage]);
|
}, [onOpenEditChatFoldersPage]);
|
||||||
|
|
||||||
|
const handleChatFolderEdit = useCallback(
|
||||||
|
(chatFolder: ChatFolder) => {
|
||||||
|
onOpenEditChatFoldersPage(chatFolder.id);
|
||||||
|
},
|
||||||
|
[onOpenEditChatFoldersPage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChatFolderDeleteInit = useCallback((chatFolder: ChatFolder) => {
|
||||||
|
setConfirmDeleteChatFolder(chatFolder);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChatFolderDeleteCancel = useCallback(() => {
|
||||||
|
setConfirmDeleteChatFolder(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChatFolderDeleteConfirm = useCallback(() => {
|
||||||
|
strictAssert(confirmDeleteChatFolder, 'Missing chat folder to delete');
|
||||||
|
onDeleteChatFolder(confirmDeleteChatFolder.id);
|
||||||
|
}, [confirmDeleteChatFolder, onDeleteChatFolder]);
|
||||||
|
|
||||||
|
const [chatFoldersReordered, setChatFoldersReordered] = useState(chatFolders);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setChatFoldersReordered(chatFolders);
|
||||||
|
}, [chatFolders]);
|
||||||
|
|
||||||
|
const { dragAndDropHooks } = useDragAndDrop({
|
||||||
|
getItems: () => {
|
||||||
|
return chatFolders.map(chatFolder => {
|
||||||
|
return { 'signal-chat-folder-id': chatFolder.id.slice(-3) };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
acceptedDragTypes: ['signal-chat-folder-id'],
|
||||||
|
getDropOperation: () => 'move',
|
||||||
|
onDragEnd: () => {
|
||||||
|
onUpdateChatFoldersPositions(
|
||||||
|
chatFoldersReordered.map(chatFolder => {
|
||||||
|
return chatFolder.id;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onReorder: event => {
|
||||||
|
const target = event.target.key as ChatFolderId;
|
||||||
|
const moving = event.keys as Set<ChatFolderId>;
|
||||||
|
const position = event.target.dropPosition;
|
||||||
|
|
||||||
|
if (position !== 'before' && position !== 'after') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChatFoldersReordered(prevChatFolders => {
|
||||||
|
return moveChatFolders(prevChatFolders, target, moving, position);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
renderDropIndicator: () => {
|
||||||
|
return <div className={tw('h-12')} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const presetItemsConfigs = useMemo(() => {
|
const presetItemsConfigs = useMemo(() => {
|
||||||
const initial: ReadonlyArray<ChatFolderPresetItemConfig> = [
|
const initial: ReadonlyArray<ChatFolderPresetItemConfig> = [
|
||||||
{
|
{
|
||||||
|
|
@ -86,6 +186,7 @@ export function PreferencesChatFoldersPage(
|
||||||
}, [i18n, chatFolders]);
|
}, [i18n, chatFolders]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<PreferencesContent
|
<PreferencesContent
|
||||||
backButton={
|
backButton={
|
||||||
<button
|
<button
|
||||||
|
|
@ -104,13 +205,12 @@ export function PreferencesChatFoldersPage(
|
||||||
title={i18n(
|
title={i18n(
|
||||||
'icu:Preferences__ChatFoldersPage__FoldersSection__Title'
|
'icu:Preferences__ChatFoldersPage__FoldersSection__Title'
|
||||||
)}
|
)}
|
||||||
|
className={tw('mt-4')}
|
||||||
>
|
>
|
||||||
<ul data-testid="ChatFoldersList">
|
|
||||||
<li>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
|
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
|
||||||
onClick={handleOpenEditChatFoldersPageForNew}
|
onClick={handleChatFolderCreate}
|
||||||
>
|
>
|
||||||
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
|
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
|
||||||
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
|
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
|
||||||
|
|
@ -119,18 +219,23 @@ export function PreferencesChatFoldersPage(
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
<ListBox
|
||||||
{props.chatFolders.map(chatFolder => {
|
selectionMode="single"
|
||||||
|
data-testid="ChatFoldersList"
|
||||||
|
items={chatFoldersReordered}
|
||||||
|
dragAndDropHooks={dragAndDropHooks}
|
||||||
|
>
|
||||||
|
{chatFolder => {
|
||||||
return (
|
return (
|
||||||
<ChatFolderListItem
|
<ChatFolderListItem
|
||||||
key={chatFolder.id}
|
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
chatFolder={chatFolder}
|
chatFolder={chatFolder}
|
||||||
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
|
onChatFolderEdit={handleChatFolderEdit}
|
||||||
|
onChatFolderDelete={handleChatFolderDeleteInit}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
</ul>
|
</ListBox>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
{presetItemsConfigs.length > 0 && (
|
{presetItemsConfigs.length > 0 && (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
|
|
@ -145,6 +250,7 @@ export function PreferencesChatFoldersPage(
|
||||||
{presetItemsConfigs.map(presetItemConfig => {
|
{presetItemsConfigs.map(presetItemConfig => {
|
||||||
return (
|
return (
|
||||||
<ChatFolderPresetItem
|
<ChatFolderPresetItem
|
||||||
|
key={presetItemConfig.id}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
config={presetItemConfig}
|
config={presetItemConfig}
|
||||||
onCreateChatFolder={props.onCreateChatFolder}
|
onCreateChatFolder={props.onCreateChatFolder}
|
||||||
|
|
@ -159,6 +265,27 @@ export function PreferencesChatFoldersPage(
|
||||||
contentsRef={props.settingsPaneRef}
|
contentsRef={props.settingsPaneRef}
|
||||||
title={i18n('icu:Preferences__ChatFoldersPage__Title')}
|
title={i18n('icu:Preferences__ChatFoldersPage__Title')}
|
||||||
/>
|
/>
|
||||||
|
{confirmDeleteChatFolder != null && (
|
||||||
|
<DeleteChatFolderDialog
|
||||||
|
i18n={i18n}
|
||||||
|
title={i18n(
|
||||||
|
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title'
|
||||||
|
)}
|
||||||
|
description={i18n(
|
||||||
|
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description',
|
||||||
|
{ chatFolderTitle: confirmDeleteChatFolder.name }
|
||||||
|
)}
|
||||||
|
deleteText={i18n(
|
||||||
|
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__DeleteButton'
|
||||||
|
)}
|
||||||
|
cancelText={i18n(
|
||||||
|
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton'
|
||||||
|
)}
|
||||||
|
onClose={handleChatFolderDeleteCancel}
|
||||||
|
onConfirm={handleChatFolderDeleteConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,87 +341,96 @@ function ChatFolderPresetItem(props: ChatFolderPresetItemProps) {
|
||||||
function ChatFolderListItem(props: {
|
function ChatFolderListItem(props: {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
chatFolder: ChatFolder;
|
chatFolder: ChatFolder;
|
||||||
onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId) => void;
|
onChatFolderEdit: (chatFolder: ChatFolder) => void;
|
||||||
|
onChatFolderDelete: (chatFolder: ChatFolder) => void;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const { i18n, chatFolder, onOpenEditChatFoldersPage } = props;
|
const { i18n, chatFolder, onChatFolderEdit } = props;
|
||||||
|
|
||||||
const handleAction = useCallback(() => {
|
const handleClickChatFolder = useCallback(() => {
|
||||||
onOpenEditChatFoldersPage(chatFolder.id);
|
onChatFolderEdit(chatFolder);
|
||||||
}, [chatFolder, onOpenEditChatFoldersPage]);
|
}, [chatFolder, onChatFolderEdit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<>
|
||||||
<button
|
{props.chatFolder.folderType === ChatFolderType.ALL && (
|
||||||
type="button"
|
<ListBoxItem
|
||||||
|
id={chatFolder.id}
|
||||||
data-testid={`ChatFolder--${chatFolder.id}`}
|
data-testid={`ChatFolder--${chatFolder.id}`}
|
||||||
onClick={handleAction}
|
className="Preferences__ChatFolders__ChatSelection__Item"
|
||||||
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
|
|
||||||
>
|
>
|
||||||
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
|
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
|
||||||
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
|
{i18n(
|
||||||
{props.chatFolder.folderType === ChatFolderType.ALL &&
|
|
||||||
i18n(
|
|
||||||
'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
|
'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
|
||||||
)}
|
)}
|
||||||
{props.chatFolder.folderType === ChatFolderType.CUSTOM && (
|
</ListBoxItem>
|
||||||
<>{props.chatFolder.name}</>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
|
||||||
</button>
|
{props.chatFolder.folderType === ChatFolderType.CUSTOM && (
|
||||||
</li>
|
<ListBoxItem
|
||||||
|
id={chatFolder.id}
|
||||||
|
data-testid={`ChatFolder--${chatFolder.id}`}
|
||||||
|
textValue={props.chatFolder.name}
|
||||||
|
onAction={handleClickChatFolder}
|
||||||
|
>
|
||||||
|
<ChatFolderListItemContextMenu
|
||||||
|
i18n={i18n}
|
||||||
|
chatFolder={props.chatFolder}
|
||||||
|
onChatFolderEdit={props.onChatFolderEdit}
|
||||||
|
onChatFolderDelete={props.onChatFolderDelete}
|
||||||
|
>
|
||||||
|
<div className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button">
|
||||||
|
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
|
||||||
|
{props.chatFolder.name}
|
||||||
|
</div>
|
||||||
|
</ChatFolderListItemContextMenu>
|
||||||
|
</ListBoxItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// function ChatFolderContextMenu(props: {
|
function ChatFolderListItemContextMenu(props: {
|
||||||
// i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
// children: ReactNode;
|
chatFolder: ChatFolder;
|
||||||
// }) {
|
onChatFolderEdit: (chatFolder: ChatFolder) => void;
|
||||||
// const { i18n } = props;
|
onChatFolderDelete: (chatFolder: ChatFolder) => void;
|
||||||
// return (
|
children: ReactNode;
|
||||||
// <AxoContextMenu.Root>
|
}) {
|
||||||
// <AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
|
const { i18n, chatFolder, onChatFolderEdit, onChatFolderDelete } = props;
|
||||||
// <AxoContextMenu.Content>
|
|
||||||
// <AxoContextMenu.Item>
|
|
||||||
// {i18n(
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
// 'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder'
|
|
||||||
// )}
|
|
||||||
// </AxoContextMenu.Item>
|
|
||||||
// <AxoContextMenu.Item>
|
|
||||||
// {i18n(
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
// 'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__DeleteFolder'
|
|
||||||
// )}
|
|
||||||
// </AxoContextMenu.Item>
|
|
||||||
// </AxoContextMenu.Content>
|
|
||||||
// </AxoContextMenu.Root>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// function DeleteChatFolderDialog(props: { i18n: LocalizerType }): JSX.Element {
|
const handleSelectChatFolderEdit = useCallback(() => {
|
||||||
// const { i18n } = props;
|
onChatFolderEdit(chatFolder);
|
||||||
// return (
|
}, [chatFolder, onChatFolderEdit]);
|
||||||
// <ConfirmationDialog
|
|
||||||
// i18n={i18n}
|
const handleSelectChatFolderDelete = useCallback(() => {
|
||||||
// dialogName="Preferences__ChatsPage__DeleteChatFolderDialog"
|
onChatFolderDelete(chatFolder);
|
||||||
// title={i18n('icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title')}
|
}, [chatFolder, onChatFolderDelete]);
|
||||||
// cancelText={i18n(
|
|
||||||
// 'icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton'
|
if (chatFolder.folderType !== ChatFolderType.CUSTOM) {
|
||||||
// )}
|
return <>{props.children}</>;
|
||||||
// actions={[
|
}
|
||||||
// {
|
|
||||||
// text: i18n(
|
return (
|
||||||
// 'icu:Preferences__ChatsPage__DeleteChatFolderDialog__DeleteButton'
|
<AxoContextMenu.Root>
|
||||||
// ),
|
<AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
|
||||||
// style: 'affirmative',
|
<AxoContextMenu.Content>
|
||||||
// action: () => null,
|
<AxoContextMenu.Item
|
||||||
// },
|
symbol="pencil"
|
||||||
// ]}
|
onSelect={handleSelectChatFolderEdit}
|
||||||
// onClose={() => null}
|
>
|
||||||
// >
|
{i18n(
|
||||||
// {i18n('icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description', {
|
'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder'
|
||||||
// chatFolderTitle: '',
|
)}
|
||||||
// })}
|
</AxoContextMenu.Item>
|
||||||
// </ConfirmationDialog>
|
<AxoContextMenu.Item
|
||||||
// );
|
symbol="trash"
|
||||||
// }
|
onSelect={handleSelectChatFolderDelete}
|
||||||
|
>
|
||||||
|
{i18n(
|
||||||
|
'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__DeleteFolder'
|
||||||
|
)}
|
||||||
|
</AxoContextMenu.Item>
|
||||||
|
</AxoContextMenu.Content>
|
||||||
|
</AxoContextMenu.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import type { MutableRefObject } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import type { ConversationType } from '../../../state/ducks/conversations.js';
|
import type { ConversationType } from '../../../state/ducks/conversations.js';
|
||||||
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js';
|
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js';
|
||||||
import type { LocalizerType } from '../../../types/I18N.js';
|
import type { LocalizerType } from '../../../types/I18N.js';
|
||||||
|
|
@ -28,12 +28,17 @@ import type {
|
||||||
import type { GetConversationByIdType } from '../../../state/selectors/conversations.js';
|
import type { GetConversationByIdType } from '../../../state/selectors/conversations.js';
|
||||||
import { strictAssert } from '../../../util/assert.js';
|
import { strictAssert } from '../../../util/assert.js';
|
||||||
import { parseStrict } from '../../../util/schemas.js';
|
import { parseStrict } from '../../../util/schemas.js';
|
||||||
|
import { BeforeNavigateResponse } from '../../../services/BeforeNavigate.js';
|
||||||
|
import { type Location } from '../../../types/Nav.js';
|
||||||
|
import { useNavBlocker } from '../../../hooks/useNavBlocker.js';
|
||||||
|
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
|
||||||
|
|
||||||
export type PreferencesEditChatFolderPageProps = Readonly<{
|
export type PreferencesEditChatFolderPageProps = Readonly<{
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
previousLocation: Location;
|
||||||
existingChatFolderId: ChatFolderId | null;
|
existingChatFolderId: ChatFolderId | null;
|
||||||
initChatFolderParams: ChatFolderParams;
|
initChatFolderParams: ChatFolderParams;
|
||||||
onBack: () => void;
|
changeLocation: (location: Location) => void;
|
||||||
conversations: ReadonlyArray<ConversationType>;
|
conversations: ReadonlyArray<ConversationType>;
|
||||||
preferredBadgeSelector: PreferredBadgeSelectorType;
|
preferredBadgeSelector: PreferredBadgeSelectorType;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
|
|
@ -52,12 +57,13 @@ export function PreferencesEditChatFolderPage(
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
const {
|
const {
|
||||||
i18n,
|
i18n,
|
||||||
|
previousLocation,
|
||||||
initChatFolderParams,
|
initChatFolderParams,
|
||||||
existingChatFolderId,
|
existingChatFolderId,
|
||||||
onCreateChatFolder,
|
onCreateChatFolder,
|
||||||
onUpdateChatFolder,
|
onUpdateChatFolder,
|
||||||
onDeleteChatFolder,
|
onDeleteChatFolder,
|
||||||
onBack,
|
changeLocation,
|
||||||
conversationSelector,
|
conversationSelector,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
|
@ -67,7 +73,6 @@ export function PreferencesEditChatFolderPage(
|
||||||
const [showInclusionsDialog, setShowInclusionsDialog] = useState(false);
|
const [showInclusionsDialog, setShowInclusionsDialog] = useState(false);
|
||||||
const [showExclusionsDialog, setShowExclusionsDialog] = useState(false);
|
const [showExclusionsDialog, setShowExclusionsDialog] = useState(false);
|
||||||
const [showDeleteFolderDialog, setShowDeleteFolderDialog] = useState(false);
|
const [showDeleteFolderDialog, setShowDeleteFolderDialog] = useState(false);
|
||||||
const [showSaveChangesDialog, setShowSaveChangesDialog] = useState(false);
|
|
||||||
|
|
||||||
const normalizedChatFolderParams = useMemo(() => {
|
const normalizedChatFolderParams = useMemo(() => {
|
||||||
return parseStrict(ChatFolderParamsSchema, chatFolderParams);
|
return parseStrict(ChatFolderParamsSchema, chatFolderParams);
|
||||||
|
|
@ -80,6 +85,12 @@ export function PreferencesEditChatFolderPage(
|
||||||
);
|
);
|
||||||
}, [initChatFolderParams, normalizedChatFolderParams]);
|
}, [initChatFolderParams, normalizedChatFolderParams]);
|
||||||
|
|
||||||
|
const didSaveOrDiscardChangesRef = useRef(false);
|
||||||
|
|
||||||
|
const blocker = useNavBlocker('PreferencesEditChatFoldersPage', () => {
|
||||||
|
return isChanged && !didSaveOrDiscardChangesRef.current;
|
||||||
|
});
|
||||||
|
|
||||||
const isValid = useMemo(() => {
|
const isValid = useMemo(() => {
|
||||||
return validateChatFolderParams(normalizedChatFolderParams);
|
return validateChatFolderParams(normalizedChatFolderParams);
|
||||||
}, [normalizedChatFolderParams]);
|
}, [normalizedChatFolderParams]);
|
||||||
|
|
@ -102,23 +113,16 @@ export function PreferencesEditChatFolderPage(
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleBackInit = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
if (!isChanged) {
|
changeLocation(previousLocation);
|
||||||
onBack();
|
}, [changeLocation, previousLocation]);
|
||||||
} else {
|
|
||||||
setShowSaveChangesDialog(true);
|
|
||||||
}
|
|
||||||
}, [isChanged, onBack]);
|
|
||||||
|
|
||||||
const handleDiscard = useCallback(() => {
|
const handleDiscardAndBack = useCallback(() => {
|
||||||
onBack();
|
didSaveOrDiscardChangesRef.current = true;
|
||||||
}, [onBack]);
|
handleBack();
|
||||||
|
}, [handleBack]);
|
||||||
|
|
||||||
const handleSaveClose = useCallback(() => {
|
const handleSaveChanges = useCallback(() => {
|
||||||
setShowSaveChangesDialog(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
strictAssert(isChanged, 'tried saving when unchanged');
|
strictAssert(isChanged, 'tried saving when unchanged');
|
||||||
strictAssert(isValid, 'tried saving when invalid');
|
strictAssert(isValid, 'tried saving when invalid');
|
||||||
|
|
||||||
|
|
@ -127,9 +131,9 @@ export function PreferencesEditChatFolderPage(
|
||||||
} else {
|
} else {
|
||||||
onCreateChatFolder(normalizedChatFolderParams);
|
onCreateChatFolder(normalizedChatFolderParams);
|
||||||
}
|
}
|
||||||
onBack();
|
|
||||||
|
didSaveOrDiscardChangesRef.current = true;
|
||||||
}, [
|
}, [
|
||||||
onBack,
|
|
||||||
existingChatFolderId,
|
existingChatFolderId,
|
||||||
isChanged,
|
isChanged,
|
||||||
isValid,
|
isValid,
|
||||||
|
|
@ -138,6 +142,24 @@ export function PreferencesEditChatFolderPage(
|
||||||
onUpdateChatFolder,
|
onUpdateChatFolder,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleSaveChangesAndBack = useCallback(() => {
|
||||||
|
handleSaveChanges();
|
||||||
|
handleBack();
|
||||||
|
}, [handleSaveChanges, handleBack]);
|
||||||
|
|
||||||
|
const handleBlockerCancelNavigation = useCallback(() => {
|
||||||
|
blocker.respond?.(BeforeNavigateResponse.CancelNavigation);
|
||||||
|
}, [blocker]);
|
||||||
|
|
||||||
|
const handleBlockerSaveChanges = useCallback(() => {
|
||||||
|
handleSaveChanges();
|
||||||
|
blocker.respond?.(BeforeNavigateResponse.WaitedForUser);
|
||||||
|
}, [handleSaveChanges, blocker]);
|
||||||
|
|
||||||
|
const handleBlockerDiscardChanges = useCallback(() => {
|
||||||
|
blocker.respond?.(BeforeNavigateResponse.WaitedForUser);
|
||||||
|
}, [blocker]);
|
||||||
|
|
||||||
const handleDeleteInit = useCallback(() => {
|
const handleDeleteInit = useCallback(() => {
|
||||||
setShowDeleteFolderDialog(true);
|
setShowDeleteFolderDialog(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -145,8 +167,8 @@ export function PreferencesEditChatFolderPage(
|
||||||
strictAssert(existingChatFolderId, 'Missing existing chat folder id');
|
strictAssert(existingChatFolderId, 'Missing existing chat folder id');
|
||||||
onDeleteChatFolder(existingChatFolderId);
|
onDeleteChatFolder(existingChatFolderId);
|
||||||
setShowDeleteFolderDialog(false);
|
setShowDeleteFolderDialog(false);
|
||||||
onBack();
|
handleBack();
|
||||||
}, [existingChatFolderId, onDeleteChatFolder, onBack]);
|
}, [existingChatFolderId, onDeleteChatFolder, handleBack]);
|
||||||
const handleDeleteClose = useCallback(() => {
|
const handleDeleteClose = useCallback(() => {
|
||||||
setShowDeleteFolderDialog(false);
|
setShowDeleteFolderDialog(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -193,7 +215,7 @@ export function PreferencesEditChatFolderPage(
|
||||||
<button
|
<button
|
||||||
aria-label={i18n('icu:goBack')}
|
aria-label={i18n('icu:goBack')}
|
||||||
className="Preferences__back-icon"
|
className="Preferences__back-icon"
|
||||||
onClick={handleBackInit}
|
onClick={handleBack}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
@ -415,16 +437,28 @@ export function PreferencesEditChatFolderPage(
|
||||||
{showDeleteFolderDialog && (
|
{showDeleteFolderDialog && (
|
||||||
<DeleteChatFolderDialog
|
<DeleteChatFolderDialog
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
title={i18n(
|
||||||
|
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Title'
|
||||||
|
)}
|
||||||
|
description={i18n(
|
||||||
|
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Description'
|
||||||
|
)}
|
||||||
|
cancelText={i18n(
|
||||||
|
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__CancelButton'
|
||||||
|
)}
|
||||||
|
deleteText={i18n(
|
||||||
|
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__DeleteButton'
|
||||||
|
)}
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
onClose={handleDeleteClose}
|
onClose={handleDeleteClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showSaveChangesDialog && (
|
{blocker.state === 'blocked' && (
|
||||||
<SaveChangesFolderDialog
|
<SaveChangesFolderDialog
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onSave={handleSave}
|
onSave={handleBlockerSaveChanges}
|
||||||
onCancel={handleDiscard}
|
onDiscard={handleBlockerDiscardChanges}
|
||||||
onClose={handleSaveClose}
|
onClose={handleBlockerCancelNavigation}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -433,12 +467,15 @@ export function PreferencesEditChatFolderPage(
|
||||||
title={i18n('icu:Preferences__EditChatFolderPage__Title')}
|
title={i18n('icu:Preferences__EditChatFolderPage__Title')}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant={ButtonVariant.Secondary} onClick={handleDiscard}>
|
<Button
|
||||||
|
variant={ButtonVariant.Secondary}
|
||||||
|
onClick={handleDiscardAndBack}
|
||||||
|
>
|
||||||
{i18n('icu:Preferences__EditChatFolderPage__CancelButton')}
|
{i18n('icu:Preferences__EditChatFolderPage__CancelButton')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={ButtonVariant.Primary}
|
variant={ButtonVariant.Primary}
|
||||||
onClick={handleSave}
|
onClick={handleSaveChangesAndBack}
|
||||||
disabled={!(isChanged && isValid)}
|
disabled={!(isChanged && isValid)}
|
||||||
>
|
>
|
||||||
{i18n('icu:Preferences__EditChatFolderPage__SaveButton')}
|
{i18n('icu:Preferences__EditChatFolderPage__SaveButton')}
|
||||||
|
|
@ -449,44 +486,10 @@ export function PreferencesEditChatFolderPage(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeleteChatFolderDialog(props: {
|
|
||||||
i18n: LocalizerType;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { i18n } = props;
|
|
||||||
return (
|
|
||||||
<ConfirmationDialog
|
|
||||||
i18n={i18n}
|
|
||||||
dialogName="Preferences__EditChatFolderPage__DeleteChatFolderDialog"
|
|
||||||
title={i18n(
|
|
||||||
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Title'
|
|
||||||
)}
|
|
||||||
cancelText={i18n(
|
|
||||||
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__CancelButton'
|
|
||||||
)}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
text: i18n(
|
|
||||||
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__DeleteButton'
|
|
||||||
),
|
|
||||||
style: 'affirmative',
|
|
||||||
action: props.onConfirm,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onClose={props.onClose}
|
|
||||||
>
|
|
||||||
{i18n(
|
|
||||||
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Description'
|
|
||||||
)}
|
|
||||||
</ConfirmationDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SaveChangesFolderDialog(props: {
|
function SaveChangesFolderDialog(props: {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onCancel: () => void;
|
onDiscard: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { i18n } = props;
|
const { i18n } = props;
|
||||||
|
|
@ -510,7 +513,7 @@ function SaveChangesFolderDialog(props: {
|
||||||
action: props.onSave,
|
action: props.onSave,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onCancel={props.onCancel}
|
onCancel={props.onDiscard}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
>
|
>
|
||||||
{i18n(
|
{i18n(
|
||||||
|
|
|
||||||
96
ts/hooks/useNavBlocker.ts
Normal file
96
ts/hooks/useNavBlocker.ts
Normal 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
3
ts/model-types.d.ts
vendored
|
|
@ -41,6 +41,7 @@ import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||||
import MemberRoleEnum = Proto.Member.Role;
|
import MemberRoleEnum = Proto.Member.Role;
|
||||||
import type { MessageRequestResponseEvent } from './types/MessageRequestResponseEvent.js';
|
import type { MessageRequestResponseEvent } from './types/MessageRequestResponseEvent.js';
|
||||||
import type { QuotedMessageForComposerType } from './state/ducks/composer.js';
|
import type { QuotedMessageForComposerType } from './state/ducks/composer.js';
|
||||||
|
import type { SEALED_SENDER } from './types/SealedSender.js';
|
||||||
|
|
||||||
export type LastMessageStatus =
|
export type LastMessageStatus =
|
||||||
| 'paused'
|
| 'paused'
|
||||||
|
|
@ -399,7 +400,7 @@ export type ConversationAttributesType = {
|
||||||
* TODO: Rename this key to be specific to the accessKey on the conversation
|
* TODO: Rename this key to be specific to the accessKey on the conversation
|
||||||
* It's not used for group endorsements.
|
* It's not used for group endorsements.
|
||||||
*/
|
*/
|
||||||
sealedSender?: unknown;
|
sealedSender?: SEALED_SENDER;
|
||||||
sentMessageCount?: number;
|
sentMessageCount?: number;
|
||||||
sharedGroupNames?: ReadonlyArray<string>;
|
sharedGroupNames?: ReadonlyArray<string>;
|
||||||
voiceNotePlaybackRate?: number;
|
voiceNotePlaybackRate?: number;
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,16 @@ export enum BeforeNavigateResponse {
|
||||||
CancelNavigation = 'CancelNavigation',
|
CancelNavigation = 'CancelNavigation',
|
||||||
TimedOut = 'TimedOut',
|
TimedOut = 'TimedOut',
|
||||||
}
|
}
|
||||||
export type BeforeNavigateCallback = (options: {
|
|
||||||
existingLocation?: Location;
|
export type BeforeNavigateTransitionDetails = Readonly<{
|
||||||
|
existingLocation: Location;
|
||||||
newLocation: Location;
|
newLocation: Location;
|
||||||
}) => Promise<BeforeNavigateResponse>;
|
}>;
|
||||||
|
|
||||||
|
export type BeforeNavigateCallback = (
|
||||||
|
details: BeforeNavigateTransitionDetails
|
||||||
|
) => Promise<BeforeNavigateResponse>;
|
||||||
|
|
||||||
export type BeforeNavigateEntry = {
|
export type BeforeNavigateEntry = {
|
||||||
name: string;
|
name: string;
|
||||||
callback: BeforeNavigateCallback;
|
callback: BeforeNavigateCallback;
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,11 @@ import { isDone as isRegistrationDone } from '../util/registration.js';
|
||||||
import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue.js';
|
import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue.js';
|
||||||
import { isMockEnvironment } from '../environment.js';
|
import { isMockEnvironment } from '../environment.js';
|
||||||
import { validateConversation } from '../util/validateConversation.js';
|
import { validateConversation } from '../util/validateConversation.js';
|
||||||
import type { ChatFolder } from '../types/ChatFolder.js';
|
import {
|
||||||
|
ChatFolderType,
|
||||||
|
toCurrentChatFolders,
|
||||||
|
type ChatFolder,
|
||||||
|
} from '../types/ChatFolder.js';
|
||||||
|
|
||||||
const { debounce, isNumber, chunk } = lodash;
|
const { debounce, isNumber, chunk } = lodash;
|
||||||
|
|
||||||
|
|
@ -1658,6 +1662,22 @@ async function processManifest(
|
||||||
storageVersion: null,
|
storageVersion: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const chatFoldersHasAllChatsFolder = chatFolders.some(chatFolder => {
|
||||||
|
return (
|
||||||
|
chatFolder.folderType === ChatFolderType.ALL &&
|
||||||
|
chatFolder.deletedAtTimestampMs === 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chatFoldersHasAllChatsFolder) {
|
||||||
|
log.info(`process(${version}): creating all chats chat folder`);
|
||||||
|
await DataWriter.createAllChatsChatFolder();
|
||||||
|
const currentChatFolders = await DataReader.getCurrentChatFolders();
|
||||||
|
window.reduxActions.chatFolders.replaceAllChatFolderRecords(
|
||||||
|
toCurrentChatFolders(currentChatFolders)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`process(${version}): done`);
|
log.info(`process(${version}): done`);
|
||||||
|
|
|
||||||
|
|
@ -1246,6 +1246,7 @@ type WritableInterface = {
|
||||||
createDonationReceipt(profile: DonationReceipt): void;
|
createDonationReceipt(profile: DonationReceipt): void;
|
||||||
|
|
||||||
createChatFolder: (chatFolder: ChatFolder) => void;
|
createChatFolder: (chatFolder: ChatFolder) => void;
|
||||||
|
createAllChatsChatFolder: () => ChatFolder;
|
||||||
updateChatFolder: (chatFolder: ChatFolder) => void;
|
updateChatFolder: (chatFolder: ChatFolder) => void;
|
||||||
updateChatFolderPositions: (chatFolders: ReadonlyArray<ChatFolder>) => void;
|
updateChatFolderPositions: (chatFolders: ReadonlyArray<ChatFolder>) => void;
|
||||||
updateChatFolderDeletedAtTimestampMsFromSync: (
|
updateChatFolderDeletedAtTimestampMsFromSync: (
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,7 @@ import {
|
||||||
getCurrentChatFolders,
|
getCurrentChatFolders,
|
||||||
getChatFolder,
|
getChatFolder,
|
||||||
createChatFolder,
|
createChatFolder,
|
||||||
|
createAllChatsChatFolder,
|
||||||
updateChatFolder,
|
updateChatFolder,
|
||||||
markChatFolderDeleted,
|
markChatFolderDeleted,
|
||||||
getOldestDeletedChatFolder,
|
getOldestDeletedChatFolder,
|
||||||
|
|
@ -700,6 +701,7 @@ export const DataWriter: ServerWritableInterface = {
|
||||||
createDonationReceipt,
|
createDonationReceipt,
|
||||||
|
|
||||||
createChatFolder,
|
createChatFolder,
|
||||||
|
createAllChatsChatFolder,
|
||||||
updateChatFolder,
|
updateChatFolder,
|
||||||
markChatFolderDeleted,
|
markChatFolderDeleted,
|
||||||
deleteExpiredChatFolders,
|
deleteExpiredChatFolders,
|
||||||
|
|
@ -7655,8 +7657,8 @@ function hydrateNotificationProfile(
|
||||||
allowAllMentions: Boolean(profile.allowAllMentions),
|
allowAllMentions: Boolean(profile.allowAllMentions),
|
||||||
scheduleEnabled: Boolean(profile.scheduleEnabled),
|
scheduleEnabled: Boolean(profile.scheduleEnabled),
|
||||||
allowedMembers: profile.allowedMembersJson
|
allowedMembers: profile.allowedMembersJson
|
||||||
? new Set(JSON.parse(profile.allowedMembersJson))
|
? new Set<string>(JSON.parse(profile.allowedMembersJson))
|
||||||
: new Set(),
|
: new Set<string>(),
|
||||||
scheduleStartTime: profile.scheduleStartTime || undefined,
|
scheduleStartTime: profile.scheduleStartTime || undefined,
|
||||||
scheduleEndTime: profile.scheduleEndTime || undefined,
|
scheduleEndTime: profile.scheduleEndTime || undefined,
|
||||||
scheduleDaysEnabled: profile.scheduleDaysEnabledJson
|
scheduleDaysEnabled: profile.scheduleDaysEnabledJson
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import { v4 as generateUuid } from 'uuid';
|
||||||
import {
|
import {
|
||||||
type ChatFolderId,
|
type ChatFolderId,
|
||||||
type ChatFolder,
|
type ChatFolder,
|
||||||
CHAT_FOLDER_DELETED_POSITION,
|
CHAT_FOLDER_DELETED_POSITION,
|
||||||
|
CHAT_FOLDER_DEFAULTS,
|
||||||
|
ChatFolderType,
|
||||||
} from '../../types/ChatFolder.js';
|
} from '../../types/ChatFolder.js';
|
||||||
import type { ReadableDB, WritableDB } from '../Interface.js';
|
import type { ReadableDB, WritableDB } from '../Interface.js';
|
||||||
import { sql } from '../util.js';
|
import { sql } from '../util.js';
|
||||||
|
|
@ -97,8 +100,7 @@ export function getChatFolder(
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
|
function _insertChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
|
||||||
return db.transaction(() => {
|
|
||||||
const chatFolderRow = chatFolderToRow(chatFolder);
|
const chatFolderRow = chatFolderToRow(chatFolder);
|
||||||
const [chatFolderQuery, chatFolderParams] = sql`
|
const [chatFolderQuery, chatFolderParams] = sql`
|
||||||
INSERT INTO chatFolders (
|
INSERT INTO chatFolders (
|
||||||
|
|
@ -136,6 +138,33 @@ export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
db.prepare(chatFolderQuery).run(chatFolderParams);
|
db.prepare(chatFolderQuery).run(chatFolderParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
|
||||||
|
return db.transaction(() => {
|
||||||
|
_insertChatFolder(db, chatFolder);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAllChatsChatFolder(db: WritableDB): ChatFolder {
|
||||||
|
return db.transaction(() => {
|
||||||
|
const allChatsChatFolder: ChatFolder = {
|
||||||
|
...CHAT_FOLDER_DEFAULTS,
|
||||||
|
id: generateUuid() as ChatFolderId,
|
||||||
|
folderType: ChatFolderType.ALL,
|
||||||
|
position: 0,
|
||||||
|
deletedAtTimestampMs: 0,
|
||||||
|
storageID: null,
|
||||||
|
storageVersion: null,
|
||||||
|
storageUnknownFields: null,
|
||||||
|
storageNeedsSync: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// shift all positions over 1
|
||||||
|
_resetAllChatFolderPositions(db, 1);
|
||||||
|
_insertChatFolder(db, allChatsChatFolder);
|
||||||
|
|
||||||
|
return allChatsChatFolder;
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,11 +216,11 @@ export function markChatFolderDeleted(
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
`;
|
`;
|
||||||
db.prepare(query).run(params);
|
db.prepare(query).run(params);
|
||||||
_resetAllChatFolderPositions(db);
|
_resetAllChatFolderPositions(db, 0);
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _resetAllChatFolderPositions(db: WritableDB) {
|
function _resetAllChatFolderPositions(db: WritableDB, offset: number) {
|
||||||
const [query, params] = sql`
|
const [query, params] = sql`
|
||||||
SELECT id FROM chatFolders
|
SELECT id FROM chatFolders
|
||||||
WHERE deletedAtTimestampMs IS 0
|
WHERE deletedAtTimestampMs IS 0
|
||||||
|
|
@ -204,7 +233,7 @@ function _resetAllChatFolderPositions(db: WritableDB) {
|
||||||
const [update, updateParams] = sql`
|
const [update, updateParams] = sql`
|
||||||
UPDATE chatFolders
|
UPDATE chatFolders
|
||||||
SET
|
SET
|
||||||
position = ${index},
|
position = ${offset + index},
|
||||||
storageNeedsSync = 1
|
storageNeedsSync = 1
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,43 @@ import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.j
|
||||||
import { useBoundActions } from '../../hooks/useBoundActions.js';
|
import { useBoundActions } from '../../hooks/useBoundActions.js';
|
||||||
import {
|
import {
|
||||||
ChatFolderParamsSchema,
|
ChatFolderParamsSchema,
|
||||||
|
lookupCurrentChatFolder,
|
||||||
|
toCurrentChatFolders,
|
||||||
|
getSortedCurrentChatFolders,
|
||||||
type ChatFolder,
|
type ChatFolder,
|
||||||
type ChatFolderId,
|
type ChatFolderId,
|
||||||
type ChatFolderParams,
|
type ChatFolderParams,
|
||||||
|
type CurrentChatFolders,
|
||||||
} from '../../types/ChatFolder.js';
|
} from '../../types/ChatFolder.js';
|
||||||
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
|
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
|
||||||
import { DataWriter } from '../../sql/Client.js';
|
import { DataWriter } from '../../sql/Client.js';
|
||||||
import { strictAssert } from '../../util/assert.js';
|
|
||||||
import { storageServiceUploadJob } from '../../services/storage.js';
|
import { storageServiceUploadJob } from '../../services/storage.js';
|
||||||
import { parseStrict } from '../../util/schemas.js';
|
import { parseStrict } from '../../util/schemas.js';
|
||||||
import { chatFolderCleanupService } from '../../services/expiring/chatFolderCleanupService.js';
|
import { chatFolderCleanupService } from '../../services/expiring/chatFolderCleanupService.js';
|
||||||
import { drop } from '../../util/drop.js';
|
import { drop } from '../../util/drop.js';
|
||||||
|
import {
|
||||||
|
TARGETED_CONVERSATION_CHANGED,
|
||||||
|
type TargetedConversationChangedActionType,
|
||||||
|
} from './conversations.js';
|
||||||
|
|
||||||
export type ChatFoldersState = ReadonlyDeep<{
|
export type ChatFoldersState = ReadonlyDeep<{
|
||||||
currentChatFolders: ReadonlyArray<ChatFolder>;
|
currentChatFolders: CurrentChatFolders;
|
||||||
|
selectedChatFolderId: ChatFolderId | null;
|
||||||
|
stableSelectedConversationIdInChatFolder: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
const CHAT_FOLDER_RECORD_REPLACE_ALL =
|
||||||
|
'chatFolders/CHAT_FOLDER_RECORD_REPLACE_ALL';
|
||||||
const CHAT_FOLDER_RECORD_ADD = 'chatFolders/RECORD_ADD';
|
const CHAT_FOLDER_RECORD_ADD = 'chatFolders/RECORD_ADD';
|
||||||
const CHAT_FOLDER_RECORD_REPLACE = 'chatFolders/RECORD_REPLACE';
|
const CHAT_FOLDER_RECORD_REPLACE = 'chatFolders/RECORD_REPLACE';
|
||||||
const CHAT_FOLDER_RECORD_REMOVE = 'chatFolders/RECORD_REMOVE';
|
const CHAT_FOLDER_RECORD_REMOVE = 'chatFolders/RECORD_REMOVE';
|
||||||
|
const CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID =
|
||||||
|
'chatFolders/CHANGE_SELECTED_CHAT_FOLDER_ID';
|
||||||
|
|
||||||
|
export type ChatFolderRecordReplaceAll = ReadonlyDeep<{
|
||||||
|
type: typeof CHAT_FOLDER_RECORD_REPLACE_ALL;
|
||||||
|
payload: CurrentChatFolders;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type ChatFolderRecordAdd = ReadonlyDeep<{
|
export type ChatFolderRecordAdd = ReadonlyDeep<{
|
||||||
type: typeof CHAT_FOLDER_RECORD_ADD;
|
type: typeof CHAT_FOLDER_RECORD_ADD;
|
||||||
|
|
@ -43,13 +61,36 @@ export type ChatFolderRecordRemove = ReadonlyDeep<{
|
||||||
payload: ChatFolderId;
|
payload: ChatFolderId;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type ChatFolderChangeSelectedChatFolderId = ReadonlyDeep<{
|
||||||
|
type: typeof CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID;
|
||||||
|
payload: ChatFolderId | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type ChatFolderAction = ReadonlyDeep<
|
export type ChatFolderAction = ReadonlyDeep<
|
||||||
ChatFolderRecordAdd | ChatFolderRecordReplace | ChatFolderRecordRemove
|
| ChatFolderRecordReplaceAll
|
||||||
|
| ChatFolderRecordAdd
|
||||||
|
| ChatFolderRecordReplace
|
||||||
|
| ChatFolderRecordRemove
|
||||||
|
| ChatFolderChangeSelectedChatFolderId
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function getEmptyState(): ChatFoldersState {
|
export function getEmptyState(): ChatFoldersState {
|
||||||
return {
|
return {
|
||||||
currentChatFolders: [],
|
currentChatFolders: {
|
||||||
|
order: [],
|
||||||
|
lookup: {},
|
||||||
|
},
|
||||||
|
selectedChatFolderId: null,
|
||||||
|
stableSelectedConversationIdInChatFolder: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAllChatFolderRecords(
|
||||||
|
currentChatFolders: CurrentChatFolders
|
||||||
|
): ChatFolderRecordReplaceAll {
|
||||||
|
return {
|
||||||
|
type: CHAT_FOLDER_RECORD_REPLACE_ALL,
|
||||||
|
payload: currentChatFolders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +128,7 @@ function createChatFolder(
|
||||||
const chatFolder: ChatFolder = {
|
const chatFolder: ChatFolder = {
|
||||||
...chatFolderParams,
|
...chatFolderParams,
|
||||||
id: generateUuid() as ChatFolderId,
|
id: generateUuid() as ChatFolderId,
|
||||||
position: chatFolders.length,
|
position: chatFolders.order.length,
|
||||||
deletedAtTimestampMs: 0,
|
deletedAtTimestampMs: 0,
|
||||||
storageID: null,
|
storageID: null,
|
||||||
storageVersion: null,
|
storageVersion: null,
|
||||||
|
|
@ -106,12 +147,12 @@ function updateChatFolder(
|
||||||
chatFolderParams: ChatFolderParams
|
chatFolderParams: ChatFolderParams
|
||||||
): ThunkAction<void, RootStateType, unknown, ChatFolderRecordReplace> {
|
): ThunkAction<void, RootStateType, unknown, ChatFolderRecordReplace> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
const chatFolders = getCurrentChatFolders(getState());
|
const currentChatFolders = getCurrentChatFolders(getState());
|
||||||
|
|
||||||
const prevChatFolder = chatFolders.find(chatFolder => {
|
const prevChatFolder = lookupCurrentChatFolder(
|
||||||
return chatFolder.id === chatFolderId;
|
currentChatFolders,
|
||||||
});
|
chatFolderId
|
||||||
strictAssert(prevChatFolder != null, 'Missing chat folder');
|
);
|
||||||
|
|
||||||
const nextChatFolder: ChatFolder = {
|
const nextChatFolder: ChatFolder = {
|
||||||
...prevChatFolder,
|
...prevChatFolder,
|
||||||
|
|
@ -136,58 +177,102 @@ function deleteChatFolder(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateChatFoldersPositions(
|
||||||
|
chatFolderIds: ReadonlyArray<ChatFolderId>
|
||||||
|
): ThunkAction<void, RootStateType, unknown, ChatFolderRecordReplaceAll> {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const currentChatFolders = getCurrentChatFolders(getState());
|
||||||
|
const chatFolders = chatFolderIds.map((chatFolderId, index) => {
|
||||||
|
const chatFolder = lookupCurrentChatFolder(
|
||||||
|
currentChatFolders,
|
||||||
|
chatFolderId
|
||||||
|
);
|
||||||
|
return { ...chatFolder, position: index + 1 };
|
||||||
|
});
|
||||||
|
await DataWriter.updateChatFolderPositions(chatFolders);
|
||||||
|
storageServiceUploadJob({ reason: 'updateChatFoldersPositions' });
|
||||||
|
dispatch(replaceAllChatFolderRecords(toCurrentChatFolders(chatFolders)));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedChangeFolderId(
|
||||||
|
chatFolderId: ChatFolderId | null
|
||||||
|
): ChatFolderChangeSelectedChatFolderId {
|
||||||
|
return {
|
||||||
|
type: CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID,
|
||||||
|
payload: chatFolderId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
replaceAllChatFolderRecords,
|
||||||
addChatFolderRecord,
|
addChatFolderRecord,
|
||||||
replaceChatFolderRecord,
|
replaceChatFolderRecord,
|
||||||
removeChatFolderRecord,
|
removeChatFolderRecord,
|
||||||
createChatFolder,
|
createChatFolder,
|
||||||
updateChatFolder,
|
updateChatFolder,
|
||||||
deleteChatFolder,
|
deleteChatFolder,
|
||||||
|
updateChatFoldersPositions,
|
||||||
|
updateSelectedChangeFolderId,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useChatFolderActions = (): BoundActionCreatorsMapObject<
|
export const useChatFolderActions = (): BoundActionCreatorsMapObject<
|
||||||
typeof actions
|
typeof actions
|
||||||
> => useBoundActions(actions);
|
> => useBoundActions(actions);
|
||||||
|
|
||||||
function toSortedChatFolders(
|
|
||||||
chatFolders: ReadonlyArray<ChatFolder>
|
|
||||||
): ReadonlyArray<ChatFolder> {
|
|
||||||
return chatFolders.toSorted((a, b) => a.position - b.position);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reducer(
|
export function reducer(
|
||||||
state: ChatFoldersState = getEmptyState(),
|
state: ChatFoldersState = getEmptyState(),
|
||||||
action: ChatFolderAction
|
action: ChatFolderAction | TargetedConversationChangedActionType
|
||||||
): ChatFoldersState {
|
): ChatFoldersState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
case CHAT_FOLDER_RECORD_REPLACE_ALL:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentChatFolders: action.payload,
|
||||||
|
};
|
||||||
case CHAT_FOLDER_RECORD_ADD:
|
case CHAT_FOLDER_RECORD_ADD:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentChatFolders: toSortedChatFolders([
|
currentChatFolders: toCurrentChatFolders([
|
||||||
...state.currentChatFolders,
|
...getSortedCurrentChatFolders(state.currentChatFolders),
|
||||||
action.payload,
|
action.payload,
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
case CHAT_FOLDER_RECORD_REPLACE:
|
case CHAT_FOLDER_RECORD_REPLACE:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentChatFolders: toSortedChatFolders(
|
currentChatFolders: toCurrentChatFolders([
|
||||||
state.currentChatFolders.map(chatFolder => {
|
...getSortedCurrentChatFolders(state.currentChatFolders).filter(
|
||||||
return chatFolder.id === action.payload.id
|
chatFolder => {
|
||||||
? action.payload
|
return chatFolder.id !== action.payload.id;
|
||||||
: chatFolder;
|
}
|
||||||
})
|
|
||||||
),
|
),
|
||||||
|
action.payload,
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
case CHAT_FOLDER_RECORD_REMOVE:
|
case CHAT_FOLDER_RECORD_REMOVE:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentChatFolders: toSortedChatFolders(
|
currentChatFolders: toCurrentChatFolders(
|
||||||
state.currentChatFolders.filter(chatFolder => {
|
getSortedCurrentChatFolders(state.currentChatFolders).filter(
|
||||||
|
chatFolder => {
|
||||||
return chatFolder.id !== action.payload;
|
return chatFolder.id !== action.payload;
|
||||||
})
|
}
|
||||||
|
)
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
case CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedChatFolderId: action.payload,
|
||||||
|
stableSelectedConversationIdInChatFolder: null,
|
||||||
|
};
|
||||||
|
case TARGETED_CONVERSATION_CHANGED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
stableSelectedConversationIdInChatFolder:
|
||||||
|
action.payload.conversationId ?? null,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ import {
|
||||||
getMe,
|
getMe,
|
||||||
getMessagesByConversation,
|
getMessagesByConversation,
|
||||||
getPendingAvatarDownloadSelector,
|
getPendingAvatarDownloadSelector,
|
||||||
|
getAllConversations,
|
||||||
} from '../selectors/conversations.js';
|
} from '../selectors/conversations.js';
|
||||||
import { getIntl } from '../selectors/user.js';
|
import { getIntl } from '../selectors/user.js';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -215,6 +216,13 @@ import { cleanupMessages } from '../../util/cleanup.js';
|
||||||
import type { ConversationModel } from '../../models/conversations.js';
|
import type { ConversationModel } from '../../models/conversations.js';
|
||||||
import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent.js';
|
import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent.js';
|
||||||
import { JobCancelReason } from '../../jobs/types.js';
|
import { JobCancelReason } from '../../jobs/types.js';
|
||||||
|
import type { ChatFolderId } from '../../types/ChatFolder.js';
|
||||||
|
import {
|
||||||
|
isConversationInChatFolder,
|
||||||
|
lookupCurrentChatFolder,
|
||||||
|
} from '../../types/ChatFolder.js';
|
||||||
|
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
|
||||||
|
import { isConversationUnread } from '../../util/isConversationUnread.js';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
chunk,
|
chunk,
|
||||||
|
|
@ -1180,6 +1188,8 @@ export const actions = {
|
||||||
loadOlderMessages,
|
loadOlderMessages,
|
||||||
markAttachmentAsCorrupted,
|
markAttachmentAsCorrupted,
|
||||||
markMessageRead,
|
markMessageRead,
|
||||||
|
markConversationRead,
|
||||||
|
markChatFolderRead,
|
||||||
markOpenConversationRead,
|
markOpenConversationRead,
|
||||||
messageChanged,
|
messageChanged,
|
||||||
messageDeleted,
|
messageDeleted,
|
||||||
|
|
@ -1237,6 +1247,7 @@ export const actions = {
|
||||||
setMessageLoadingState,
|
setMessageLoadingState,
|
||||||
setMessageToEdit,
|
setMessageToEdit,
|
||||||
setMuteExpiration,
|
setMuteExpiration,
|
||||||
|
setChatFolderMuteExpiration,
|
||||||
setPinned,
|
setPinned,
|
||||||
setPreJoinConversation,
|
setPreJoinConversation,
|
||||||
setProfileUpdateError,
|
setProfileUpdateError,
|
||||||
|
|
@ -1448,6 +1459,57 @@ function loadOlderMessages(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _getAllConversationsInChatFolder(
|
||||||
|
state: RootStateType,
|
||||||
|
chatFolderId: ChatFolderId
|
||||||
|
) {
|
||||||
|
const currentChatFolders = getCurrentChatFolders(state);
|
||||||
|
const chatFolder = lookupCurrentChatFolder(currentChatFolders, chatFolderId);
|
||||||
|
const allConversations = getAllConversations(state);
|
||||||
|
return allConversations.filter(conversation => {
|
||||||
|
return isConversationInChatFolder(chatFolder, conversation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markChatFolderRead(
|
||||||
|
chatFolderId: ChatFolderId
|
||||||
|
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const chatFolderConversations = _getAllConversationsInChatFolder(
|
||||||
|
getState(),
|
||||||
|
chatFolderId
|
||||||
|
);
|
||||||
|
|
||||||
|
const unreadChatFolderConversations = chatFolderConversations.filter(
|
||||||
|
conversation => {
|
||||||
|
return isConversationUnread(conversation);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const conversation of unreadChatFolderConversations) {
|
||||||
|
dispatch(markConversationRead(conversation.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function markConversationRead(
|
||||||
|
conversationId: string
|
||||||
|
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
||||||
|
return async dispatch => {
|
||||||
|
const model = window.ConversationController.get(conversationId);
|
||||||
|
strictAssert(model, 'Conversation must be found');
|
||||||
|
model.setMarkedUnread(false);
|
||||||
|
|
||||||
|
const lastMessage = await DataReader.getLastConversationMessage({
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
if (lastMessage == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch(markMessageRead(conversationId, lastMessage.id));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function markMessageRead(
|
function markMessageRead(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string
|
messageId: string
|
||||||
|
|
@ -1727,6 +1789,22 @@ function setDontNotifyForMentionsIfMuted(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setChatFolderMuteExpiration(
|
||||||
|
chatFolderId: ChatFolderId,
|
||||||
|
muteExpiresAt: number
|
||||||
|
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const chatFolderConversations = _getAllConversationsInChatFolder(
|
||||||
|
getState(),
|
||||||
|
chatFolderId
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const conversation of chatFolderConversations) {
|
||||||
|
dispatch(setMuteExpiration(conversation.id, muteExpiresAt));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function setMuteExpiration(
|
function setMuteExpiration(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
muteExpiresAt = 0
|
muteExpiresAt = 0
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,7 @@ import { createLogger } from '../../logging/log.js';
|
||||||
import { searchConversationTitles } from '../../util/searchConversationTitles.js';
|
import { searchConversationTitles } from '../../util/searchConversationTitles.js';
|
||||||
import { isDirectConversation } from '../../util/whatTypeOfConversation.js';
|
import { isDirectConversation } from '../../util/whatTypeOfConversation.js';
|
||||||
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly.js';
|
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly.js';
|
||||||
import {
|
import { isConversationUnread } from '../../util/countUnreadStats.js';
|
||||||
countConversationUnreadStats,
|
|
||||||
hasUnread,
|
|
||||||
} from '../../util/countUnreadStats.js';
|
|
||||||
|
|
||||||
const { debounce, omit, reject } = lodash;
|
const { debounce, omit, reject } = lodash;
|
||||||
|
|
||||||
|
|
@ -336,9 +333,7 @@ function shouldRemoveConversationFromUnreadList(
|
||||||
conversation &&
|
conversation &&
|
||||||
(selectedConversationId == null ||
|
(selectedConversationId == null ||
|
||||||
selectedConversationId !== conversation.id) &&
|
selectedConversationId !== conversation.id) &&
|
||||||
!hasUnread(
|
!isConversationUnread(conversation, { includeMuted: true })
|
||||||
countConversationUnreadStats(conversation, { includeMuted: true })
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -503,11 +498,9 @@ const doSearch = debounce(
|
||||||
selectedConversationId &&
|
selectedConversationId &&
|
||||||
selectedConversation &&
|
selectedConversation &&
|
||||||
state.search.conversationIds.includes(selectedConversationId) &&
|
state.search.conversationIds.includes(selectedConversationId) &&
|
||||||
!hasUnread(
|
!isConversationUnread(selectedConversation, {
|
||||||
countConversationUnreadStats(selectedConversation, {
|
|
||||||
includeMuted: true,
|
includeMuted: true,
|
||||||
})
|
})
|
||||||
)
|
|
||||||
? selectedConversation
|
? selectedConversation
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import { getEmptyState as usernameEmptyState } from './ducks/username.js';
|
||||||
import OS from '../util/os/osMain.js';
|
import OS from '../util/os/osMain.js';
|
||||||
import { getInteractionMode } from '../services/InteractionMode.js';
|
import { getInteractionMode } from '../services/InteractionMode.js';
|
||||||
import { makeLookup } from '../util/makeLookup.js';
|
import { makeLookup } from '../util/makeLookup.js';
|
||||||
|
import { toCurrentChatFolders } from '../types/ChatFolder.js';
|
||||||
|
|
||||||
import type { StateType } from './reducer.js';
|
import type { StateType } from './reducer.js';
|
||||||
import type { MainWindowStatsType } from '../windows/context.js';
|
import type { MainWindowStatsType } from '../windows/context.js';
|
||||||
|
|
@ -91,7 +92,7 @@ export function getInitialState(
|
||||||
},
|
},
|
||||||
chatFolders: {
|
chatFolders: {
|
||||||
...chatFoldersEmptyState(),
|
...chatFoldersEmptyState(),
|
||||||
currentChatFolders: chatFolders,
|
currentChatFolders: toCurrentChatFolders(chatFolders),
|
||||||
},
|
},
|
||||||
donations,
|
donations,
|
||||||
emojis: recentEmoji,
|
emojis: recentEmoji,
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@ import type { Store } from 'redux';
|
||||||
|
|
||||||
import { SmartApp } from '../smart/App.js';
|
import { SmartApp } from '../smart/App.js';
|
||||||
import { SmartVoiceNotesPlaybackProvider } from '../smart/VoiceNotesPlaybackProvider.js';
|
import { SmartVoiceNotesPlaybackProvider } from '../smart/VoiceNotesPlaybackProvider.js';
|
||||||
|
import { AxoProvider } from '../../axo/AxoProvider.js';
|
||||||
|
|
||||||
export const createApp = (store: Store): ReactElement => (
|
export const createApp = (store: Store): ReactElement => (
|
||||||
|
<AxoProvider dir={window.i18n.getLocaleDirection()}>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<SmartVoiceNotesPlaybackProvider>
|
<SmartVoiceNotesPlaybackProvider>
|
||||||
<SmartApp />
|
<SmartApp />
|
||||||
</SmartVoiceNotesPlaybackProvider>
|
</SmartVoiceNotesPlaybackProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
</AxoProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,44 @@
|
||||||
|
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import type { StateType } from '../reducer.js';
|
import type { StateType } from '../reducer.js';
|
||||||
|
import type { StateSelector } from '../types.js';
|
||||||
import type { ChatFoldersState } from '../ducks/chatFolders.js';
|
import type { ChatFoldersState } from '../ducks/chatFolders.js';
|
||||||
|
import type { CurrentChatFolders, ChatFolder } from '../../types/ChatFolder.js';
|
||||||
|
import {
|
||||||
|
getSortedCurrentChatFolders,
|
||||||
|
lookupCurrentChatFolder,
|
||||||
|
} from '../../types/ChatFolder.js';
|
||||||
|
|
||||||
export function getChatFoldersState(state: StateType): ChatFoldersState {
|
export function getChatFoldersState(state: StateType): ChatFoldersState {
|
||||||
return state.chatFolders;
|
return state.chatFolders;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCurrentChatFolders = createSelector(
|
export const getCurrentChatFolders: StateSelector<CurrentChatFolders> =
|
||||||
getChatFoldersState,
|
createSelector(getChatFoldersState, state => {
|
||||||
state => {
|
|
||||||
return state.currentChatFolders;
|
return state.currentChatFolders;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getSortedChatFolders: StateSelector<ReadonlyArray<ChatFolder>> =
|
||||||
|
createSelector(getCurrentChatFolders, currentChatFolders => {
|
||||||
|
return getSortedCurrentChatFolders(currentChatFolders);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getSelectedChatFolder: StateSelector<ChatFolder | null> =
|
||||||
|
createSelector(
|
||||||
|
getChatFoldersState,
|
||||||
|
getCurrentChatFolders,
|
||||||
|
(state, currentChatFolders) => {
|
||||||
|
const selectedChatFolderId =
|
||||||
|
state.selectedChatFolderId ?? currentChatFolders.order.at(0);
|
||||||
|
if (selectedChatFolderId == null) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
);
|
return lookupCurrentChatFolder(currentChatFolders, selectedChatFolderId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getStableSelectedConversationIdInChatFolder: StateSelector<
|
||||||
|
string | null
|
||||||
|
> = createSelector(getChatFoldersState, state => {
|
||||||
|
return state.stableSelectedConversationIdInChatFolder;
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,8 @@
|
||||||
import memoizee from 'memoizee';
|
import memoizee from 'memoizee';
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
import type { StateType } from '../reducer.js';
|
import type { StateType } from '../reducer.js';
|
||||||
|
import type { StateSelector } from '../types.js';
|
||||||
import type {
|
import type {
|
||||||
ConversationLookupType,
|
ConversationLookupType,
|
||||||
ConversationMessageType,
|
ConversationMessageType,
|
||||||
|
|
@ -64,10 +63,25 @@ import type { HasStories } from '../../types/Stories.js';
|
||||||
import { getHasStoriesSelector } from './stories2.js';
|
import { getHasStoriesSelector } from './stories2.js';
|
||||||
import { canEditMessage } from '../../util/canEditMessage.js';
|
import { canEditMessage } from '../../util/canEditMessage.js';
|
||||||
import { isOutgoing } from '../../messages/helpers.js';
|
import { isOutgoing } from '../../messages/helpers.js';
|
||||||
import {
|
import type {
|
||||||
countAllConversationsUnreadStats,
|
AllChatFoldersUnreadStats,
|
||||||
type UnreadStats,
|
UnreadStats,
|
||||||
} from '../../util/countUnreadStats.js';
|
} from '../../util/countUnreadStats.js';
|
||||||
|
import {
|
||||||
|
isConversationInChatFolder,
|
||||||
|
type ChatFolder,
|
||||||
|
} from '../../types/ChatFolder.js';
|
||||||
|
import {
|
||||||
|
getSelectedChatFolder,
|
||||||
|
getSortedChatFolders,
|
||||||
|
getStableSelectedConversationIdInChatFolder,
|
||||||
|
} from './chatFolders.js';
|
||||||
|
import {
|
||||||
|
countAllChatFoldersUnreadStats,
|
||||||
|
countAllConversationsUnreadStats,
|
||||||
|
} from '../../util/countUnreadStats.js';
|
||||||
|
import type { AllChatFoldersMutedStats } from '../../util/countMutedStats.js';
|
||||||
|
import { countAllChatFoldersMutedStats } from '../../util/countMutedStats.js';
|
||||||
|
|
||||||
const { isNumber, pick } = lodash;
|
const { isNumber, pick } = lodash;
|
||||||
|
|
||||||
|
|
@ -364,21 +378,71 @@ type LeftPaneLists = Readonly<{
|
||||||
pinnedConversations: ReadonlyArray<ConversationType>;
|
pinnedConversations: ReadonlyArray<ConversationType>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const _getLeftPaneLists = (
|
function _shouldIncludeInChatFolder(
|
||||||
lookup: ConversationLookupType,
|
conversation: ConversationType,
|
||||||
comparator: (left: ConversationType, right: ConversationType) => number,
|
selectedChatFolder: ChatFolder | null,
|
||||||
selectedConversation?: string,
|
stableSelectedConversationIdInChatFolder: string | null
|
||||||
pinnedConversationIds?: ReadonlyArray<string>
|
): boolean {
|
||||||
): LeftPaneLists => {
|
if (selectedChatFolder == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This keeps conversation items from instantly disappearing from the left
|
||||||
|
// pane list when you open them and they get marked read
|
||||||
|
if (
|
||||||
|
stableSelectedConversationIdInChatFolder != null &&
|
||||||
|
conversation.id === stableSelectedConversationIdInChatFolder
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConversationInChatFolder(selectedChatFolder, conversation)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetLeftPaneListsProps = Readonly<{
|
||||||
|
conversationLookup: ConversationLookupType;
|
||||||
|
conversationComparator: (
|
||||||
|
left: ConversationType,
|
||||||
|
right: ConversationType
|
||||||
|
) => number;
|
||||||
|
selectedConversationId: string | undefined;
|
||||||
|
pinnedConversationIds: ReadonlyArray<string> | null;
|
||||||
|
selectedChatFolder: ChatFolder | null;
|
||||||
|
stableSelectedConversationIdInChatFolder: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const _getLeftPaneLists = ({
|
||||||
|
conversationLookup,
|
||||||
|
conversationComparator,
|
||||||
|
selectedConversationId,
|
||||||
|
pinnedConversationIds,
|
||||||
|
selectedChatFolder,
|
||||||
|
stableSelectedConversationIdInChatFolder,
|
||||||
|
}: GetLeftPaneListsProps): LeftPaneLists => {
|
||||||
const conversations: Array<ConversationType> = [];
|
const conversations: Array<ConversationType> = [];
|
||||||
const archivedConversations: Array<ConversationType> = [];
|
const archivedConversations: Array<ConversationType> = [];
|
||||||
const pinnedConversations: Array<ConversationType> = [];
|
const pinnedConversations: Array<ConversationType> = [];
|
||||||
|
|
||||||
const values = Object.values(lookup);
|
const values = Object.values(conversationLookup);
|
||||||
const max = values.length;
|
const max = values.length;
|
||||||
for (let i = 0; i < max; i += 1) {
|
for (let i = 0; i < max; i += 1) {
|
||||||
let conversation = values[i];
|
let conversation = values[i];
|
||||||
if (selectedConversation === conversation.id) {
|
|
||||||
|
if (
|
||||||
|
!_shouldIncludeInChatFolder(
|
||||||
|
conversation,
|
||||||
|
selectedChatFolder,
|
||||||
|
stableSelectedConversationIdInChatFolder
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedConversationId === conversation.id) {
|
||||||
conversation = {
|
conversation = {
|
||||||
...conversation,
|
...conversation,
|
||||||
isSelected: true,
|
isSelected: true,
|
||||||
|
|
@ -400,8 +464,8 @@ export const _getLeftPaneLists = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conversations.sort(comparator);
|
conversations.sort(conversationComparator);
|
||||||
archivedConversations.sort(comparator);
|
archivedConversations.sort(conversationComparator);
|
||||||
|
|
||||||
pinnedConversations.sort(
|
pinnedConversations.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
|
|
@ -417,7 +481,25 @@ export const getLeftPaneLists = createSelector(
|
||||||
getConversationComparator,
|
getConversationComparator,
|
||||||
getSelectedConversationId,
|
getSelectedConversationId,
|
||||||
getPinnedConversationIds,
|
getPinnedConversationIds,
|
||||||
_getLeftPaneLists
|
getSelectedChatFolder,
|
||||||
|
getStableSelectedConversationIdInChatFolder,
|
||||||
|
(
|
||||||
|
conversationLookup,
|
||||||
|
conversationComparator,
|
||||||
|
selectedConversationId,
|
||||||
|
pinnedConversationIds,
|
||||||
|
selectedChatFolder,
|
||||||
|
stableSelectedConversationIdInChatFolder
|
||||||
|
) => {
|
||||||
|
return _getLeftPaneLists({
|
||||||
|
conversationLookup,
|
||||||
|
conversationComparator,
|
||||||
|
selectedConversationId,
|
||||||
|
pinnedConversationIds,
|
||||||
|
selectedChatFolder,
|
||||||
|
stableSelectedConversationIdInChatFolder,
|
||||||
|
});
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getMaximumGroupSizeModalState = createSelector(
|
export const getMaximumGroupSizeModalState = createSelector(
|
||||||
|
|
@ -615,6 +697,30 @@ export const getAllConversationsUnreadStats = createSelector(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getAllChatFoldersUnreadStats: StateSelector<AllChatFoldersUnreadStats> =
|
||||||
|
createSelector(
|
||||||
|
getSortedChatFolders,
|
||||||
|
getAllConversations,
|
||||||
|
(sortedChatFolders, allConversations) => {
|
||||||
|
return countAllChatFoldersUnreadStats(
|
||||||
|
sortedChatFolders,
|
||||||
|
allConversations,
|
||||||
|
{
|
||||||
|
includeMuted: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getAllChatFoldersMutedStats: StateSelector<AllChatFoldersMutedStats> =
|
||||||
|
createSelector(
|
||||||
|
getSortedChatFolders,
|
||||||
|
getAllConversations,
|
||||||
|
(sortedChatFolders, allConversations) => {
|
||||||
|
return countAllChatFoldersMutedStats(sortedChatFolders, allConversations);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
|
* getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
|
||||||
* composer and group members, a different list from your primary system contacts.
|
* composer and group members, a different list from your primary system contacts.
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,13 @@ export const getOtherTabsUnreadStats = createSelector(
|
||||||
): UnreadStats => {
|
): UnreadStats => {
|
||||||
let unreadCount = 0;
|
let unreadCount = 0;
|
||||||
let unreadMentionsCount = 0;
|
let unreadMentionsCount = 0;
|
||||||
let markedUnread = false;
|
let readChatsMarkedUnreadCount = 0;
|
||||||
|
|
||||||
if (selectedNavTab !== NavTab.Chats) {
|
if (selectedNavTab !== NavTab.Chats) {
|
||||||
unreadCount += conversationsUnreadStats.unreadCount;
|
unreadCount += conversationsUnreadStats.unreadCount;
|
||||||
unreadMentionsCount += conversationsUnreadStats.unreadMentionsCount;
|
unreadMentionsCount += conversationsUnreadStats.unreadMentionsCount;
|
||||||
markedUnread ||= conversationsUnreadStats.markedUnread;
|
readChatsMarkedUnreadCount +=
|
||||||
|
conversationsUnreadStats.readChatsMarkedUnreadCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Conversation unread stats includes the call history unread count.
|
// Note: Conversation unread stats includes the call history unread count.
|
||||||
|
|
@ -56,7 +57,7 @@ export const getOtherTabsUnreadStats = createSelector(
|
||||||
return {
|
return {
|
||||||
unreadCount,
|
unreadCount,
|
||||||
unreadMentionsCount,
|
unreadMentionsCount,
|
||||||
markedUnread,
|
readChatsMarkedUnreadCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,18 @@ import {
|
||||||
resumeBackupMediaDownload,
|
resumeBackupMediaDownload,
|
||||||
} from '../../util/backupMediaDownload.js';
|
} from '../../util/backupMediaDownload.js';
|
||||||
import { useNavActions } from '../ducks/nav.js';
|
import { useNavActions } from '../ducks/nav.js';
|
||||||
|
import { SmartLeftPaneChatFolders } from './LeftPaneChatFolders.js';
|
||||||
|
import { SmartLeftPaneConversationListItemContextMenu } from './LeftPaneConversationListItemContextMenu.js';
|
||||||
|
import type { RenderConversationListItemContextMenuProps } from '../../components/conversationList/BaseConversationListItem.js';
|
||||||
|
|
||||||
function renderMessageSearchResult(id: string): JSX.Element {
|
function renderMessageSearchResult(id: string): JSX.Element {
|
||||||
return <SmartMessageSearchResult id={id} />;
|
return <SmartMessageSearchResult id={id} />;
|
||||||
}
|
}
|
||||||
|
function renderConversationListItemContextMenu(
|
||||||
|
props: RenderConversationListItemContextMenuProps
|
||||||
|
): JSX.Element {
|
||||||
|
return <SmartLeftPaneConversationListItemContextMenu {...props} />;
|
||||||
|
}
|
||||||
function renderNetworkStatus(
|
function renderNetworkStatus(
|
||||||
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
|
|
@ -140,6 +148,9 @@ function renderExpiredBuildDialog(
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
return <DialogExpiredBuild {...props} />;
|
return <DialogExpiredBuild {...props} />;
|
||||||
}
|
}
|
||||||
|
function renderLeftPaneChatFolders(): JSX.Element {
|
||||||
|
return <SmartLeftPaneChatFolders />;
|
||||||
|
}
|
||||||
function renderUnsupportedOSDialog(
|
function renderUnsupportedOSDialog(
|
||||||
props: Readonly<SmartUnsupportedOSDialogPropsType>
|
props: Readonly<SmartUnsupportedOSDialogPropsType>
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
|
|
@ -420,7 +431,11 @@ export const SmartLeftPane = memo(function SmartLeftPane({
|
||||||
renderCaptchaDialog={renderCaptchaDialog}
|
renderCaptchaDialog={renderCaptchaDialog}
|
||||||
renderCrashReportDialog={renderCrashReportDialog}
|
renderCrashReportDialog={renderCrashReportDialog}
|
||||||
renderExpiredBuildDialog={renderExpiredBuildDialog}
|
renderExpiredBuildDialog={renderExpiredBuildDialog}
|
||||||
|
renderLeftPaneChatFolders={renderLeftPaneChatFolders}
|
||||||
renderMessageSearchResult={renderMessageSearchResult}
|
renderMessageSearchResult={renderMessageSearchResult}
|
||||||
|
renderConversationListItemContextMenu={
|
||||||
|
renderConversationListItemContextMenu
|
||||||
|
}
|
||||||
renderNetworkStatus={renderNetworkStatus}
|
renderNetworkStatus={renderNetworkStatus}
|
||||||
renderRelinkDialog={renderRelinkDialog}
|
renderRelinkDialog={renderRelinkDialog}
|
||||||
renderToastManager={renderToastManager}
|
renderToastManager={renderToastManager}
|
||||||
|
|
|
||||||
76
ts/state/smart/LeftPaneChatFolders.tsx
Normal file
76
ts/state/smart/LeftPaneChatFolders.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
71
ts/state/smart/LeftPaneConversationListItemContextMenu.tsx
Normal file
71
ts/state/smart/LeftPaneConversationListItemContextMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -64,7 +64,8 @@ import {
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges.js';
|
import { getPreferredBadgeSelector } from '../selectors/badges.js';
|
||||||
import { SmartProfileEditor } from './ProfileEditor.js';
|
import { SmartProfileEditor } from './ProfileEditor.js';
|
||||||
import { useNavActions } from '../ducks/nav.js';
|
import { useNavActions } from '../ducks/nav.js';
|
||||||
import { NavTab, ProfileEditorPage, SettingsPage } from '../../types/Nav.js';
|
import type { SettingsLocation } from '../../types/Nav.js';
|
||||||
|
import { NavTab } from '../../types/Nav.js';
|
||||||
import { SmartToastManager } from './ToastManager.js';
|
import { SmartToastManager } from './ToastManager.js';
|
||||||
import { useToastActions } from '../ducks/toast.js';
|
import { useToastActions } from '../ducks/toast.js';
|
||||||
import { DataReader } from '../../sql/Client.js';
|
import { DataReader } from '../../sql/Client.js';
|
||||||
|
|
@ -127,19 +128,19 @@ function renderToastManager(props: {
|
||||||
|
|
||||||
function renderDonationsPane({
|
function renderDonationsPane({
|
||||||
contentsRef,
|
contentsRef,
|
||||||
page,
|
settingsLocation,
|
||||||
setPage,
|
setSettingsLocation,
|
||||||
}: {
|
}: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
page: SettingsPage;
|
settingsLocation: SettingsLocation;
|
||||||
setPage: (page: SettingsPage) => void;
|
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<DonationsErrorBoundary>
|
<DonationsErrorBoundary>
|
||||||
<SmartPreferencesDonations
|
<SmartPreferencesDonations
|
||||||
contentsRef={contentsRef}
|
contentsRef={contentsRef}
|
||||||
page={page}
|
settingsLocation={settingsLocation}
|
||||||
setPage={setPage}
|
setSettingsLocation={setSettingsLocation}
|
||||||
/>
|
/>
|
||||||
</DonationsErrorBoundary>
|
</DonationsErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
@ -719,24 +720,12 @@ export function SmartPreferences(): JSX.Element | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page } = currentLocation.details;
|
const settingsLocation = currentLocation.details;
|
||||||
const setPage = (newPage: SettingsPage, editState?: ProfileEditorPage) => {
|
|
||||||
if (newPage === SettingsPage.Profile) {
|
|
||||||
changeLocation({
|
|
||||||
tab: NavTab.Settings,
|
|
||||||
details: {
|
|
||||||
page: newPage,
|
|
||||||
state: editState || ProfileEditorPage.None,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const setSettingsLocation = (location: SettingsLocation) => {
|
||||||
changeLocation({
|
changeLocation({
|
||||||
tab: NavTab.Settings,
|
tab: NavTab.Settings,
|
||||||
details: {
|
details: location,
|
||||||
page: newPage,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -873,7 +862,7 @@ export function SmartPreferences(): JSX.Element | null {
|
||||||
onWhoCanSeeMeChange={onWhoCanSeeMeChange}
|
onWhoCanSeeMeChange={onWhoCanSeeMeChange}
|
||||||
onZoomFactorChange={onZoomFactorChange}
|
onZoomFactorChange={onZoomFactorChange}
|
||||||
otherTabsUnreadStats={otherTabsUnreadStats}
|
otherTabsUnreadStats={otherTabsUnreadStats}
|
||||||
page={page}
|
settingsLocation={settingsLocation}
|
||||||
pickLocalBackupFolder={pickLocalBackupFolder}
|
pickLocalBackupFolder={pickLocalBackupFolder}
|
||||||
preferredSystemLocales={preferredSystemLocales}
|
preferredSystemLocales={preferredSystemLocales}
|
||||||
preferredWidthFromStorage={preferredWidthFromStorage}
|
preferredWidthFromStorage={preferredWidthFromStorage}
|
||||||
|
|
@ -902,7 +891,7 @@ export function SmartPreferences(): JSX.Element | null {
|
||||||
selectedSpeaker={selectedSpeaker}
|
selectedSpeaker={selectedSpeaker}
|
||||||
sentMediaQualitySetting={sentMediaQualitySetting}
|
sentMediaQualitySetting={sentMediaQualitySetting}
|
||||||
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
|
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
|
||||||
setPage={setPage}
|
setSettingsLocation={setSettingsLocation}
|
||||||
shouldShowUpdateDialog={shouldShowUpdateDialog}
|
shouldShowUpdateDialog={shouldShowUpdateDialog}
|
||||||
showToast={showToast}
|
showToast={showToast}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useSelector } from 'react-redux';
|
||||||
import type { PreferencesChatFoldersPageProps } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
|
import type { PreferencesChatFoldersPageProps } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
|
||||||
import { PreferencesChatFoldersPage } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
|
import { PreferencesChatFoldersPage } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
|
||||||
import { getIntl } from '../selectors/user.js';
|
import { getIntl } from '../selectors/user.js';
|
||||||
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
|
import { getSortedChatFolders } from '../selectors/chatFolders.js';
|
||||||
import type { ChatFolderId } from '../../types/ChatFolder.js';
|
import type { ChatFolderId } from '../../types/ChatFolder.js';
|
||||||
import { useChatFolderActions } from '../ducks/chatFolders.js';
|
import { useChatFolderActions } from '../ducks/chatFolders.js';
|
||||||
|
|
||||||
|
|
@ -19,8 +19,9 @@ export function SmartPreferencesChatFoldersPage(
|
||||||
props: SmartPreferencesChatFoldersPageProps
|
props: SmartPreferencesChatFoldersPageProps
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
const i18n = useSelector(getIntl);
|
const i18n = useSelector(getIntl);
|
||||||
const chatFolders = useSelector(getCurrentChatFolders);
|
const chatFolders = useSelector(getSortedChatFolders);
|
||||||
const { createChatFolder } = useChatFolderActions();
|
const { createChatFolder, deleteChatFolder, updateChatFoldersPositions } =
|
||||||
|
useChatFolderActions();
|
||||||
return (
|
return (
|
||||||
<PreferencesChatFoldersPage
|
<PreferencesChatFoldersPage
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
|
@ -29,6 +30,8 @@ export function SmartPreferencesChatFoldersPage(
|
||||||
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
|
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
|
||||||
chatFolders={chatFolders}
|
chatFolders={chatFolders}
|
||||||
onCreateChatFolder={createChatFolder}
|
onCreateChatFolder={createChatFolder}
|
||||||
|
onDeleteChatFolder={deleteChatFolder}
|
||||||
|
onUpdateChatFoldersPositions={updateChatFoldersPositions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import type { MutableRefObject } from 'react';
|
||||||
import { getIntl, getTheme, getUserNumber } from '../selectors/user.js';
|
import { getIntl, getTheme, getUserNumber } from '../selectors/user.js';
|
||||||
import { getMe } from '../selectors/conversations.js';
|
import { getMe } from '../selectors/conversations.js';
|
||||||
import { PreferencesDonations } from '../../components/PreferencesDonations.js';
|
import { PreferencesDonations } from '../../components/PreferencesDonations.js';
|
||||||
import type { SettingsPage } from '../../types/Nav.js';
|
import type { SettingsLocation } from '../../types/Nav.js';
|
||||||
import { useDonationsActions } from '../ducks/donations.js';
|
import { useDonationsActions } from '../ducks/donations.js';
|
||||||
import type { StateType } from '../reducer.js';
|
import type { StateType } from '../reducer.js';
|
||||||
import { useConversationsActions } from '../ducks/conversations.js';
|
import { useConversationsActions } from '../ducks/conversations.js';
|
||||||
|
|
@ -40,12 +40,12 @@ const log = createLogger('SmartPreferencesDonations');
|
||||||
export const SmartPreferencesDonations = memo(
|
export const SmartPreferencesDonations = memo(
|
||||||
function SmartPreferencesDonations({
|
function SmartPreferencesDonations({
|
||||||
contentsRef,
|
contentsRef,
|
||||||
page,
|
settingsLocation,
|
||||||
setPage,
|
setSettingsLocation,
|
||||||
}: {
|
}: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
page: SettingsPage;
|
settingsLocation: SettingsLocation;
|
||||||
setPage: (page: SettingsPage) => void;
|
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
|
||||||
}) {
|
}) {
|
||||||
const [validCurrencies, setValidCurrencies] = useState<
|
const [validCurrencies, setValidCurrencies] = useState<
|
||||||
ReadonlyArray<string>
|
ReadonlyArray<string>
|
||||||
|
|
@ -142,7 +142,7 @@ export const SmartPreferencesDonations = memo(
|
||||||
contentsRef={contentsRef}
|
contentsRef={contentsRef}
|
||||||
initialCurrency={initialCurrency}
|
initialCurrency={initialCurrency}
|
||||||
isOnline={isOnline}
|
isOnline={isOnline}
|
||||||
page={page}
|
settingsLocation={settingsLocation}
|
||||||
didResumeWorkflowAtStartup={donationsState.didResumeWorkflowAtStartup}
|
didResumeWorkflowAtStartup={donationsState.didResumeWorkflowAtStartup}
|
||||||
lastError={donationsState.lastError}
|
lastError={donationsState.lastError}
|
||||||
workflow={donationsState.currentWorkflow}
|
workflow={donationsState.currentWorkflow}
|
||||||
|
|
@ -151,7 +151,7 @@ export const SmartPreferencesDonations = memo(
|
||||||
resumeWorkflow={resumeWorkflow}
|
resumeWorkflow={resumeWorkflow}
|
||||||
updateLastError={updateLastError}
|
updateLastError={updateLastError}
|
||||||
submitDonation={submitDonation}
|
submitDonation={submitDonation}
|
||||||
setPage={setPage}
|
setSettingsLocation={setSettingsLocation}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
donationBadge={badgesById[BOOST_ID] ?? undefined}
|
donationBadge={badgesById[BOOST_ID] ?? undefined}
|
||||||
fetchBadgeData={fetchBadgeData}
|
fetchBadgeData={fetchBadgeData}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@ import {
|
||||||
} from '../selectors/conversations.js';
|
} from '../selectors/conversations.js';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges.js';
|
import { getPreferredBadgeSelector } from '../selectors/badges.js';
|
||||||
import { useChatFolderActions } from '../ducks/chatFolders.js';
|
import { useChatFolderActions } from '../ducks/chatFolders.js';
|
||||||
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
|
import { getSortedChatFolders } from '../selectors/chatFolders.js';
|
||||||
import { strictAssert } from '../../util/assert.js';
|
import { strictAssert } from '../../util/assert.js';
|
||||||
|
import { useNavActions } from '../ducks/nav.js';
|
||||||
|
import type { Location } from '../../types/Nav.js';
|
||||||
|
|
||||||
export type SmartPreferencesEditChatFolderPageProps = Readonly<{
|
export type SmartPreferencesEditChatFolderPageProps = Readonly<{
|
||||||
onBack: () => void;
|
previousLocation: Location;
|
||||||
existingChatFolderId: PreferencesEditChatFolderPageProps['existingChatFolderId'];
|
existingChatFolderId: PreferencesEditChatFolderPageProps['existingChatFolderId'];
|
||||||
settingsPaneRef: PreferencesEditChatFolderPageProps['settingsPaneRef'];
|
settingsPaneRef: PreferencesEditChatFolderPageProps['settingsPaneRef'];
|
||||||
}>;
|
}>;
|
||||||
|
|
@ -31,9 +33,10 @@ export function SmartPreferencesEditChatFolderPage(
|
||||||
const conversations = useSelector(getAllComposableConversations);
|
const conversations = useSelector(getAllComposableConversations);
|
||||||
const conversationSelector = useSelector(getConversationSelector);
|
const conversationSelector = useSelector(getConversationSelector);
|
||||||
const preferredBadgeSelector = useSelector(getPreferredBadgeSelector);
|
const preferredBadgeSelector = useSelector(getPreferredBadgeSelector);
|
||||||
const chatFolders = useSelector(getCurrentChatFolders);
|
const chatFolders = useSelector(getSortedChatFolders);
|
||||||
const { createChatFolder, updateChatFolder, deleteChatFolder } =
|
const { createChatFolder, updateChatFolder, deleteChatFolder } =
|
||||||
useChatFolderActions();
|
useChatFolderActions();
|
||||||
|
const { changeLocation } = useNavActions();
|
||||||
|
|
||||||
const initChatFolderParams = useMemo(() => {
|
const initChatFolderParams = useMemo(() => {
|
||||||
if (existingChatFolderId == null) {
|
if (existingChatFolderId == null) {
|
||||||
|
|
@ -49,9 +52,10 @@ export function SmartPreferencesEditChatFolderPage(
|
||||||
return (
|
return (
|
||||||
<PreferencesEditChatFolderPage
|
<PreferencesEditChatFolderPage
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
previousLocation={props.previousLocation}
|
||||||
existingChatFolderId={props.existingChatFolderId}
|
existingChatFolderId={props.existingChatFolderId}
|
||||||
initChatFolderParams={initChatFolderParams}
|
initChatFolderParams={initChatFolderParams}
|
||||||
onBack={props.onBack}
|
changeLocation={changeLocation}
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
preferredBadgeSelector={preferredBadgeSelector}
|
preferredBadgeSelector={preferredBadgeSelector}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Selector } from 'reselect';
|
||||||
|
import type { StateType } from './reducer.js';
|
||||||
import type { actions as accounts } from './ducks/accounts.js';
|
import type { actions as accounts } from './ducks/accounts.js';
|
||||||
import type { actions as app } from './ducks/app.js';
|
import type { actions as app } from './ducks/app.js';
|
||||||
import type { actions as audioPlayer } from './ducks/audioPlayer.js';
|
import type { actions as audioPlayer } from './ducks/audioPlayer.js';
|
||||||
|
|
@ -72,3 +74,5 @@ export type ReduxActions = {
|
||||||
user: typeof user;
|
user: typeof user;
|
||||||
username: typeof username;
|
username: typeof username;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StateSelector<T> = Selector<StateType, T>;
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ describe('sql/notificationProfiles', () => {
|
||||||
allowAllCalls: false,
|
allowAllCalls: false,
|
||||||
allowAllMentions: false,
|
allowAllMentions: false,
|
||||||
|
|
||||||
allowedMembers: new Set(),
|
allowedMembers: new Set<string>(),
|
||||||
scheduleEnabled: false,
|
scheduleEnabled: false,
|
||||||
|
|
||||||
scheduleStartTime: undefined,
|
scheduleStartTime: undefined,
|
||||||
|
|
@ -148,7 +148,7 @@ describe('sql/notificationProfiles', () => {
|
||||||
allowAllCalls: false,
|
allowAllCalls: false,
|
||||||
allowAllMentions: false,
|
allowAllMentions: false,
|
||||||
|
|
||||||
allowedMembers: new Set(),
|
allowedMembers: new Set<string>(),
|
||||||
scheduleEnabled: false,
|
scheduleEnabled: false,
|
||||||
|
|
||||||
scheduleStartTime: undefined,
|
scheduleStartTime: undefined,
|
||||||
|
|
@ -220,7 +220,7 @@ describe('sql/notificationProfiles', () => {
|
||||||
allowAllCalls: false,
|
allowAllCalls: false,
|
||||||
allowAllMentions: false,
|
allowAllMentions: false,
|
||||||
|
|
||||||
allowedMembers: new Set(),
|
allowedMembers: new Set<string>(),
|
||||||
scheduleEnabled: false,
|
scheduleEnabled: false,
|
||||||
|
|
||||||
scheduleStartTime: undefined,
|
scheduleStartTime: undefined,
|
||||||
|
|
|
||||||
|
|
@ -224,9 +224,7 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmDeleteBtn = window
|
const confirmDeleteBtn = window
|
||||||
.getByTestId(
|
.getByTestId('ConfirmationDialog.Preferences__DeleteChatFolderDialog')
|
||||||
'ConfirmationDialog.Preferences__EditChatFolderPage__DeleteChatFolderDialog'
|
|
||||||
)
|
|
||||||
.locator('button:has-text("Delete")');
|
.locator('button:has-text("Delete")');
|
||||||
|
|
||||||
let state = await phone.expectStorageState('initial state');
|
let state = await phone.expectStorageState('initial state');
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import createDebug from 'debug';
|
import createDebug from 'debug';
|
||||||
|
import { v4 as generateUuid } from 'uuid';
|
||||||
import type {
|
import type {
|
||||||
Group,
|
Group,
|
||||||
PrimaryDevice,
|
PrimaryDevice,
|
||||||
|
|
@ -107,6 +108,22 @@ export async function initStorage(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
state = state.addRecord({
|
||||||
|
type: IdentifierType.CHAT_FOLDER,
|
||||||
|
record: {
|
||||||
|
chatFolder: {
|
||||||
|
id: uuidToBytes(generateUuid()),
|
||||||
|
name: null,
|
||||||
|
position: 0,
|
||||||
|
showOnlyUnread: false,
|
||||||
|
showMutedChats: true,
|
||||||
|
includeAllIndividualChats: true,
|
||||||
|
includeAllGroupChats: true,
|
||||||
|
folderType: Proto.ChatFolderRecord.FolderType.ALL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await phone.setStorageState(state);
|
await phone.setStorageState(state);
|
||||||
|
|
||||||
// Link new device
|
// Link new device
|
||||||
|
|
|
||||||
|
|
@ -1149,7 +1149,7 @@ describe('both/state/selectors/conversations-extra', () => {
|
||||||
|
|
||||||
describe('#_getLeftPaneLists', () => {
|
describe('#_getLeftPaneLists', () => {
|
||||||
it('sorts conversations based on timestamp then by intl-friendly title', () => {
|
it('sorts conversations based on timestamp then by intl-friendly title', () => {
|
||||||
const data: ConversationLookupType = {
|
const conversationLookup: ConversationLookupType = {
|
||||||
id1: getDefaultConversation({
|
id1: getDefaultConversation({
|
||||||
id: 'id1',
|
id: 'id1',
|
||||||
e164: '+18005551111',
|
e164: '+18005551111',
|
||||||
|
|
@ -1256,9 +1256,16 @@ describe('both/state/selectors/conversations-extra', () => {
|
||||||
acceptedMessageRequest: true,
|
acceptedMessageRequest: true,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const comparator = _getConversationComparator();
|
const conversationComparator = _getConversationComparator();
|
||||||
const { archivedConversations, conversations, pinnedConversations } =
|
const { archivedConversations, conversations, pinnedConversations } =
|
||||||
_getLeftPaneLists(data, comparator);
|
_getLeftPaneLists({
|
||||||
|
conversationLookup,
|
||||||
|
conversationComparator,
|
||||||
|
selectedConversationId: undefined,
|
||||||
|
pinnedConversationIds: null,
|
||||||
|
selectedChatFolder: null,
|
||||||
|
stableSelectedConversationIdInChatFolder: null,
|
||||||
|
});
|
||||||
|
|
||||||
assert.strictEqual(conversations[0].name, 'First!');
|
assert.strictEqual(conversations[0].name, 'First!');
|
||||||
assert.strictEqual(conversations[1].name, 'Á');
|
assert.strictEqual(conversations[1].name, 'Á');
|
||||||
|
|
@ -1274,7 +1281,7 @@ describe('both/state/selectors/conversations-extra', () => {
|
||||||
|
|
||||||
describe('given pinned conversations', () => {
|
describe('given pinned conversations', () => {
|
||||||
it('sorts pinned conversations based on order in storage', () => {
|
it('sorts pinned conversations based on order in storage', () => {
|
||||||
const data: ConversationLookupType = {
|
const conversationLookup: ConversationLookupType = {
|
||||||
pin2: getDefaultConversation({
|
pin2: getDefaultConversation({
|
||||||
id: 'pin2',
|
id: 'pin2',
|
||||||
e164: '+18005551111',
|
e164: '+18005551111',
|
||||||
|
|
@ -1344,9 +1351,16 @@ describe('both/state/selectors/conversations-extra', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const pinnedConversationIds = ['pin1', 'pin2', 'pin3'];
|
const pinnedConversationIds = ['pin1', 'pin2', 'pin3'];
|
||||||
const comparator = _getConversationComparator();
|
const conversationComparator = _getConversationComparator();
|
||||||
const { archivedConversations, conversations, pinnedConversations } =
|
const { archivedConversations, conversations, pinnedConversations } =
|
||||||
_getLeftPaneLists(data, comparator, undefined, pinnedConversationIds);
|
_getLeftPaneLists({
|
||||||
|
conversationLookup,
|
||||||
|
conversationComparator,
|
||||||
|
selectedConversationId: undefined,
|
||||||
|
pinnedConversationIds,
|
||||||
|
selectedChatFolder: null,
|
||||||
|
stableSelectedConversationIdInChatFolder: null,
|
||||||
|
});
|
||||||
|
|
||||||
assert.strictEqual(pinnedConversations[0].name, 'Pin One');
|
assert.strictEqual(pinnedConversations[0].name, 'Pin One');
|
||||||
assert.strictEqual(pinnedConversations[1].name, 'Pin Two');
|
assert.strictEqual(pinnedConversations[1].name, 'Pin Two');
|
||||||
|
|
@ -1358,7 +1372,7 @@ describe('both/state/selectors/conversations-extra', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes archived and pinned conversations with no active_at', () => {
|
it('includes archived and pinned conversations with no active_at', () => {
|
||||||
const data: ConversationLookupType = {
|
const conversationLookup: ConversationLookupType = {
|
||||||
pin2: getDefaultConversation({
|
pin2: getDefaultConversation({
|
||||||
id: 'pin2',
|
id: 'pin2',
|
||||||
e164: '+18005551111',
|
e164: '+18005551111',
|
||||||
|
|
@ -1468,9 +1482,16 @@ describe('both/state/selectors/conversations-extra', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const pinnedConversationIds = ['pin1', 'pin2', 'pin3'];
|
const pinnedConversationIds = ['pin1', 'pin2', 'pin3'];
|
||||||
const comparator = _getConversationComparator();
|
const conversationComparator = _getConversationComparator();
|
||||||
const { archivedConversations, conversations, pinnedConversations } =
|
const { archivedConversations, conversations, pinnedConversations } =
|
||||||
_getLeftPaneLists(data, comparator, undefined, pinnedConversationIds);
|
_getLeftPaneLists({
|
||||||
|
conversationLookup,
|
||||||
|
conversationComparator,
|
||||||
|
selectedConversationId: undefined,
|
||||||
|
pinnedConversationIds,
|
||||||
|
selectedChatFolder: null,
|
||||||
|
stableSelectedConversationIdInChatFolder: null,
|
||||||
|
});
|
||||||
|
|
||||||
assert.strictEqual(pinnedConversations[0].name, 'Pin One');
|
assert.strictEqual(pinnedConversations[0].name, 'Pin One');
|
||||||
assert.strictEqual(pinnedConversations[1].name, 'Pin Two');
|
assert.strictEqual(pinnedConversations[1].name, 'Pin Two');
|
||||||
|
|
|
||||||
|
|
@ -2,267 +2,198 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
|
import { v4 as generateUuid } from 'uuid';
|
||||||
import { countConversationUnreadStats } from '../../util/countUnreadStats.js';
|
import { countConversationUnreadStats } from '../../util/countUnreadStats.js';
|
||||||
|
import type {
|
||||||
|
UnreadStats,
|
||||||
|
ConversationPropsForUnreadStats,
|
||||||
|
} from '../../util/countUnreadStats.js';
|
||||||
|
|
||||||
describe('countConversationUnreadStats', () => {
|
function getFutureMutedTimestamp() {
|
||||||
const mutedTimestamp = (): number => Date.now() + 12345;
|
return Date.now() + 12345;
|
||||||
const oldMutedTimestamp = (): number => Date.now() - 1000;
|
}
|
||||||
|
|
||||||
|
function getPastMutedTimestamp() {
|
||||||
|
return Date.now() - 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockChat(
|
||||||
|
props: Partial<ConversationPropsForUnreadStats>
|
||||||
|
): ConversationPropsForUnreadStats {
|
||||||
|
return {
|
||||||
|
id: generateUuid(),
|
||||||
|
type: 'direct',
|
||||||
|
activeAt: Date.now(),
|
||||||
|
isArchived: false,
|
||||||
|
markedUnread: false,
|
||||||
|
unreadCount: 0,
|
||||||
|
unreadMentionsCount: 0,
|
||||||
|
muteExpiresAt: undefined,
|
||||||
|
left: false,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockStats(props: Partial<UnreadStats>): UnreadStats {
|
||||||
|
return {
|
||||||
|
unreadCount: 0,
|
||||||
|
unreadMentionsCount: 0,
|
||||||
|
readChatsMarkedUnreadCount: 0,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('countUnreadStats', () => {
|
||||||
|
describe('countConversationUnreadStats', () => {
|
||||||
it('returns 0 if the conversation is archived', () => {
|
it('returns 0 if the conversation is archived', () => {
|
||||||
|
const isArchived = true;
|
||||||
|
|
||||||
const archivedConversations = [
|
const archivedConversations = [
|
||||||
{
|
mockChat({ isArchived, markedUnread: false, unreadCount: 0 }),
|
||||||
activeAt: Date.now(),
|
mockChat({ isArchived, markedUnread: false, unreadCount: 123 }),
|
||||||
isArchived: true,
|
mockChat({ isArchived, markedUnread: true, unreadCount: 0 }),
|
||||||
markedUnread: false,
|
mockChat({ isArchived, markedUnread: true, unreadCount: undefined }),
|
||||||
unreadCount: 0,
|
mockChat({ isArchived, markedUnread: undefined, unreadCount: 0 }),
|
||||||
},
|
|
||||||
{
|
|
||||||
activeAt: Date.now(),
|
|
||||||
isArchived: true,
|
|
||||||
markedUnread: false,
|
|
||||||
unreadCount: 123,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
activeAt: Date.now(),
|
|
||||||
isArchived: true,
|
|
||||||
markedUnread: true,
|
|
||||||
unreadCount: 0,
|
|
||||||
},
|
|
||||||
{ activeAt: Date.now(), isArchived: true, markedUnread: true },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const conversation of archivedConversations) {
|
for (const conversation of archivedConversations) {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: true }),
|
countConversationUnreadStats(conversation, { includeMuted: true }),
|
||||||
{
|
mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
|
||||||
unreadCount: 0,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: false }),
|
countConversationUnreadStats(conversation, { includeMuted: false }),
|
||||||
{
|
mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
|
||||||
unreadCount: 0,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 0 if the conversation is muted and the user doesn't want to include those in the result", () => {
|
it("returns 0 if the conversation is muted and the user doesn't want to include those in the result", () => {
|
||||||
|
const muteExpiresAt = getFutureMutedTimestamp();
|
||||||
const mutedConversations = [
|
const mutedConversations = [
|
||||||
{
|
mockChat({ muteExpiresAt, markedUnread: false, unreadCount: 0 }),
|
||||||
activeAt: Date.now(),
|
mockChat({ muteExpiresAt, markedUnread: false, unreadCount: 9 }),
|
||||||
muteExpiresAt: mutedTimestamp(),
|
mockChat({ muteExpiresAt, markedUnread: true, unreadCount: 0 }),
|
||||||
markedUnread: false,
|
mockChat({ muteExpiresAt, markedUnread: true, unreadCount: undefined }),
|
||||||
unreadCount: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
activeAt: Date.now(),
|
|
||||||
muteExpiresAt: mutedTimestamp(),
|
|
||||||
markedUnread: false,
|
|
||||||
unreadCount: 9,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
activeAt: Date.now(),
|
|
||||||
muteExpiresAt: mutedTimestamp(),
|
|
||||||
markedUnread: true,
|
|
||||||
unreadCount: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
activeAt: Date.now(),
|
|
||||||
muteExpiresAt: mutedTimestamp(),
|
|
||||||
markedUnread: true,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
for (const conversation of mutedConversations) {
|
for (const conversation of mutedConversations) {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: false }),
|
countConversationUnreadStats(conversation, { includeMuted: false }),
|
||||||
{
|
mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
|
||||||
unreadCount: 0,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the unread count if nonzero (and not archived)', () => {
|
it('returns the unread count if nonzero (and not archived)', () => {
|
||||||
const conversationsWithUnreadCount = [
|
const conversationsWithUnreadCount = [
|
||||||
{ activeAt: Date.now(), unreadCount: 9, markedUnread: false },
|
mockChat({ unreadCount: 9, markedUnread: false }),
|
||||||
{ activeAt: Date.now(), unreadCount: 9, markedUnread: true },
|
mockChat({ unreadCount: 9, markedUnread: true }),
|
||||||
{
|
mockChat({ unreadCount: 9, muteExpiresAt: getPastMutedTimestamp() }),
|
||||||
activeAt: Date.now(),
|
|
||||||
unreadCount: 9,
|
|
||||||
markedUnread: false,
|
|
||||||
muteExpiresAt: oldMutedTimestamp(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
activeAt: Date.now(),
|
|
||||||
unreadCount: 9,
|
|
||||||
markedUnread: false,
|
|
||||||
isArchived: false,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const conversation of conversationsWithUnreadCount) {
|
for (const conversation of conversationsWithUnreadCount) {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: false }),
|
countConversationUnreadStats(conversation, { includeMuted: false }),
|
||||||
{
|
mockStats({ unreadCount: 9 })
|
||||||
unreadCount: 9,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: conversation.markedUnread,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: true }),
|
countConversationUnreadStats(conversation, { includeMuted: true }),
|
||||||
{
|
mockStats({ unreadCount: 9 })
|
||||||
unreadCount: 9,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: conversation.markedUnread,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mutedWithUnreads = {
|
const mutedWithUnreads = mockChat({
|
||||||
activeAt: Date.now(),
|
|
||||||
unreadCount: 123,
|
unreadCount: 123,
|
||||||
markedUnread: false,
|
muteExpiresAt: getFutureMutedTimestamp(),
|
||||||
muteExpiresAt: mutedTimestamp(),
|
});
|
||||||
};
|
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(mutedWithUnreads, { includeMuted: true }),
|
countConversationUnreadStats(mutedWithUnreads, { includeMuted: true }),
|
||||||
{
|
mockStats({ unreadCount: 123 })
|
||||||
unreadCount: 123,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns markedUnread:true if the conversation is marked unread', () => {
|
it('returns markedUnread:true if the conversation is marked unread', () => {
|
||||||
const conversationsMarkedUnread = [
|
const conversationsMarkedUnread = [
|
||||||
{ activeAt: Date.now(), markedUnread: true },
|
mockChat({ markedUnread: true }),
|
||||||
{ activeAt: Date.now(), markedUnread: true, unreadCount: 0 },
|
mockChat({
|
||||||
{
|
|
||||||
activeAt: Date.now(),
|
|
||||||
markedUnread: true,
|
markedUnread: true,
|
||||||
muteExpiresAt: oldMutedTimestamp(),
|
muteExpiresAt: getPastMutedTimestamp(),
|
||||||
},
|
}),
|
||||||
{
|
|
||||||
activeAt: Date.now(),
|
|
||||||
markedUnread: true,
|
|
||||||
muteExpiresAt: oldMutedTimestamp(),
|
|
||||||
isArchived: false,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
for (const conversation of conversationsMarkedUnread) {
|
for (const conversation of conversationsMarkedUnread) {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: false }),
|
countConversationUnreadStats(conversation, { includeMuted: false }),
|
||||||
{
|
mockStats({ readChatsMarkedUnreadCount: 1 })
|
||||||
unreadCount: 0,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: true,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: true }),
|
countConversationUnreadStats(conversation, { includeMuted: true }),
|
||||||
{
|
mockStats({ readChatsMarkedUnreadCount: 1 })
|
||||||
unreadCount: 0,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: true,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mutedConversationsMarkedUnread = [
|
const mutedConversationsMarkedUnread = [
|
||||||
{
|
mockChat({
|
||||||
activeAt: Date.now(),
|
|
||||||
markedUnread: true,
|
markedUnread: true,
|
||||||
muteExpiresAt: mutedTimestamp(),
|
muteExpiresAt: getFutureMutedTimestamp(),
|
||||||
},
|
}),
|
||||||
{
|
mockChat({
|
||||||
activeAt: Date.now(),
|
|
||||||
markedUnread: true,
|
markedUnread: true,
|
||||||
muteExpiresAt: mutedTimestamp(),
|
muteExpiresAt: getFutureMutedTimestamp(),
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
},
|
}),
|
||||||
];
|
];
|
||||||
for (const conversation of mutedConversationsMarkedUnread) {
|
for (const conversation of mutedConversationsMarkedUnread) {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: true }),
|
countConversationUnreadStats(conversation, { includeMuted: true }),
|
||||||
{
|
mockStats({ readChatsMarkedUnreadCount: 1 })
|
||||||
unreadCount: 0,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: true,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 0 if the conversation is read', () => {
|
it('returns 0 if the conversation is read', () => {
|
||||||
const readConversations = [
|
const readConversations = [
|
||||||
{ activeAt: Date.now(), markedUnread: false },
|
mockChat({ markedUnread: false, unreadCount: undefined }),
|
||||||
{ activeAt: Date.now(), markedUnread: false, unreadCount: 0 },
|
mockChat({ markedUnread: false, unreadCount: 0 }),
|
||||||
{
|
mockChat({
|
||||||
activeAt: Date.now(),
|
|
||||||
markedUnread: false,
|
markedUnread: false,
|
||||||
mutedTimestamp: mutedTimestamp(),
|
muteExpiresAt: getFutureMutedTimestamp(),
|
||||||
},
|
}),
|
||||||
{
|
mockChat({
|
||||||
activeAt: Date.now(),
|
|
||||||
markedUnread: false,
|
markedUnread: false,
|
||||||
mutedTimestamp: oldMutedTimestamp(),
|
muteExpiresAt: getPastMutedTimestamp(),
|
||||||
},
|
}),
|
||||||
];
|
];
|
||||||
for (const conversation of readConversations) {
|
for (const conversation of readConversations) {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: false }),
|
countConversationUnreadStats(conversation, { includeMuted: false }),
|
||||||
{
|
mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
|
||||||
unreadCount: 0,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: true }),
|
countConversationUnreadStats(conversation, { includeMuted: true }),
|
||||||
{
|
mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
|
||||||
unreadCount: 0,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 0 if the conversation has falsey activeAt', () => {
|
it('returns 0 if the conversation has falsey activeAt', () => {
|
||||||
const readConversations = [
|
const readConversations = [
|
||||||
{ activeAt: undefined, markedUnread: false, unreadCount: 2 },
|
mockChat({ activeAt: undefined, unreadCount: 2 }),
|
||||||
{
|
mockChat({
|
||||||
activeAt: 0,
|
activeAt: 0,
|
||||||
unreadCount: 2,
|
unreadCount: 2,
|
||||||
markedUnread: false,
|
muteExpiresAt: getPastMutedTimestamp(),
|
||||||
mutedTimestamp: oldMutedTimestamp(),
|
}),
|
||||||
},
|
|
||||||
];
|
];
|
||||||
for (const conversation of readConversations) {
|
for (const conversation of readConversations) {
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: false }),
|
countConversationUnreadStats(conversation, { includeMuted: false }),
|
||||||
{
|
mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
|
||||||
unreadCount: 0,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
countConversationUnreadStats(conversation, { includeMuted: true }),
|
countConversationUnreadStats(conversation, { includeMuted: true }),
|
||||||
{
|
mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
|
||||||
unreadCount: 0,
|
|
||||||
unreadMentionsCount: 0,
|
|
||||||
markedUnread: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import type { Simplify } from 'type-fest';
|
||||||
import {
|
import {
|
||||||
Environment,
|
Environment,
|
||||||
getEnvironment,
|
getEnvironment,
|
||||||
|
|
@ -9,6 +10,9 @@ import {
|
||||||
import * as grapheme from '../util/grapheme.js';
|
import * as grapheme from '../util/grapheme.js';
|
||||||
import * as RemoteConfig from '../RemoteConfig.js';
|
import * as RemoteConfig from '../RemoteConfig.js';
|
||||||
import { isAlpha, isBeta, isProduction } from '../util/version.js';
|
import { isAlpha, isBeta, isProduction } from '../util/version.js';
|
||||||
|
import type { ConversationType } from '../state/ducks/conversations.js';
|
||||||
|
import { strictAssert } from '../util/assert.js';
|
||||||
|
import { isConversationUnread } from '../util/isConversationUnread.js';
|
||||||
|
|
||||||
export const CHAT_FOLDER_NAME_MAX_CHAR_LENGTH = 32;
|
export const CHAT_FOLDER_NAME_MAX_CHAR_LENGTH = 32;
|
||||||
|
|
||||||
|
|
@ -22,7 +26,8 @@ export enum ChatFolderType {
|
||||||
|
|
||||||
export type ChatFolderId = string & { ChatFolderId: never }; // uuid
|
export type ChatFolderId = string & { ChatFolderId: never }; // uuid
|
||||||
|
|
||||||
export type ChatFolderPreset = Readonly<{
|
export type ChatFolderPreset = Simplify<
|
||||||
|
Readonly<{
|
||||||
folderType: ChatFolderType;
|
folderType: ChatFolderType;
|
||||||
showOnlyUnread: boolean;
|
showOnlyUnread: boolean;
|
||||||
showMutedChats: boolean;
|
showMutedChats: boolean;
|
||||||
|
|
@ -30,15 +35,19 @@ export type ChatFolderPreset = Readonly<{
|
||||||
includeAllGroupChats: boolean;
|
includeAllGroupChats: boolean;
|
||||||
includedConversationIds: ReadonlyArray<string>;
|
includedConversationIds: ReadonlyArray<string>;
|
||||||
excludedConversationIds: ReadonlyArray<string>;
|
excludedConversationIds: ReadonlyArray<string>;
|
||||||
}>;
|
}>
|
||||||
|
>;
|
||||||
|
|
||||||
export type ChatFolderParams = Readonly<
|
export type ChatFolderParams = Simplify<
|
||||||
|
Readonly<
|
||||||
ChatFolderPreset & {
|
ChatFolderPreset & {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type ChatFolder = Readonly<
|
export type ChatFolder = Simplify<
|
||||||
|
Readonly<
|
||||||
ChatFolderParams & {
|
ChatFolderParams & {
|
||||||
id: ChatFolderId;
|
id: ChatFolderId;
|
||||||
position: number;
|
position: number;
|
||||||
|
|
@ -48,6 +57,7 @@ export type ChatFolder = Readonly<
|
||||||
storageUnknownFields: Uint8Array | null;
|
storageUnknownFields: Uint8Array | null;
|
||||||
storageNeedsSync: boolean;
|
storageNeedsSync: boolean;
|
||||||
}
|
}
|
||||||
|
>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const ChatFolderPresetSchema = z.object({
|
export const ChatFolderPresetSchema = z.object({
|
||||||
|
|
@ -173,3 +183,91 @@ export function isChatFoldersEnabled(): boolean {
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConversationPropsForChatFolder = Pick<
|
||||||
|
ConversationType,
|
||||||
|
'type' | 'id' | 'unreadCount' | 'markedUnread' | 'muteExpiresAt'
|
||||||
|
>;
|
||||||
|
|
||||||
|
function _isConversationIncludedInChatFolder(
|
||||||
|
chatFolder: ChatFolder,
|
||||||
|
conversation: ConversationPropsForChatFolder
|
||||||
|
): boolean {
|
||||||
|
if (chatFolder.includeAllIndividualChats && conversation.type === 'direct') {
|
||||||
|
return true; // is individual chat
|
||||||
|
}
|
||||||
|
if (chatFolder.includeAllGroupChats && conversation.type === 'group') {
|
||||||
|
return true; // is group chat
|
||||||
|
}
|
||||||
|
if (chatFolder.includedConversationIds.includes(conversation.id)) {
|
||||||
|
return true; // is included by id
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isConversationExcludedFromChatFolder(
|
||||||
|
chatFolder: ChatFolder,
|
||||||
|
conversation: ConversationPropsForChatFolder
|
||||||
|
): boolean {
|
||||||
|
if (chatFolder.showOnlyUnread && !isConversationUnread(conversation)) {
|
||||||
|
return true; // not unread, only showing unread
|
||||||
|
}
|
||||||
|
if (!chatFolder.showMutedChats && (conversation.muteExpiresAt ?? 0) > 0) {
|
||||||
|
return true; // muted, not showing muted chats
|
||||||
|
}
|
||||||
|
if (chatFolder.excludedConversationIds.includes(conversation.id)) {
|
||||||
|
return true; // is excluded by id
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConversationInChatFolder(
|
||||||
|
chatFolder: ChatFolder,
|
||||||
|
conversation: ConversationPropsForChatFolder
|
||||||
|
): boolean {
|
||||||
|
if (chatFolder.folderType === ChatFolderType.ALL) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
_isConversationIncludedInChatFolder(chatFolder, conversation) &&
|
||||||
|
!_isConversationExcludedFromChatFolder(chatFolder, conversation)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CurrentChatFolders = Readonly<{
|
||||||
|
order: ReadonlyArray<ChatFolderId>;
|
||||||
|
lookup: Partial<Record<ChatFolderId, ChatFolder>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function toCurrentChatFolders(
|
||||||
|
chatFolders: ReadonlyArray<ChatFolder>
|
||||||
|
): CurrentChatFolders {
|
||||||
|
const order = chatFolders
|
||||||
|
.toSorted((a, b) => a.position - b.position)
|
||||||
|
.map(chatFolder => chatFolder.id);
|
||||||
|
|
||||||
|
const lookup: Record<ChatFolderId, ChatFolder> = {};
|
||||||
|
for (const chatFolder of chatFolders) {
|
||||||
|
lookup[chatFolder.id] = chatFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { order, lookup };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSortedCurrentChatFolders(
|
||||||
|
currentChatFolders: CurrentChatFolders
|
||||||
|
): ReadonlyArray<ChatFolder> {
|
||||||
|
return currentChatFolders.order.map(chatFolderId => {
|
||||||
|
return lookupCurrentChatFolder(currentChatFolders, chatFolderId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookupCurrentChatFolder(
|
||||||
|
currentChatFolders: CurrentChatFolders,
|
||||||
|
chatFolderId: ChatFolderId
|
||||||
|
): ChatFolder {
|
||||||
|
const chatFolder = currentChatFolders.lookup[chatFolderId];
|
||||||
|
strictAssert(chatFolder != null, 'Missing chat folder');
|
||||||
|
return chatFolder;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,30 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ReadonlyDeep } from 'type-fest';
|
import type { ReadonlyDeep } from 'type-fest';
|
||||||
|
import type { ChatFolderId } from './ChatFolder.js';
|
||||||
|
|
||||||
export type Location = ReadonlyDeep<
|
export type SettingsLocation = ReadonlyDeep<
|
||||||
| {
|
|
||||||
tab: NavTab.Settings;
|
|
||||||
details:
|
|
||||||
| {
|
| {
|
||||||
page: SettingsPage.Profile;
|
page: SettingsPage.Profile;
|
||||||
state: ProfileEditorPage;
|
state: ProfileEditorPage;
|
||||||
}
|
}
|
||||||
| { page: Exclude<SettingsPage, SettingsPage.Profile> };
|
| {
|
||||||
|
page: SettingsPage.EditChatFolder;
|
||||||
|
chatFolderId: ChatFolderId | null;
|
||||||
|
previousLocation: Location;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
page: Exclude<
|
||||||
|
SettingsPage,
|
||||||
|
SettingsPage.Profile | SettingsPage.EditChatFolder
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type Location = ReadonlyDeep<
|
||||||
|
| {
|
||||||
|
tab: NavTab.Settings;
|
||||||
|
details: SettingsLocation;
|
||||||
}
|
}
|
||||||
| { tab: Exclude<NavTab, NavTab.Settings> }
|
| { tab: Exclude<NavTab, NavTab.Settings> }
|
||||||
>;
|
>;
|
||||||
|
|
|
||||||
60
ts/util/countMutedStats.ts
Normal file
60
ts/util/countMutedStats.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -2,23 +2,44 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ConversationType } from '../state/ducks/conversations.js';
|
import type { ConversationType } from '../state/ducks/conversations.js';
|
||||||
|
import { isConversationInChatFolder } from '../types/ChatFolder.js';
|
||||||
|
import type { ChatFolder, ChatFolderId } from '../types/ChatFolder.js';
|
||||||
import { isConversationMuted } from './isConversationMuted.js';
|
import { isConversationMuted } from './isConversationMuted.js';
|
||||||
|
|
||||||
|
type MutableUnreadStats = {
|
||||||
|
/**
|
||||||
|
* Total of `conversation.unreadCount`
|
||||||
|
* in all countable conversations in the set.
|
||||||
|
*
|
||||||
|
* Note: `conversation.unreadCount` should always include the number of
|
||||||
|
* unread messages with mentions.
|
||||||
|
*/
|
||||||
|
unreadCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total of `conversation.unreadMentionsCount`
|
||||||
|
* in all countable conversations in the set.
|
||||||
|
*/
|
||||||
|
unreadMentionsCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total of `unreadCount === 0 && markedRead == true`
|
||||||
|
* in all countable conversations in the set.
|
||||||
|
*/
|
||||||
|
readChatsMarkedUnreadCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This can be used to describe unread counts of chats, stories, and calls,
|
* This can be used to describe unread counts of chats, stories, and calls,
|
||||||
* individually or all of them together.
|
* individually or all of them together.
|
||||||
*/
|
*/
|
||||||
export type UnreadStats = Readonly<{
|
export type UnreadStats = Readonly<MutableUnreadStats>;
|
||||||
unreadCount: number;
|
|
||||||
unreadMentionsCount: number;
|
|
||||||
markedUnread: boolean;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
function getEmptyUnreadStats(): UnreadStats {
|
function createUnreadStats(): MutableUnreadStats {
|
||||||
return {
|
return {
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
unreadMentionsCount: 0,
|
unreadMentionsCount: 0,
|
||||||
markedUnread: false,
|
readChatsMarkedUnreadCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,6 +50,8 @@ export type UnreadStatsOptions = Readonly<{
|
||||||
export type ConversationPropsForUnreadStats = Readonly<
|
export type ConversationPropsForUnreadStats = Readonly<
|
||||||
Pick<
|
Pick<
|
||||||
ConversationType,
|
ConversationType,
|
||||||
|
| 'id'
|
||||||
|
| 'type'
|
||||||
| 'activeAt'
|
| 'activeAt'
|
||||||
| 'isArchived'
|
| 'isArchived'
|
||||||
| 'markedUnread'
|
| 'markedUnread'
|
||||||
|
|
@ -39,7 +62,9 @@ export type ConversationPropsForUnreadStats = Readonly<
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function canCountConversation(
|
export type AllChatFoldersUnreadStats = Map<ChatFolderId, UnreadStats>;
|
||||||
|
|
||||||
|
function _canCountConversation(
|
||||||
conversation: ConversationPropsForUnreadStats,
|
conversation: ConversationPropsForUnreadStats,
|
||||||
options: UnreadStatsOptions
|
options: UnreadStatsOptions
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
@ -58,39 +83,109 @@ function canCountConversation(
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
function _countConversation(
|
||||||
|
unreadStats: MutableUnreadStats,
|
||||||
|
conversation: ConversationPropsForUnreadStats
|
||||||
|
): void {
|
||||||
|
const mutable = unreadStats;
|
||||||
|
const {
|
||||||
|
unreadCount = 0,
|
||||||
|
unreadMentionsCount = 0,
|
||||||
|
markedUnread = false,
|
||||||
|
} = conversation;
|
||||||
|
|
||||||
|
const hasUnreadCount = unreadCount > 0;
|
||||||
|
|
||||||
|
if (hasUnreadCount) {
|
||||||
|
mutable.unreadCount += unreadCount;
|
||||||
|
mutable.unreadMentionsCount += unreadMentionsCount;
|
||||||
|
} else if (markedUnread) {
|
||||||
|
mutable.readChatsMarkedUnreadCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConversationUnread(
|
||||||
|
conversation: ConversationPropsForUnreadStats,
|
||||||
|
options: UnreadStatsOptions
|
||||||
|
): boolean {
|
||||||
|
if (!_canCountConversation(conversation, options)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Note: Don't need to look at unreadMentionsCount
|
||||||
|
const { unreadCount, markedUnread } = conversation;
|
||||||
|
if (unreadCount != null && unreadCount !== 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (markedUnread) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function countConversationUnreadStats(
|
export function countConversationUnreadStats(
|
||||||
conversation: ConversationPropsForUnreadStats,
|
conversation: ConversationPropsForUnreadStats,
|
||||||
options: UnreadStatsOptions
|
options: UnreadStatsOptions
|
||||||
): UnreadStats {
|
): UnreadStats {
|
||||||
if (canCountConversation(conversation, options)) {
|
const unreadStats = createUnreadStats();
|
||||||
return {
|
if (_canCountConversation(conversation, options)) {
|
||||||
unreadCount: conversation.unreadCount ?? 0,
|
_countConversation(unreadStats, conversation);
|
||||||
unreadMentionsCount: conversation.unreadMentionsCount ?? 0,
|
|
||||||
markedUnread: conversation.markedUnread ?? false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return getEmptyUnreadStats();
|
return unreadStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countAllConversationsUnreadStats(
|
export function countAllConversationsUnreadStats(
|
||||||
conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
|
conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
|
||||||
options: UnreadStatsOptions
|
options: UnreadStatsOptions
|
||||||
): UnreadStats {
|
): UnreadStats {
|
||||||
return conversations.reduce<UnreadStats>((total, conversation) => {
|
const unreadStats = createUnreadStats();
|
||||||
const stats = countConversationUnreadStats(conversation, options);
|
|
||||||
return {
|
for (const conversation of conversations) {
|
||||||
unreadCount: total.unreadCount + stats.unreadCount,
|
if (_canCountConversation(conversation, options)) {
|
||||||
unreadMentionsCount:
|
_countConversation(unreadStats, conversation);
|
||||||
total.unreadMentionsCount + stats.unreadMentionsCount,
|
}
|
||||||
markedUnread: total.markedUnread || stats.markedUnread,
|
}
|
||||||
};
|
|
||||||
}, getEmptyUnreadStats());
|
return unreadStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasUnread(unreadStats: UnreadStats): boolean {
|
export function countAllChatFoldersUnreadStats(
|
||||||
return (
|
sortedChatFolders: ReadonlyArray<ChatFolder>,
|
||||||
unreadStats.unreadCount > 0 ||
|
conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
|
||||||
unreadStats.unreadMentionsCount > 0 ||
|
options: UnreadStatsOptions
|
||||||
unreadStats.markedUnread
|
): AllChatFoldersUnreadStats {
|
||||||
);
|
const results = new Map<ChatFolderId, MutableUnreadStats>();
|
||||||
|
|
||||||
|
for (const conversation of conversations) {
|
||||||
|
// skip if we shouldn't count it
|
||||||
|
if (!_canCountConversation(conversation, options)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
unreadCount = 0,
|
||||||
|
unreadMentionsCount = 0,
|
||||||
|
markedUnread = false,
|
||||||
|
} = conversation;
|
||||||
|
|
||||||
|
// skip if we don't have any unreads
|
||||||
|
if (unreadCount === 0 && unreadMentionsCount === 0 && !markedUnread) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check which chatFolders should count this conversation
|
||||||
|
for (const chatFolder of sortedChatFolders) {
|
||||||
|
if (isConversationInChatFolder(chatFolder, conversation)) {
|
||||||
|
let unreadStats = results.get(chatFolder.id);
|
||||||
|
if (unreadStats == null) {
|
||||||
|
unreadStats = createUnreadStats();
|
||||||
|
results.set(chatFolder.id, unreadStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
_countConversation(unreadStats, conversation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { ConversationType } from '../state/ducks/conversations.js';
|
||||||
import { parseAndFormatPhoneNumber } from './libphonenumberInstance.js';
|
import { parseAndFormatPhoneNumber } from './libphonenumberInstance.js';
|
||||||
import { WEEK } from './durations/index.js';
|
import { WEEK } from './durations/index.js';
|
||||||
import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse.js';
|
import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse.js';
|
||||||
import { countConversationUnreadStats, hasUnread } from './countUnreadStats.js';
|
import { isConversationUnread } from './countUnreadStats.js';
|
||||||
import { getE164 } from './getE164.js';
|
import { getE164 } from './getE164.js';
|
||||||
import { removeDiacritics } from './removeDiacritics.js';
|
import { removeDiacritics } from './removeDiacritics.js';
|
||||||
import { isAciString } from './isAciString.js';
|
import { isAciString } from './isAciString.js';
|
||||||
|
|
@ -69,9 +69,7 @@ function filterConversationsByUnread(
|
||||||
includeMuted: boolean
|
includeMuted: boolean
|
||||||
): Array<ConversationType> {
|
): Array<ConversationType> {
|
||||||
return conversations.filter(conversation => {
|
return conversations.filter(conversation => {
|
||||||
return hasUnread(
|
return isConversationUnread(conversation, { includeMuted });
|
||||||
countConversationUnreadStats(conversation, { includeMuted })
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,24 +12,10 @@ export type MuteOption = {
|
||||||
value: number;
|
value: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getMuteOptions(
|
export function getMuteValuesOptions(
|
||||||
muteExpiresAt: null | undefined | number,
|
|
||||||
i18n: LocalizerType
|
i18n: LocalizerType
|
||||||
): Array<MuteOption> {
|
): ReadonlyArray<MuteOption> {
|
||||||
return [
|
return [
|
||||||
...(muteExpiresAt && isConversationMuted({ muteExpiresAt })
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: getMutedUntilText(muteExpiresAt, i18n),
|
|
||||||
disabled: true,
|
|
||||||
value: -1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n('icu:unmute'),
|
|
||||||
value: 0,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
{
|
||||||
name: i18n('icu:muteHour'),
|
name: i18n('icu:muteHour'),
|
||||||
value: durations.HOUR,
|
value: durations.HOUR,
|
||||||
|
|
@ -52,3 +38,25 @@ export function getMuteOptions(
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMuteOptions(
|
||||||
|
muteExpiresAt: null | undefined | number,
|
||||||
|
i18n: LocalizerType
|
||||||
|
): Array<MuteOption> {
|
||||||
|
return [
|
||||||
|
...(muteExpiresAt && isConversationMuted({ muteExpiresAt })
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: getMutedUntilText(muteExpiresAt, i18n),
|
||||||
|
disabled: true,
|
||||||
|
value: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n('icu:unmute'),
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...getMuteValuesOptions(i18n),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2083,6 +2083,13 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2025-02-19T20:14:46.879Z"
|
"updated": "2025-02-19T20:14:46.879Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx",
|
||||||
|
"line": " const didSaveOrDiscardChangesRef = useRef(false);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2025-09-24T17:08:10.620Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/preferences/donations/DonateInputAmount.tsx",
|
"path": "ts/components/preferences/donations/DonateInputAmount.tsx",
|
||||||
|
|
@ -2141,6 +2148,20 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2023-10-04T20:50:45.297Z"
|
"updated": "2023-10-04T20:50:45.297Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/hooks/useNavBlocker.ts",
|
||||||
|
"line": " const nameRef = useRef(name);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2025-09-24T17:08:10.620Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/hooks/useNavBlocker.ts",
|
||||||
|
"line": " const shouldBlockRef = useRef(shouldBlock);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2025-09-24T17:08:10.620Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/hooks/usePrevious.ts",
|
"path": "ts/hooks/usePrevious.ts",
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||||
"target": "ES2021",
|
"target": "ES2023",
|
||||||
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||||
"lib": [
|
"lib": [
|
||||||
"DOM", // Required to access `window`
|
"DOM", // Required to access `window`
|
||||||
"DOM.Iterable",
|
"DOM.Iterable",
|
||||||
"ES2022",
|
"ESNext"
|
||||||
"ES2023.Array",
|
|
||||||
"ESNext.Disposable" // For `playwright`
|
|
||||||
],
|
],
|
||||||
/* Specify what JSX code is generated. */
|
/* Specify what JSX code is generated. */
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue