-
-
Notifications
You must be signed in to change notification settings - Fork 208
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
5105fd5
commit 654e5dc
Showing
2 changed files
with
289 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import { ControllerMessenger } from '../ControllerMessenger'; | ||
import { | ||
ControllerActions, | ||
NotificationController, | ||
NotificationControllerStateChange, | ||
} from './NotificationController'; | ||
|
||
const name = 'NotificationController'; | ||
|
||
/** | ||
* Constructs a unrestricted controller messenger. | ||
* | ||
* @returns A unrestricted controller messenger. | ||
*/ | ||
function getUnrestrictedMessenger() { | ||
return new ControllerMessenger< | ||
ControllerActions, | ||
NotificationControllerStateChange | ||
>(); | ||
} | ||
|
||
/** | ||
* Constructs a restricted controller messenger. | ||
* | ||
* @param controllerMessenger - An optional unrestricted messenger | ||
* @returns A restricted controller messenger. | ||
*/ | ||
function getRestrictedMessenger( | ||
controllerMessenger = getUnrestrictedMessenger(), | ||
) { | ||
return controllerMessenger.getRestricted<typeof name, never, never>({ | ||
name, | ||
}); | ||
} | ||
|
||
const origin = 'snap_test'; | ||
const message = 'foo'; | ||
|
||
describe('NotificationController', () => { | ||
it('action: NotificationController:show', async () => { | ||
const unrestricted = getUnrestrictedMessenger(); | ||
const messenger = getRestrictedMessenger(unrestricted); | ||
|
||
const controller = new NotificationController({ | ||
messenger, | ||
}); | ||
|
||
expect( | ||
await unrestricted.call('NotificationController:show', origin, message), | ||
).toBeUndefined(); | ||
const notifications = Object.values(controller.state.notifications); | ||
expect(notifications).toHaveLength(1); | ||
expect(notifications).toContainEqual({ | ||
createdDate: expect.any(Number), | ||
id: expect.any(String), | ||
message, | ||
origin, | ||
readDate: null, | ||
}); | ||
}); | ||
|
||
it('action: NotificationController:markViewed', async () => { | ||
const unrestricted = getUnrestrictedMessenger(); | ||
const messenger = getRestrictedMessenger(unrestricted); | ||
|
||
const controller = new NotificationController({ | ||
messenger, | ||
}); | ||
|
||
expect( | ||
await unrestricted.call('NotificationController:show', origin, message), | ||
).toBeUndefined(); | ||
const notifications = Object.values(controller.state.notifications); | ||
expect(notifications).toHaveLength(1); | ||
expect( | ||
await unrestricted.call('NotificationController:markRead', [ | ||
notifications[0].id, | ||
'foo', | ||
]), | ||
).toBeUndefined(); | ||
|
||
const newNotifications = Object.values(controller.state.notifications); | ||
expect(newNotifications).toContainEqual({ | ||
...notifications[0], | ||
readDate: expect.any(Number), | ||
}); | ||
|
||
expect(newNotifications).toHaveLength(1); | ||
}); | ||
|
||
it('action: NotificationController:dismiss', async () => { | ||
const unrestricted = getUnrestrictedMessenger(); | ||
const messenger = getRestrictedMessenger(unrestricted); | ||
|
||
const controller = new NotificationController({ | ||
messenger, | ||
}); | ||
|
||
expect( | ||
await unrestricted.call('NotificationController:show', origin, message), | ||
).toBeUndefined(); | ||
const notifications = Object.values(controller.state.notifications); | ||
expect(notifications).toHaveLength(1); | ||
expect( | ||
await unrestricted.call('NotificationController:dismiss', [ | ||
notifications[0].id, | ||
'foo', | ||
]), | ||
).toBeUndefined(); | ||
|
||
expect(Object.values(controller.state.notifications)).toHaveLength(0); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
import type { Patch } from 'immer'; | ||
import { nanoid } from 'nanoid'; | ||
|
||
import { hasProperty } from '../util'; | ||
import { BaseController } from '../BaseControllerV2'; | ||
|
||
import type { RestrictedControllerMessenger } from '../ControllerMessenger'; | ||
|
||
/** | ||
* @typedef NotificationControllerState | ||
* @property notifications - Stores existing notifications to be shown in the UI | ||
*/ | ||
export type NotificationControllerState = { | ||
notifications: Record<string, Notification>; | ||
}; | ||
|
||
/** | ||
* @typedef Notification - Stores information about in-app notifications, to be shown in the UI | ||
* @property id - A UUID that identifies the notification | ||
* @property origin - The origin that requested the notification | ||
* @property createdDate - The notification creation date in milliseconds elapsed since the UNIX epoch | ||
* @property readDate - The notification read date in milliseconds elapsed since the UNIX epoch or null if unread | ||
* @property message - The notification message | ||
*/ | ||
export type Notification = { | ||
id: string; | ||
origin: string; | ||
createdDate: number; | ||
readDate: number | null; | ||
message: string; | ||
}; | ||
|
||
const name = 'NotificationController'; | ||
|
||
export type NotificationControllerStateChange = { | ||
type: `${typeof name}:stateChange`; | ||
payload: [NotificationControllerState, Patch[]]; | ||
}; | ||
|
||
export type GetNotificationControllerState = { | ||
type: `${typeof name}:getState`; | ||
handler: () => NotificationControllerState; | ||
}; | ||
|
||
export type ShowNotification = { | ||
type: `${typeof name}:show`; | ||
handler: NotificationController['show']; | ||
}; | ||
|
||
export type DismissNotification = { | ||
type: `${typeof name}:dismiss`; | ||
handler: NotificationController['dismiss']; | ||
}; | ||
|
||
export type MarkNotificationRead = { | ||
type: `${typeof name}:markRead`; | ||
handler: NotificationController['markRead']; | ||
}; | ||
|
||
export type ControllerActions = | ||
| GetNotificationControllerState | ||
| ShowNotification | ||
| DismissNotification | ||
| MarkNotificationRead; | ||
|
||
export type NotificationControllerMessenger = RestrictedControllerMessenger< | ||
typeof name, | ||
ControllerActions, | ||
NotificationControllerStateChange, | ||
never, | ||
never | ||
>; | ||
|
||
const metadata = { | ||
notifications: { persist: true, anonymous: false }, | ||
}; | ||
|
||
const defaultState = { | ||
notifications: {}, | ||
}; | ||
|
||
/** | ||
* Controller that handles storing notifications and showing them to the user | ||
*/ | ||
export class NotificationController extends BaseController< | ||
typeof name, | ||
NotificationControllerState, | ||
NotificationControllerMessenger | ||
> { | ||
/** | ||
* Creates a NotificationController instance. | ||
* | ||
* @param options - Constructor options. | ||
* @param options.messenger - A reference to the messaging system. | ||
* @param options.state - Initial state to set on this controller. | ||
*/ | ||
constructor({ | ||
messenger, | ||
state, | ||
}: { | ||
messenger: NotificationControllerMessenger; | ||
state?: Partial<NotificationControllerState>; | ||
}) { | ||
super({ | ||
name, | ||
metadata, | ||
messenger, | ||
state: { ...defaultState, ...state }, | ||
}); | ||
|
||
this.messagingSystem.registerActionHandler( | ||
`${name}:show` as const, | ||
(origin: string, message: string) => this.show(origin, message), | ||
); | ||
|
||
this.messagingSystem.registerActionHandler( | ||
`${name}:dismiss` as const, | ||
(ids: string[]) => this.dismiss(ids), | ||
); | ||
|
||
this.messagingSystem.registerActionHandler( | ||
`${name}:markRead` as const, | ||
(ids: string[]) => this.markRead(ids), | ||
); | ||
} | ||
|
||
/** | ||
* Shows a notification. | ||
* | ||
* @param origin - The origin trying to send a notification | ||
* @param message - A message to show on the notification | ||
*/ | ||
show(origin: string, message: string) { | ||
const id = nanoid(); | ||
const notification = { | ||
id, | ||
origin, | ||
createdDate: Date.now(), | ||
readDate: null, | ||
message, | ||
}; | ||
this.update((state) => { | ||
state.notifications[id] = notification; | ||
}); | ||
} | ||
|
||
/** | ||
* Dimisses a list of notifications. | ||
* | ||
* @param ids - A list of notification IDs | ||
*/ | ||
dismiss(ids: string[]) { | ||
this.update((state) => { | ||
for (const id of ids) { | ||
if (hasProperty(state.notifications, id)) { | ||
delete state.notifications[id]; | ||
} | ||
} | ||
}); | ||
} | ||
|
||
/** | ||
* Marks a list of notifications as read. | ||
* | ||
* @param ids - A list of notification IDs | ||
*/ | ||
markRead(ids: string[]) { | ||
this.update((state) => { | ||
for (const id of ids) { | ||
if (hasProperty(state.notifications, id)) { | ||
state.notifications[id].readDate = Date.now(); | ||
} | ||
} | ||
}); | ||
} | ||
} |