mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-09-12 11:37:39 +01:00
Chat folders bugfixes & ui updates
This commit is contained in:
@@ -339,6 +339,14 @@
|
||||
"messageformat": "Archived Chats",
|
||||
"description": "Shown in place of the search box when showing archived conversation list"
|
||||
},
|
||||
"icu:LeftPane__MoreActionsMenu__AddChatFolder": {
|
||||
"messageformat": "Add chat folder",
|
||||
"description": "Chats Tab > Chats List > Header > More Actions > Menu Item > Add chat folder (when you don't have any chat folders)"
|
||||
},
|
||||
"icu:LeftPane__MoreActionsMenu__FolderSettings": {
|
||||
"messageformat": "Folder settings",
|
||||
"description": "Chats Tab > Chats List > Header > More Actions > Menu Item > Folder settings (when you have chat folders)"
|
||||
},
|
||||
"icu:LeftPane--pinned": {
|
||||
"messageformat": "Pinned",
|
||||
"description": "Shown as a header for pinned conversations in the left pane"
|
||||
@@ -391,6 +399,14 @@
|
||||
"messageformat": "Enter a username followed by a dot and its set of numbers.",
|
||||
"description": "Description displayed under search input in left pane when looking up someone by username"
|
||||
},
|
||||
"icu:LeftPane__EmptyView--WithSelectedChatFolder": {
|
||||
"messageformat": "No chats to display",
|
||||
"description": "Left Pane > Inbox > Chat List > Empty View (when you have a custom chat folder selected)"
|
||||
},
|
||||
"icu:LeftPane__EmptyView--WithSelectedChatFolder__FolderSettings": {
|
||||
"messageformat": "Folder Settings",
|
||||
"description": "Left Pane > Inbox > Chat List > Empty View (when you have a custom chat folder selected) > Folder Settings Button"
|
||||
},
|
||||
"icu:LeftPaneChatFolders__ItemLabel--All--Short": {
|
||||
"messageformat": "All",
|
||||
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats (needs to fit in very small space)"
|
||||
@@ -872,6 +888,10 @@
|
||||
"messageformat": "Error saving receipt. Please try again.",
|
||||
"description": "Toast message shown when a donation receipt fails to save to disk"
|
||||
},
|
||||
"icu:Toast--ChatFolderCreated": {
|
||||
"messageformat": "{chatFolderName} folder added",
|
||||
"description": "Toast message show when a chat folder is created (in some places like creating a preset)"
|
||||
},
|
||||
"icu:cannotSelectPhotosAndVideosAlongWithFiles": {
|
||||
"messageformat": "You can't select photos and videos along with files.",
|
||||
"description": "An error popup when the user has attempted to add an attachment"
|
||||
@@ -1802,10 +1822,26 @@
|
||||
"messageformat": "Add a chat folder",
|
||||
"description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Title"
|
||||
},
|
||||
"icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Title--WithChatFolders": {
|
||||
"messageformat": "Add or edit folders",
|
||||
"description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Title (with chat folders)"
|
||||
},
|
||||
"icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Description": {
|
||||
"messageformat": "Organize your chats into folders and quickly switch between them on your chat list.",
|
||||
"description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Description"
|
||||
},
|
||||
"icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Description--WithChatFolders": {
|
||||
"messageformat": "{chatFoldersCount, plural, one {# folder} other {# folders}}",
|
||||
"description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Description (with chat folders)"
|
||||
},
|
||||
"icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Button": {
|
||||
"messageformat": "Set up",
|
||||
"description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Button"
|
||||
},
|
||||
"icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Button--WithChatFolders": {
|
||||
"messageformat": "Manage",
|
||||
"description": "Preferences > Chats Page > Chat Folders Section > Add Chat Folder Item > Button (with chat folders)"
|
||||
},
|
||||
"icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder": {
|
||||
"messageformat": "Edit folder",
|
||||
"description": "Preferences > Chats Page > Chat Folders Section > Chat Folder Item > Context Menu > Edit Folder"
|
||||
@@ -1850,6 +1886,10 @@
|
||||
"messageformat": "Create a folder",
|
||||
"description": "Preferences > Chat Folders Page > Folders > Create A Folder Button"
|
||||
},
|
||||
"icu:Preferences__ChatFoldersPage__FoldersSection__DragHandle__Label": {
|
||||
"messageformat": "Drag to move chat folder",
|
||||
"description": "Preferences > Chat Folders Page > Folders > Item > Drag Handle > Accessibility Label"
|
||||
},
|
||||
"icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__Title": {
|
||||
"messageformat": "Suggested folders",
|
||||
"description": "Preferences > Chat Folders Page > Suggested Folders > Title"
|
||||
@@ -1858,10 +1898,6 @@
|
||||
"messageformat": "Add",
|
||||
"description": "Preferences > Chat Folders Page > Suggested Folders > Add Button"
|
||||
},
|
||||
"icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast": {
|
||||
"messageformat": "{chatFolderTitle} folder added",
|
||||
"description": "Preferences > Chat Folders Page > Suggested Folders > Add Button > Toast"
|
||||
},
|
||||
"icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__UnreadFolder__Title": {
|
||||
"messageformat": "Unread",
|
||||
"description": "Preferences > Chat Folders Page > Suggested Folders > Unread Folder > Title"
|
||||
|
||||
@@ -118,21 +118,6 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.CallsTab__ClearCallHistoryIcon {
|
||||
@include mixins.light-theme {
|
||||
@include mixins.color-svg(
|
||||
'../images/icons/v3/trash/trash-compact.svg',
|
||||
variables.$color-gray-90
|
||||
);
|
||||
}
|
||||
@include mixins.dark-theme {
|
||||
@include mixins.color-svg(
|
||||
'../images/icons/v3/trash/trash-compact.svg',
|
||||
variables.$color-white
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.CallsList__Header {
|
||||
display: flex;
|
||||
gap: 0px;
|
||||
|
||||
@@ -1357,29 +1357,47 @@ $secondary-text-color: light-dark(
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.Preferences__ChatFolders__ChatSelection__Item--Button {
|
||||
@include mixins.button-reset();
|
||||
&:hover {
|
||||
background: light-dark(variables.$color-gray-02, variables.$color-gray-80);
|
||||
}
|
||||
@include mixins.keyboard-mode {
|
||||
&:focus {
|
||||
outline: 2px solid variables.$color-ultramarine;
|
||||
}
|
||||
.Preferences__ChatFolders__ChatSelection__Item {
|
||||
&[data-dragging='true'] {
|
||||
opacity: 0%;
|
||||
// delay making the item transparent until the browser has a chance to take
|
||||
// a snapshot of the item before for the drag preview
|
||||
transition: opacity 0ms linear;
|
||||
transition-delay: 1ms;
|
||||
}
|
||||
}
|
||||
|
||||
.Preferences__ChatFolders__ChatSelection__Item {
|
||||
.Preferences__ChatFolders__ChatSelection__Item--Button {
|
||||
@include mixins.button-reset();
|
||||
& {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.Preferences__ChatFolders__ChatSelection__Item--Button,
|
||||
.Preferences__ChatFolders__ChatSelection__Item--ListItem {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.Preferences__ChatFolders__ChatSelection__Item--Button,
|
||||
.Preferences__ChatFolders__ChatSelection__Item--Clickable {
|
||||
&:hover .Preferences__ChatFolders__ChatSelection__ItemContent {
|
||||
background: light-dark(variables.$color-gray-02, variables.$color-gray-80);
|
||||
}
|
||||
}
|
||||
|
||||
.Preferences__ChatFolders__ChatSelection__ItemContent {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 24px;
|
||||
border-radius: 1px;
|
||||
|
||||
&[data-dragging='true'] {
|
||||
opacity: 50%;
|
||||
@include mixins.keyboard-mode {
|
||||
.Preferences__ChatFolders__ChatSelection__Item:focus & {
|
||||
outline: 2px solid variables.$color-ultramarine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
--color-background-primary: light-dark(/* */ #FFFFFF /* */, /* */ #1A1A1A /* */);
|
||||
--color-background-secondary: light-dark(/* */ #F6F6F6 /* */, /* */ #262626 /* */);
|
||||
--color-background-overlay: light-dark(--alpha(#000000 / 20%), --alpha(#000000 / 40%));
|
||||
--color-background-overlay-secondary: light-dark(--alpha(#000000 / 15%), --alpha(#FFFFFF / 15%));
|
||||
|
||||
/* Colors/Elevated Background */
|
||||
--color-elevated-background-primary: light-dark(#FAFAFA, #2A2A2A);
|
||||
@@ -146,7 +145,6 @@
|
||||
--color-background-primary: light-dark(/* */ #FFFFFF /* */, /* */ #121212 /* */);
|
||||
--color-background-secondary: light-dark(/* */ #F6F6F6 /* */, /* */ #1E1E1E /* */);
|
||||
--color-background-overlay: light-dark(--alpha(#000000 / 40%), --alpha(#000000 / 60%));
|
||||
--color-background-overlay-secondary: light-dark(--alpha(#000000 / 15%), --alpha(#FFFFFF / 15%));
|
||||
|
||||
/* Colors/Elevated Background */
|
||||
--color-elevated-background-primary: light-dark(#FFFFFF, #222222);
|
||||
@@ -312,8 +310,6 @@
|
||||
@theme {
|
||||
/* box-shadow */
|
||||
--shadow-*: initial; /* reset defaults */
|
||||
--shadow-legacy-outline:
|
||||
0 0 0 2px #2c6bed;
|
||||
--shadow-elevation-0:
|
||||
0 1px 2px 0 var(--color-shadow-elevation-1);
|
||||
--shadow-elevation-1:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
@@ -11,12 +12,20 @@ export default {
|
||||
title: 'Axo/AxoDropdownMenu',
|
||||
} satisfies Meta;
|
||||
|
||||
function Container(props: { children: ReactNode }) {
|
||||
return (
|
||||
<div className={tw('flex h-96 w-full items-center justify-center')}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Basic(): JSX.Element {
|
||||
const [showBookmarks, setShowBookmarks] = useState(true);
|
||||
const [showFullUrls, setShowFullUrls] = useState(false);
|
||||
const [selectedPerson, setSelectedPerson] = useState('jamie');
|
||||
return (
|
||||
<div className={tw('flex h-96 w-full items-center justify-center')}>
|
||||
<Container>
|
||||
<AxoDropdownMenu.Root>
|
||||
<AxoDropdownMenu.Trigger>
|
||||
<AxoButton.Root variant="secondary" size="medium">
|
||||
@@ -96,6 +105,135 @@ export function Basic(): JSX.Element {
|
||||
</AxoDropdownMenu.RadioGroup>
|
||||
</AxoDropdownMenu.Content>
|
||||
</AxoDropdownMenu.Root>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithHeader(): JSX.Element {
|
||||
return (
|
||||
<Container>
|
||||
<AxoDropdownMenu.Root>
|
||||
<AxoDropdownMenu.Trigger>
|
||||
<AxoButton.Root variant="secondary" size="medium">
|
||||
Open Dropdown Menu
|
||||
</AxoButton.Root>
|
||||
</AxoDropdownMenu.Trigger>
|
||||
<AxoDropdownMenu.Content>
|
||||
<AxoDropdownMenu.Header
|
||||
label="Notification profile"
|
||||
description="On"
|
||||
/>
|
||||
<AxoDropdownMenu.Item onSelect={action('onSleep')}>
|
||||
Sleep
|
||||
</AxoDropdownMenu.Item>
|
||||
<AxoDropdownMenu.Item onSelect={action('onWork')}>
|
||||
Work
|
||||
</AxoDropdownMenu.Item>
|
||||
<AxoDropdownMenu.Sub>
|
||||
<AxoDropdownMenu.SubTrigger>Sub-menu</AxoDropdownMenu.SubTrigger>
|
||||
<AxoDropdownMenu.SubContent>
|
||||
<AxoDropdownMenu.Header
|
||||
label="Notification profile"
|
||||
description="On"
|
||||
/>
|
||||
<AxoDropdownMenu.Item onSelect={action('onSleep')}>
|
||||
Sleep
|
||||
</AxoDropdownMenu.Item>
|
||||
<AxoDropdownMenu.Item onSelect={action('onWork')}>
|
||||
Work
|
||||
</AxoDropdownMenu.Item>
|
||||
</AxoDropdownMenu.SubContent>
|
||||
</AxoDropdownMenu.Sub>
|
||||
</AxoDropdownMenu.Content>
|
||||
</AxoDropdownMenu.Root>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const LONG_TEXT =
|
||||
'Lorem ipsum dolor sit amet consectetur adipisicing elit. Cum nostrum, inventore quia tenetur sunt non ab fuga explicabo ullam tempore.';
|
||||
|
||||
export function StressTestLongText(): JSX.Element {
|
||||
const items = (
|
||||
<>
|
||||
<AxoDropdownMenu.Item onSelect={action('onSelect')}>
|
||||
<strong>Item:</strong> {LONG_TEXT}
|
||||
</AxoDropdownMenu.Item>
|
||||
<AxoDropdownMenu.Item
|
||||
symbol="megaphone"
|
||||
keyboardShortcut="⌘["
|
||||
onSelect={action('onSelect')}
|
||||
>
|
||||
<strong>Item:</strong> {LONG_TEXT}
|
||||
</AxoDropdownMenu.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
const checkboxItems = (
|
||||
<>
|
||||
<AxoDropdownMenu.CheckboxItem
|
||||
checked={false}
|
||||
onCheckedChange={action('onCheckedChange')}
|
||||
>
|
||||
<strong>CheckboxItem:</strong> {LONG_TEXT}
|
||||
</AxoDropdownMenu.CheckboxItem>
|
||||
<AxoDropdownMenu.CheckboxItem
|
||||
symbol="megaphone"
|
||||
keyboardShortcut="⌘["
|
||||
checked={false}
|
||||
onCheckedChange={action('onCheckedChange')}
|
||||
>
|
||||
<strong>CheckboxItem:</strong> {LONG_TEXT}
|
||||
</AxoDropdownMenu.CheckboxItem>
|
||||
</>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<AxoDropdownMenu.Header label={LONG_TEXT} description={LONG_TEXT} />
|
||||
{items}
|
||||
{checkboxItems}
|
||||
<AxoDropdownMenu.RadioGroup
|
||||
value="value"
|
||||
onValueChange={action('onValueChange')}
|
||||
>
|
||||
<AxoDropdownMenu.RadioItem value="value">
|
||||
<strong>RadioItem:</strong> {LONG_TEXT}
|
||||
</AxoDropdownMenu.RadioItem>
|
||||
<AxoDropdownMenu.RadioItem
|
||||
symbol="megaphone"
|
||||
keyboardShortcut="⌘["
|
||||
value="value"
|
||||
>
|
||||
<strong>RadioItem:</strong> {LONG_TEXT}
|
||||
</AxoDropdownMenu.RadioItem>
|
||||
</AxoDropdownMenu.RadioGroup>
|
||||
<AxoDropdownMenu.Separator />
|
||||
<AxoDropdownMenu.Group>
|
||||
<AxoDropdownMenu.Label>
|
||||
<strong>Label:</strong> {LONG_TEXT}
|
||||
</AxoDropdownMenu.Label>
|
||||
{items}
|
||||
{checkboxItems}
|
||||
</AxoDropdownMenu.Group>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<AxoDropdownMenu.Root>
|
||||
<AxoDropdownMenu.Trigger>
|
||||
<AxoButton.Root variant="secondary" size="medium">
|
||||
Open Dropdown Menu
|
||||
</AxoButton.Root>
|
||||
</AxoDropdownMenu.Trigger>
|
||||
<AxoDropdownMenu.Content>
|
||||
{content}
|
||||
<AxoDropdownMenu.Sub>
|
||||
<AxoDropdownMenu.SubTrigger>{LONG_TEXT}</AxoDropdownMenu.SubTrigger>
|
||||
<AxoDropdownMenu.SubContent>{content}</AxoDropdownMenu.SubContent>
|
||||
</AxoDropdownMenu.Sub>
|
||||
</AxoDropdownMenu.Content>
|
||||
</AxoDropdownMenu.Root>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
+142
-33
@@ -1,11 +1,16 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useId } from 'react';
|
||||
import { DropdownMenu } from 'radix-ui';
|
||||
import type { FC } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { AxoSymbol } from './AxoSymbol.js';
|
||||
import { AxoBaseMenu } from './_internal/AxoBaseMenu.js';
|
||||
import { tw } from './tw.js';
|
||||
import {
|
||||
AriaLabellingProvider,
|
||||
useAriaLabellingContext,
|
||||
useCreateAriaLabellingContext,
|
||||
} from './_internal/AriaLabellingContext.js';
|
||||
|
||||
const Namespace = 'AxoDropdownMenu';
|
||||
|
||||
@@ -57,17 +62,21 @@ export namespace AxoDropdownMenu {
|
||||
* ---------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = AxoBaseMenu.MenuRootProps & {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
export type RootProps = AxoBaseMenu.MenuRootProps &
|
||||
Readonly<{
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Contains all the parts of a dropdown menu.
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
return <DropdownMenu.Root {...props}>{props.children}</DropdownMenu.Root>;
|
||||
return (
|
||||
<DropdownMenu.Root open={props.open} onOpenChange={props.onOpenChange}>
|
||||
{props.children}
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = `${Namespace}.Root`;
|
||||
@@ -104,30 +113,73 @@ export namespace AxoDropdownMenu {
|
||||
* Uses a portal to render the content part into the `body`.
|
||||
*/
|
||||
export const Content: FC<ContentProps> = memo(props => {
|
||||
const { context, labelId, descriptionId } = useCreateAriaLabellingContext();
|
||||
return (
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
sideOffset={4}
|
||||
align="start"
|
||||
collisionPadding={6}
|
||||
className={AxoBaseMenu.menuContentStyles}
|
||||
>
|
||||
{props.children}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
<AriaLabellingProvider value={context}>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
sideOffset={4}
|
||||
align="start"
|
||||
collisionPadding={6}
|
||||
className={AxoBaseMenu.menuContentStyles}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descriptionId}
|
||||
>
|
||||
{props.children}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</AriaLabellingProvider>
|
||||
);
|
||||
});
|
||||
|
||||
Content.displayName = `${Namespace}.Content`;
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.CustomItem>
|
||||
* -------------------------------------
|
||||
*/
|
||||
|
||||
export type CustomItemProps = Pick<
|
||||
AxoBaseMenu.MenuItemProps,
|
||||
'disabled' | 'textValue' | 'keyboardShortcut' | 'onSelect'
|
||||
> &
|
||||
Readonly<{
|
||||
leading?: ReactNode;
|
||||
// trailing?: ReactNode;
|
||||
text: ReactNode;
|
||||
// prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
}>;
|
||||
|
||||
export const CustomItem: FC<CustomItemProps> = memo(props => {
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
disabled={props.disabled}
|
||||
textValue={props.textValue}
|
||||
onSelect={props.onSelect}
|
||||
className={AxoBaseMenu.menuItemStyles}
|
||||
>
|
||||
{props.leading && (
|
||||
<AxoBaseMenu.ItemLeadingSlot>
|
||||
{props.leading}
|
||||
</AxoBaseMenu.ItemLeadingSlot>
|
||||
)}
|
||||
<AxoBaseMenu.ItemContentSlot>
|
||||
<AxoBaseMenu.ItemText>{props.text}</AxoBaseMenu.ItemText>
|
||||
{props.suffix}
|
||||
</AxoBaseMenu.ItemContentSlot>
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
});
|
||||
|
||||
CustomItem.displayName = `${Namespace}.CustomItem`;
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Item>
|
||||
* ---------------------------------
|
||||
*/
|
||||
|
||||
export type ItemProps = AxoBaseMenu.MenuItemProps & {
|
||||
customIcon?: React.ReactNode;
|
||||
};
|
||||
export type ItemProps = AxoBaseMenu.MenuItemProps;
|
||||
|
||||
/**
|
||||
* The component that contains the dropdown menu items.
|
||||
@@ -151,11 +203,6 @@ export namespace AxoDropdownMenu {
|
||||
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
|
||||
</AxoBaseMenu.ItemLeadingSlot>
|
||||
)}
|
||||
{props.customIcon && (
|
||||
<AxoBaseMenu.ItemLeadingSlot>
|
||||
{props.customIcon}
|
||||
</AxoBaseMenu.ItemLeadingSlot>
|
||||
)}
|
||||
<AxoBaseMenu.ItemContentSlot>
|
||||
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
|
||||
{props.keyboardShortcut && (
|
||||
@@ -212,6 +259,49 @@ export namespace AxoDropdownMenu {
|
||||
|
||||
Label.displayName = `${Namespace}.Label`;
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Header>
|
||||
* -----------------------------------
|
||||
*/
|
||||
|
||||
export type HeaderProps = Readonly<{
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
}>;
|
||||
|
||||
export const Header: FC<HeaderProps> = memo(props => {
|
||||
const labelId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
const { labelRef, descriptionRef } = useAriaLabellingContext(
|
||||
`<${Namespace}.Header>`,
|
||||
`<${Namespace}.Content/SubContent>`
|
||||
);
|
||||
|
||||
return (
|
||||
<span aria-hidden="true" className={AxoBaseMenu.menuHeaderStyles}>
|
||||
<span
|
||||
ref={labelRef}
|
||||
id={labelId}
|
||||
className={AxoBaseMenu.menuHeaderLabelStyles}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
{props.description && (
|
||||
<span
|
||||
ref={descriptionRef}
|
||||
id={descriptionId}
|
||||
className={AxoBaseMenu.menuHeaderDescriptionStyles}
|
||||
>
|
||||
{props.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Header.displayName = `${Namespace}.Header`;
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.CheckboxItem>
|
||||
* -----------------------------------------
|
||||
@@ -343,6 +433,20 @@ export namespace AxoDropdownMenu {
|
||||
|
||||
Separator.displayName = `${Namespace}.Separator`;
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.ContentSeparator>
|
||||
*/
|
||||
|
||||
export const ContentSeparator: FC<SeparatorProps> = memo(() => {
|
||||
return (
|
||||
<DropdownMenu.Separator
|
||||
className={AxoBaseMenu.menuContentSeparatorStyles}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ContentSeparator.displayName = `${Namespace}.ContentSeparator`;
|
||||
|
||||
/**
|
||||
* Component: <AxoDropdownMenu.Sub>
|
||||
* -------------------------------
|
||||
@@ -402,14 +506,19 @@ export namespace AxoDropdownMenu {
|
||||
* inside {@link AxoDropdownMenu.Sub}.
|
||||
*/
|
||||
export const SubContent: FC<SubContentProps> = memo(props => {
|
||||
const { context, labelId, descriptionId } = useCreateAriaLabellingContext();
|
||||
return (
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-6}
|
||||
collisionPadding={6}
|
||||
className={AxoBaseMenu.menuSubContentStyles}
|
||||
>
|
||||
{props.children}
|
||||
</DropdownMenu.SubContent>
|
||||
<AriaLabellingProvider value={context}>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-6}
|
||||
collisionPadding={6}
|
||||
className={AxoBaseMenu.menuSubContentStyles}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descriptionId}
|
||||
>
|
||||
{props.children}
|
||||
</DropdownMenu.SubContent>
|
||||
</AriaLabellingProvider>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { RefCallback } from 'react';
|
||||
import { createContext, useContext, useMemo, useState } from 'react';
|
||||
import { assert } from './assert.js';
|
||||
|
||||
type AriaLabellingContextType = Readonly<{
|
||||
labelRef: RefCallback<HTMLElement>;
|
||||
descriptionRef: RefCallback<HTMLElement>;
|
||||
}>;
|
||||
|
||||
const AriaLabellingContext = createContext<AriaLabellingContextType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
export type CreateAriaLabellingContextResult = Readonly<{
|
||||
context: AriaLabellingContextType;
|
||||
labelId: string | undefined;
|
||||
descriptionId: string | undefined;
|
||||
}>;
|
||||
|
||||
export function useCreateAriaLabellingContext(): CreateAriaLabellingContextResult {
|
||||
const [labelId, setLabelId] = useState<string | undefined>();
|
||||
const [descriptionId, setDescriptionId] = useState<string | undefined>();
|
||||
|
||||
const context = useMemo((): AriaLabellingContextType => {
|
||||
function labelRef(element: HTMLElement | null) {
|
||||
setLabelId(element?.id);
|
||||
}
|
||||
|
||||
function descriptionRef(element: HTMLElement | null) {
|
||||
setDescriptionId(element?.id);
|
||||
}
|
||||
|
||||
return { labelRef, descriptionRef };
|
||||
}, []);
|
||||
|
||||
return { context, labelId, descriptionId };
|
||||
}
|
||||
|
||||
export const AriaLabellingProvider = AriaLabellingContext.Provider;
|
||||
|
||||
export function useAriaLabellingContext(
|
||||
componentName: string,
|
||||
providerName: string
|
||||
): AriaLabellingContextType {
|
||||
return assert(
|
||||
useContext(AriaLabellingContext),
|
||||
`${componentName} must be wrapped with a ${providerName}`
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export namespace AxoBaseMenu {
|
||||
'forced-colors:text-[CanvasText]'
|
||||
);
|
||||
|
||||
const baseContentGridStyles = tw('grid grid-cols-[min-content_auto] p-1.5');
|
||||
const baseContentGridStyles = tw('grid grid-cols-[min-content_1fr] p-1.5');
|
||||
|
||||
// <Group/RadioGroup>
|
||||
const baseGroupStyles = tw('col-span-full grid grid-cols-subgrid');
|
||||
@@ -252,6 +252,18 @@ export namespace AxoBaseMenu {
|
||||
export const menuLabelStyles = tw(baseLabelStyles);
|
||||
export const selectLabelStyles = tw(baseLabelStyles);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Header
|
||||
*/
|
||||
|
||||
export const menuHeaderStyles = tw('col-span-full col-start-1 p-1.5');
|
||||
export const menuHeaderLabelStyles = tw(
|
||||
'block truncate type-title-small text-label-primary'
|
||||
);
|
||||
export const menuHeaderDescriptionStyles = tw(
|
||||
'block truncate type-caption text-label-secondary'
|
||||
);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: CheckboxItem
|
||||
* -------------------------
|
||||
@@ -315,13 +327,17 @@ export namespace AxoBaseMenu {
|
||||
// N/A
|
||||
}>;
|
||||
|
||||
const baseSeparatorStyles = tw(
|
||||
baseItemStyles,
|
||||
'mx-0.5 my-1 border-t-[0.5px] border-border-primary'
|
||||
);
|
||||
const baseSeparatorStyles = tw('my-1 border-t-[0.5px] border-border-primary');
|
||||
|
||||
export const menuSeparatorStyles = tw(baseSeparatorStyles);
|
||||
export const selectSeperatorStyles = tw(baseSeparatorStyles);
|
||||
export const menuSeparatorStyles = tw(
|
||||
'col-span-full col-start-1 mx-0.5',
|
||||
baseSeparatorStyles
|
||||
);
|
||||
export const menuContentSeparatorStyles = tw(
|
||||
'col-span-full col-start-2',
|
||||
baseSeparatorStyles
|
||||
);
|
||||
export const selectSeperatorStyles = tw(baseItemStyles, baseSeparatorStyles);
|
||||
|
||||
/**
|
||||
* AxoBaseMenu: Sub
|
||||
|
||||
+17
-28
@@ -17,7 +17,6 @@ import type {
|
||||
ActiveCallStateType,
|
||||
PeekNotConnectedGroupCallType,
|
||||
} from '../state/ducks/calling.js';
|
||||
import { ContextMenu } from './ContextMenu.js';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog.js';
|
||||
import type { UnreadStats } from '../util/countUnreadStats.js';
|
||||
import type { WidthBreakpoint } from './_util.js';
|
||||
@@ -25,6 +24,7 @@ import type { CallLinkType } from '../types/CallLink.js';
|
||||
import type { CallStateType } from '../state/selectors/calling.js';
|
||||
import type { StartCallData } from './ConfirmLeaveCallModal.js';
|
||||
import { I18n } from './I18n.js';
|
||||
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.js';
|
||||
|
||||
enum CallsTabSidebarView {
|
||||
CallsListView,
|
||||
@@ -235,33 +235,22 @@ export function CallsTab({
|
||||
updateSidebarView(CallsTabSidebarView.NewCallView);
|
||||
}}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'CallsTab__ClearCallHistoryIcon',
|
||||
label: i18n('icu:CallsTab__ClearCallHistoryLabel'),
|
||||
onClick: handleOpenClearCallHistoryDialog,
|
||||
},
|
||||
]}
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
portalToRoot
|
||||
>
|
||||
{({ onClick, onKeyDown, ref }) => {
|
||||
return (
|
||||
<NavSidebarActionButton
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
icon={<span className="CallsTab__MoreActionsIcon" />}
|
||||
label={i18n('icu:CallsTab__MoreActionsLabel')}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ContextMenu>
|
||||
<AxoDropdownMenu.Root>
|
||||
<AxoDropdownMenu.Trigger>
|
||||
<NavSidebarActionButton
|
||||
icon={<span className="CallsTab__MoreActionsIcon" />}
|
||||
label={i18n('icu:CallsTab__MoreActionsLabel')}
|
||||
/>
|
||||
</AxoDropdownMenu.Trigger>
|
||||
<AxoDropdownMenu.Content>
|
||||
<AxoDropdownMenu.Item
|
||||
symbol="trash"
|
||||
onSelect={handleOpenClearCallHistoryDialog}
|
||||
>
|
||||
{i18n('icu:CallsTab__ClearCallHistoryLabel')}
|
||||
</AxoDropdownMenu.Item>
|
||||
</AxoDropdownMenu.Content>
|
||||
</AxoDropdownMenu.Root>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { GroupListItemConversationType } from './conversationList/GroupList
|
||||
import { ServerAlert } from '../types/ServerAlert.js';
|
||||
import { LeftPaneChatFolders } from './leftPane/LeftPaneChatFolders.js';
|
||||
import { LeftPaneConversationListItemContextMenu } from './leftPane/LeftPaneConversationListItemContextMenu.js';
|
||||
import { CurrentChatFolders } from '../types/CurrentChatFolders.js';
|
||||
|
||||
const { i18n } = window.SignalContext;
|
||||
|
||||
@@ -119,6 +120,7 @@ const defaultModeSpecificProps = {
|
||||
conversations: defaultConversations,
|
||||
archivedConversations: defaultArchivedConversations,
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
};
|
||||
|
||||
const emptySearchResultsGroup = { isLoading: false, results: [] };
|
||||
@@ -195,6 +197,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||
challengeStatus: 'idle',
|
||||
crashReportCount: 0,
|
||||
|
||||
hasAnyCurrentCustomChatFolders: false,
|
||||
hasNetworkDialog: false,
|
||||
hasExpiredDialog: false,
|
||||
hasRelinkDialog: false,
|
||||
@@ -214,6 +217,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||
preloadConversation: action('preloadConversation'),
|
||||
showConversation: action('showConversation'),
|
||||
blockConversation: action('blockConversation'),
|
||||
onChatFoldersOpenSettings: action('onChatFoldersOpenSettings'),
|
||||
onOutgoingAudioCallInConversation: action(
|
||||
'onOutgoingAudioCallInConversation'
|
||||
),
|
||||
@@ -249,8 +253,8 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
renderNotificationProfilesMenu: ({ trigger }) => (
|
||||
<div className="fakeNotificationProfilesMenu">{trigger}</div>
|
||||
renderNotificationProfilesMenu: () => (
|
||||
<div className="fakeNotificationProfilesMenu" />
|
||||
),
|
||||
renderRelinkDialog: props => (
|
||||
<DialogRelink
|
||||
@@ -319,7 +323,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||
<LeftPaneChatFolders
|
||||
i18n={i18n}
|
||||
navSidebarWidthBreakpoint={null}
|
||||
sortedChatFolders={[]}
|
||||
currentChatFolders={CurrentChatFolders.createEmpty()}
|
||||
allChatFoldersUnreadStats={new Map()}
|
||||
allChatFoldersMutedStats={new Map()}
|
||||
selectedChatFolder={null}
|
||||
@@ -347,6 +351,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||
{props.children}
|
||||
</LeftPaneConversationListItemContextMenu>
|
||||
),
|
||||
selectedChatFolder: null,
|
||||
selectedConversationId: undefined,
|
||||
targetedMessageId: undefined,
|
||||
openUsernameReservationModal: action('openUsernameReservationModal'),
|
||||
@@ -400,6 +405,7 @@ export function InboxNoConversations(): JSX.Element {
|
||||
conversations: [],
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
@@ -439,6 +445,7 @@ export function InboxBackupMediaDownloadWithDialogsAndUnpinnedConversations(): J
|
||||
conversations: defaultConversations,
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
@@ -496,6 +503,7 @@ export function InboxUsernameCorrupted(): JSX.Element {
|
||||
conversations: [],
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
usernameCorrupted: true,
|
||||
})}
|
||||
@@ -514,6 +522,7 @@ export function InboxUsernameLinkCorrupted(): JSX.Element {
|
||||
conversations: [],
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
usernameLinkCorrupted: true,
|
||||
})}
|
||||
@@ -532,6 +541,7 @@ export function InboxOnlyPinnedConversations(): JSX.Element {
|
||||
conversations: [],
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
@@ -549,6 +559,7 @@ export function InboxOnlyNonPinnedConversations(): JSX.Element {
|
||||
conversations: defaultConversations,
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
@@ -566,6 +577,7 @@ export function InboxOnlyArchivedConversations(): JSX.Element {
|
||||
conversations: [],
|
||||
archivedConversations: defaultArchivedConversations,
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
@@ -583,6 +595,7 @@ export function InboxPinnedAndArchivedConversations(): JSX.Element {
|
||||
conversations: [],
|
||||
archivedConversations: defaultArchivedConversations,
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
@@ -600,6 +613,7 @@ export function InboxNonPinnedAndArchivedConversations(): JSX.Element {
|
||||
conversations: defaultConversations,
|
||||
archivedConversations: defaultArchivedConversations,
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
@@ -617,6 +631,7 @@ export function InboxPinnedAndNonPinnedConversations(): JSX.Element {
|
||||
conversations: defaultConversations,
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
@@ -634,6 +649,7 @@ export function InboxPinnedAndNonPinnedConversationsWithBackupDownload(): JSX.El
|
||||
conversations: defaultConversations,
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
backupMediaDownloadProgress,
|
||||
})}
|
||||
@@ -1080,6 +1096,7 @@ export function CaptchaDialogRequired(): JSX.Element {
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
searchTerm: '',
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
challengeStatus: 'required',
|
||||
})}
|
||||
@@ -1099,6 +1116,7 @@ export function CaptchaDialogPending(): JSX.Element {
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
searchTerm: '',
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
challengeStatus: 'pending',
|
||||
})}
|
||||
@@ -1118,6 +1136,7 @@ export function _CrashReportDialog(): JSX.Element {
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
searchTerm: '',
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
crashReportCount: 42,
|
||||
})}
|
||||
@@ -1270,6 +1289,7 @@ export function SearchingConversation(): JSX.Element {
|
||||
isAboutToSearch: false,
|
||||
searchConversation: getDefaultConversation(),
|
||||
searchTerm: '',
|
||||
selectedChatFolder: null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
+149
-148
@@ -5,7 +5,7 @@ import React, { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import lodash from 'lodash';
|
||||
|
||||
import type { LeftPaneHelper, ToFindType } from './leftPane/LeftPaneHelper.js';
|
||||
import type { ToFindType } from './leftPane/LeftPaneHelper.js';
|
||||
import { FindDirection } from './leftPane/LeftPaneHelper.js';
|
||||
import type { LeftPaneInboxPropsType } from './leftPane/LeftPaneInboxHelper.js';
|
||||
import { LeftPaneInboxHelper } from './leftPane/LeftPaneInboxHelper.js';
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
NavSidebarActionButton,
|
||||
NavSidebarSearchHeader,
|
||||
} from './NavSidebar.js';
|
||||
import { ContextMenu } from './ContextMenu.js';
|
||||
import type { UnreadStats } from '../util/countUnreadStats.js';
|
||||
import { BackupMediaDownloadProgress } from './BackupMediaDownloadProgress.js';
|
||||
import type { ServerAlertsType, ServerAlert } from '../types/ServerAlert.js';
|
||||
@@ -61,7 +60,9 @@ import { getServerAlertDialog } from './ServerAlerts.js';
|
||||
import { NavTab, SettingsPage, ProfileEditorPage } from '../types/Nav.js';
|
||||
import type { Location } from '../types/Nav.js';
|
||||
import type { RenderConversationListItemContextMenuProps } from './conversationList/BaseConversationListItem.js';
|
||||
import type { ExternalProps as NotificationProfilesMenuProps } from '../state/smart/NotificationProfilesMenu.js';
|
||||
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.js';
|
||||
import type { ChatFolder } from '../types/ChatFolder.js';
|
||||
import { isChatFoldersEnabled } from '../types/ChatFolder.js';
|
||||
import { ProfileAvatar } from './PreferencesNotificationProfiles.js';
|
||||
import { tw } from '../axo/tw.js';
|
||||
|
||||
@@ -77,6 +78,7 @@ export type PropsType = {
|
||||
downloadBannerDismissed: boolean;
|
||||
};
|
||||
otherTabsUnreadStats: UnreadStats;
|
||||
hasAnyCurrentCustomChatFolders: boolean;
|
||||
hasExpiredDialog: boolean;
|
||||
hasFailedStorySends: boolean;
|
||||
hasNetworkDialog: boolean;
|
||||
@@ -123,6 +125,7 @@ export type PropsType = {
|
||||
isMacOS: boolean;
|
||||
isNotificationProfileActive: boolean;
|
||||
preferredWidthFromStorage: number;
|
||||
selectedChatFolder: ChatFolder | null;
|
||||
selectedConversationId: undefined | string;
|
||||
targetedMessageId: undefined | string;
|
||||
challengeStatus: 'idle' | 'required' | 'pending';
|
||||
@@ -150,6 +153,7 @@ export type PropsType = {
|
||||
endSearch: () => void;
|
||||
navTabsCollapsed: boolean;
|
||||
openUsernameReservationModal: () => void;
|
||||
onChatFoldersOpenSettings: () => void;
|
||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||
removeConversation: (conversationId: string) => void;
|
||||
@@ -199,9 +203,7 @@ export type PropsType = {
|
||||
renderCrashReportDialog: () => JSX.Element;
|
||||
renderExpiredBuildDialog: (_: DialogExpiredBuildPropsType) => JSX.Element;
|
||||
renderLeftPaneChatFolders: () => JSX.Element;
|
||||
renderNotificationProfilesMenu: (
|
||||
props: NotificationProfilesMenuProps
|
||||
) => JSX.Element;
|
||||
renderNotificationProfilesMenu: () => JSX.Element;
|
||||
renderToastManager: (_: {
|
||||
containerWidthBreakpoint: WidthBreakpoint;
|
||||
}) => JSX.Element;
|
||||
@@ -228,6 +230,7 @@ export function LeftPane({
|
||||
endSearch,
|
||||
getPreferredBadge,
|
||||
getServerAlertToShow,
|
||||
hasAnyCurrentCustomChatFolders,
|
||||
hasExpiredDialog,
|
||||
hasFailedStorySends,
|
||||
hasNetworkDialog,
|
||||
@@ -242,6 +245,7 @@ export function LeftPane({
|
||||
isUpdateDownloaded,
|
||||
modeSpecificProps,
|
||||
navTabsCollapsed,
|
||||
onChatFoldersOpenSettings,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
|
||||
@@ -266,6 +270,7 @@ export function LeftPane({
|
||||
saveAlerts,
|
||||
savePreferredLeftPaneWidth,
|
||||
searchInConversation,
|
||||
selectedChatFolder,
|
||||
selectedConversationId,
|
||||
targetedMessageId,
|
||||
toggleNavTabsCollapse,
|
||||
@@ -320,7 +325,15 @@ export function LeftPane({
|
||||
//
|
||||
// Unfortunately, there's a little bit of repetition here because TypeScript isn't quite
|
||||
// smart enough.
|
||||
let helper: LeftPaneHelper<unknown>;
|
||||
let helper:
|
||||
| LeftPaneInboxHelper
|
||||
| LeftPaneSearchHelper
|
||||
| LeftPaneArchiveHelper
|
||||
| LeftPaneComposeHelper
|
||||
| LeftPaneFindByUsernameHelper
|
||||
| LeftPaneFindByPhoneNumberHelper
|
||||
| LeftPaneChooseGroupMembersHelper
|
||||
| LeftPaneSetGroupMetadataHelper;
|
||||
let shouldRecomputeRowHeights: boolean;
|
||||
switch (modeSpecificProps.mode) {
|
||||
case LeftPaneMode.Inbox: {
|
||||
@@ -523,9 +536,7 @@ export function LeftPane({
|
||||
startSearch,
|
||||
]);
|
||||
|
||||
const backgroundNode = helper.getBackgroundNode({
|
||||
i18n,
|
||||
});
|
||||
const isEmpty = helper.getRowCount() === 0;
|
||||
|
||||
const preRowsNode = helper.getPreRowsNode({
|
||||
clearConversationSearch,
|
||||
@@ -744,21 +755,6 @@ export function LeftPane({
|
||||
|
||||
const hasDialogs = dialogs.length ? !hideHeader : false;
|
||||
|
||||
// The notification profile menu shows in two places - under its own icon and
|
||||
// under the more actions context menu.
|
||||
const [isNotificationProfilesMenuOpen, setIsNotificationProfilesMenuOpen] =
|
||||
React.useState(false);
|
||||
const [
|
||||
isNotificationProfilesSubMenuOpen,
|
||||
setIsNotificationProfilesSubMenuOpen,
|
||||
] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isNotificationProfileActive) {
|
||||
setIsNotificationProfilesMenuOpen(false);
|
||||
}
|
||||
}, [isNotificationProfileActive, setIsNotificationProfilesMenuOpen]);
|
||||
|
||||
return (
|
||||
<NavSidebar
|
||||
title={i18n('icu:LeftPane--chats')}
|
||||
@@ -775,76 +771,73 @@ export function LeftPane({
|
||||
renderToastManager={renderToastManager}
|
||||
actions={
|
||||
<>
|
||||
{isNotificationProfileActive &&
|
||||
renderNotificationProfilesMenu({
|
||||
isOpen: isNotificationProfilesMenuOpen,
|
||||
onClose: () => {
|
||||
setIsNotificationProfilesMenuOpen(false);
|
||||
},
|
||||
trigger: (
|
||||
{isNotificationProfileActive && (
|
||||
<AxoDropdownMenu.Root>
|
||||
<AxoDropdownMenu.Trigger>
|
||||
<button
|
||||
className={tw(
|
||||
'rounded-sm focus:outline-none focus-visible:shadow-legacy-outline'
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => setIsNotificationProfilesMenuOpen(true)}
|
||||
onKeyUp={(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.code === 'Enter' || event.code === 'Space') {
|
||||
setIsNotificationProfilesMenuOpen(true);
|
||||
}
|
||||
}}
|
||||
className={tw(
|
||||
'rounded-full outline-0 outline-border-focused focused:outline-[2.5px]'
|
||||
)}
|
||||
>
|
||||
<ProfileAvatar i18n={i18n} size="medium-small" />
|
||||
</button>
|
||||
),
|
||||
})}
|
||||
</AxoDropdownMenu.Trigger>
|
||||
<AxoDropdownMenu.Content>
|
||||
{renderNotificationProfilesMenu()}
|
||||
</AxoDropdownMenu.Content>
|
||||
</AxoDropdownMenu.Root>
|
||||
)}
|
||||
<NavSidebarActionButton
|
||||
label={i18n('icu:newConversation')}
|
||||
icon={<span className="module-left-pane__startComposingIcon" />}
|
||||
onClick={startComposing}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
label: i18n('icu:avatarMenuViewArchive'),
|
||||
onClick: showArchivedConversations,
|
||||
},
|
||||
{
|
||||
label: i18n('icu:NotificationProfileMenuItem'),
|
||||
onClick: () => setIsNotificationProfilesSubMenuOpen(true),
|
||||
},
|
||||
]}
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
portalToRoot
|
||||
>
|
||||
{({ onClick, onKeyDown, ref }) =>
|
||||
renderNotificationProfilesMenu({
|
||||
isOpen: isNotificationProfilesSubMenuOpen,
|
||||
onClose: () => {
|
||||
setIsNotificationProfilesSubMenuOpen(false);
|
||||
},
|
||||
trigger: (
|
||||
<NavSidebarActionButton
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
icon={
|
||||
<span className="module-left-pane__moreActionsIcon" />
|
||||
}
|
||||
label="More Actions"
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
</ContextMenu>
|
||||
<AxoDropdownMenu.Root>
|
||||
<AxoDropdownMenu.Trigger>
|
||||
<NavSidebarActionButton
|
||||
icon={<span className="module-left-pane__moreActionsIcon" />}
|
||||
label="More Actions"
|
||||
/>
|
||||
</AxoDropdownMenu.Trigger>
|
||||
<AxoDropdownMenu.Content>
|
||||
<AxoDropdownMenu.Item
|
||||
symbol="archive"
|
||||
onSelect={showArchivedConversations}
|
||||
>
|
||||
{i18n('icu:avatarMenuViewArchive')}
|
||||
</AxoDropdownMenu.Item>
|
||||
{isChatFoldersEnabled() && !hasAnyCurrentCustomChatFolders && (
|
||||
<AxoDropdownMenu.Item
|
||||
symbol="folder"
|
||||
onSelect={onChatFoldersOpenSettings}
|
||||
>
|
||||
{i18n('icu:LeftPane__MoreActionsMenu__AddChatFolder')}
|
||||
</AxoDropdownMenu.Item>
|
||||
)}
|
||||
{isChatFoldersEnabled() && hasAnyCurrentCustomChatFolders && (
|
||||
<AxoDropdownMenu.Item
|
||||
// TODO: This should be the "folder-settings" symbol
|
||||
// once it is added to the font
|
||||
symbol="folder"
|
||||
onSelect={onChatFoldersOpenSettings}
|
||||
>
|
||||
{i18n('icu:LeftPane__MoreActionsMenu__FolderSettings')}
|
||||
</AxoDropdownMenu.Item>
|
||||
)}
|
||||
<AxoDropdownMenu.Sub>
|
||||
<AxoDropdownMenu.SubTrigger symbol="moon">
|
||||
{i18n('icu:NotificationProfileMenuItem')}
|
||||
</AxoDropdownMenu.SubTrigger>
|
||||
<AxoDropdownMenu.SubContent>
|
||||
{renderNotificationProfilesMenu()}
|
||||
</AxoDropdownMenu.SubContent>
|
||||
</AxoDropdownMenu.Sub>
|
||||
</AxoDropdownMenu.Content>
|
||||
</AxoDropdownMenu.Root>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{backgroundNode}
|
||||
<nav
|
||||
className={classNames(
|
||||
'module-left-pane',
|
||||
@@ -907,74 +900,82 @@ export function LeftPane({
|
||||
) : null}
|
||||
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
|
||||
<div className="module-left-pane__list--measure" ref={measureRef}>
|
||||
<div className="module-left-pane__list--wrapper">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="module-left-pane__list"
|
||||
data-supertab
|
||||
key={listKey}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ConversationList
|
||||
key={modeSpecificProps.mode}
|
||||
dimensions={measureSize ?? undefined}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
getRow={getRow}
|
||||
i18n={i18n}
|
||||
hasDialogPadding={hasDialogs}
|
||||
onClickArchiveButton={showArchivedConversations}
|
||||
onClickContactCheckbox={(
|
||||
conversationId: string,
|
||||
disabledReason: undefined | ContactCheckboxDisabledReason
|
||||
) => {
|
||||
switch (disabledReason) {
|
||||
case undefined:
|
||||
toggleConversationInChooseMembers(conversationId);
|
||||
break;
|
||||
case ContactCheckboxDisabledReason.AlreadyAdded:
|
||||
case ContactCheckboxDisabledReason.MaximumContactsSelected:
|
||||
// These are no-ops.
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(disabledReason);
|
||||
{isEmpty &&
|
||||
helper.getEmptyViewNode({
|
||||
i18n,
|
||||
selectedChatFolder,
|
||||
changeLocation,
|
||||
})}
|
||||
{!isEmpty && (
|
||||
<div className="module-left-pane__list--wrapper">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="module-left-pane__list"
|
||||
data-supertab
|
||||
key={listKey}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ConversationList
|
||||
key={modeSpecificProps.mode}
|
||||
dimensions={measureSize ?? undefined}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
getRow={getRow}
|
||||
i18n={i18n}
|
||||
hasDialogPadding={hasDialogs}
|
||||
onClickArchiveButton={showArchivedConversations}
|
||||
onClickContactCheckbox={(
|
||||
conversationId: string,
|
||||
disabledReason: undefined | ContactCheckboxDisabledReason
|
||||
) => {
|
||||
switch (disabledReason) {
|
||||
case undefined:
|
||||
toggleConversationInChooseMembers(conversationId);
|
||||
break;
|
||||
case ContactCheckboxDisabledReason.AlreadyAdded:
|
||||
case ContactCheckboxDisabledReason.MaximumContactsSelected:
|
||||
// These are no-ops.
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(disabledReason);
|
||||
}
|
||||
}}
|
||||
onClickClearFilterButton={() => {
|
||||
updateFilterByUnread(false);
|
||||
}}
|
||||
showUserNotFoundModal={showUserNotFoundModal}
|
||||
setIsFetchingUUID={setIsFetchingUUID}
|
||||
lookupConversationWithoutServiceId={
|
||||
lookupConversationWithoutServiceId
|
||||
}
|
||||
}}
|
||||
onClickClearFilterButton={() => {
|
||||
updateFilterByUnread(false);
|
||||
}}
|
||||
showUserNotFoundModal={showUserNotFoundModal}
|
||||
setIsFetchingUUID={setIsFetchingUUID}
|
||||
lookupConversationWithoutServiceId={
|
||||
lookupConversationWithoutServiceId
|
||||
}
|
||||
showConversation={showConversation}
|
||||
blockConversation={blockConversation}
|
||||
onPreloadConversation={preloadConversation}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onOutgoingAudioCallInConversation={
|
||||
onOutgoingAudioCallInConversation
|
||||
}
|
||||
onOutgoingVideoCallInConversation={
|
||||
onOutgoingVideoCallInConversation
|
||||
}
|
||||
removeConversation={removeConversation}
|
||||
renderMessageSearchResult={renderMessageSearchResult}
|
||||
renderConversationListItemContextMenu={
|
||||
renderConversationListItemContextMenu
|
||||
}
|
||||
rowCount={helper.getRowCount()}
|
||||
scrollBehavior={scrollBehavior}
|
||||
scrollToRowIndex={rowIndexToScrollTo}
|
||||
scrollable={isScrollable}
|
||||
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
|
||||
showChooseGroupMembers={showChooseGroupMembers}
|
||||
showFindByUsername={showFindByUsername}
|
||||
showFindByPhoneNumber={showFindByPhoneNumber}
|
||||
theme={theme}
|
||||
/>
|
||||
showConversation={showConversation}
|
||||
blockConversation={blockConversation}
|
||||
onPreloadConversation={preloadConversation}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onOutgoingAudioCallInConversation={
|
||||
onOutgoingAudioCallInConversation
|
||||
}
|
||||
onOutgoingVideoCallInConversation={
|
||||
onOutgoingVideoCallInConversation
|
||||
}
|
||||
removeConversation={removeConversation}
|
||||
renderMessageSearchResult={renderMessageSearchResult}
|
||||
renderConversationListItemContextMenu={
|
||||
renderConversationListItemContextMenu
|
||||
}
|
||||
rowCount={helper.getRowCount()}
|
||||
scrollBehavior={scrollBehavior}
|
||||
scrollToRowIndex={rowIndexToScrollTo}
|
||||
scrollable={isScrollable}
|
||||
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
|
||||
showChooseGroupMembers={showChooseGroupMembers}
|
||||
showFindByUsername={showFindByUsername}
|
||||
showFindByPhoneNumber={showFindByPhoneNumber}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{footerContents && (
|
||||
<div className="module-left-pane__footer">{footerContents}</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import React, { createContext, useEffect, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useMove } from 'react-aria';
|
||||
@@ -19,27 +19,25 @@ import type { UnreadStats } from '../util/countUnreadStats.js';
|
||||
export const NavSidebarWidthBreakpointContext =
|
||||
createContext<WidthBreakpoint | null>(null);
|
||||
|
||||
type NavSidebarActionButtonProps = {
|
||||
icon: ReactNode;
|
||||
label: ReactNode;
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
type NavSidebarActionButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
Readonly<{
|
||||
icon: ReactNode;
|
||||
label: ReactNode;
|
||||
}>;
|
||||
|
||||
export const NavSidebarActionButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
NavSidebarActionButtonProps
|
||||
>(function NavSidebarActionButtonInner(
|
||||
{ icon, label, onClick, onKeyDown },
|
||||
{ icon, label, ...rest },
|
||||
ref
|
||||
): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="button"
|
||||
className="NavSidebar__ActionButton"
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{icon}
|
||||
<span className="NavSidebar__ActionButtonLabel">{label}</span>
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { DayOfWeek } from '../types/NotificationProfile.js';
|
||||
import type { NotificationProfileIdString } from '../types/NotificationProfile.js';
|
||||
import { HOUR } from '../util/durations/index.js';
|
||||
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.js';
|
||||
import { AxoButton } from '../axo/AxoButton.js';
|
||||
|
||||
const { i18n } = window.SignalContext;
|
||||
|
||||
@@ -121,11 +123,8 @@ export default {
|
||||
allProfiles: threeProfiles,
|
||||
currentOverride: undefined,
|
||||
i18n,
|
||||
isOpen: true,
|
||||
onClose: action('onClose'),
|
||||
onGoToSettings: action('onGoToSettings'),
|
||||
setProfileOverride: action('setProfileOverride'),
|
||||
trigger: <div>This is the trigger</div>,
|
||||
},
|
||||
} satisfies Meta<Props>;
|
||||
|
||||
@@ -135,7 +134,6 @@ function createProps(args: Partial<Props>) {
|
||||
allProfiles: threeProfiles,
|
||||
currentOverride: undefined,
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
onGoToSettings: action('onGoToSettings'),
|
||||
setProfileOverride: action('setProfileOverride'),
|
||||
...args,
|
||||
@@ -144,7 +142,18 @@ function createProps(args: Partial<Props>) {
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
const Template: StoryFn<Props> = args => {
|
||||
return <NotificationProfilesMenu {...args} />;
|
||||
return (
|
||||
<AxoDropdownMenu.Root>
|
||||
<AxoDropdownMenu.Trigger>
|
||||
<AxoButton.Root size="medium" variant="secondary">
|
||||
Open
|
||||
</AxoButton.Root>
|
||||
</AxoDropdownMenu.Trigger>
|
||||
<AxoDropdownMenu.Content>
|
||||
<NotificationProfilesMenu {...args} />
|
||||
</AxoDropdownMenu.Content>
|
||||
</AxoDropdownMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
@@ -13,20 +13,15 @@ import {
|
||||
import { DAY, HOUR, SECOND } from '../util/durations/index.js';
|
||||
import { formatTimestamp } from '../util/formatTimestamp.js';
|
||||
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.js';
|
||||
import { tw } from '../axo/tw.js';
|
||||
import { ProfileAvatar } from './PreferencesNotificationProfiles.js';
|
||||
import { AxoSymbol } from '../axo/AxoSymbol.js';
|
||||
|
||||
export type Props = Readonly<{
|
||||
activeProfileId: NotificationProfileIdString | undefined;
|
||||
allProfiles: ReadonlyArray<NotificationProfileType>;
|
||||
currentOverride: NotificationProfileOverride | undefined;
|
||||
i18n: LocalizerType;
|
||||
isOpen: boolean;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onGoToSettings: () => void;
|
||||
trigger: React.ReactNode;
|
||||
setProfileOverride: (
|
||||
id: NotificationProfileIdString,
|
||||
enabled: boolean,
|
||||
@@ -52,11 +47,8 @@ export function NotificationProfilesMenu({
|
||||
allProfiles,
|
||||
currentOverride,
|
||||
i18n,
|
||||
isOpen,
|
||||
loading,
|
||||
onClose,
|
||||
onGoToSettings,
|
||||
trigger,
|
||||
setProfileOverride,
|
||||
}: Props): JSX.Element {
|
||||
const enabledOverrideEndTime = currentOverride?.enabled?.endsAtMs;
|
||||
@@ -111,103 +103,67 @@ export function NotificationProfilesMenu({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AxoDropdownMenu.Root
|
||||
open={isOpen}
|
||||
onOpenChange={open => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AxoDropdownMenu.Trigger>{trigger}</AxoDropdownMenu.Trigger>
|
||||
<AxoDropdownMenu.Content>
|
||||
<div className={tw('col-span-2 p-1.5')}>
|
||||
<div className={tw('type-title-small text-label-primary')}>
|
||||
{i18n('icu:NotificationProfileMenu--header')}
|
||||
</div>
|
||||
<div className={tw('type-caption text-label-secondary')}>
|
||||
{enabledLabel}
|
||||
</div>
|
||||
</div>
|
||||
{profilesToRender.length > 0 ? (
|
||||
<AxoDropdownMenu.Separator />
|
||||
) : undefined}
|
||||
{profilesToRender.map((profile, index) => {
|
||||
const isActive = activeProfileId && profile.id === activeProfileId;
|
||||
<>
|
||||
<AxoDropdownMenu.Header
|
||||
label={i18n('icu:NotificationProfileMenu--header')}
|
||||
description={enabledLabel}
|
||||
/>
|
||||
{profilesToRender.length > 0 ? <AxoDropdownMenu.Separator /> : undefined}
|
||||
{profilesToRender.map((profile, index) => {
|
||||
const isActive = activeProfileId && profile.id === activeProfileId;
|
||||
|
||||
return (
|
||||
<React.Fragment key={profile.id}>
|
||||
{index > 0 && (
|
||||
<div
|
||||
key={`${profile.id}_separator`}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
className={tw(
|
||||
'col-span-1 col-start-2 mx-0.5 my-1 border-t-[0.5px] border-border-primary'
|
||||
)}
|
||||
return (
|
||||
<React.Fragment key={profile.id}>
|
||||
{index > 0 && <AxoDropdownMenu.ContentSeparator />}
|
||||
<AxoDropdownMenu.CustomItem
|
||||
key={profile.id}
|
||||
leading={
|
||||
<ProfileAvatar
|
||||
i18n={i18n}
|
||||
isActive={isActive}
|
||||
profile={profile}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
}
|
||||
text={profile.name}
|
||||
onSelect={event => {
|
||||
event.preventDefault();
|
||||
setProfileOverride(profile.id, !isActive);
|
||||
}}
|
||||
/>
|
||||
{isActive ? (
|
||||
<AxoDropdownMenu.Item
|
||||
key={profile.id}
|
||||
customIcon={
|
||||
<ProfileAvatar
|
||||
i18n={i18n}
|
||||
isActive={isActive}
|
||||
profile={profile}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
key={`${profile.id}_one-hour`}
|
||||
onSelect={event => {
|
||||
event.preventDefault();
|
||||
setProfileOverride(profile.id, !isActive);
|
||||
setProfileOverride(profile.id, true, Date.now() + HOUR);
|
||||
}}
|
||||
>
|
||||
{profile.name}
|
||||
{i18n('icu:NotificationProfileMenu--for-one-hour')}
|
||||
</AxoDropdownMenu.Item>
|
||||
{isActive ? (
|
||||
<AxoDropdownMenu.Item
|
||||
key={`${profile.id}_one-hour`}
|
||||
onSelect={event => {
|
||||
event.preventDefault();
|
||||
setProfileOverride(profile.id, true, Date.now() + HOUR);
|
||||
}}
|
||||
>
|
||||
{i18n('icu:NotificationProfileMenu--for-one-hour')}
|
||||
</AxoDropdownMenu.Item>
|
||||
) : null}
|
||||
{isActive ? (
|
||||
<AxoDropdownMenu.Item
|
||||
key={`${profile.id}_until-time`}
|
||||
onSelect={event => {
|
||||
event.preventDefault();
|
||||
setProfileOverride(profile.id, true, targetTime);
|
||||
}}
|
||||
>
|
||||
{i18n('icu:NotificationProfileMenu--until-time', {
|
||||
time: formatTimestamp(targetTime, {
|
||||
timeStyle: 'short',
|
||||
}),
|
||||
})}
|
||||
</AxoDropdownMenu.Item>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<AxoDropdownMenu.Separator />
|
||||
<AxoDropdownMenu.Item
|
||||
onSelect={event => {
|
||||
event.preventDefault();
|
||||
onGoToSettings();
|
||||
}}
|
||||
customIcon={
|
||||
<span className={tw('p-0.5 leading-0')}>
|
||||
<AxoSymbol.Icon size={16} symbol="settings" label={null} />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{i18n('icu:NotificationProfileMenu--settings')}
|
||||
</AxoDropdownMenu.Item>
|
||||
</AxoDropdownMenu.Content>
|
||||
</AxoDropdownMenu.Root>
|
||||
) : null}
|
||||
{isActive ? (
|
||||
<AxoDropdownMenu.Item
|
||||
key={`${profile.id}_until-time`}
|
||||
onSelect={event => {
|
||||
event.preventDefault();
|
||||
setProfileOverride(profile.id, true, targetTime);
|
||||
}}
|
||||
>
|
||||
{i18n('icu:NotificationProfileMenu--until-time', {
|
||||
time: formatTimestamp(targetTime, {
|
||||
timeStyle: 'short',
|
||||
}),
|
||||
})}
|
||||
</AxoDropdownMenu.Item>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<AxoDropdownMenu.Separator />
|
||||
<AxoDropdownMenu.Item onSelect={onGoToSettings} symbol="settings">
|
||||
{i18n('icu:NotificationProfileMenu--settings')}
|
||||
</AxoDropdownMenu.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
import type { AnyToast } from '../types/Toast.js';
|
||||
import type { SmartPreferencesChatFoldersPageProps } from '../state/smart/PreferencesChatFoldersPage.js';
|
||||
import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/PreferencesEditChatFolderPage.js';
|
||||
import { CurrentChatFolders } from '../types/CurrentChatFolders.js';
|
||||
import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.js';
|
||||
import type { NotificationProfileIdString } from '../types/NotificationProfile.js';
|
||||
|
||||
@@ -287,9 +288,10 @@ function renderPreferencesChatFoldersPage(
|
||||
return (
|
||||
<PreferencesChatFoldersPage
|
||||
i18n={i18n}
|
||||
onBack={props.onBack}
|
||||
previousLocation={props.previousLocation}
|
||||
settingsPaneRef={props.settingsPaneRef}
|
||||
chatFolders={[]}
|
||||
changeLocation={action('changeLocation')}
|
||||
currentChatFolders={CurrentChatFolders.createEmpty()}
|
||||
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
|
||||
onCreateChatFolder={action('onCreateChatFolder')}
|
||||
onDeleteChatFolder={action('onDeletChatFolder')}
|
||||
@@ -307,7 +309,7 @@ function renderPreferencesEditChatFolderPage(
|
||||
theme={ThemeType.light}
|
||||
previousLocation={{
|
||||
tab: NavTab.Settings,
|
||||
details: { page: SettingsPage.ChatFolders },
|
||||
details: { page: SettingsPage.ChatFolders, previousLocation: null },
|
||||
}}
|
||||
settingsPaneRef={props.settingsPaneRef}
|
||||
existingChatFolderId={props.existingChatFolderId}
|
||||
@@ -406,11 +408,13 @@ export default {
|
||||
backupSubscriptionStatus: { status: 'off' },
|
||||
badge: undefined,
|
||||
blockedCount: 0,
|
||||
currentChatFoldersCount: 0,
|
||||
customColors: {},
|
||||
defaultConversationColor: DEFAULT_CONVERSATION_COLOR,
|
||||
deviceName: 'Work Windows ME',
|
||||
emojiSkinToneDefault: EmojiSkinTone.None,
|
||||
phoneNumber: '+1 555 123-4567',
|
||||
hasAnyCurrentCustomChatFolders: false,
|
||||
hasAudioNotifications: true,
|
||||
hasAutoConvertEmoji: true,
|
||||
hasAutoDownloadUpdate: true,
|
||||
@@ -651,17 +655,17 @@ Chats.args = {
|
||||
};
|
||||
export const ChatFolders = Template.bind({});
|
||||
ChatFolders.args = {
|
||||
settingsLocation: { page: SettingsPage.ChatFolders },
|
||||
settingsLocation: {
|
||||
page: SettingsPage.ChatFolders,
|
||||
previousLocation: null,
|
||||
},
|
||||
};
|
||||
export const EditChatFolder = Template.bind({});
|
||||
EditChatFolder.args = {
|
||||
settingsLocation: {
|
||||
page: SettingsPage.EditChatFolder,
|
||||
chatFolderId: null,
|
||||
previousLocation: {
|
||||
tab: NavTab.Settings,
|
||||
details: { page: SettingsPage.ChatFolders },
|
||||
},
|
||||
previousLocation: null,
|
||||
},
|
||||
};
|
||||
export const Calls = Template.bind({});
|
||||
|
||||
@@ -15,7 +15,6 @@ import classNames from 'classnames';
|
||||
import * as LocaleMatcher from '@formatjs/intl-localematcher';
|
||||
import type { MutableRefObject, ReactNode } from 'react';
|
||||
import type { RowType } from '@signalapp/sqlcipher';
|
||||
|
||||
import { Button, ButtonVariant } from './Button.js';
|
||||
import { ChatColorPicker } from './ChatColorPicker.js';
|
||||
import { Checkbox } from './Checkbox.js';
|
||||
@@ -53,12 +52,9 @@ import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider
|
||||
import { Avatar, AvatarSize } from './Avatar.js';
|
||||
import { NavSidebar } from './NavSidebar.js';
|
||||
import type { SettingsLocation } from '../types/Nav.js';
|
||||
import { SettingsPage, ProfileEditorPage, NavTab } from '../types/Nav.js';
|
||||
import { SettingsPage, ProfileEditorPage } from '../types/Nav.js';
|
||||
import { tw } from '../axo/tw.js';
|
||||
import { isBackupPage } from '../types/PreferencesBackupPage.js';
|
||||
import { isChatFoldersEnabled } from '../types/ChatFolder.js';
|
||||
import { FullWidthButton } from './PreferencesNotificationProfiles.js';
|
||||
|
||||
import type { EmojiSkinTone } from './fun/data/emojis.js';
|
||||
import type { MediaDeviceSettings } from '../types/Calling.js';
|
||||
import type { ValidationResultType as BackupValidationResultType } from '../services/backups/index.js';
|
||||
@@ -91,6 +87,7 @@ import type { UnreadStats } from '../util/countUnreadStats.js';
|
||||
import type { BadgeType } from '../badges/types.js';
|
||||
import type { MessageCountBySchemaVersionType } from '../sql/Interface.js';
|
||||
import type { MessageAttributesType } from '../model-types.js';
|
||||
import { isBackupPage } from '../types/PreferencesBackupPage.js';
|
||||
import type { PreferencesBackupPage } from '../types/PreferencesBackupPage.js';
|
||||
import type {
|
||||
PromptOSAuthReasonType,
|
||||
@@ -98,8 +95,10 @@ import type {
|
||||
} from '../util/os/promptOSAuthMain.js';
|
||||
import type { DonationReceipt } from '../types/Donations.js';
|
||||
import type { ChatFolderId } from '../types/ChatFolder.js';
|
||||
import { isChatFoldersEnabled } from '../types/ChatFolder.js';
|
||||
import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/PreferencesEditChatFolderPage.js';
|
||||
import type { SmartPreferencesChatFoldersPageProps } from '../state/smart/PreferencesChatFoldersPage.js';
|
||||
import { AxoButton } from '../axo/AxoButton.js';
|
||||
import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.js';
|
||||
|
||||
const { isNumber, noop, partition } = lodash;
|
||||
@@ -115,6 +114,7 @@ export type PropsDataType = {
|
||||
backupKeyViewed: boolean;
|
||||
backupLocalBackupsEnabled: boolean;
|
||||
localBackupFolder: string | undefined;
|
||||
currentChatFoldersCount: number;
|
||||
cloudBackupStatus?: BackupStatusType;
|
||||
backupSubscriptionStatus: BackupsSubscriptionType;
|
||||
backupMediaDownloadStatus?: BackupMediaDownloadStatusType;
|
||||
@@ -126,6 +126,7 @@ export type PropsDataType = {
|
||||
defaultConversationColor: DefaultConversationColorType;
|
||||
deviceName?: string;
|
||||
emojiSkinToneDefault: EmojiSkinTone;
|
||||
hasAnyCurrentCustomChatFolders: boolean;
|
||||
hasAudioNotifications?: boolean;
|
||||
hasAutoConvertEmoji: boolean;
|
||||
hasAutoDownloadUpdate: boolean;
|
||||
@@ -387,6 +388,7 @@ export function Preferences({
|
||||
backupLocalBackupsEnabled,
|
||||
badge,
|
||||
blockedCount,
|
||||
currentChatFoldersCount,
|
||||
cloudBackupStatus,
|
||||
customColors,
|
||||
defaultConversationColor,
|
||||
@@ -398,6 +400,7 @@ export function Preferences({
|
||||
getConversationsWithCustomColor,
|
||||
getMessageCountBySchemaVersion,
|
||||
getMessageSampleForSchemaVersion,
|
||||
hasAnyCurrentCustomChatFolders,
|
||||
hasAudioNotifications,
|
||||
hasAutoConvertEmoji,
|
||||
hasAutoDownloadUpdate,
|
||||
@@ -554,13 +557,10 @@ export function Preferences({
|
||||
setSettingsLocation({
|
||||
page: SettingsPage.EditChatFolder,
|
||||
chatFolderId,
|
||||
previousLocation: {
|
||||
tab: NavTab.Settings,
|
||||
details: settingsLocation,
|
||||
},
|
||||
previousLocation: null,
|
||||
});
|
||||
},
|
||||
[setSettingsLocation, settingsLocation]
|
||||
[setSettingsLocation]
|
||||
);
|
||||
|
||||
function closeLanguageDialog() {
|
||||
@@ -1183,22 +1183,43 @@ export function Preferences({
|
||||
>
|
||||
<Control
|
||||
left={
|
||||
<>
|
||||
<div>
|
||||
{i18n(
|
||||
hasAnyCurrentCustomChatFolders
|
||||
? i18n(
|
||||
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Title--WithChatFolders'
|
||||
)
|
||||
: i18n(
|
||||
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Title'
|
||||
)}
|
||||
</div>
|
||||
<div className="Preferences__description">
|
||||
{i18n(
|
||||
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Description'
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
right={null}
|
||||
onClick={() =>
|
||||
setSettingsLocation({ page: SettingsPage.ChatFolders })
|
||||
description={
|
||||
hasAnyCurrentCustomChatFolders
|
||||
? i18n(
|
||||
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Description--WithChatFolders',
|
||||
{ chatFoldersCount: currentChatFoldersCount }
|
||||
)
|
||||
: i18n(
|
||||
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Description'
|
||||
)
|
||||
}
|
||||
right={
|
||||
<AxoButton.Root
|
||||
size="medium"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSettingsLocation({
|
||||
page: SettingsPage.ChatFolders,
|
||||
previousLocation: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{hasAnyCurrentCustomChatFolders
|
||||
? i18n(
|
||||
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Button--WithChatFolders'
|
||||
)
|
||||
: i18n(
|
||||
'icu:Preferences__ChatsPage__ChatFoldersSection__AddChatFolderItem__Button'
|
||||
)}
|
||||
</AxoButton.Root>
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
@@ -1984,7 +2005,7 @@ export function Preferences({
|
||||
);
|
||||
} else if (settingsLocation.page === SettingsPage.ChatFolders) {
|
||||
content = renderPreferencesChatFoldersPage({
|
||||
onBack: () => setSettingsLocation({ page: SettingsPage.Chats }),
|
||||
previousLocation: settingsLocation.previousLocation,
|
||||
onOpenEditChatFoldersPage: handleOpenEditChatFoldersPage,
|
||||
settingsPaneRef,
|
||||
});
|
||||
|
||||
@@ -2130,7 +2130,7 @@ function TimePicker({
|
||||
<button
|
||||
className={classNames(
|
||||
tw(
|
||||
'ms-3 p-0.5 leading-none outline-0 focus-visible:bg-background-overlay-secondary'
|
||||
'ms-3 p-0.5 leading-none outline-0 focus-visible:bg-fill-selected'
|
||||
),
|
||||
isDisabled ? tw('text-label-placeholder') : null
|
||||
)}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function SettingsControl({
|
||||
left: ReactNode;
|
||||
onClick?: () => unknown;
|
||||
right: ReactNode;
|
||||
description?: boolean;
|
||||
description?: ReactNode;
|
||||
}): JSX.Element {
|
||||
const content = (
|
||||
<>
|
||||
@@ -76,11 +76,13 @@ export function SettingsControl({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="Preferences__control--key">{left}</div>
|
||||
<div className="Preferences__control--key">
|
||||
{left}
|
||||
{description ? (
|
||||
<div className="Preferences__description">{description}</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
<div className="Preferences__control--value">{right}</div>
|
||||
{description ? (
|
||||
<div className="Preferences__control--value">{description}</div>
|
||||
) : undefined}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ import { MyStories } from './MyStories.js';
|
||||
import { StoriesPane } from './StoriesPane.js';
|
||||
import { NavSidebar, NavSidebarActionButton } from './NavSidebar.js';
|
||||
import { StoriesAddStoryButton } from './StoriesAddStoryButton.js';
|
||||
import { ContextMenu } from './ContextMenu.js';
|
||||
import { I18n } from './I18n.js';
|
||||
import type { WidthBreakpoint } from './_util.js';
|
||||
import type { UnreadStats } from '../util/countUnreadStats.js';
|
||||
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.js';
|
||||
|
||||
export type PropsType = {
|
||||
addStoryData: AddStoryData;
|
||||
@@ -160,33 +160,22 @@ export function StoriesTab({
|
||||
onAddStory={onAddStory}
|
||||
showToast={showToast}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
label: i18n('icu:StoriesSettings__context-menu'),
|
||||
onClick: showStoriesSettings,
|
||||
},
|
||||
]}
|
||||
moduleClassName="Stories__pane__settings"
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
portalToRoot
|
||||
>
|
||||
{({ onClick, onKeyDown, ref }) => {
|
||||
return (
|
||||
<NavSidebarActionButton
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
icon={<span className="StoriesTab__MoreActionsIcon" />}
|
||||
label={i18n('icu:StoriesTab__MoreActionsLabel')}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ContextMenu>
|
||||
<AxoDropdownMenu.Root>
|
||||
<AxoDropdownMenu.Trigger>
|
||||
<NavSidebarActionButton
|
||||
icon={<span className="StoriesTab__MoreActionsIcon" />}
|
||||
label={i18n('icu:StoriesTab__MoreActionsLabel')}
|
||||
/>
|
||||
</AxoDropdownMenu.Trigger>
|
||||
<AxoDropdownMenu.Content>
|
||||
<AxoDropdownMenu.Item
|
||||
symbol="lock"
|
||||
onSelect={showStoriesSettings}
|
||||
>
|
||||
{i18n('icu:StoriesSettings__context-menu')}
|
||||
</AxoDropdownMenu.Item>
|
||||
</AxoDropdownMenu.Content>
|
||||
</AxoDropdownMenu.Root>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -61,6 +61,11 @@ function getToast(toastType: ToastType): AnyToast {
|
||||
return { toastType: ToastType.CaptchaFailed };
|
||||
case ToastType.CaptchaSolved:
|
||||
return { toastType: ToastType.CaptchaSolved };
|
||||
case ToastType.ChatFolderCreated:
|
||||
return {
|
||||
toastType: ToastType.ChatFolderCreated,
|
||||
parameters: { chatFolderName: 'Unread' },
|
||||
};
|
||||
case ToastType.ConversationArchived:
|
||||
return {
|
||||
toastType: ToastType.ConversationArchived,
|
||||
|
||||
@@ -21,6 +21,8 @@ import type { LocalizerType } from '../types/Util.js';
|
||||
import type { AnyToast } from '../types/Toast.js';
|
||||
import type { AnyActionableMegaphone } from '../types/Megaphone.js';
|
||||
import type { Location } from '../types/Nav.js';
|
||||
import { I18n } from './I18n.js';
|
||||
import { UserText } from './UserText.js';
|
||||
|
||||
export type PropsType = {
|
||||
changeLocation: (newLocation: Location) => unknown;
|
||||
@@ -187,6 +189,20 @@ export function renderToast({
|
||||
);
|
||||
}
|
||||
|
||||
if (toastType === ToastType.ChatFolderCreated) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>
|
||||
<I18n
|
||||
i18n={i18n}
|
||||
id="icu:Toast--ChatFolderCreated"
|
||||
components={{
|
||||
chatFolderName: <UserText text={toast.parameters.chatFolderName} />,
|
||||
}}
|
||||
/>
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
if (toastType === ToastType.ConversationArchived) {
|
||||
return (
|
||||
<Toast
|
||||
|
||||
@@ -27,11 +27,13 @@ import type {
|
||||
MutedStats,
|
||||
} from '../../util/countMutedStats.js';
|
||||
import type { AxoSymbol } from '../../axo/AxoSymbol.js';
|
||||
import { UserText } from '../UserText.js';
|
||||
import { CurrentChatFolders } from '../../types/CurrentChatFolders.js';
|
||||
|
||||
export type LeftPaneChatFoldersProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
navSidebarWidthBreakpoint: WidthBreakpoint | null;
|
||||
sortedChatFolders: ReadonlyArray<ChatFolder>;
|
||||
currentChatFolders: CurrentChatFolders;
|
||||
allChatFoldersUnreadStats: AllChatFoldersUnreadStats;
|
||||
allChatFoldersMutedStats: AllChatFoldersMutedStats;
|
||||
selectedChatFolder: ChatFolder | null;
|
||||
@@ -60,7 +62,7 @@ function getChatFolderLabel(
|
||||
i18n: LocalizerType,
|
||||
chatFolder: ChatFolder,
|
||||
preferShort: boolean
|
||||
): string {
|
||||
): ReactNode {
|
||||
if (chatFolder.folderType === ChatFolderType.ALL) {
|
||||
if (preferShort) {
|
||||
return i18n('icu:LeftPaneChatFolders__ItemLabel--All--Short');
|
||||
@@ -68,7 +70,7 @@ function getChatFolderLabel(
|
||||
return i18n('icu:LeftPaneChatFolders__ItemLabel--All');
|
||||
}
|
||||
if (chatFolder.folderType === ChatFolderType.CUSTOM) {
|
||||
return chatFolder.name;
|
||||
return <UserText text={chatFolder.name} />;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -86,7 +88,11 @@ function getChatFolderIconName(
|
||||
export function LeftPaneChatFolders(
|
||||
props: LeftPaneChatFoldersProps
|
||||
): JSX.Element | null {
|
||||
const { i18n, onSelectedChatFolderIdChange } = props;
|
||||
const { i18n, currentChatFolders, onSelectedChatFolderIdChange } = props;
|
||||
|
||||
const sortedChatFolders = useMemo(() => {
|
||||
return CurrentChatFolders.toSortedArray(currentChatFolders);
|
||||
}, [currentChatFolders]);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(newValue: string | null) => {
|
||||
@@ -104,7 +110,7 @@ export function LeftPaneChatFolders(
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (props.sortedChatFolders.length < 2) {
|
||||
if (!currentChatFolders.hasAnyCurrentCustomChatFolders) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -122,7 +128,7 @@ export function LeftPaneChatFolders(
|
||||
chevron="on-hover"
|
||||
/>
|
||||
<AxoSelect.Content position="dropdown">
|
||||
{props.sortedChatFolders.map(chatFolder => {
|
||||
{sortedChatFolders.map(chatFolder => {
|
||||
const unreadStats =
|
||||
props.allChatFoldersUnreadStats.get(chatFolder.id) ?? null;
|
||||
return (
|
||||
@@ -154,7 +160,7 @@ export function LeftPaneChatFolders(
|
||||
value={props.selectedChatFolder?.id ?? null}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
{props.sortedChatFolders.map(chatFolder => {
|
||||
{sortedChatFolders.map(chatFolder => {
|
||||
const unreadStats =
|
||||
props.allChatFoldersUnreadStats.get(chatFolder.id) ?? null;
|
||||
const mutedStats =
|
||||
|
||||
@@ -67,7 +67,7 @@ export abstract class LeftPaneHelper<T> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getBackgroundNode(
|
||||
getEmptyViewNode(
|
||||
_: Readonly<{
|
||||
i18n: LocalizerType;
|
||||
}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import lodash from 'lodash';
|
||||
import type { ReactChild } from 'react';
|
||||
import type { ReactChild, ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { ToFindType } from './LeftPaneHelper.js';
|
||||
@@ -14,11 +14,15 @@ import { LeftPaneHelper } from './LeftPaneHelper.js';
|
||||
import { getConversationInDirection } from './getConversationInDirection.js';
|
||||
import type { Row } from '../ConversationList.js';
|
||||
import { RowType } from '../ConversationList.js';
|
||||
import { NavSidebarEmpty } from '../NavSidebar.js';
|
||||
import type { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem.js';
|
||||
import type { LocalizerType } from '../../types/Util.js';
|
||||
import { handleKeydownForSearch } from './handleKeydownForSearch.js';
|
||||
import { LeftPaneSearchInput } from '../LeftPaneSearchInput.js';
|
||||
import type { ChatFolder } from '../../types/ChatFolder.js';
|
||||
import { ChatFolderType } from '../../types/ChatFolder.js';
|
||||
import { AxoButton } from '../../axo/AxoButton.js';
|
||||
import { NavTab, SettingsPage, type Location } from '../../types/Nav.js';
|
||||
import { tw } from '../../axo/tw.js';
|
||||
|
||||
const { last } = lodash;
|
||||
|
||||
@@ -32,6 +36,7 @@ export type LeftPaneInboxPropsType = {
|
||||
searchDisabled: boolean;
|
||||
searchTerm: string;
|
||||
searchConversation: undefined | ConversationType;
|
||||
selectedChatFolder: ChatFolder | null;
|
||||
filterByUnread: boolean;
|
||||
};
|
||||
|
||||
@@ -45,6 +50,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
|
||||
readonly #searchDisabled: boolean;
|
||||
readonly #searchTerm: string;
|
||||
readonly #searchConversation: undefined | ConversationType;
|
||||
readonly #selectedChatFolder: ChatFolder | null;
|
||||
readonly #filterByUnread: boolean;
|
||||
|
||||
constructor({
|
||||
@@ -57,6 +63,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
|
||||
searchDisabled,
|
||||
searchTerm,
|
||||
searchConversation,
|
||||
selectedChatFolder,
|
||||
filterByUnread,
|
||||
}: Readonly<LeftPaneInboxPropsType>) {
|
||||
super();
|
||||
@@ -70,6 +77,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
|
||||
this.#searchDisabled = searchDisabled;
|
||||
this.#searchTerm = searchTerm;
|
||||
this.#searchConversation = searchConversation;
|
||||
this.#selectedChatFolder = selectedChatFolder;
|
||||
this.#filterByUnread = filterByUnread;
|
||||
}
|
||||
|
||||
@@ -82,7 +90,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
|
||||
headerCount += 1;
|
||||
}
|
||||
}
|
||||
const buttonCount = this.#archivedConversations.length ? 1 : 0;
|
||||
const buttonCount = this.#shouldShowArchiveButton() ? 1 : 0;
|
||||
return (
|
||||
headerCount +
|
||||
this.#pinnedConversations.length +
|
||||
@@ -139,23 +147,76 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
|
||||
return renderLeftPaneChatFolders();
|
||||
}
|
||||
|
||||
override getBackgroundNode({
|
||||
override getEmptyViewNode({
|
||||
i18n,
|
||||
selectedChatFolder,
|
||||
changeLocation,
|
||||
}: Readonly<{
|
||||
i18n: LocalizerType;
|
||||
selectedChatFolder: ChatFolder | null;
|
||||
changeLocation: (location: Location) => void;
|
||||
}>): ReactChild | null {
|
||||
if (this.getRowCount() === 0) {
|
||||
if (selectedChatFolder?.folderType === ChatFolderType.CUSTOM) {
|
||||
return (
|
||||
<EmptyView>
|
||||
<h3 className={tw('type-body-large text-label-primary')}>
|
||||
{i18n('icu:LeftPane__EmptyView--WithSelectedChatFolder')}
|
||||
</h3>
|
||||
<AxoButton.Root
|
||||
variant="floating-secondary"
|
||||
size="medium"
|
||||
onClick={() => {
|
||||
changeLocation({
|
||||
tab: NavTab.Settings,
|
||||
details: {
|
||||
page: SettingsPage.EditChatFolder,
|
||||
chatFolderId: selectedChatFolder.id,
|
||||
previousLocation: {
|
||||
tab: NavTab.Chats,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{i18n(
|
||||
'icu:LeftPane__EmptyView--WithSelectedChatFolder__FolderSettings'
|
||||
)}
|
||||
</AxoButton.Root>
|
||||
</EmptyView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavSidebarEmpty
|
||||
title={i18n('icu:emptyInbox__title')}
|
||||
subtitle={i18n('icu:emptyInbox__subtitle')}
|
||||
/>
|
||||
<EmptyView>
|
||||
<h3 className={tw('type-title-medium text-label-secondary')}>
|
||||
{i18n('icu:emptyInbox__title')}
|
||||
</h3>
|
||||
<p className={tw('type-body-medium text-label-secondary')}>
|
||||
{i18n('icu:emptyInbox__subtitle')}
|
||||
</p>
|
||||
</EmptyView>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#shouldShowArchiveButton(): boolean {
|
||||
if (this.#archivedConversations.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.#selectedChatFolder != null &&
|
||||
this.#selectedChatFolder.folderType !== ChatFolderType.ALL
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getRow(rowIndex: number): undefined | Row {
|
||||
const pinnedConversations = this.#pinnedConversations;
|
||||
const archivedConversations = this.#archivedConversations;
|
||||
@@ -202,7 +263,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
|
||||
}
|
||||
index -= conversations.length;
|
||||
|
||||
if (index === 0 && archivedConversationsCount) {
|
||||
if (index === 0 && this.#shouldShowArchiveButton()) {
|
||||
return {
|
||||
type: RowType.ArchiveButton,
|
||||
archivedConversationsCount,
|
||||
@@ -297,3 +358,13 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
|
||||
return this.#conversations.length !== 0;
|
||||
}
|
||||
}
|
||||
|
||||
function EmptyView(props: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={tw('flex h-full flex-col items-center justify-center gap-3')}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import type { LocalizerType } from '../../../types/I18N.js';
|
||||
import { ConfirmationDialog } from '../../ConfirmationDialog.js';
|
||||
@@ -8,7 +9,7 @@ import { ConfirmationDialog } from '../../ConfirmationDialog.js';
|
||||
export function DeleteChatFolderDialog(props: {
|
||||
i18n: LocalizerType;
|
||||
title: string;
|
||||
description: string;
|
||||
description: ReactNode;
|
||||
cancelText: string;
|
||||
deleteText: string;
|
||||
onConfirm: () => void;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { tw } from '../../../axo/tw.js';
|
||||
import type { LocalizerType } from '../../../types/I18N.js';
|
||||
import { AxoSymbol } from '../../../axo/AxoSymbol.js';
|
||||
import type { ChatFolderPresetId } from './PreferencesChatFoldersPage.js';
|
||||
|
||||
export const itemClassName = classNames(
|
||||
tw('group'),
|
||||
'Preferences__ChatFolders__ChatSelection__Item'
|
||||
);
|
||||
export const itemListItemClassName =
|
||||
'Preferences__ChatFolders__ChatSelection__Item--ListItem';
|
||||
export const itemClickableClassName =
|
||||
'Preferences__ChatFolders__ChatSelection__Item--Clickable';
|
||||
export const itemButtonClassName =
|
||||
'Preferences__ChatFolders__ChatSelection__Item--Button';
|
||||
|
||||
export function ItemContent(props: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemContent">
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemAvatar(props: {
|
||||
kind: 'Folder' | 'Add' | ChatFolderPresetId;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={`Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--${props.kind}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemBody(props: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemBody">
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemTitle(props: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemDescription(props: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemDescription">
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemDragHandle(props: { i18n: LocalizerType }): JSX.Element {
|
||||
const { i18n } = props;
|
||||
return (
|
||||
<span
|
||||
className={tw(
|
||||
'cursor-grab text-label-primary opacity-0 group-hovered:opacity-100 group-focused:opacity-100'
|
||||
)}
|
||||
>
|
||||
<AxoSymbol.Icon
|
||||
symbol="draghandle-alt"
|
||||
size={24}
|
||||
label={i18n(
|
||||
'icu:Preferences__ChatFoldersPage__FoldersSection__DragHandle__Label'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { MutableRefObject, ReactNode } from 'react';
|
||||
import { ListBox, ListBoxItem, useDragAndDrop } from 'react-aria-components';
|
||||
import { partition } from 'lodash';
|
||||
import { isEqual, partition } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import type { LocalizerType } from '../../../types/I18N.js';
|
||||
import { PreferencesContent } from '../../Preferences.js';
|
||||
import { SettingsRow } from '../../PreferencesUtil.js';
|
||||
@@ -19,19 +20,35 @@ import type {
|
||||
ChatFolderPreset,
|
||||
ChatFolder,
|
||||
} from '../../../types/ChatFolder.js';
|
||||
import { Button, ButtonVariant } from '../../Button.js';
|
||||
import { AxoContextMenu } from '../../../axo/AxoContextMenu.js';
|
||||
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
|
||||
import { strictAssert } from '../../../util/assert.js';
|
||||
import { tw } from '../../../axo/tw.js';
|
||||
// import { showToast } from '../../state/ducks/toast';
|
||||
import { UserText } from '../../UserText.js';
|
||||
import { I18n } from '../../I18n.js';
|
||||
import { NavTab, SettingsPage, type Location } from '../../../types/Nav.js';
|
||||
import { AxoButton } from '../../../axo/AxoButton.js';
|
||||
import type { CurrentChatFolder } from '../../../types/CurrentChatFolders.js';
|
||||
import { CurrentChatFolders } from '../../../types/CurrentChatFolders.js';
|
||||
import {
|
||||
ItemAvatar,
|
||||
ItemBody,
|
||||
itemButtonClassName,
|
||||
itemClassName,
|
||||
itemClickableClassName,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemDragHandle,
|
||||
itemListItemClassName,
|
||||
ItemTitle,
|
||||
} from './PreferencesChatFolderItems.js';
|
||||
|
||||
function moveChatFolders(
|
||||
chatFolders: ReadonlyArray<ChatFolder>,
|
||||
chatFolders: ReadonlyArray<CurrentChatFolder>,
|
||||
target: ChatFolderId,
|
||||
moving: Set<ChatFolderId>,
|
||||
position: 'before' | 'after'
|
||||
) {
|
||||
): ReadonlyArray<CurrentChatFolder> {
|
||||
const [toSplice, toInsert] = partition(chatFolders, chatFolder => {
|
||||
return !moving.has(chatFolder.id);
|
||||
});
|
||||
@@ -49,16 +66,29 @@ function moveChatFolders(
|
||||
return toSplice.toSpliced(spliceIndex, 0, ...toInsert);
|
||||
}
|
||||
|
||||
function hasChatFoldersOrderChanged(
|
||||
sortedChatFolders: ReadonlyArray<ChatFolder>,
|
||||
chatFoldersReordered: ReadonlyArray<ChatFolder>
|
||||
): boolean {
|
||||
const a = sortedChatFolders.map(chatFolder => chatFolder.id);
|
||||
const b = chatFoldersReordered.map(chatFolder => chatFolder.id);
|
||||
return !isEqual(a, b);
|
||||
}
|
||||
|
||||
export type PreferencesChatFoldersPageProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
onBack: () => void;
|
||||
onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void;
|
||||
chatFolders: ReadonlyArray<ChatFolder>;
|
||||
onCreateChatFolder: (params: ChatFolderParams) => void;
|
||||
changeLocation: (location: Location) => void;
|
||||
currentChatFolders: CurrentChatFolders;
|
||||
onCreateChatFolder: (
|
||||
params: ChatFolderParams,
|
||||
showToastOnSuccess: boolean
|
||||
) => void;
|
||||
onDeleteChatFolder: (chatFolderId: ChatFolderId) => void;
|
||||
onUpdateChatFoldersPositions: (
|
||||
chatFolderIds: ReadonlyArray<ChatFolderId>
|
||||
) => void;
|
||||
previousLocation: Location | null;
|
||||
settingsPaneRef: MutableRefObject<HTMLDivElement | null>;
|
||||
}>;
|
||||
|
||||
@@ -70,15 +100,13 @@ export function PreferencesChatFoldersPage(
|
||||
onOpenEditChatFoldersPage,
|
||||
onDeleteChatFolder,
|
||||
onUpdateChatFoldersPositions,
|
||||
chatFolders,
|
||||
previousLocation,
|
||||
changeLocation,
|
||||
currentChatFolders,
|
||||
} = props;
|
||||
const [confirmDeleteChatFolder, setConfirmDeleteChatFolder] =
|
||||
useState<ChatFolder | null>(null);
|
||||
|
||||
// showToast(
|
||||
// i18n("icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast")
|
||||
// )
|
||||
|
||||
const handleChatFolderCreate = useCallback(() => {
|
||||
onOpenEditChatFoldersPage(null);
|
||||
}, [onOpenEditChatFoldersPage]);
|
||||
@@ -103,21 +131,46 @@ export function PreferencesChatFoldersPage(
|
||||
onDeleteChatFolder(confirmDeleteChatFolder.id);
|
||||
}, [confirmDeleteChatFolder, onDeleteChatFolder]);
|
||||
|
||||
const [chatFoldersReordered, setChatFoldersReordered] = useState(chatFolders);
|
||||
const handleBack = useCallback(() => {
|
||||
changeLocation(
|
||||
previousLocation ?? {
|
||||
tab: NavTab.Settings,
|
||||
details: {
|
||||
page: SettingsPage.Chats,
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [changeLocation, previousLocation]);
|
||||
|
||||
const sortedChatFolders = useMemo(() => {
|
||||
return CurrentChatFolders.toSortedArray(currentChatFolders);
|
||||
}, [currentChatFolders]);
|
||||
|
||||
const [chatFoldersReordered, setChatFoldersReordered] =
|
||||
useState(sortedChatFolders);
|
||||
|
||||
useEffect(() => {
|
||||
setChatFoldersReordered(chatFolders);
|
||||
}, [chatFolders]);
|
||||
setChatFoldersReordered(sortedChatFolders);
|
||||
}, [sortedChatFolders]);
|
||||
|
||||
const { dragAndDropHooks } = useDragAndDrop({
|
||||
getItems: () => {
|
||||
return chatFolders.map(chatFolder => {
|
||||
return sortedChatFolders.map(chatFolder => {
|
||||
return { 'signal-chat-folder-id': chatFolder.id.slice(-3) };
|
||||
});
|
||||
},
|
||||
getAllowedDropOperations() {
|
||||
return ['move'];
|
||||
},
|
||||
acceptedDragTypes: ['signal-chat-folder-id'],
|
||||
getDropOperation: () => 'move',
|
||||
onDragEnd: () => {
|
||||
if (
|
||||
!hasChatFoldersOrderChanged(sortedChatFolders, chatFoldersReordered)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdateChatFoldersPositions(
|
||||
chatFoldersReordered.map(chatFolder => {
|
||||
return chatFolder.id;
|
||||
@@ -138,7 +191,9 @@ export function PreferencesChatFoldersPage(
|
||||
});
|
||||
},
|
||||
renderDropIndicator: () => {
|
||||
return <div className={tw('h-12')} />;
|
||||
return (
|
||||
<div className={tw('-my-px h-0.5 rounded-full bg-fill-inverted')} />
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -177,13 +232,13 @@ export function PreferencesChatFoldersPage(
|
||||
];
|
||||
|
||||
const filtered = initial.filter(config => {
|
||||
return !chatFolders.some(chatFolder => {
|
||||
return !sortedChatFolders.some(chatFolder => {
|
||||
return matchesChatFolderPreset(chatFolder, config.preset);
|
||||
});
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [i18n, chatFolders]);
|
||||
}, [i18n, sortedChatFolders]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -193,7 +248,7 @@ export function PreferencesChatFoldersPage(
|
||||
type="button"
|
||||
aria-label={i18n('icu:goBack')}
|
||||
className="Preferences__back-icon"
|
||||
onClick={props.onBack}
|
||||
onClick={handleBack}
|
||||
/>
|
||||
}
|
||||
contents={
|
||||
@@ -209,15 +264,17 @@ export function PreferencesChatFoldersPage(
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
|
||||
className={classNames(itemClassName, itemButtonClassName)}
|
||||
onClick={handleChatFolderCreate}
|
||||
>
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
|
||||
{i18n(
|
||||
'icu:Preferences__ChatFoldersPage__FoldersSection__CreateAFolderButton'
|
||||
)}
|
||||
</span>
|
||||
<ItemContent>
|
||||
<ItemAvatar kind="Add" />
|
||||
<ItemTitle>
|
||||
{i18n(
|
||||
'icu:Preferences__ChatFoldersPage__FoldersSection__CreateAFolderButton'
|
||||
)}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
</button>
|
||||
<ListBox
|
||||
selectionMode="single"
|
||||
@@ -271,10 +328,17 @@ export function PreferencesChatFoldersPage(
|
||||
title={i18n(
|
||||
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title'
|
||||
)}
|
||||
description={i18n(
|
||||
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description',
|
||||
{ chatFolderTitle: confirmDeleteChatFolder.name }
|
||||
)}
|
||||
description={
|
||||
<I18n
|
||||
i18n={i18n}
|
||||
id="icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description"
|
||||
components={{
|
||||
chatFolderTitle: (
|
||||
<UserText text={confirmDeleteChatFolder.name} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
deleteText={i18n(
|
||||
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__DeleteButton'
|
||||
)}
|
||||
@@ -289,8 +353,10 @@ export function PreferencesChatFoldersPage(
|
||||
);
|
||||
}
|
||||
|
||||
export type ChatFolderPresetId = 'UnreadChats' | 'DirectChats' | 'GroupChats';
|
||||
|
||||
type ChatFolderPresetItemConfig = Readonly<{
|
||||
id: 'UnreadChats' | 'DirectChats' | 'GroupChats';
|
||||
id: ChatFolderPresetId;
|
||||
title: string;
|
||||
description: string;
|
||||
preset: ChatFolderPreset;
|
||||
@@ -299,7 +365,10 @@ type ChatFolderPresetItemConfig = Readonly<{
|
||||
type ChatFolderPresetItemProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
config: ChatFolderPresetItemConfig;
|
||||
onCreateChatFolder: (params: ChatFolderParams) => void;
|
||||
onCreateChatFolder: (
|
||||
params: ChatFolderParams,
|
||||
showToastOnSuccess: boolean
|
||||
) => void;
|
||||
}>;
|
||||
|
||||
function ChatFolderPresetItem(props: ChatFolderPresetItemProps) {
|
||||
@@ -307,33 +376,30 @@ function ChatFolderPresetItem(props: ChatFolderPresetItemProps) {
|
||||
const { title, preset } = config;
|
||||
|
||||
const handleCreateChatFolder = useCallback(() => {
|
||||
onCreateChatFolder({ ...preset, name: title });
|
||||
onCreateChatFolder({ ...preset, name: title }, true);
|
||||
}, [onCreateChatFolder, title, preset]);
|
||||
|
||||
return (
|
||||
<li
|
||||
data-testid={`ChatFolderPreset--${props.config.id}`}
|
||||
className="Preferences__ChatFolders__ChatSelection__Item"
|
||||
className={itemClassName}
|
||||
>
|
||||
<span
|
||||
className={`Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--${props.config.id}`}
|
||||
/>
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemBody">
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
|
||||
{props.config.title}
|
||||
</span>
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemDescription">
|
||||
{props.config.description}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
variant={ButtonVariant.Secondary}
|
||||
onClick={handleCreateChatFolder}
|
||||
>
|
||||
{i18n(
|
||||
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton'
|
||||
)}
|
||||
</Button>
|
||||
<ItemContent>
|
||||
<ItemAvatar kind={props.config.id} />
|
||||
<ItemBody>
|
||||
<ItemTitle>{props.config.title}</ItemTitle>
|
||||
<ItemDescription>{props.config.description}</ItemDescription>
|
||||
</ItemBody>
|
||||
<AxoButton.Root
|
||||
size="medium"
|
||||
variant="secondary"
|
||||
onClick={handleCreateChatFolder}
|
||||
>
|
||||
{i18n(
|
||||
'icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton'
|
||||
)}
|
||||
</AxoButton.Root>
|
||||
</ItemContent>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -356,12 +422,19 @@ function ChatFolderListItem(props: {
|
||||
<ListBoxItem
|
||||
id={chatFolder.id}
|
||||
data-testid={`ChatFolder--${chatFolder.id}`}
|
||||
className="Preferences__ChatFolders__ChatSelection__Item"
|
||||
className={classNames(itemClassName, itemListItemClassName)}
|
||||
>
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
|
||||
{i18n(
|
||||
'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
|
||||
)}
|
||||
<ItemContent>
|
||||
<ItemAvatar kind="Folder" />
|
||||
<ItemBody>
|
||||
<ItemTitle>
|
||||
{i18n(
|
||||
'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
|
||||
)}
|
||||
</ItemTitle>
|
||||
</ItemBody>
|
||||
<ItemDragHandle i18n={i18n} />
|
||||
</ItemContent>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
|
||||
@@ -371,6 +444,11 @@ function ChatFolderListItem(props: {
|
||||
data-testid={`ChatFolder--${chatFolder.id}`}
|
||||
textValue={props.chatFolder.name}
|
||||
onAction={handleClickChatFolder}
|
||||
className={classNames(
|
||||
itemClassName,
|
||||
itemListItemClassName,
|
||||
itemClickableClassName
|
||||
)}
|
||||
>
|
||||
<ChatFolderListItemContextMenu
|
||||
i18n={i18n}
|
||||
@@ -378,10 +456,15 @@ function ChatFolderListItem(props: {
|
||||
onChatFolderEdit={props.onChatFolderEdit}
|
||||
onChatFolderDelete={props.onChatFolderDelete}
|
||||
>
|
||||
<div className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button">
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
|
||||
{props.chatFolder.name}
|
||||
</div>
|
||||
<ItemContent>
|
||||
<ItemAvatar kind="Folder" />
|
||||
<ItemBody>
|
||||
<ItemTitle>
|
||||
<UserText text={props.chatFolder.name} />
|
||||
</ItemTitle>
|
||||
</ItemBody>
|
||||
<ItemDragHandle i18n={i18n} />
|
||||
</ItemContent>
|
||||
</ChatFolderListItemContextMenu>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { MutableRefObject } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import type { ConversationType } from '../../../state/ducks/conversations.js';
|
||||
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js';
|
||||
import type { LocalizerType } from '../../../types/I18N.js';
|
||||
import type { ThemeType } from '../../../types/Util.js';
|
||||
import { Input } from '../../Input.js';
|
||||
import { Button, ButtonVariant } from '../../Button.js';
|
||||
import { ConfirmationDialog } from '../../ConfirmationDialog.js';
|
||||
import type { ChatFolderSelection } from '../PreferencesSelectChatsDialog.js';
|
||||
import { SettingsControl, SettingsRow } from '../../PreferencesUtil.js';
|
||||
import { PreferencesSelectChatsDialog } from '../PreferencesSelectChatsDialog.js';
|
||||
import { SettingsRow } from '../../PreferencesUtil.js';
|
||||
import { Checkbox } from '../../Checkbox.js';
|
||||
import { Avatar, AvatarSize } from '../../Avatar.js';
|
||||
import { PreferencesContent } from '../../Preferences.js';
|
||||
import {
|
||||
@@ -17,27 +21,36 @@ import {
|
||||
isSameChatFolderParams,
|
||||
validateChatFolderParams,
|
||||
} from '../../../types/ChatFolder.js';
|
||||
import { strictAssert } from '../../../util/assert.js';
|
||||
import { parseStrict } from '../../../util/schemas.js';
|
||||
import { BeforeNavigateResponse } from '../../../services/BeforeNavigate.js';
|
||||
import { useNavBlocker } from '../../../hooks/useNavBlocker.js';
|
||||
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
|
||||
|
||||
import type { ConversationType } from '../../../state/ducks/conversations.js';
|
||||
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js';
|
||||
import type { LocalizerType } from '../../../types/I18N.js';
|
||||
import type { ThemeType } from '../../../types/Util.js';
|
||||
import type { ChatFolderSelection } from '../PreferencesSelectChatsDialog.js';
|
||||
import type {
|
||||
ChatFolderId,
|
||||
ChatFolderParams,
|
||||
} from '../../../types/ChatFolder.js';
|
||||
import type { GetConversationByIdType } from '../../../state/selectors/conversations.js';
|
||||
import { strictAssert } from '../../../util/assert.js';
|
||||
import { parseStrict } from '../../../util/schemas.js';
|
||||
import { BeforeNavigateResponse } from '../../../services/BeforeNavigate.js';
|
||||
import { NavTab, SettingsPage } from '../../../types/Nav.js';
|
||||
import type { Location } from '../../../types/Nav.js';
|
||||
import { useNavBlocker } from '../../../hooks/useNavBlocker.js';
|
||||
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
|
||||
import { UserText } from '../../UserText.js';
|
||||
import { AxoSwitch } from '../../../axo/AxoSwitch.js';
|
||||
import { FunEmojiPickerButton } from '../../fun/FunButton.js';
|
||||
import { FunEmojiPicker } from '../../fun/FunEmojiPicker.js';
|
||||
import type { FunEmojiSelection } from '../../fun/panels/FunPanelEmojis.js';
|
||||
import { getEmojiVariantByKey } from '../../fun/data/emojis.js';
|
||||
import {
|
||||
ItemAvatar,
|
||||
ItemBody,
|
||||
itemButtonClassName,
|
||||
itemClassName,
|
||||
ItemContent,
|
||||
ItemTitle,
|
||||
} from './PreferencesChatFolderItems.js';
|
||||
|
||||
export type PreferencesEditChatFolderPageProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
previousLocation: Location;
|
||||
previousLocation: Location | null;
|
||||
existingChatFolderId: ChatFolderId | null;
|
||||
initChatFolderParams: ChatFolderParams;
|
||||
changeLocation: (location: Location) => void;
|
||||
@@ -47,7 +60,10 @@ export type PreferencesEditChatFolderPageProps = Readonly<{
|
||||
settingsPaneRef: MutableRefObject<HTMLDivElement | null>;
|
||||
conversationSelector: GetConversationByIdType;
|
||||
onDeleteChatFolder: (chatFolderId: ChatFolderId) => void;
|
||||
onCreateChatFolder: (chatFolderParams: ChatFolderParams) => void;
|
||||
onCreateChatFolder: (
|
||||
chatFolderParams: ChatFolderParams,
|
||||
showToastOnSuccess: boolean
|
||||
) => void;
|
||||
onUpdateChatFolder: (
|
||||
chatFolderId: ChatFolderId,
|
||||
chatFolderParams: ChatFolderParams
|
||||
@@ -69,9 +85,12 @@ export function PreferencesEditChatFolderPage(
|
||||
conversationSelector,
|
||||
} = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [chatFolderParams, setChatFolderParams] =
|
||||
useState(initChatFolderParams);
|
||||
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
||||
const [showInclusionsDialog, setShowInclusionsDialog] = useState(false);
|
||||
const [showExclusionsDialog, setShowExclusionsDialog] = useState(false);
|
||||
const [showDeleteFolderDialog, setShowDeleteFolderDialog] = useState(false);
|
||||
@@ -103,6 +122,28 @@ export function PreferencesEditChatFolderPage(
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectEmoji = useCallback((emojiSelection: FunEmojiSelection) => {
|
||||
setChatFolderParams(prevParams => {
|
||||
strictAssert(inputRef.current, 'Missing input ref');
|
||||
const input = inputRef.current;
|
||||
const { selectionStart, selectionEnd } = input;
|
||||
|
||||
const variant = getEmojiVariantByKey(emojiSelection.variantKey);
|
||||
const emoji = variant.value;
|
||||
|
||||
let newName: string;
|
||||
if (selectionStart == null || selectionEnd == null) {
|
||||
newName = `${prevParams.name}${emoji}`;
|
||||
} else {
|
||||
const before = prevParams.name.slice(0, selectionStart);
|
||||
const after = prevParams.name.slice(selectionEnd);
|
||||
newName = `${before}${emoji}${after}`;
|
||||
}
|
||||
|
||||
return { ...prevParams, name: newName };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleShowOnlyUnreadChange = useCallback((newValue: boolean) => {
|
||||
setChatFolderParams(prevParams => {
|
||||
return { ...prevParams, showOnlyUnread: newValue };
|
||||
@@ -116,7 +157,15 @@ export function PreferencesEditChatFolderPage(
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
changeLocation(previousLocation);
|
||||
changeLocation(
|
||||
previousLocation ?? {
|
||||
tab: NavTab.Settings,
|
||||
details: {
|
||||
page: SettingsPage.ChatFolders,
|
||||
previousLocation: null,
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [changeLocation, previousLocation]);
|
||||
|
||||
const handleDiscardAndBack = useCallback(() => {
|
||||
@@ -131,7 +180,7 @@ export function PreferencesEditChatFolderPage(
|
||||
if (existingChatFolderId != null) {
|
||||
onUpdateChatFolder(existingChatFolderId, normalizedChatFolderParams);
|
||||
} else {
|
||||
onCreateChatFolder(normalizedChatFolderParams);
|
||||
onCreateChatFolder(normalizedChatFolderParams, false);
|
||||
}
|
||||
|
||||
didSaveOrDiscardChangesRef.current = true;
|
||||
@@ -233,6 +282,7 @@ export function PreferencesEditChatFolderPage(
|
||||
data-testid="EditChatFolderName"
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
i18n={i18n}
|
||||
value={chatFolderParams.name}
|
||||
onChange={handleNameChange}
|
||||
@@ -241,7 +291,16 @@ export function PreferencesEditChatFolderPage(
|
||||
)}
|
||||
maxLengthCount={CHAT_FOLDER_NAME_MAX_CHAR_LENGTH}
|
||||
whenToShowRemainingCount={CHAT_FOLDER_NAME_MAX_CHAR_LENGTH - 10}
|
||||
/>
|
||||
>
|
||||
<FunEmojiPicker
|
||||
open={emojiPickerOpen}
|
||||
onOpenChange={setEmojiPickerOpen}
|
||||
onSelectEmoji={handleSelectEmoji}
|
||||
closeOnSelect
|
||||
>
|
||||
<FunEmojiPickerButton i18n={i18n} />
|
||||
</FunEmojiPicker>
|
||||
</Input>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
@@ -251,54 +310,67 @@ export function PreferencesEditChatFolderPage(
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
|
||||
className={classNames(itemClassName, itemButtonClassName)}
|
||||
onClick={handleSelectInclusions}
|
||||
>
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
|
||||
{i18n(
|
||||
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__AddChatsButton'
|
||||
)}
|
||||
</span>
|
||||
<ItemContent>
|
||||
<ItemAvatar kind="Add" />
|
||||
<ItemBody>
|
||||
<ItemTitle>
|
||||
{i18n(
|
||||
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__AddChatsButton'
|
||||
)}
|
||||
</ItemTitle>
|
||||
</ItemBody>
|
||||
</ItemContent>
|
||||
</button>
|
||||
<ul className="Preferences__ChatFolders__ChatSelection__List">
|
||||
{chatFolderParams.includeAllIndividualChats && (
|
||||
<li className="Preferences__ChatFolders__ChatSelection__Item">
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--DirectChats" />
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
|
||||
{i18n(
|
||||
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__DirectChats'
|
||||
)}
|
||||
</span>
|
||||
<li className={itemClassName}>
|
||||
<ItemContent>
|
||||
<ItemAvatar kind="DirectChats" />
|
||||
<ItemBody>
|
||||
<ItemTitle>
|
||||
{i18n(
|
||||
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__DirectChats'
|
||||
)}
|
||||
</ItemTitle>
|
||||
</ItemBody>
|
||||
</ItemContent>
|
||||
</li>
|
||||
)}
|
||||
{chatFolderParams.includeAllGroupChats && (
|
||||
<li className="Preferences__ChatFolders__ChatSelection__Item">
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--GroupChats" />
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
|
||||
{i18n(
|
||||
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__GroupChats'
|
||||
)}
|
||||
</span>
|
||||
<li className={itemClassName}>
|
||||
<ItemContent>
|
||||
<ItemAvatar kind="GroupChats" />
|
||||
<ItemBody>
|
||||
<ItemTitle>
|
||||
{i18n(
|
||||
'icu:Preferences__EditChatFolderPage__IncludedChatsSection__GroupChats'
|
||||
)}
|
||||
</ItemTitle>
|
||||
</ItemBody>
|
||||
</ItemContent>
|
||||
</li>
|
||||
)}
|
||||
{chatFolderParams.includedConversationIds.map(conversationId => {
|
||||
const conversation = conversationSelector(conversationId);
|
||||
return (
|
||||
<li
|
||||
key={conversationId}
|
||||
className="Preferences__ChatFolders__ChatSelection__Item"
|
||||
>
|
||||
<Avatar
|
||||
i18n={i18n}
|
||||
conversationType={conversation.type}
|
||||
size={AvatarSize.THIRTY_SIX}
|
||||
badge={undefined}
|
||||
{...conversation}
|
||||
/>
|
||||
<span className="Preferences__ChatFolders__ChatList__ItemTitle">
|
||||
{conversation.title}
|
||||
</span>
|
||||
<li key={conversationId} className={itemClassName}>
|
||||
<ItemContent>
|
||||
<Avatar
|
||||
i18n={i18n}
|
||||
conversationType={conversation.type}
|
||||
size={AvatarSize.THIRTY_SIX}
|
||||
badge={undefined}
|
||||
{...conversation}
|
||||
/>
|
||||
<ItemBody>
|
||||
<ItemTitle>
|
||||
<UserText text={conversation.title} />
|
||||
</ItemTitle>
|
||||
</ItemBody>
|
||||
</ItemContent>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -318,34 +390,39 @@ export function PreferencesEditChatFolderPage(
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
|
||||
className={classNames(itemClassName, itemButtonClassName)}
|
||||
onClick={handleSelectExclusions}
|
||||
>
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
|
||||
<span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
|
||||
{i18n(
|
||||
'icu:Preferences__EditChatFolderPage__ExceptionsSection__ExcludeChatsButton'
|
||||
)}
|
||||
</span>
|
||||
<ItemContent>
|
||||
<ItemAvatar kind="Add" />
|
||||
<ItemBody>
|
||||
<ItemTitle>
|
||||
{i18n(
|
||||
'icu:Preferences__EditChatFolderPage__ExceptionsSection__ExcludeChatsButton'
|
||||
)}
|
||||
</ItemTitle>
|
||||
</ItemBody>
|
||||
</ItemContent>
|
||||
</button>
|
||||
<ul className="Preferences__ChatFolders__ChatSelection__List">
|
||||
{chatFolderParams.excludedConversationIds.map(conversationId => {
|
||||
const conversation = conversationSelector(conversationId);
|
||||
return (
|
||||
<li
|
||||
key={conversationId}
|
||||
className="Preferences__ChatFolders__ChatSelection__Item"
|
||||
>
|
||||
<Avatar
|
||||
i18n={i18n}
|
||||
conversationType={conversation.type}
|
||||
size={AvatarSize.THIRTY_SIX}
|
||||
badge={undefined}
|
||||
{...conversation}
|
||||
/>
|
||||
<span className="Preferences__ChatFolders__ChatList__ItemTitle">
|
||||
{conversation.title}
|
||||
</span>
|
||||
<li key={conversationId} className={itemClassName}>
|
||||
<ItemContent>
|
||||
<Avatar
|
||||
i18n={i18n}
|
||||
conversationType={conversation.type}
|
||||
size={AvatarSize.THIRTY_SIX}
|
||||
badge={undefined}
|
||||
{...conversation}
|
||||
/>
|
||||
<ItemBody>
|
||||
<ItemTitle>
|
||||
<UserText text={conversation.title} />
|
||||
</ItemTitle>
|
||||
</ItemBody>
|
||||
</ItemContent>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -359,26 +436,30 @@ export function PreferencesEditChatFolderPage(
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<Checkbox
|
||||
checked={chatFolderParams.showOnlyUnread}
|
||||
label={i18n(
|
||||
<SettingsControl
|
||||
left={i18n(
|
||||
'icu:Preferences__EditChatFolderPage__OnlyShowUnreadChatsCheckbox__Label'
|
||||
)}
|
||||
description={i18n(
|
||||
'icu:Preferences__EditChatFolderPage__OnlyShowUnreadChatsCheckbox__Description'
|
||||
)}
|
||||
moduleClassName="Preferences__checkbox"
|
||||
name="showOnlyUnread"
|
||||
onChange={handleShowOnlyUnreadChange}
|
||||
right={
|
||||
<AxoSwitch.Root
|
||||
checked={chatFolderParams.showOnlyUnread}
|
||||
onCheckedChange={handleShowOnlyUnreadChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={chatFolderParams.showMutedChats}
|
||||
label={i18n(
|
||||
<SettingsControl
|
||||
left={i18n(
|
||||
'icu:Preferences__EditChatFolderPage__IncludeMutedChatsCheckbox__Label'
|
||||
)}
|
||||
moduleClassName="Preferences__checkbox"
|
||||
name="showMutedChats"
|
||||
onChange={handleShowMutedChatsChange}
|
||||
right={
|
||||
<AxoSwitch.Root
|
||||
checked={chatFolderParams.showMutedChats}
|
||||
onCheckedChange={handleShowMutedChatsChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
{props.existingChatFolderId != null && (
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { DataReader } from '../sql/Client.js';
|
||||
import type { ChatFolder } from '../types/ChatFolder.js';
|
||||
import type { CurrentChatFolder } from '../types/CurrentChatFolders.js';
|
||||
import { strictAssert } from '../util/assert.js';
|
||||
|
||||
let chatFolders: ReadonlyArray<ChatFolder>;
|
||||
let chatFolders: ReadonlyArray<CurrentChatFolder>;
|
||||
|
||||
export async function loadChatFolders(): Promise<void> {
|
||||
chatFolders = await DataReader.getCurrentChatFolders();
|
||||
}
|
||||
|
||||
export function getChatFoldersForRedux(): ReadonlyArray<ChatFolder> {
|
||||
export function getChatFoldersForRedux(): ReadonlyArray<CurrentChatFolder> {
|
||||
strictAssert(chatFolders != null, 'chatFolders has not been loaded');
|
||||
return chatFolders;
|
||||
}
|
||||
|
||||
@@ -96,8 +96,8 @@ import { isDone as isRegistrationDone } from '../util/registration.js';
|
||||
import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue.js';
|
||||
import { isMockEnvironment } from '../environment.js';
|
||||
import { validateConversation } from '../util/validateConversation.js';
|
||||
import { hasAllChatsChatFolder } from '../types/ChatFolder.js';
|
||||
import type { ChatFolder } from '../types/ChatFolder.js';
|
||||
import { isCurrentAllChatFolder } from '../types/CurrentChatFolders.js';
|
||||
import type { NotificationProfileType } from '../types/NotificationProfile.js';
|
||||
import { itemStorage } from '../textsecure/Storage.js';
|
||||
|
||||
@@ -1755,9 +1755,15 @@ async function processManifest(
|
||||
storageID: null,
|
||||
storageVersion: null,
|
||||
});
|
||||
|
||||
window.reduxActions.chatFolders.refetchChatFolders();
|
||||
});
|
||||
|
||||
if (!hasAllChatsChatFolder(chatFolders)) {
|
||||
const hasCurrentAllChatFolder = chatFolders.some(chatFolder => {
|
||||
return isCurrentAllChatFolder(chatFolder);
|
||||
});
|
||||
|
||||
if (!hasCurrentAllChatFolder) {
|
||||
log.info(`process(${version}): creating all chats chat folder`);
|
||||
window.reduxActions.chatFolders.createAllChatsChatFolder();
|
||||
}
|
||||
|
||||
+2
-1
@@ -60,6 +60,7 @@ import type { NotificationProfileType } from '../types/NotificationProfile.js';
|
||||
import type { DonationReceipt } from '../types/Donations.js';
|
||||
import type { InsertOrUpdateCallLinkFromSyncResult } from './server/callLinks.js';
|
||||
import type { ChatFolderId, ChatFolder } from '../types/ChatFolder.js';
|
||||
import type { CurrentChatFolder } from '../types/CurrentChatFolders.js';
|
||||
|
||||
export type ReadableDB = Database & { __readable_db: never };
|
||||
export type WritableDB = ReadableDB & { __writable_db: never };
|
||||
@@ -927,7 +928,7 @@ type ReadableInterface = {
|
||||
getDonationReceiptById(id: string): DonationReceipt | undefined;
|
||||
|
||||
getAllChatFolders: () => ReadonlyArray<ChatFolder>;
|
||||
getCurrentChatFolders: () => ReadonlyArray<ChatFolder>;
|
||||
getCurrentChatFolders: () => ReadonlyArray<CurrentChatFolder>;
|
||||
getChatFolder: (id: ChatFolderId) => ChatFolder | null;
|
||||
hasAllChatsChatFolder: () => boolean;
|
||||
getOldestDeletedChatFolder: () => ChatFolder | null;
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import type { ReadableDB, WritableDB } from '../Interface.js';
|
||||
import { sql } from '../util.js';
|
||||
import { strictAssert } from '../../util/assert.js';
|
||||
import type { CurrentChatFolder } from '../../types/CurrentChatFolders.js';
|
||||
import { isCurrentChatFolder } from '../../types/CurrentChatFolders.js';
|
||||
|
||||
export type ChatFolderRow = Readonly<
|
||||
Omit<
|
||||
@@ -71,17 +73,25 @@ export function getAllChatFolders(db: ReadableDB): ReadonlyArray<ChatFolder> {
|
||||
|
||||
export function getCurrentChatFolders(
|
||||
db: ReadableDB
|
||||
): ReadonlyArray<ChatFolder> {
|
||||
): ReadonlyArray<CurrentChatFolder> {
|
||||
const [query, params] = sql`
|
||||
SELECT *
|
||||
FROM chatFolders
|
||||
WHERE deletedAtTimestampMs IS 0
|
||||
WHERE folderType IS NOT ${ChatFolderType.UNKNOWN}
|
||||
AND deletedAtTimestampMs IS 0
|
||||
ORDER BY position ASC
|
||||
`;
|
||||
return db
|
||||
.prepare(query)
|
||||
.all<ChatFolderRow>(params)
|
||||
.map(row => rowToChatFolder(row));
|
||||
.map(row => {
|
||||
const chatFolder = rowToChatFolder(row);
|
||||
strictAssert(
|
||||
isCurrentChatFolder(chatFolder),
|
||||
`Query returned row that is not a current chat folder (${chatFolder.id})`
|
||||
);
|
||||
return chatFolder;
|
||||
});
|
||||
}
|
||||
|
||||
export function getChatFolder(
|
||||
|
||||
@@ -9,12 +9,9 @@ import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.j
|
||||
import { useBoundActions } from '../../hooks/useBoundActions.js';
|
||||
import {
|
||||
ChatFolderParamsSchema,
|
||||
lookupCurrentChatFolder,
|
||||
toCurrentChatFolders,
|
||||
type ChatFolder,
|
||||
type ChatFolderId,
|
||||
type ChatFolderParams,
|
||||
type CurrentChatFolders,
|
||||
} from '../../types/ChatFolder.js';
|
||||
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
|
||||
import { DataReader, DataWriter } from '../../sql/Client.js';
|
||||
@@ -26,6 +23,11 @@ import {
|
||||
TARGETED_CONVERSATION_CHANGED,
|
||||
type TargetedConversationChangedActionType,
|
||||
} from './conversations.js';
|
||||
import type { ShowToastActionType } from './toast.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { ToastType } from '../../types/Toast.js';
|
||||
import type { CurrentChatFolder } from '../../types/CurrentChatFolders.js';
|
||||
import { CurrentChatFolders } from '../../types/CurrentChatFolders.js';
|
||||
|
||||
export type ChatFoldersState = ReadonlyDeep<{
|
||||
currentChatFolders: CurrentChatFolders;
|
||||
@@ -54,15 +56,20 @@ export type ChatFolderAction = ReadonlyDeep<
|
||||
|
||||
export function getEmptyState(): ChatFoldersState {
|
||||
return {
|
||||
currentChatFolders: {
|
||||
order: [],
|
||||
lookup: {},
|
||||
},
|
||||
currentChatFolders: CurrentChatFolders.createEmpty(),
|
||||
selectedChatFolderId: null,
|
||||
stableSelectedConversationIdInChatFolder: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getInitialChatFoldersState(
|
||||
chatFolders: ReadonlyArray<CurrentChatFolder>
|
||||
): ChatFoldersState {
|
||||
return toNextChatFoldersState(getEmptyState(), {
|
||||
currentChatFolders: CurrentChatFolders.fromArray(chatFolders),
|
||||
});
|
||||
}
|
||||
|
||||
function replaceAllChatFolderRecords(
|
||||
currentChatFolders: CurrentChatFolders
|
||||
): ChatFolderRecordReplaceAll {
|
||||
@@ -80,7 +87,7 @@ function _refetchChatFolders(): ThunkAction<
|
||||
> {
|
||||
return async dispatch => {
|
||||
const chatFolders = await DataReader.getCurrentChatFolders();
|
||||
const currentChatFolders = toCurrentChatFolders(chatFolders);
|
||||
const currentChatFolders = CurrentChatFolders.fromArray(chatFolders);
|
||||
dispatch(replaceAllChatFolderRecords(currentChatFolders));
|
||||
};
|
||||
}
|
||||
@@ -90,15 +97,17 @@ function _refetchChatFolders(): ThunkAction<
|
||||
const refetchChatFolders = throttle(_refetchChatFolders, 100);
|
||||
|
||||
function createChatFolder(
|
||||
chatFolderParams: ChatFolderParams
|
||||
): ThunkAction<void, RootStateType, unknown, never> {
|
||||
chatFolderParams: ChatFolderParams,
|
||||
showToastOnSuccess: boolean
|
||||
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
|
||||
return async (dispatch, getState) => {
|
||||
const chatFolders = getCurrentChatFolders(getState());
|
||||
const currentChatFolders = getCurrentChatFolders(getState());
|
||||
const size = CurrentChatFolders.size(currentChatFolders);
|
||||
|
||||
const chatFolder: ChatFolder = {
|
||||
...chatFolderParams,
|
||||
id: generateUuid() as ChatFolderId,
|
||||
position: chatFolders.order.length,
|
||||
position: size,
|
||||
deletedAtTimestampMs: 0,
|
||||
storageID: null,
|
||||
storageVersion: null,
|
||||
@@ -109,6 +118,15 @@ function createChatFolder(
|
||||
await DataWriter.createChatFolder(chatFolder);
|
||||
storageServiceUploadJob({ reason: 'createChatFolder' });
|
||||
dispatch(_refetchChatFolders());
|
||||
|
||||
if (showToastOnSuccess) {
|
||||
dispatch(
|
||||
showToast({
|
||||
toastType: ToastType.ChatFolderCreated,
|
||||
parameters: { chatFolderName: chatFolder.name },
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,9 +150,10 @@ function updateChatFolder(
|
||||
return async (dispatch, getState) => {
|
||||
const currentChatFolders = getCurrentChatFolders(getState());
|
||||
|
||||
const prevChatFolder = lookupCurrentChatFolder(
|
||||
const prevChatFolder = CurrentChatFolders.expect(
|
||||
currentChatFolders,
|
||||
chatFolderId
|
||||
chatFolderId,
|
||||
'updateChatFolder'
|
||||
);
|
||||
|
||||
const nextChatFolder: ChatFolder = {
|
||||
@@ -166,9 +185,10 @@ function updateChatFoldersPositions(
|
||||
return async (dispatch, getState) => {
|
||||
const currentChatFolders = getCurrentChatFolders(getState());
|
||||
const chatFolders = chatFolderIds.map((chatFolderId, index) => {
|
||||
const chatFolder = lookupCurrentChatFolder(
|
||||
const chatFolder = CurrentChatFolders.expect(
|
||||
currentChatFolders,
|
||||
chatFolderId
|
||||
chatFolderId,
|
||||
'updateChatFoldersPositions'
|
||||
);
|
||||
return { ...chatFolder, position: index + 1 };
|
||||
});
|
||||
@@ -201,28 +221,66 @@ export const useChatFolderActions = (): BoundActionCreatorsMapObject<
|
||||
typeof actions
|
||||
> => useBoundActions(actions);
|
||||
|
||||
function getDefaultSelectedChatFolderId(
|
||||
currentChatFolders: CurrentChatFolders
|
||||
): ChatFolderId | null {
|
||||
// Default to the first chat folder in the list rather than "All chats"
|
||||
return CurrentChatFolders.at(currentChatFolders, 0)?.id ?? null;
|
||||
}
|
||||
|
||||
function toNextChatFoldersState(
|
||||
prevState: ChatFoldersState,
|
||||
changes: Partial<ChatFoldersState>
|
||||
): ChatFoldersState {
|
||||
const nextState = { ...prevState, ...changes };
|
||||
|
||||
// Ensure that the `selectedChatFolderId` is not referencing a chat folder
|
||||
// that is no longer current
|
||||
if (nextState.selectedChatFolderId != null) {
|
||||
const isSelectedChatFolderIdCurrent = CurrentChatFolders.has(
|
||||
nextState.currentChatFolders,
|
||||
nextState.selectedChatFolderId
|
||||
);
|
||||
|
||||
if (!isSelectedChatFolderIdCurrent) {
|
||||
nextState.selectedChatFolderId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure `selectedChatFolderId` is set if `currentChatFolders` isnt empty
|
||||
// Components should still handle if `selectedChatFolderId` is `null` though
|
||||
// But some of them could assume they should render "All chats"
|
||||
nextState.selectedChatFolderId ??= getDefaultSelectedChatFolderId(
|
||||
nextState.currentChatFolders
|
||||
);
|
||||
|
||||
// Ensure `stableSelectedConversationIdInChatFolder`
|
||||
// is reset if `selectedChatFolderId` changes
|
||||
if (nextState.selectedChatFolderId !== prevState.selectedChatFolderId) {
|
||||
nextState.stableSelectedConversationIdInChatFolder = null;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: ChatFoldersState = getEmptyState(),
|
||||
action: ChatFolderAction | TargetedConversationChangedActionType
|
||||
): ChatFoldersState {
|
||||
switch (action.type) {
|
||||
case CHAT_FOLDER_RECORD_REPLACE_ALL:
|
||||
return {
|
||||
...state,
|
||||
return toNextChatFoldersState(state, {
|
||||
currentChatFolders: action.payload,
|
||||
};
|
||||
});
|
||||
case CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID:
|
||||
return {
|
||||
...state,
|
||||
return toNextChatFoldersState(state, {
|
||||
selectedChatFolderId: action.payload,
|
||||
stableSelectedConversationIdInChatFolder: null,
|
||||
};
|
||||
});
|
||||
case TARGETED_CONVERSATION_CHANGED:
|
||||
return {
|
||||
...state,
|
||||
return toNextChatFoldersState(state, {
|
||||
stableSelectedConversationIdInChatFolder:
|
||||
action.payload.conversationId ?? null,
|
||||
};
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -225,12 +225,10 @@ import type { ConversationModel } from '../../models/conversations.js';
|
||||
import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent.js';
|
||||
import { JobCancelReason } from '../../jobs/types.js';
|
||||
import type { ChatFolderId } from '../../types/ChatFolder.js';
|
||||
import {
|
||||
isConversationInChatFolder,
|
||||
lookupCurrentChatFolder,
|
||||
} from '../../types/ChatFolder.js';
|
||||
import { isConversationInChatFolder } from '../../types/ChatFolder.js';
|
||||
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
|
||||
import { isConversationUnread } from '../../util/isConversationUnread.js';
|
||||
import { CurrentChatFolders } from '../../types/CurrentChatFolders.js';
|
||||
import { itemStorage } from '../../textsecure/Storage.js';
|
||||
|
||||
const {
|
||||
@@ -1473,7 +1471,11 @@ function _getAllConversationsInChatFolder(
|
||||
chatFolderId: ChatFolderId
|
||||
) {
|
||||
const currentChatFolders = getCurrentChatFolders(state);
|
||||
const chatFolder = lookupCurrentChatFolder(currentChatFolders, chatFolderId);
|
||||
const chatFolder = CurrentChatFolders.expect(
|
||||
currentChatFolders,
|
||||
chatFolderId,
|
||||
'_getAllConversationsInChatFolder'
|
||||
);
|
||||
const allConversations = getAllConversations(state);
|
||||
return allConversations.filter(conversation => {
|
||||
return isConversationInChatFolder(chatFolder, conversation);
|
||||
|
||||
@@ -8,7 +8,10 @@ import { getEmptyState as audioRecorderEmptyState } from './ducks/audioRecorder.
|
||||
import { getEmptyState as badgesEmptyState } from './ducks/badges.js';
|
||||
import { getEmptyState as callHistoryEmptyState } from './ducks/callHistory.js';
|
||||
import { getEmptyState as callingEmptyState } from './ducks/calling.js';
|
||||
import { getEmptyState as chatFoldersEmptyState } from './ducks/chatFolders.js';
|
||||
import {
|
||||
getEmptyState as chatFoldersEmptyState,
|
||||
getInitialChatFoldersState,
|
||||
} from './ducks/chatFolders.js';
|
||||
import { getEmptyState as composerEmptyState } from './ducks/composer.js';
|
||||
import { getEmptyState as conversationsEmptyState } from './ducks/conversations.js';
|
||||
import { getEmptyState as crashReportsEmptyState } from './ducks/crashReports.js';
|
||||
@@ -45,7 +48,6 @@ import {
|
||||
STICKERS_PATH,
|
||||
TEMP_PATH,
|
||||
} from '../util/basePaths.js';
|
||||
import { toCurrentChatFolders } from '../types/ChatFolder.js';
|
||||
|
||||
import type { StateType } from './reducer.js';
|
||||
import type { MainWindowStatsType } from '../windows/context.js';
|
||||
@@ -96,10 +98,7 @@ export function getInitialState(
|
||||
...callingEmptyState(),
|
||||
callLinks: makeLookup(callLinks, 'roomId'),
|
||||
},
|
||||
chatFolders: {
|
||||
...chatFoldersEmptyState(),
|
||||
currentChatFolders: toCurrentChatFolders(chatFolders),
|
||||
},
|
||||
chatFolders: getInitialChatFoldersState(chatFolders),
|
||||
donations,
|
||||
emojis: recentEmoji,
|
||||
gifs,
|
||||
|
||||
@@ -19,14 +19,14 @@ import type { RecentEmojiObjectType } from '../util/loadRecentEmojis.js';
|
||||
import type { StickersStateType } from './ducks/stickers.js';
|
||||
import type { GifsStateType } from './ducks/gifs.js';
|
||||
import type { NotificationProfileType } from '../types/NotificationProfile.js';
|
||||
import type { ChatFolder } from '../types/ChatFolder.js';
|
||||
import type { CurrentChatFolder } from '../types/CurrentChatFolders.js';
|
||||
|
||||
export type ReduxInitData = {
|
||||
badgesState: BadgesStateType;
|
||||
callHistory: ReadonlyArray<CallHistoryDetails>;
|
||||
callHistoryUnreadCount: number;
|
||||
callLinks: ReadonlyArray<CallLinkType>;
|
||||
chatFolders: ReadonlyArray<ChatFolder>;
|
||||
chatFolders: ReadonlyArray<CurrentChatFolder>;
|
||||
donations: DonationsStateType;
|
||||
gifs: GifsStateType;
|
||||
mainWindowStats: MainWindowStatsType;
|
||||
|
||||
@@ -5,11 +5,8 @@ import { createSelector } from 'reselect';
|
||||
import type { StateType } from '../reducer.js';
|
||||
import type { StateSelector } from '../types.js';
|
||||
import type { ChatFoldersState } from '../ducks/chatFolders.js';
|
||||
import type { CurrentChatFolders, ChatFolder } from '../../types/ChatFolder.js';
|
||||
import {
|
||||
getSortedCurrentChatFolders,
|
||||
lookupCurrentChatFolder,
|
||||
} from '../../types/ChatFolder.js';
|
||||
import type { CurrentChatFolder } from '../../types/CurrentChatFolders.js';
|
||||
import { CurrentChatFolders } from '../../types/CurrentChatFolders.js';
|
||||
|
||||
export function getChatFoldersState(state: StateType): ChatFoldersState {
|
||||
return state.chatFolders;
|
||||
@@ -20,22 +17,32 @@ export const getCurrentChatFolders: StateSelector<CurrentChatFolders> =
|
||||
return state.currentChatFolders;
|
||||
});
|
||||
|
||||
export const getSortedChatFolders: StateSelector<ReadonlyArray<ChatFolder>> =
|
||||
export const getCurrentChatFoldersCount: StateSelector<number> = createSelector(
|
||||
getCurrentChatFolders,
|
||||
currentChatFolders => {
|
||||
return CurrentChatFolders.size(currentChatFolders);
|
||||
}
|
||||
);
|
||||
|
||||
export const getHasAnyCurrentCustomChatFolders: StateSelector<boolean> =
|
||||
createSelector(getCurrentChatFolders, currentChatFolders => {
|
||||
return getSortedCurrentChatFolders(currentChatFolders);
|
||||
return currentChatFolders.hasAnyCurrentCustomChatFolders;
|
||||
});
|
||||
|
||||
export const getSelectedChatFolder: StateSelector<ChatFolder | null> =
|
||||
export const getSelectedChatFolder: StateSelector<CurrentChatFolder | null> =
|
||||
createSelector(
|
||||
getChatFoldersState,
|
||||
getCurrentChatFolders,
|
||||
(state, currentChatFolders) => {
|
||||
const selectedChatFolderId =
|
||||
state.selectedChatFolderId ?? currentChatFolders.order.at(0);
|
||||
const { selectedChatFolderId } = state;
|
||||
if (selectedChatFolderId == null) {
|
||||
return null;
|
||||
}
|
||||
return lookupCurrentChatFolder(currentChatFolders, selectedChatFolderId);
|
||||
return CurrentChatFolders.expect(
|
||||
currentChatFolders,
|
||||
selectedChatFolderId,
|
||||
'getSelectedChatFolder'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ import {
|
||||
} from '../../types/ChatFolder.js';
|
||||
import {
|
||||
getSelectedChatFolder,
|
||||
getSortedChatFolders,
|
||||
getCurrentChatFolders,
|
||||
getStableSelectedConversationIdInChatFolder,
|
||||
} from './chatFolders.js';
|
||||
import {
|
||||
@@ -692,11 +692,11 @@ export const getAllConversationsUnreadStats = createSelector(
|
||||
|
||||
export const getAllChatFoldersUnreadStats: StateSelector<AllChatFoldersUnreadStats> =
|
||||
createSelector(
|
||||
getSortedChatFolders,
|
||||
getCurrentChatFolders,
|
||||
getAllConversations,
|
||||
(sortedChatFolders, allConversations) => {
|
||||
(currentChatFolders, allConversations) => {
|
||||
return countAllChatFoldersUnreadStats(
|
||||
sortedChatFolders,
|
||||
currentChatFolders,
|
||||
allConversations,
|
||||
{
|
||||
includeMuted: false,
|
||||
@@ -707,10 +707,13 @@ export const getAllChatFoldersUnreadStats: StateSelector<AllChatFoldersUnreadSta
|
||||
|
||||
export const getAllChatFoldersMutedStats: StateSelector<AllChatFoldersMutedStats> =
|
||||
createSelector(
|
||||
getSortedChatFolders,
|
||||
getCurrentChatFolders,
|
||||
getAllConversations,
|
||||
(sortedChatFolders, allConversations) => {
|
||||
return countAllChatFoldersMutedStats(sortedChatFolders, allConversations);
|
||||
(currentChatFolders, allConversations) => {
|
||||
return countAllChatFoldersMutedStats(
|
||||
currentChatFolders,
|
||||
allConversations
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2019 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { PropsType as DialogExpiredBuildPropsType } from '../../components/DialogExpiredBuild.js';
|
||||
import { DialogExpiredBuild } from '../../components/DialogExpiredBuild.js';
|
||||
@@ -116,8 +116,12 @@ import { useNavActions } from '../ducks/nav.js';
|
||||
import { SmartLeftPaneChatFolders } from './LeftPaneChatFolders.js';
|
||||
import { SmartLeftPaneConversationListItemContextMenu } from './LeftPaneConversationListItemContextMenu.js';
|
||||
import type { RenderConversationListItemContextMenuProps } from '../../components/conversationList/BaseConversationListItem.js';
|
||||
import {
|
||||
getHasAnyCurrentCustomChatFolders,
|
||||
getSelectedChatFolder,
|
||||
} from '../selectors/chatFolders.js';
|
||||
import { NavTab, SettingsPage } from '../../types/Nav.js';
|
||||
import { SmartNotificationProfilesMenu } from './NotificationProfilesMenu.js';
|
||||
import type { ExternalProps as NotificationProfilesMenuProps } from './NotificationProfilesMenu.js';
|
||||
import { getActiveProfile } from '../selectors/notificationProfiles.js';
|
||||
|
||||
function renderMessageSearchResult(id: string): JSX.Element {
|
||||
@@ -174,10 +178,8 @@ function renderToastManagerWithoutMegaphone(props: {
|
||||
return <SmartToastManager disableMegaphone {...props} />;
|
||||
}
|
||||
|
||||
function renderNotificationProfilesMenu(
|
||||
props: NotificationProfilesMenuProps
|
||||
): JSX.Element {
|
||||
return <SmartNotificationProfilesMenu {...props} />;
|
||||
function renderNotificationProfilesMenu(): JSX.Element {
|
||||
return <SmartNotificationProfilesMenu />;
|
||||
}
|
||||
|
||||
const getModeSpecificProps = (
|
||||
@@ -220,6 +222,7 @@ const getModeSpecificProps = (
|
||||
searchTerm: getQuery(state),
|
||||
startSearchCounter: getStartSearchCounter(state),
|
||||
filterByUnread: getFilterByUnread(state),
|
||||
selectedChatFolder: getSelectedChatFolder(state),
|
||||
...getLeftPaneLists(state),
|
||||
};
|
||||
case ComposerStep.StartDirectConversation:
|
||||
@@ -310,6 +313,9 @@ export const SmartLeftPane = memo(function SmartLeftPane({
|
||||
const crashReportCount = useSelector(getCrashReportCount);
|
||||
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
|
||||
const hasAppExpired = useSelector(hasExpired);
|
||||
const hasAnyCurrentCustomChatFolders = useSelector(
|
||||
getHasAnyCurrentCustomChatFolders
|
||||
);
|
||||
const hasNetworkDialog = useSelector(getHasNetworkDialog);
|
||||
const hasSearchQuery = useSelector(getHasSearchQuery);
|
||||
const hasUnsupportedOS = useSelector(isOSUnsupported);
|
||||
@@ -320,6 +326,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
|
||||
const modeSpecificProps = useSelector(getModeSpecificProps);
|
||||
const navTabsCollapsed = useSelector(getNavTabsCollapsed);
|
||||
const preferredWidthFromStorage = useSelector(getPreferredLeftPaneWidth);
|
||||
const selectedChatFolder = useSelector(getSelectedChatFolder);
|
||||
const selectedConversationId = useSelector(getSelectedConversationId);
|
||||
const showArchived = useSelector(getShowArchived);
|
||||
const targetedMessage = useSelector(getTargetedMessage);
|
||||
@@ -381,6 +388,18 @@ export const SmartLeftPane = memo(function SmartLeftPane({
|
||||
const { showUserNotFoundModal } = useGlobalModalActions();
|
||||
const { changeLocation } = useNavActions();
|
||||
|
||||
const handleChatFolderOpenSettings = useCallback(() => {
|
||||
changeLocation({
|
||||
tab: NavTab.Settings,
|
||||
details: {
|
||||
page: SettingsPage.ChatFolders,
|
||||
previousLocation: {
|
||||
tab: NavTab.Chats,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [changeLocation]);
|
||||
|
||||
let hasExpiredDialog = false;
|
||||
let unsupportedOSDialogType: 'error' | 'warning' | undefined;
|
||||
if (hasAppExpired) {
|
||||
@@ -425,6 +444,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
|
||||
endSearch={endSearch}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
getServerAlertToShow={getServerAlertToShow}
|
||||
hasAnyCurrentCustomChatFolders={hasAnyCurrentCustomChatFolders}
|
||||
hasExpiredDialog={hasExpiredDialog}
|
||||
hasFailedStorySends={hasFailedStorySends}
|
||||
hasNetworkDialog={hasNetworkDialog}
|
||||
@@ -439,6 +459,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
|
||||
lookupConversationWithoutServiceId={lookupConversationWithoutServiceId}
|
||||
modeSpecificProps={modeSpecificProps}
|
||||
navTabsCollapsed={navTabsCollapsed}
|
||||
onChatFoldersOpenSettings={handleChatFolderOpenSettings}
|
||||
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
|
||||
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
|
||||
openUsernameReservationModal={openUsernameReservationModal}
|
||||
@@ -465,6 +486,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
|
||||
saveAlerts={saveAlerts}
|
||||
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||
searchInConversation={searchInConversation}
|
||||
selectedChatFolder={selectedChatFolder}
|
||||
selectedConversationId={selectedConversationId}
|
||||
serverAlerts={serverAlerts}
|
||||
setChallengeStatus={setChallengeStatus}
|
||||
|
||||
@@ -4,8 +4,8 @@ import React, { memo, useCallback, useContext } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { LeftPaneChatFolders } from '../../components/leftPane/LeftPaneChatFolders.js';
|
||||
import {
|
||||
getCurrentChatFolders,
|
||||
getSelectedChatFolder,
|
||||
getSortedChatFolders,
|
||||
} from '../selectors/chatFolders.js';
|
||||
import { getIntl } from '../selectors/user.js';
|
||||
import {
|
||||
@@ -26,7 +26,7 @@ import { useConversationsActions } from '../ducks/conversations.js';
|
||||
export const SmartLeftPaneChatFolders = memo(
|
||||
function SmartLeftPaneChatFolders() {
|
||||
const i18n = useSelector(getIntl);
|
||||
const sortedChatFolders = useSelector(getSortedChatFolders);
|
||||
const currentChatFolders = useSelector(getCurrentChatFolders);
|
||||
const allChatFoldersUnreadStats = useSelector(getAllChatFoldersUnreadStats);
|
||||
const allChatFoldersMutedStats = useSelector(getAllChatFoldersMutedStats);
|
||||
const selectedChatFolder = useSelector(getSelectedChatFolder);
|
||||
@@ -62,7 +62,7 @@ export const SmartLeftPaneChatFolders = memo(
|
||||
<LeftPaneChatFolders
|
||||
i18n={i18n}
|
||||
navSidebarWidthBreakpoint={navSidebarWidthBreakpoint}
|
||||
sortedChatFolders={sortedChatFolders}
|
||||
currentChatFolders={currentChatFolders}
|
||||
allChatFoldersUnreadStats={allChatFoldersUnreadStats}
|
||||
allChatFoldersMutedStats={allChatFoldersMutedStats}
|
||||
selectedChatFolder={selectedChatFolder}
|
||||
|
||||
@@ -16,17 +16,7 @@ import {
|
||||
import { useNavActions } from '../ducks/nav.js';
|
||||
import { NavTab, SettingsPage } from '../../types/Nav.js';
|
||||
|
||||
export type ExternalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function SmartNotificationProfilesMenu({
|
||||
isOpen,
|
||||
onClose,
|
||||
trigger,
|
||||
}: ExternalProps): JSX.Element {
|
||||
export function SmartNotificationProfilesMenu(): JSX.Element {
|
||||
const i18n = useSelector(getIntl);
|
||||
|
||||
const allProfiles = useSelector(getProfiles);
|
||||
@@ -52,12 +42,9 @@ export function SmartNotificationProfilesMenu({
|
||||
allProfiles={allProfiles}
|
||||
currentOverride={currentOverride}
|
||||
i18n={i18n}
|
||||
isOpen={isOpen}
|
||||
loading={loading}
|
||||
onClose={onClose}
|
||||
onGoToSettings={goToSettings}
|
||||
setProfileOverride={setProfileOverride}
|
||||
trigger={trigger}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,10 @@ import { SmartPreferencesChatFoldersPage } from './PreferencesChatFoldersPage.js
|
||||
import type { SmartPreferencesEditChatFolderPageProps } from './PreferencesEditChatFolderPage.js';
|
||||
import { SmartPreferencesEditChatFolderPage } from './PreferencesEditChatFolderPage.js';
|
||||
import { AxoProvider } from '../../axo/AxoProvider.js';
|
||||
import {
|
||||
getCurrentChatFoldersCount,
|
||||
getHasAnyCurrentCustomChatFolders,
|
||||
} from '../selectors/chatFolders.js';
|
||||
import {
|
||||
SmartNotificationProfilesCreateFlow,
|
||||
SmartNotificationProfilesHome,
|
||||
@@ -241,6 +245,10 @@ export function SmartPreferences(): JSX.Element | null {
|
||||
|
||||
const shouldShowUpdateDialog = dialogType !== DialogType.None;
|
||||
const badge = getPreferredBadge(me.badges);
|
||||
const currentChatFoldersCount = useSelector(getCurrentChatFoldersCount);
|
||||
const hasAnyCurrentCustomChatFolders = useSelector(
|
||||
getHasAnyCurrentCustomChatFolders
|
||||
);
|
||||
|
||||
// The weird ones
|
||||
|
||||
@@ -774,6 +782,7 @@ export function SmartPreferences(): JSX.Element | null {
|
||||
backupLocalBackupsEnabled={backupLocalBackupsEnabled}
|
||||
badge={badge}
|
||||
blockedCount={blockedCount}
|
||||
currentChatFoldersCount={currentChatFoldersCount}
|
||||
cloudBackupStatus={cloudBackupStatus}
|
||||
customColors={customColors}
|
||||
defaultConversationColor={defaultConversationColor}
|
||||
@@ -790,6 +799,7 @@ export function SmartPreferences(): JSX.Element | null {
|
||||
getMessageSampleForSchemaVersion={
|
||||
DataReader.getMessageSampleForSchemaVersion
|
||||
}
|
||||
hasAnyCurrentCustomChatFolders={hasAnyCurrentCustomChatFolders}
|
||||
hasAudioNotifications={hasAudioNotifications}
|
||||
hasAutoConvertEmoji={hasAutoConvertEmoji}
|
||||
hasAutoDownloadUpdate={hasAutoDownloadUpdate}
|
||||
|
||||
@@ -5,13 +5,15 @@ import { useSelector } from 'react-redux';
|
||||
import type { PreferencesChatFoldersPageProps } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
|
||||
import { PreferencesChatFoldersPage } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
|
||||
import { getIntl } from '../selectors/user.js';
|
||||
import { getSortedChatFolders } from '../selectors/chatFolders.js';
|
||||
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
|
||||
import type { ChatFolderId } from '../../types/ChatFolder.js';
|
||||
import { useChatFolderActions } from '../ducks/chatFolders.js';
|
||||
import type { Location } from '../../types/Nav.js';
|
||||
import { useNavActions } from '../ducks/nav.js';
|
||||
|
||||
export type SmartPreferencesChatFoldersPageProps = Readonly<{
|
||||
settingsPaneRef: PreferencesChatFoldersPageProps['settingsPaneRef'];
|
||||
onBack: () => void;
|
||||
previousLocation: Location | null;
|
||||
onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void;
|
||||
}>;
|
||||
|
||||
@@ -19,16 +21,18 @@ export function SmartPreferencesChatFoldersPage(
|
||||
props: SmartPreferencesChatFoldersPageProps
|
||||
): JSX.Element {
|
||||
const i18n = useSelector(getIntl);
|
||||
const chatFolders = useSelector(getSortedChatFolders);
|
||||
const currentChatFolders = useSelector(getCurrentChatFolders);
|
||||
const { createChatFolder, deleteChatFolder, updateChatFoldersPositions } =
|
||||
useChatFolderActions();
|
||||
const { changeLocation } = useNavActions();
|
||||
return (
|
||||
<PreferencesChatFoldersPage
|
||||
i18n={i18n}
|
||||
settingsPaneRef={props.settingsPaneRef}
|
||||
onBack={props.onBack}
|
||||
previousLocation={props.previousLocation}
|
||||
onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
|
||||
chatFolders={chatFolders}
|
||||
changeLocation={changeLocation}
|
||||
currentChatFolders={currentChatFolders}
|
||||
onCreateChatFolder={createChatFolder}
|
||||
onDeleteChatFolder={deleteChatFolder}
|
||||
onUpdateChatFoldersPositions={updateChatFoldersPositions}
|
||||
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
} from '../selectors/conversations.js';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges.js';
|
||||
import { useChatFolderActions } from '../ducks/chatFolders.js';
|
||||
import { getSortedChatFolders } from '../selectors/chatFolders.js';
|
||||
import { strictAssert } from '../../util/assert.js';
|
||||
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
|
||||
import { useNavActions } from '../ducks/nav.js';
|
||||
import type { Location } from '../../types/Nav.js';
|
||||
import { CurrentChatFolders } from '../../types/CurrentChatFolders.js';
|
||||
|
||||
export type SmartPreferencesEditChatFolderPageProps = Readonly<{
|
||||
previousLocation: Location;
|
||||
previousLocation: Location | null;
|
||||
existingChatFolderId: PreferencesEditChatFolderPageProps['existingChatFolderId'];
|
||||
settingsPaneRef: PreferencesEditChatFolderPageProps['settingsPaneRef'];
|
||||
}>;
|
||||
@@ -33,7 +33,7 @@ export function SmartPreferencesEditChatFolderPage(
|
||||
const conversations = useSelector(getAllComposableConversations);
|
||||
const conversationSelector = useSelector(getConversationSelector);
|
||||
const preferredBadgeSelector = useSelector(getPreferredBadgeSelector);
|
||||
const chatFolders = useSelector(getSortedChatFolders);
|
||||
const currentChatFolders = useSelector(getCurrentChatFolders);
|
||||
const { createChatFolder, updateChatFolder, deleteChatFolder } =
|
||||
useChatFolderActions();
|
||||
const { changeLocation } = useNavActions();
|
||||
@@ -42,12 +42,12 @@ export function SmartPreferencesEditChatFolderPage(
|
||||
if (existingChatFolderId == null) {
|
||||
return CHAT_FOLDER_DEFAULTS;
|
||||
}
|
||||
const found = chatFolders.find(chatFolder => {
|
||||
return chatFolder.id === existingChatFolderId;
|
||||
});
|
||||
strictAssert(found, 'Unable to find chat folder');
|
||||
return found;
|
||||
}, [chatFolders, existingChatFolderId]);
|
||||
return CurrentChatFolders.expect(
|
||||
currentChatFolders,
|
||||
existingChatFolderId,
|
||||
'initChatFolderParams'
|
||||
);
|
||||
}, [currentChatFolders, existingChatFolderId]);
|
||||
|
||||
return (
|
||||
<PreferencesEditChatFolderPage
|
||||
|
||||
@@ -329,7 +329,7 @@ describe('backups', function (this: Mocha.Suite) {
|
||||
|
||||
debug('Opening story privacy');
|
||||
await window.locator('.StoriesTab__MoreActionsIcon').click();
|
||||
await window.getByRole('button', { name: 'Story Privacy' }).click();
|
||||
await window.getByRole('menuitem', { name: 'Story Privacy' }).click();
|
||||
await expect(
|
||||
window.locator('.StoriesSettingsModal__overlay')
|
||||
).toHaveCSS('opacity', '1');
|
||||
|
||||
@@ -18,13 +18,14 @@ const IdentifierType = Proto.ManifestRecord.Identifier.Type;
|
||||
const ALL_CHATS_PREDICATE = getChatFolderRecordPredicate('ALL', '', false);
|
||||
|
||||
async function openChatFolderSettings(window: Page) {
|
||||
const openSettingsBtn = window.locator(
|
||||
'[data-testid="NavTabsItem--Settings"]'
|
||||
);
|
||||
const openChatsSettingsBtn = window.locator('.Preferences__button--chats');
|
||||
const openChatFoldersSettingsBtn = window.locator(
|
||||
'.Preferences__control:has-text("Add a chat folder")'
|
||||
);
|
||||
const openSettingsBtn = window.getByRole('tab', { name: 'Settings' });
|
||||
const openChatsSettingsBtn = window
|
||||
.getByRole('tabpanel', { name: 'Settings' })
|
||||
.getByRole('navigation')
|
||||
.getByRole('button', { name: 'Chats' });
|
||||
const openChatFoldersSettingsBtn = window
|
||||
.getByRole('group', { name: 'Chat folders' })
|
||||
.getByRole('button', { name: 'Set up' });
|
||||
|
||||
await openSettingsBtn.click();
|
||||
await openChatsSettingsBtn.click();
|
||||
@@ -159,7 +160,7 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
|
||||
await app.waitForManifestVersion(state.version);
|
||||
|
||||
await expect(allGroupsListItem).toBeVisible();
|
||||
await expect(allGroupsListItem).toHaveText(ALL_GROUPS_NAME);
|
||||
await expect(allGroupsListItem).toContainText(ALL_GROUPS_NAME);
|
||||
}
|
||||
|
||||
debug('updating "All Groups" chat folder via storage service');
|
||||
@@ -185,7 +186,7 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
|
||||
|
||||
await expect(allChatsListItem).toBeVisible();
|
||||
await expect(allGroupsListItem).toBeVisible();
|
||||
await expect(allGroupsListItem).toHaveText(ALL_GROUPS_NAME_UPDATED);
|
||||
await expect(allGroupsListItem).toContainText(ALL_GROUPS_NAME_UPDATED);
|
||||
}
|
||||
|
||||
debug('removing "All Groups" chat folder via storage service');
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('storage service', function (this: Mocha.Suite) {
|
||||
await window.getByTestId('NavTabsItem--Stories').click();
|
||||
|
||||
await window.locator('.StoriesTab__MoreActionsIcon').click();
|
||||
await window.getByRole('button', { name: 'Story Privacy' }).click();
|
||||
await window.getByRole('menuitem', { name: 'Story Privacy' }).click();
|
||||
|
||||
await window
|
||||
.getByTestId('StoriesSettingsModal__list')
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('storage service/notification profiles', function (this: Mocha.Suite) {
|
||||
debug('Click triple-dot button');
|
||||
await window.getByRole('button', { name: 'More Actions' }).click();
|
||||
await window
|
||||
.getByRole('button', { name: 'Notification profile', exact: true })
|
||||
.getByRole('menuitem', { name: 'Notification profile', exact: true })
|
||||
.click();
|
||||
|
||||
debug('Click to add enabled=true override');
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('LeftPaneInboxHelper', () => {
|
||||
searchDisabled: false,
|
||||
searchTerm: '',
|
||||
startSearchCounter: 0,
|
||||
selectedChatFolder: null,
|
||||
};
|
||||
|
||||
describe('getBackAction', () => {
|
||||
|
||||
@@ -11,7 +11,6 @@ import * as grapheme from '../util/grapheme.js';
|
||||
import * as RemoteConfig from '../RemoteConfig.js';
|
||||
import { isAlpha, isBeta, isProduction } from '../util/version.js';
|
||||
import type { ConversationType } from '../state/ducks/conversations.js';
|
||||
import { strictAssert } from '../util/assert.js';
|
||||
import { isConversationUnread } from '../util/isConversationUnread.js';
|
||||
|
||||
export const CHAT_FOLDER_NAME_MAX_CHAR_LENGTH = 32;
|
||||
@@ -234,51 +233,3 @@ export function isConversationInChatFolder(
|
||||
!_isConversationExcludedFromChatFolder(chatFolder, conversation)
|
||||
);
|
||||
}
|
||||
|
||||
export type CurrentChatFolders = Readonly<{
|
||||
order: ReadonlyArray<ChatFolderId>;
|
||||
lookup: Partial<Record<ChatFolderId, ChatFolder>>;
|
||||
}>;
|
||||
|
||||
export function toCurrentChatFolders(
|
||||
chatFolders: ReadonlyArray<ChatFolder>
|
||||
): CurrentChatFolders {
|
||||
const order = chatFolders
|
||||
.toSorted((a, b) => a.position - b.position)
|
||||
.map(chatFolder => chatFolder.id);
|
||||
|
||||
const lookup: Record<ChatFolderId, ChatFolder> = {};
|
||||
for (const chatFolder of chatFolders) {
|
||||
lookup[chatFolder.id] = chatFolder;
|
||||
}
|
||||
|
||||
return { order, lookup };
|
||||
}
|
||||
|
||||
export function getSortedCurrentChatFolders(
|
||||
currentChatFolders: CurrentChatFolders
|
||||
): ReadonlyArray<ChatFolder> {
|
||||
return currentChatFolders.order.map(chatFolderId => {
|
||||
return lookupCurrentChatFolder(currentChatFolders, chatFolderId);
|
||||
});
|
||||
}
|
||||
|
||||
export function lookupCurrentChatFolder(
|
||||
currentChatFolders: CurrentChatFolders,
|
||||
chatFolderId: ChatFolderId
|
||||
): ChatFolder {
|
||||
const chatFolder = currentChatFolders.lookup[chatFolderId];
|
||||
strictAssert(chatFolder != null, 'Missing chat folder');
|
||||
return chatFolder;
|
||||
}
|
||||
|
||||
export function hasAllChatsChatFolder(
|
||||
chatFolders: ReadonlyArray<ChatFolder>
|
||||
): boolean {
|
||||
return chatFolders.some(chatFolder => {
|
||||
return (
|
||||
chatFolder.folderType === ChatFolderType.ALL &&
|
||||
chatFolder.deletedAtTimestampMs === 0
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { strictAssert } from '../util/assert.js';
|
||||
import { ChatFolderType } from './ChatFolder.js';
|
||||
import type { ChatFolder, ChatFolderId } from './ChatFolder.js';
|
||||
|
||||
export type CurrentChatFolder = ChatFolder &
|
||||
Readonly<{
|
||||
folderType: ChatFolderType.ALL | ChatFolderType.CUSTOM;
|
||||
deletedAtTimestampMs: 0;
|
||||
}>;
|
||||
|
||||
export type CurrentAllChatFolder = CurrentChatFolder &
|
||||
Readonly<{
|
||||
folderType: ChatFolderType.ALL;
|
||||
}>;
|
||||
|
||||
export type CurrentCustomChatFolder = CurrentChatFolder &
|
||||
Readonly<{
|
||||
folderType: ChatFolderType.CUSTOM;
|
||||
}>;
|
||||
|
||||
export function isCurrentChatFolder(
|
||||
chatFolder: ChatFolder
|
||||
): chatFolder is CurrentChatFolder {
|
||||
return (
|
||||
chatFolder.deletedAtTimestampMs === 0 &&
|
||||
chatFolder.folderType !== ChatFolderType.UNKNOWN
|
||||
);
|
||||
}
|
||||
|
||||
export function isCurrentAllChatFolder(
|
||||
chatFolder: ChatFolder
|
||||
): chatFolder is CurrentAllChatFolder {
|
||||
return (
|
||||
isCurrentChatFolder(chatFolder) &&
|
||||
chatFolder.folderType === ChatFolderType.ALL
|
||||
);
|
||||
}
|
||||
|
||||
export function isCurrentCustomChatFolder(
|
||||
chatFolder: ChatFolder
|
||||
): chatFolder is CurrentCustomChatFolder {
|
||||
return (
|
||||
isCurrentChatFolder(chatFolder) &&
|
||||
chatFolder.folderType === ChatFolderType.CUSTOM
|
||||
);
|
||||
}
|
||||
|
||||
export type CurrentChatFolders = Readonly<{
|
||||
order: ReadonlyArray<ChatFolderId>;
|
||||
lookup: Partial<Record<ChatFolderId, CurrentChatFolder>>;
|
||||
currentAllChatFolder: CurrentAllChatFolder | null;
|
||||
hasAnyCurrentCustomChatFolders: boolean;
|
||||
}>;
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare
|
||||
export namespace CurrentChatFolders {
|
||||
export function createEmpty(): CurrentChatFolders {
|
||||
return {
|
||||
order: [],
|
||||
lookup: {},
|
||||
currentAllChatFolder: null,
|
||||
hasAnyCurrentCustomChatFolders: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function fromArray(
|
||||
chatFolders: ReadonlyArray<CurrentChatFolder>
|
||||
): CurrentChatFolders {
|
||||
let currentAllChatFolder: CurrentAllChatFolder | null = null;
|
||||
let hasAnyCurrentCustomChatFolders = false;
|
||||
|
||||
const order = chatFolders
|
||||
.toSorted((a, b) => a.position - b.position)
|
||||
.map(chatFolder => chatFolder.id);
|
||||
|
||||
const lookup: Record<ChatFolderId, CurrentChatFolder> = {};
|
||||
for (const chatFolder of chatFolders) {
|
||||
if (isCurrentCustomChatFolder(chatFolder)) {
|
||||
hasAnyCurrentCustomChatFolders = true;
|
||||
} else if (isCurrentAllChatFolder(chatFolder)) {
|
||||
if (currentAllChatFolder != null) {
|
||||
throw new Error(
|
||||
`Multiple current all chats chat folders (${currentAllChatFolder.id}, ${chatFolder.id})`
|
||||
);
|
||||
}
|
||||
currentAllChatFolder = chatFolder;
|
||||
} else {
|
||||
throw new TypeError(
|
||||
`Chat folder is not current ${chatFolder.id} (${chatFolder.folderType}, ${chatFolder.deletedAtTimestampMs})`
|
||||
);
|
||||
}
|
||||
|
||||
lookup[chatFolder.id] = chatFolder;
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
lookup,
|
||||
currentAllChatFolder,
|
||||
hasAnyCurrentCustomChatFolders,
|
||||
};
|
||||
}
|
||||
|
||||
export function size(state: CurrentChatFolders): number {
|
||||
return state.order.length;
|
||||
}
|
||||
|
||||
export function has(state: CurrentChatFolders, id: ChatFolderId): boolean {
|
||||
return Object.hasOwn(state.lookup, id);
|
||||
}
|
||||
|
||||
export function get(
|
||||
state: CurrentChatFolders,
|
||||
id: ChatFolderId
|
||||
): CurrentChatFolder | null {
|
||||
if (has(state, id)) {
|
||||
return state.lookup[id] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function expect(
|
||||
state: CurrentChatFolders,
|
||||
id: ChatFolderId,
|
||||
reason: string
|
||||
): CurrentChatFolder {
|
||||
const chatFolder = get(state, id);
|
||||
strictAssert(
|
||||
chatFolder != null,
|
||||
`Expected chat folder to exist in state ${id} (${reason})`
|
||||
);
|
||||
return chatFolder;
|
||||
}
|
||||
|
||||
export function at(
|
||||
state: CurrentChatFolders,
|
||||
index: number
|
||||
): CurrentChatFolder | null {
|
||||
const chatFolderId = state.order.at(index);
|
||||
if (chatFolderId != null) {
|
||||
return get(state, chatFolderId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toSortedArray(
|
||||
state: CurrentChatFolders
|
||||
): ReadonlyArray<CurrentChatFolder> {
|
||||
return state.order.map(id => {
|
||||
return expect(state, id, 'toSortedArray');
|
||||
});
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -9,15 +9,21 @@ export type SettingsLocation = ReadonlyDeep<
|
||||
page: SettingsPage.Profile;
|
||||
state: ProfileEditorPage;
|
||||
}
|
||||
| {
|
||||
page: SettingsPage.ChatFolders;
|
||||
previousLocation: Location | null;
|
||||
}
|
||||
| {
|
||||
page: SettingsPage.EditChatFolder;
|
||||
chatFolderId: ChatFolderId | null;
|
||||
previousLocation: Location;
|
||||
previousLocation: Location | null;
|
||||
}
|
||||
| {
|
||||
page: Exclude<
|
||||
SettingsPage,
|
||||
SettingsPage.Profile | SettingsPage.EditChatFolder
|
||||
| SettingsPage.Profile
|
||||
| SettingsPage.ChatFolders
|
||||
| SettingsPage.EditChatFolder
|
||||
>;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -19,6 +19,7 @@ export enum ToastType {
|
||||
CannotOpenGiftBadgeIncoming = 'CannotOpenGiftBadgeIncoming',
|
||||
CannotOpenGiftBadgeOutgoing = 'CannotOpenGiftBadgeOutgoing',
|
||||
CannotStartGroupCall = 'CannotStartGroupCall',
|
||||
ChatFolderCreated = 'ChatFolderCreated',
|
||||
ConversationArchived = 'ConversationArchived',
|
||||
ConversationMarkedUnread = 'ConversationMarkedUnread',
|
||||
ConversationRemoved = 'ConversationRemoved',
|
||||
@@ -117,6 +118,10 @@ export type AnyToast =
|
||||
| { toastType: ToastType.CannotStartGroupCall }
|
||||
| { toastType: ToastType.CaptchaFailed }
|
||||
| { toastType: ToastType.CaptchaSolved }
|
||||
| {
|
||||
toastType: ToastType.ChatFolderCreated;
|
||||
parameters: { chatFolderName: string };
|
||||
}
|
||||
| {
|
||||
toastType: ToastType.ConversationArchived;
|
||||
parameters: { conversationId: string; wasPinned: boolean };
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
import type { ConversationType } from '../state/ducks/conversations.js';
|
||||
import {
|
||||
isConversationInChatFolder,
|
||||
type ChatFolder,
|
||||
type ChatFolderId,
|
||||
} from '../types/ChatFolder.js';
|
||||
import { CurrentChatFolders } from '../types/CurrentChatFolders.js';
|
||||
import { isConversationMuted } from './isConversationMuted.js';
|
||||
|
||||
type MutableMutedStats = {
|
||||
@@ -30,10 +30,12 @@ export type ConversationPropsForMutedStats = Readonly<
|
||||
>;
|
||||
|
||||
export function countAllChatFoldersMutedStats(
|
||||
sortedChatFolders: ReadonlyArray<ChatFolder>,
|
||||
currentChatFolders: CurrentChatFolders,
|
||||
conversations: ReadonlyArray<ConversationPropsForMutedStats>
|
||||
): AllChatFoldersMutedStats {
|
||||
const results = new Map<ChatFolderId, MutableMutedStats>();
|
||||
const sortedChatFolders =
|
||||
CurrentChatFolders.toSortedArray(currentChatFolders);
|
||||
|
||||
for (const conversation of conversations) {
|
||||
const isMuted = isConversationMuted(conversation);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
import type { ConversationType } from '../state/ducks/conversations.js';
|
||||
import { isConversationInChatFolder } from '../types/ChatFolder.js';
|
||||
import type { ChatFolder, ChatFolderId } from '../types/ChatFolder.js';
|
||||
import type { ChatFolderId } from '../types/ChatFolder.js';
|
||||
import { CurrentChatFolders } from '../types/CurrentChatFolders.js';
|
||||
import { isConversationMuted } from './isConversationMuted.js';
|
||||
|
||||
type MutableUnreadStats = {
|
||||
@@ -150,11 +151,13 @@ export function countAllConversationsUnreadStats(
|
||||
}
|
||||
|
||||
export function countAllChatFoldersUnreadStats(
|
||||
sortedChatFolders: ReadonlyArray<ChatFolder>,
|
||||
currentChatFolders: CurrentChatFolders,
|
||||
conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
|
||||
options: UnreadStatsOptions
|
||||
): AllChatFoldersUnreadStats {
|
||||
const results = new Map<ChatFolderId, MutableUnreadStats>();
|
||||
const sortedChatFolders =
|
||||
CurrentChatFolders.toSortedArray(currentChatFolders);
|
||||
|
||||
for (const conversation of conversations) {
|
||||
// skip if we shouldn't count it
|
||||
|
||||
@@ -2092,6 +2092,13 @@
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2025-09-24T17:08:10.620Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx",
|
||||
"line": " const inputRef = useRef<HTMLInputElement>(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2025-10-09T23:03:57.441Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/preferences/donations/DonateInputAmount.tsx",
|
||||
|
||||
Reference in New Issue
Block a user