Skip to content

Commit 654e5dc

Browse files
Reintroduce NotificationController for in-app notifications (#709)
* Revive NotificationControllerV2 for in-app notifications * Add more functionality and tests * Test another case * Remove getCurrent, add getNotifications * Add a bit more information to the notifications * Add getUnreadCount * Rename to NotificationController * Support multiple ids for dismissing and marking as read * Simplify further * Run linting * Fix some PR comments * Remove getters * Add readDate * Add some documentation * Expand test slightly * Fix test type * Remove native notifications
1 parent 5105fd5 commit 654e5dc

File tree

2 files changed

+289
-0
lines changed

2 files changed

+289
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { ControllerMessenger } from '../ControllerMessenger';
2+
import {
3+
ControllerActions,
4+
NotificationController,
5+
NotificationControllerStateChange,
6+
} from './NotificationController';
7+
8+
const name = 'NotificationController';
9+
10+
/**
11+
* Constructs a unrestricted controller messenger.
12+
*
13+
* @returns A unrestricted controller messenger.
14+
*/
15+
function getUnrestrictedMessenger() {
16+
return new ControllerMessenger<
17+
ControllerActions,
18+
NotificationControllerStateChange
19+
>();
20+
}
21+
22+
/**
23+
* Constructs a restricted controller messenger.
24+
*
25+
* @param controllerMessenger - An optional unrestricted messenger
26+
* @returns A restricted controller messenger.
27+
*/
28+
function getRestrictedMessenger(
29+
controllerMessenger = getUnrestrictedMessenger(),
30+
) {
31+
return controllerMessenger.getRestricted<typeof name, never, never>({
32+
name,
33+
});
34+
}
35+
36+
const origin = 'snap_test';
37+
const message = 'foo';
38+
39+
describe('NotificationController', () => {
40+
it('action: NotificationController:show', async () => {
41+
const unrestricted = getUnrestrictedMessenger();
42+
const messenger = getRestrictedMessenger(unrestricted);
43+
44+
const controller = new NotificationController({
45+
messenger,
46+
});
47+
48+
expect(
49+
await unrestricted.call('NotificationController:show', origin, message),
50+
).toBeUndefined();
51+
const notifications = Object.values(controller.state.notifications);
52+
expect(notifications).toHaveLength(1);
53+
expect(notifications).toContainEqual({
54+
createdDate: expect.any(Number),
55+
id: expect.any(String),
56+
message,
57+
origin,
58+
readDate: null,
59+
});
60+
});
61+
62+
it('action: NotificationController:markViewed', async () => {
63+
const unrestricted = getUnrestrictedMessenger();
64+
const messenger = getRestrictedMessenger(unrestricted);
65+
66+
const controller = new NotificationController({
67+
messenger,
68+
});
69+
70+
expect(
71+
await unrestricted.call('NotificationController:show', origin, message),
72+
).toBeUndefined();
73+
const notifications = Object.values(controller.state.notifications);
74+
expect(notifications).toHaveLength(1);
75+
expect(
76+
await unrestricted.call('NotificationController:markRead', [
77+
notifications[0].id,
78+
'foo',
79+
]),
80+
).toBeUndefined();
81+
82+
const newNotifications = Object.values(controller.state.notifications);
83+
expect(newNotifications).toContainEqual({
84+
...notifications[0],
85+
readDate: expect.any(Number),
86+
});
87+
88+
expect(newNotifications).toHaveLength(1);
89+
});
90+
91+
it('action: NotificationController:dismiss', async () => {
92+
const unrestricted = getUnrestrictedMessenger();
93+
const messenger = getRestrictedMessenger(unrestricted);
94+
95+
const controller = new NotificationController({
96+
messenger,
97+
});
98+
99+
expect(
100+
await unrestricted.call('NotificationController:show', origin, message),
101+
).toBeUndefined();
102+
const notifications = Object.values(controller.state.notifications);
103+
expect(notifications).toHaveLength(1);
104+
expect(
105+
await unrestricted.call('NotificationController:dismiss', [
106+
notifications[0].id,
107+
'foo',
108+
]),
109+
).toBeUndefined();
110+
111+
expect(Object.values(controller.state.notifications)).toHaveLength(0);
112+
});
113+
});
+176
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import type { Patch } from 'immer';
2+
import { nanoid } from 'nanoid';
3+
4+
import { hasProperty } from '../util';
5+
import { BaseController } from '../BaseControllerV2';
6+
7+
import type { RestrictedControllerMessenger } from '../ControllerMessenger';
8+
9+
/**
10+
* @typedef NotificationControllerState
11+
* @property notifications - Stores existing notifications to be shown in the UI
12+
*/
13+
export type NotificationControllerState = {
14+
notifications: Record<string, Notification>;
15+
};
16+
17+
/**
18+
* @typedef Notification - Stores information about in-app notifications, to be shown in the UI
19+
* @property id - A UUID that identifies the notification
20+
* @property origin - The origin that requested the notification
21+
* @property createdDate - The notification creation date in milliseconds elapsed since the UNIX epoch
22+
* @property readDate - The notification read date in milliseconds elapsed since the UNIX epoch or null if unread
23+
* @property message - The notification message
24+
*/
25+
export type Notification = {
26+
id: string;
27+
origin: string;
28+
createdDate: number;
29+
readDate: number | null;
30+
message: string;
31+
};
32+
33+
const name = 'NotificationController';
34+
35+
export type NotificationControllerStateChange = {
36+
type: `${typeof name}:stateChange`;
37+
payload: [NotificationControllerState, Patch[]];
38+
};
39+
40+
export type GetNotificationControllerState = {
41+
type: `${typeof name}:getState`;
42+
handler: () => NotificationControllerState;
43+
};
44+
45+
export type ShowNotification = {
46+
type: `${typeof name}:show`;
47+
handler: NotificationController['show'];
48+
};
49+
50+
export type DismissNotification = {
51+
type: `${typeof name}:dismiss`;
52+
handler: NotificationController['dismiss'];
53+
};
54+
55+
export type MarkNotificationRead = {
56+
type: `${typeof name}:markRead`;
57+
handler: NotificationController['markRead'];
58+
};
59+
60+
export type ControllerActions =
61+
| GetNotificationControllerState
62+
| ShowNotification
63+
| DismissNotification
64+
| MarkNotificationRead;
65+
66+
export type NotificationControllerMessenger = RestrictedControllerMessenger<
67+
typeof name,
68+
ControllerActions,
69+
NotificationControllerStateChange,
70+
never,
71+
never
72+
>;
73+
74+
const metadata = {
75+
notifications: { persist: true, anonymous: false },
76+
};
77+
78+
const defaultState = {
79+
notifications: {},
80+
};
81+
82+
/**
83+
* Controller that handles storing notifications and showing them to the user
84+
*/
85+
export class NotificationController extends BaseController<
86+
typeof name,
87+
NotificationControllerState,
88+
NotificationControllerMessenger
89+
> {
90+
/**
91+
* Creates a NotificationController instance.
92+
*
93+
* @param options - Constructor options.
94+
* @param options.messenger - A reference to the messaging system.
95+
* @param options.state - Initial state to set on this controller.
96+
*/
97+
constructor({
98+
messenger,
99+
state,
100+
}: {
101+
messenger: NotificationControllerMessenger;
102+
state?: Partial<NotificationControllerState>;
103+
}) {
104+
super({
105+
name,
106+
metadata,
107+
messenger,
108+
state: { ...defaultState, ...state },
109+
});
110+
111+
this.messagingSystem.registerActionHandler(
112+
`${name}:show` as const,
113+
(origin: string, message: string) => this.show(origin, message),
114+
);
115+
116+
this.messagingSystem.registerActionHandler(
117+
`${name}:dismiss` as const,
118+
(ids: string[]) => this.dismiss(ids),
119+
);
120+
121+
this.messagingSystem.registerActionHandler(
122+
`${name}:markRead` as const,
123+
(ids: string[]) => this.markRead(ids),
124+
);
125+
}
126+
127+
/**
128+
* Shows a notification.
129+
*
130+
* @param origin - The origin trying to send a notification
131+
* @param message - A message to show on the notification
132+
*/
133+
show(origin: string, message: string) {
134+
const id = nanoid();
135+
const notification = {
136+
id,
137+
origin,
138+
createdDate: Date.now(),
139+
readDate: null,
140+
message,
141+
};
142+
this.update((state) => {
143+
state.notifications[id] = notification;
144+
});
145+
}
146+
147+
/**
148+
* Dimisses a list of notifications.
149+
*
150+
* @param ids - A list of notification IDs
151+
*/
152+
dismiss(ids: string[]) {
153+
this.update((state) => {
154+
for (const id of ids) {
155+
if (hasProperty(state.notifications, id)) {
156+
delete state.notifications[id];
157+
}
158+
}
159+
});
160+
}
161+
162+
/**
163+
* Marks a list of notifications as read.
164+
*
165+
* @param ids - A list of notification IDs
166+
*/
167+
markRead(ids: string[]) {
168+
this.update((state) => {
169+
for (const id of ids) {
170+
if (hasProperty(state.notifications, id)) {
171+
state.notifications[id].readDate = Date.now();
172+
}
173+
}
174+
});
175+
}
176+
}

0 commit comments

Comments
 (0)