Skip to content

Commit ec7d072

Browse files
Init Chat Folders UI
1 parent 726234a commit ec7d072

File tree

88 files changed

+4076
-1300
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

88 files changed

+4076
-1300
lines changed

.eslintrc.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,24 @@ module.exports = {
436436
],
437437
},
438438
},
439+
{
440+
files: ['ts/axo/**/*.tsx'],
441+
rules: {
442+
'@typescript-eslint/no-namespace': 'off',
443+
'@typescript-eslint/no-redeclare': [
444+
'error',
445+
{
446+
ignoreDeclarationMerge: true,
447+
},
448+
],
449+
'@typescript-eslint/explicit-module-boundary-types': [
450+
'error',
451+
{
452+
allowHigherOrderFunctions: false,
453+
},
454+
],
455+
},
456+
},
439457
],
440458

441459
rules: {

_locales/en/messages.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,38 @@
379379
"messageformat": "Enter a username followed by a dot and its set of numbers.",
380380
"description": "Description displayed under search input in left pane when looking up someone by username"
381381
},
382+
"icu:LeftPaneChatFolders__ItemLabel--All--Short": {
383+
"messageformat": "All",
384+
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats (needs to fit in very small space)"
385+
},
386+
"icu:LeftPaneChatFolders__ItemLabel--All": {
387+
"messageformat": "All chats",
388+
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats"
389+
},
390+
"icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount": {
391+
"messageformat": "{maxCount, number}+",
392+
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Badge Count > When over the max count (Example: 1000 or more would be 999+)"
393+
},
394+
"icu:LeftPaneChatFolders__Item__ContextMenu__MarkAllRead": {
395+
"messageformat": "Mark all read",
396+
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mark all unread chats in chat folder as read"
397+
},
398+
"icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications": {
399+
"messageformat": "Mute notifications",
400+
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications"
401+
},
402+
"icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll": {
403+
"messageformat": "Unmute all",
404+
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications > Sub-Menu > Unmute all"
405+
},
406+
"icu:LeftPaneChatFolders__Item__ContextMenu__UnmuteAll": {
407+
"messageformat": "Unmute all",
408+
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Unmute all chats in chat folder"
409+
},
410+
"icu:LeftPaneChatFolders__Item__ContextMenu__EditFolder": {
411+
"messageformat": "Edit folder",
412+
"description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Open settings for current chat folder"
413+
},
382414
"icu:CountryCodeSelect__placeholder": {
383415
"messageformat": "Country code",
384416
"description": "Placeholder displayed as default value of country code select element"
@@ -447,6 +479,10 @@
447479
"messageformat": "Mark as unread",
448480
"description": "Shown in menu for conversation, and marks conversation as unread"
449481
},
482+
"icu:markRead": {
483+
"messageformat": "Mark read",
484+
"description": "Shown in menu for conversation, and marks conversation read"
485+
},
450486
"icu:ConversationHeader__menu__selectMessages": {
451487
"messageformat": "Select messages",
452488
"description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation"

stylesheets/components/Preferences.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,6 +1374,10 @@ $secondary-text-color: light-dark(
13741374
padding-block: 8px;
13751375
padding-inline: 24px;
13761376
border-radius: 1px;
1377+
1378+
&[data-dragging='true'] {
1379+
opacity: 50%;
1380+
}
13771381
}
13781382

13791383
.Preferences__ChatFolders__ChatSelection__ItemAvatar {

stylesheets/tailwind-config.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,3 +410,9 @@
410410
}
411411
}
412412
}
413+
414+
@property --axo-select-trigger-mask-start {
415+
syntax: '<color>';
416+
inherits: false;
417+
initial-value: transparent;
418+
}

