Skip to content

Commit

Permalink
Reintroduce NotificationController for in-app notifications (#709)
Browse files Browse the repository at this point in the history
* 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
FrederikBolding authored Apr 27, 2022
1 parent 5105fd5 commit 654e5dc
Show file tree
Hide file tree
Showing 2 changed files with 289 additions and 0 deletions.
113 changes: 113 additions & 0 deletions src/notification/NotificationController.test.ts
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);
});
});
176 changes: 176 additions & 0 deletions src/notification/NotificationController.ts
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();
}
}
});
}
}

0 comments on commit 654e5dc

Please sign in to comment.