Skip to content

Commit 21e66ad

Browse files
authored
feat: support persistent and sorted notifications with manual timeout start (#1706)
1 parent e20b480 commit 21e66ad

3 files changed

Lines changed: 195 additions & 24 deletions

File tree

src/notifications/NotificationManager.ts

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ export class NotificationManager {
4242
add({ message, origin, options = {} }: AddNotificationPayload): string {
4343
const id = generateUUIDv4();
4444
const now = Date.now();
45-
const severity = options.severity || 'info';
46-
const duration = options.duration ?? this.config.durations[severity];
45+
const severity = options.severity;
46+
const duration =
47+
options.duration ?? (severity ? this.config.durations[severity] : undefined);
4748

4849
const notification: Notification = {
4950
id,
@@ -52,24 +53,21 @@ export class NotificationManager {
5253
type: options?.type,
5354
severity,
5455
createdAt: now,
55-
expiresAt: now + duration,
56+
duration,
5657
actions: options.actions,
5758
metadata: options.metadata,
59+
tags: options.tags,
5860
originalError: options.originalError,
5961
};
6062

63+
const notifications = [...this.store.getLatestValue().notifications, notification];
64+
6165
this.store.partialNext({
62-
notifications: [...this.store.getLatestValue().notifications, notification],
66+
notifications: this.config.sortComparator
67+
? [...notifications].sort(this.config.sortComparator)
68+
: notifications,
6369
});
6470

65-
if (notification.expiresAt) {
66-
const timeout = setTimeout(() => {
67-
this.remove(id);
68-
}, options.duration || this.config.durations[notification.severity]);
69-
70-
this.timeouts.set(id, timeout);
71-
}
72-
7371
return id;
7472
}
7573

@@ -89,12 +87,34 @@ export class NotificationManager {
8987
return this.add({ message, origin, options: { ...options, severity: 'success' } });
9088
}
9189

92-
remove(id: string): void {
90+
clearTimeout(id: string): void {
9391
const timeout = this.timeouts.get(id);
94-
if (timeout) {
95-
clearTimeout(timeout);
96-
this.timeouts.delete(id);
97-
}
92+
93+
if (!timeout) return;
94+
95+
clearTimeout(timeout);
96+
this.timeouts.delete(id);
97+
}
98+
99+
startTimeout(id: string, durationOverride?: number): void {
100+
const notification = this.store
101+
.getLatestValue()
102+
.notifications.find((n) => n.id === id);
103+
const duration = durationOverride ?? notification?.duration;
104+
105+
if (!notification || !duration) return;
106+
107+
this.clearTimeout(id);
108+
109+
const timeout = setTimeout(() => {
110+
this.remove(id);
111+
}, duration);
112+
113+
this.timeouts.set(id, timeout);
114+
}
115+
116+
remove(id: string): void {
117+
this.clearTimeout(id);
98118

99119
this.store.partialNext({
100120
notifications: this.store.getLatestValue().notifications.filter((n) => n.id !== id),

src/notifications/types.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@ export type Notification = {
2424
id: string;
2525
/** The notification message text */
2626
message: string;
27-
/** The severity level of the notification */
28-
severity: NotificationSeverity;
2927
/** Timestamp when notification was created */
3028
createdAt: number;
3129
/**
@@ -35,6 +33,8 @@ export type Notification = {
3533
origin: NotificationOrigin;
3634
/** Array of action buttons for the notification */
3735
actions?: NotificationAction[];
36+
/** The severity level of the notification. Defaults to undefined unless explicitly provided. */
37+
severity?: NotificationSeverity;
3838
/**
3939
* Optional code that can be used to group the notifications of the same type, e.g. attachment-upload-blocked.
4040
* Format: domain:entity:operation:result
@@ -79,19 +79,27 @@ export type Notification = {
7979
* 'system:resource:unavailable'; // System resource unavailable
8080
*/
8181
type?: string;
82-
/** Optional timestamp when notification should expire */
83-
expiresAt?: number;
82+
/** Optional auto-dismiss duration in milliseconds. The timeout starts when NotificationManager.startTimeout() is called. */
83+
duration?: number;
8484
/** Optional metadata to attach to the notification */
8585
metadata?: Record<string, unknown>;
86+
/** Optional tags that can be used for routing or grouping notifications (e.g. `target:channel`). */
87+
tags?: string[];
8688
/** In case of error notification the instance of the originally thrown error */
8789
originalError?: Error;
8890
};
8991

9092
/** Configuration options when creating a notification */
9193
export type NotificationOptions = Partial<
92-
Pick<Notification, 'type' | 'severity' | 'actions' | 'metadata' | 'originalError'>
94+
Pick<
95+
Notification,
96+
'type' | 'severity' | 'actions' | 'metadata' | 'tags' | 'originalError'
97+
>
9398
> & {
94-
/** How long a notification should be displayed in milliseconds */
99+
/**
100+
* How long a notification should be displayed in milliseconds.
101+
* Use `0` for persistent (no auto-dismiss); call `client.notifications.remove(id)` to dismiss.
102+
*/
95103
duration?: number;
96104
};
97105

@@ -107,8 +115,11 @@ export type NotificationState = {
107115
/** State shape for the notification store */
108116
export type NotificationManagerState = NotificationState;
109117

118+
export type NotificationSortComparator = (a: Notification, b: Notification) => number;
119+
110120
export type NotificationManagerConfig = {
111-
durations: Record<NotificationSeverity, number>;
121+
durations: Partial<Record<NotificationSeverity, number>>;
122+
sortComparator?: NotificationSortComparator;
112123
};
113124

114125
export type AddNotificationPayload = Pick<Notification, 'message' | 'origin'> & {
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { NotificationManager } from '../../../src';
4+
5+
describe('NotificationManager', () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers();
8+
});
9+
10+
it('sorts notifications with the configured comparator', () => {
11+
const manager = new NotificationManager({
12+
sortComparator: (a, b) => {
13+
const aTimed = !!a.duration;
14+
const bTimed = !!b.duration;
15+
16+
if (aTimed !== bTimed) return aTimed ? -1 : 1;
17+
return a.createdAt - b.createdAt;
18+
},
19+
});
20+
21+
vi.setSystemTime(new Date('2026-03-13T10:00:00.000Z'));
22+
manager.addInfo({
23+
message: 'persistent',
24+
origin: { emitter: 'test' },
25+
options: { duration: 0 },
26+
});
27+
28+
vi.setSystemTime(new Date('2026-03-13T10:00:01.000Z'));
29+
manager.addInfo({ message: 'timed', origin: { emitter: 'test' } });
30+
31+
expect(manager.notifications.map(({ message }) => message)).toEqual([
32+
'timed',
33+
'persistent',
34+
]);
35+
});
36+
37+
it('stores severity as undefined by default', () => {
38+
const manager = new NotificationManager();
39+
40+
manager.add({
41+
message: 'plain',
42+
origin: { emitter: 'test' },
43+
});
44+
45+
expect(manager.notifications[0]).toMatchObject({
46+
duration: undefined,
47+
severity: undefined,
48+
});
49+
});
50+
51+
it('stores configured duration for severity-based notifications', () => {
52+
const manager = new NotificationManager();
53+
54+
manager.addInfo({
55+
message: 'timed',
56+
origin: { emitter: 'test' },
57+
});
58+
59+
expect(manager.notifications[0]).toMatchObject({
60+
duration: 3000,
61+
severity: 'info',
62+
});
63+
});
64+
65+
it('stores tags on the notification', () => {
66+
const manager = new NotificationManager();
67+
68+
manager.addError({
69+
message: 'tagged',
70+
origin: { emitter: 'test' },
71+
options: { tags: ['composer', 'upload'] },
72+
});
73+
74+
expect(manager.notifications[0]).toMatchObject({
75+
severity: 'error',
76+
tags: ['composer', 'upload'],
77+
});
78+
});
79+
80+
it('starts removal timeout only when startTimeout is triggered', () => {
81+
const manager = new NotificationManager();
82+
83+
const notificationId = manager.addInfo({
84+
message: 'timed',
85+
origin: { emitter: 'test' },
86+
options: { duration: 1000 },
87+
});
88+
89+
vi.advanceTimersByTime(1000);
90+
expect(manager.notifications).toHaveLength(1);
91+
92+
manager.startTimeout(notificationId);
93+
vi.advanceTimersByTime(999);
94+
expect(manager.notifications).toHaveLength(1);
95+
96+
vi.advanceTimersByTime(1);
97+
expect(manager.notifications).toHaveLength(0);
98+
});
99+
100+
it('restarts timeout when startTimeout is called again for the same notification', () => {
101+
const manager = new NotificationManager();
102+
103+
const notificationId = manager.addInfo({
104+
message: 'timed',
105+
origin: { emitter: 'test' },
106+
options: { duration: 1000 },
107+
});
108+
109+
manager.startTimeout(notificationId);
110+
vi.advanceTimersByTime(500);
111+
112+
manager.startTimeout(notificationId);
113+
vi.advanceTimersByTime(999);
114+
expect(manager.notifications).toHaveLength(1);
115+
116+
vi.advanceTimersByTime(1);
117+
expect(manager.notifications).toHaveLength(0);
118+
});
119+
120+
it('treats missing duration as a no-op unless an override is provided', () => {
121+
const manager = new NotificationManager();
122+
123+
const notificationId = manager.addInfo({
124+
message: 'persistent',
125+
origin: { emitter: 'test' },
126+
options: { duration: 0 },
127+
});
128+
129+
manager.startTimeout(notificationId);
130+
vi.advanceTimersByTime(1000);
131+
expect(manager.notifications).toHaveLength(1);
132+
133+
manager.startTimeout(notificationId, 250);
134+
vi.advanceTimersByTime(249);
135+
expect(manager.notifications).toHaveLength(1);
136+
137+
vi.advanceTimersByTime(1);
138+
expect(manager.notifications).toHaveLength(0);
139+
});
140+
});

0 commit comments

Comments
 (0)