ts/ConversationController.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,8 @@ export class ConversationController {
368368
// because `conversation.format()` can return cached props by the
369369
// time this runs
370370
return {
371+
id: conversation.get('id'),
372+
type: conversation.get('type') === 'private' ? 'direct' : 'group',
371373
activeAt: conversation.get('active_at') ?? undefined,
372374
isArchived: conversation.get('isArchived'),
373375
markedUnread: conversation.get('markedUnread'),
@@ -383,15 +385,16 @@ export class ConversationController {
383385
drop(window.storage.put('unreadCount', unreadStats.unreadCount));
384386

385387
if (unreadStats.unreadCount > 0) {
386-
window.IPC.setBadge(unreadStats.unreadCount);
387-
window.IPC.updateTrayIcon(unreadStats.unreadCount);
388-
window.document.title = `${window.getTitle()} (${
389-
unreadStats.unreadCount
390-
})`;
391-
} else if (unreadStats.markedUnread) {
392-
window.IPC.setBadge('marked-unread');
393-
window.IPC.updateTrayIcon(1);
394-
window.document.title = `${window.getTitle()} (1)`;
388+
const total =
389+
unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount;
390+
window.IPC.setBadge(total);
391+
window.IPC.updateTrayIcon(total);
392+
window.document.title = `${window.getTitle()} (${total})`;
393+
} else if (unreadStats.readChatsMarkedUnreadCount > 0) {
394+
const total = unreadStats.readChatsMarkedUnreadCount;
395+
window.IPC.setBadge(total);
396+
window.IPC.updateTrayIcon(total);
397+
window.document.title = `${window.getTitle()} (${total})`;
395398
} else {
396399
window.IPC.setBadge(0);
397400
window.IPC.updateTrayIcon(0);

ts/axo/AriaClickable.stories.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,13 @@ function CardButton(props: {
7878
}) {
7979
return (
8080
<AriaClickable.SubWidget>
81-
<AxoButton variant={props.variant} size="medium" onClick={props.onClick}>
81+
<AxoButton.Root
82+
variant={props.variant}
83+
size="medium"
84+
onClick={props.onClick}
85+
>
8286
{props.children}
83-
</AxoButton>
87+
</AxoButton.Root>
8488
</AriaClickable.SubWidget>
8589
);
8690
}

ts/axo/AriaClickable.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const Namespace = 'AriaClickable';
2727
* <AriaClickable.HiddenTrigger aria-labelledby="see-more-1"/>
2828
* </p>
2929
* <AriaClickable.SubWidget>
30-
* <AxoButton>Delete</AxoButton>
30+
* <AxoButton.Root>Delete</AxoButton.Root>
3131
* </AriaClickable.SubWidget>
3232
* <AriaClickable.SubWidget>
3333
* <AxoLink>Edit</AxoLink>
@@ -36,7 +36,6 @@ const Namespace = 'AriaClickable';
3636
* );
3737
* ```
3838
*/
39-
// eslint-disable-next-line @typescript-eslint/no-namespace
4039
export namespace AriaClickable {
4140
type TriggerState = Readonly<{
4241
hovered: boolean;

ts/axo/AxoBadge.stories.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright 2025 Signal Messenger, LLC
2+
// SPDX-License-Identifier: AGPL-3.0-only
3+
import type { Meta } from '@storybook/react';
4+
import React from 'react';
5+
import { ExperimentalAxoBadge } from './AxoBadge.js';
6+
import { tw } from './tw.js';
7+
8+
export default {
9+
title: 'Axo/AriaBadge (Experimental)',
10+
} satisfies Meta;
11+
12+
export function All(): JSX.Element {
13+
const values: ReadonlyArray<ExperimentalAxoBadge.BadgeValue> = [
14+
-1,
15+
0,
16+
1,
17+
10,
18+
123,
19+
1234,
20+
12345,
21+
'mention',
22+
'unread',
23+
];
24+
25+
return (
26+
<table className={tw('border-separate border-spacing-2 text-center')}>
27+
<thead>
28+
<th>size</th>
29+
{values.map(value => {
30+
return <th key={value}>{value}</th>;
31+
})}
32+
</thead>
33+
<tbody>
34+
{ExperimentalAxoBadge._getAllBadgeSizes().map(size => {
35+
return (
36+
<tr key={size}>
37+
<th>{size}</th>
38+
{values.map(value => {
39+
return (
40+
<td key={value} className={tw('')}>
41+
<ExperimentalAxoBadge.Root
42+
size={size}
43+
value={value}
44+
max={99}
45+
maxDisplay="99+"
46+
aria-label={null}
47+
/>
48+
</td>
49+
);
50+
})}
51+
</tr>
52+
);
53+
})}
54+
</tbody>
55+
</table>
56+
);
57+
}

ts/axo/AxoBadge.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright 2025 Signal Messenger, LLC
2+
// SPDX-License-Identifier: AGPL-3.0-only
3+
import type { FC } from 'react';
4+
import React, { memo, useMemo } from 'react';
5+
import { AxoSymbol } from './AxoSymbol.js';
6+
import type { TailwindStyles } from './tw.js';
7+
import { tw } from './tw.js';
8+
import { unreachable } from './_internal/assert.js';
9+
10+
const Namespace = 'AxoBadge';
11+
12+
/**
13+
* @example Anatomy
14+
* ```tsx
15+
* <AxoBadge.Root aria-label="42 unread messages">
16+
* <AxoBadge.Count value={42} max={999}/>
17+
* </AxoBadge.Root>
18+
*
19+
* <AxoBadge.Root aria-label="Marked unread"/>
20+
*
21+
* <AxoBadge.Root aria-label="You were mentioned">
22+
* <AxoBadge.Icon symbol="at" />
23+
* </AxoBadge.Root>
24+
* ````
25+
*/
26+
export namespace ExperimentalAxoBadge {
27+
export type BadgeSize = 'sm' | 'md' | 'lg';
28+
export type BadgeValue = number | 'mention' | 'unread';
29+
30+
const baseStyles = tw(
31+
'flex size-fit items-center justify-center-safe overflow-clip',
32+
'rounded-full font-semibold',
33+
'bg-color-fill-primary text-label-primary-on-color',
34+
'select-none'
35+
);
36+
37+
type BadgeConfig = Readonly<{
38+
rootStyles: TailwindStyles;
39+
countStyles: TailwindStyles;
40+
}>;
41+
42+
const BadgeSizes: Record<BadgeSize, BadgeConfig> = {
43+
sm: {
44+
rootStyles: tw(baseStyles, 'min-h-3.5 min-w-3.5 text-[8px] leading-3.5'),
45+
countStyles: tw('px-[3px]'),
46+
},
47+
md: {
48+
rootStyles: tw(baseStyles, 'min-h-4 min-w-4 text-[11px] leading-4'),
49+
countStyles: tw('px-[4px]'),
50+
},
51+
lg: {
52+
rootStyles: tw(baseStyles, 'min-h-4.5 min-w-4.5 text-[11px] leading-4.5'),
53+
countStyles: tw('px-[5px]'),
54+
},
55+
};
56+
57+
export function _getAllBadgeSizes(): ReadonlyArray<BadgeSize> {
58+
return Object.keys(BadgeSizes) as Array<BadgeSize>;
59+
}
60+
61+
let cachedNumberFormat: Intl.NumberFormat;
62+
63+
// eslint-disable-next-line no-inner-declarations
64+
function formatBadgeCount(
65+
value: number,
66+
max: number,
67+
maxDisplay: string
68+
): string {
69+
if (value > max) {
70+
return maxDisplay;
71+
}
72+
cachedNumberFormat ??= new Intl.NumberFormat();
73+
return cachedNumberFormat.format(value);
74+
}
75+
76+
/**
77+
* Component: <AxoBadge.Root>
78+
* --------------------------
79+
*/
80+
81+
export type RootProps = Readonly<{
82+
size: BadgeSize;
83+
value: BadgeValue;
84+
max: number;
85+
maxDisplay: string;
86+
'aria-label': string | null;
87+
}>;
88+
89+
export const Root: FC<RootProps> = memo(props => {
90+
const { value, max, maxDisplay } = props;
91+
const config = BadgeSizes[props.size];
92+
93+
const children = useMemo(() => {
94+
if (value === 'unread') {
95+
return null;
96+
}
97+
if (value === 'mention') {
98+
return (
99+
<span aria-hidden className={tw('leading-none')}>
100+
<AxoSymbol.InlineGlyph symbol="at" label={null} />
101+
</span>
102+
);
103+
}
104+
if (typeof value === 'number') {
105+
return (
106+
<span aria-hidden className={config.countStyles}>
107+
{formatBadgeCount(value, max, maxDisplay)}
108+
</span>
109+
);
110+
}
111+
unreachable(value);
112+
}, [value, max, maxDisplay, config]);
113+
114+
return (
115+
<span
116+
aria-label={props['aria-label'] ?? undefined}
117+
className={config.rootStyles}
118+
>
119+
{children}
120+
</span>
121+
);
122+
});
123+
124+
Root.displayName = `${Namespace}.Root`;
125+
}

0 commit comments

Comments
 (0)