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