diff --git a/.eslintrc.js b/.eslintrc.js index cec6acf3a6..d07da83d00 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -436,6 +436,24 @@ module.exports = { ], }, }, + { + files: ['ts/axo/**/*.tsx'], + rules: { + '@typescript-eslint/no-namespace': 'off', + '@typescript-eslint/no-redeclare': [ + 'error', + { + ignoreDeclarationMerge: true, + }, + ], + '@typescript-eslint/explicit-module-boundary-types': [ + 'error', + { + allowHigherOrderFunctions: false, + }, + ], + }, + }, ], rules: { diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 32caf65d2b..78f54ca34c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -379,6 +379,38 @@ "messageformat": "Enter a username followed by a dot and its set of numbers.", "description": "Description displayed under search input in left pane when looking up someone by username" }, + "icu:LeftPaneChatFolders__ItemLabel--All--Short": { + "messageformat": "All", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats (needs to fit in very small space)" + }, + "icu:LeftPaneChatFolders__ItemLabel--All": { + "messageformat": "All chats", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats" + }, + "icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount": { + "messageformat": "{maxCount, number}+", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Badge Count > When over the max count (Example: 1000 or more would be 999+)" + }, + "icu:LeftPaneChatFolders__Item__ContextMenu__MarkAllRead": { + "messageformat": "Mark all read", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mark all unread chats in chat folder as read" + }, + "icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications": { + "messageformat": "Mute notifications", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications" + }, + "icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll": { + "messageformat": "Unmute all", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications > Sub-Menu > Unmute all" + }, + "icu:LeftPaneChatFolders__Item__ContextMenu__UnmuteAll": { + "messageformat": "Unmute all", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Unmute all chats in chat folder" + }, + "icu:LeftPaneChatFolders__Item__ContextMenu__EditFolder": { + "messageformat": "Edit folder", + "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Open settings for current chat folder" + }, "icu:CountryCodeSelect__placeholder": { "messageformat": "Country code", "description": "Placeholder displayed as default value of country code select element" @@ -447,6 +479,10 @@ "messageformat": "Mark as unread", "description": "Shown in menu for conversation, and marks conversation as unread" }, + "icu:markRead": { + "messageformat": "Mark read", + "description": "Shown in menu for conversation, and marks conversation read" + }, "icu:ConversationHeader__menu__selectMessages": { "messageformat": "Select messages", "description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation" diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index 2974064a1b..1e05124145 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -1374,6 +1374,10 @@ $secondary-text-color: light-dark( padding-block: 8px; padding-inline: 24px; border-radius: 1px; + + &[data-dragging='true'] { + opacity: 50%; + } } .Preferences__ChatFolders__ChatSelection__ItemAvatar { diff --git a/stylesheets/tailwind-config.css b/stylesheets/tailwind-config.css index 649d25b8cd..061f88791a 100644 --- a/stylesheets/tailwind-config.css +++ b/stylesheets/tailwind-config.css @@ -410,3 +410,9 @@ } } } + +@property --axo-select-trigger-mask-start { + syntax: ''; + inherits: false; + initial-value: transparent; +} diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index f046b6beea..971503b61a 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -368,6 +368,8 @@ export class ConversationController { // because `conversation.format()` can return cached props by the // time this runs return { + id: conversation.get('id'), + type: conversation.get('type') === 'private' ? 'direct' : 'group', activeAt: conversation.get('active_at') ?? undefined, isArchived: conversation.get('isArchived'), markedUnread: conversation.get('markedUnread'), @@ -383,15 +385,16 @@ export class ConversationController { drop(window.storage.put('unreadCount', unreadStats.unreadCount)); if (unreadStats.unreadCount > 0) { - window.IPC.setBadge(unreadStats.unreadCount); - window.IPC.updateTrayIcon(unreadStats.unreadCount); - window.document.title = `${window.getTitle()} (${ - unreadStats.unreadCount - })`; - } else if (unreadStats.markedUnread) { - window.IPC.setBadge('marked-unread'); - window.IPC.updateTrayIcon(1); - window.document.title = `${window.getTitle()} (1)`; + const total = + unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount; + window.IPC.setBadge(total); + window.IPC.updateTrayIcon(total); + window.document.title = `${window.getTitle()} (${total})`; + } else if (unreadStats.readChatsMarkedUnreadCount > 0) { + const total = unreadStats.readChatsMarkedUnreadCount; + window.IPC.setBadge(total); + window.IPC.updateTrayIcon(total); + window.document.title = `${window.getTitle()} (${total})`; } else { window.IPC.setBadge(0); window.IPC.updateTrayIcon(0); diff --git a/ts/axo/AriaClickable.stories.tsx b/ts/axo/AriaClickable.stories.tsx index 99ce148f53..5417058dbb 100644 --- a/ts/axo/AriaClickable.stories.tsx +++ b/ts/axo/AriaClickable.stories.tsx @@ -78,9 +78,13 @@ function CardButton(props: { }) { return ( - + {props.children} - + ); } diff --git a/ts/axo/AriaClickable.tsx b/ts/axo/AriaClickable.tsx index 984dba87c0..6a89c212df 100644 --- a/ts/axo/AriaClickable.tsx +++ b/ts/axo/AriaClickable.tsx @@ -27,7 +27,7 @@ const Namespace = 'AriaClickable'; * *

* - * Delete + * Delete * * * Edit @@ -36,7 +36,6 @@ const Namespace = 'AriaClickable'; * ); * ``` */ -// eslint-disable-next-line @typescript-eslint/no-namespace export namespace AriaClickable { type TriggerState = Readonly<{ hovered: boolean; diff --git a/ts/axo/AxoBadge.stories.tsx b/ts/axo/AxoBadge.stories.tsx new file mode 100644 index 0000000000..7977928ad5 --- /dev/null +++ b/ts/axo/AxoBadge.stories.tsx @@ -0,0 +1,57 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { Meta } from '@storybook/react'; +import React from 'react'; +import { ExperimentalAxoBadge } from './AxoBadge.js'; +import { tw } from './tw.js'; + +export default { + title: 'Axo/AriaBadge (Experimental)', +} satisfies Meta; + +export function All(): JSX.Element { + const values: ReadonlyArray = [ + -1, + 0, + 1, + 10, + 123, + 1234, + 12345, + 'mention', + 'unread', + ]; + + return ( + + + + {values.map(value => { + return ; + })} + + + {ExperimentalAxoBadge._getAllBadgeSizes().map(size => { + return ( + + + {values.map(value => { + return ( + + ); + })} + + ); + })} + +
size{value}
{size} + +
+ ); +} diff --git a/ts/axo/AxoBadge.tsx b/ts/axo/AxoBadge.tsx new file mode 100644 index 0000000000..ede14876bf --- /dev/null +++ b/ts/axo/AxoBadge.tsx @@ -0,0 +1,125 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { FC } from 'react'; +import React, { memo, useMemo } from 'react'; +import { AxoSymbol } from './AxoSymbol.js'; +import type { TailwindStyles } from './tw.js'; +import { tw } from './tw.js'; +import { unreachable } from './_internal/assert.js'; + +const Namespace = 'AxoBadge'; + +/** + * @example Anatomy + * ```tsx + * + * + * + * + * + * + * + * + * + * ```` + */ +export namespace ExperimentalAxoBadge { + export type BadgeSize = 'sm' | 'md' | 'lg'; + export type BadgeValue = number | 'mention' | 'unread'; + + const baseStyles = tw( + 'flex size-fit items-center justify-center-safe overflow-clip', + 'rounded-full font-semibold', + 'bg-color-fill-primary text-label-primary-on-color', + 'select-none' + ); + + type BadgeConfig = Readonly<{ + rootStyles: TailwindStyles; + countStyles: TailwindStyles; + }>; + + const BadgeSizes: Record = { + sm: { + rootStyles: tw(baseStyles, 'min-h-3.5 min-w-3.5 text-[8px] leading-3.5'), + countStyles: tw('px-[3px]'), + }, + md: { + rootStyles: tw(baseStyles, 'min-h-4 min-w-4 text-[11px] leading-4'), + countStyles: tw('px-[4px]'), + }, + lg: { + rootStyles: tw(baseStyles, 'min-h-4.5 min-w-4.5 text-[11px] leading-4.5'), + countStyles: tw('px-[5px]'), + }, + }; + + export function _getAllBadgeSizes(): ReadonlyArray { + return Object.keys(BadgeSizes) as Array; + } + + let cachedNumberFormat: Intl.NumberFormat; + + // eslint-disable-next-line no-inner-declarations + function formatBadgeCount( + value: number, + max: number, + maxDisplay: string + ): string { + if (value > max) { + return maxDisplay; + } + cachedNumberFormat ??= new Intl.NumberFormat(); + return cachedNumberFormat.format(value); + } + + /** + * Component: + * -------------------------- + */ + + export type RootProps = Readonly<{ + size: BadgeSize; + value: BadgeValue; + max: number; + maxDisplay: string; + 'aria-label': string | null; + }>; + + export const Root: FC = memo(props => { + const { value, max, maxDisplay } = props; + const config = BadgeSizes[props.size]; + + const children = useMemo(() => { + if (value === 'unread') { + return null; + } + if (value === 'mention') { + return ( + + + + ); + } + if (typeof value === 'number') { + return ( + + {formatBadgeCount(value, max, maxDisplay)} + + ); + } + unreachable(value); + }, [value, max, maxDisplay, config]); + + return ( + + {children} + + ); + }); + + Root.displayName = `${Namespace}.Root`; +} diff --git a/ts/axo/AxoButton.stories.tsx b/ts/axo/AxoButton.stories.tsx index 8b26680e91..a201b3d3c0 100644 --- a/ts/axo/AxoButton.stories.tsx +++ b/ts/axo/AxoButton.stories.tsx @@ -26,33 +26,33 @@ export function Basic(): JSX.Element { {variants.map(variant => { return (
- {variant} - + - Disabled - + - Icon - + - Disabled - + - Arrow - + - Disabled - +
); })} diff --git a/ts/axo/AxoButton.tsx b/ts/axo/AxoButton.tsx index 69ea32068b..1905acd520 100644 --- a/ts/axo/AxoButton.tsx +++ b/ts/axo/AxoButton.tsx @@ -139,15 +139,6 @@ type BaseButtonAttrs = Omit< type AxoButtonVariant = keyof typeof AxoButtonVariants; type AxoButtonSize = keyof typeof AxoButtonSizes; -type AxoButtonProps = BaseButtonAttrs & - Readonly<{ - variant: AxoButtonVariant; - size: AxoButtonSize; - symbol?: AxoSymbol.InlineGlyphName; - arrow?: boolean; - children: ReactNode; - }>; - export function _getAllAxoButtonVariants(): ReadonlyArray { return Object.keys(AxoButtonVariants) as Array; } @@ -156,41 +147,47 @@ export function _getAllAxoButtonSizes(): ReadonlyArray { return Object.keys(AxoButtonSizes) as Array; } -// eslint-disable-next-line import/export -export const AxoButton: FC = memo( - forwardRef((props, ref: ForwardedRef) => { - const { variant, size, symbol, arrow, children, ...rest } = props; - const variantStyles = assert( - AxoButtonVariants[variant], - `${Namespace}: Invalid variant ${variant}` - ); - const sizeStyles = assert( - AxoButtonSizes[size], - `${Namespace}: Invalid size ${size}` - ); - return ( - - ); - }) -); - -AxoButton.displayName = `${Namespace}`; - -// eslint-disable-next-line max-len -// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export export namespace AxoButton { export type Variant = AxoButtonVariant; export type Size = AxoButtonSize; - export type Props = AxoButtonProps; + export type RootProps = BaseButtonAttrs & + Readonly<{ + variant: AxoButtonVariant; + size: AxoButtonSize; + symbol?: AxoSymbol.InlineGlyphName; + arrow?: boolean; + children: ReactNode; + }>; + + export const Root: FC = memo( + forwardRef((props, ref: ForwardedRef) => { + const { variant, size, symbol, arrow, children, ...rest } = props; + const variantStyles = assert( + AxoButtonVariants[variant], + `${Namespace}: Invalid variant ${variant}` + ); + const sizeStyles = assert( + AxoButtonSizes[size], + `${Namespace}: Invalid size ${size}` + ); + return ( + + ); + }) + ); + + Root.displayName = `${Namespace}.Root`; } diff --git a/ts/axo/AxoCheckbox.stories.tsx b/ts/axo/AxoCheckbox.stories.tsx index a5685c7b9c..61caaa8133 100644 --- a/ts/axo/AxoCheckbox.stories.tsx +++ b/ts/axo/AxoCheckbox.stories.tsx @@ -17,7 +17,7 @@ function Template(props: { const [checked, setChecked] = useState(props.defaultChecked); return (