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 (
+
+
+ | size |
+ {values.map(value => {
+ return {value} | ;
+ })}
+
+
+ {ExperimentalAxoBadge._getAllBadgeSizes().map(size => {
+ return (
+
+ | {size} |
+ {values.map(value => {
+ return (
+
+
+ |
+ );
+ })}
+
+ );
+ })}
+
+
+ );
+}
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 (