Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reintroduce NotificationController for in-app notifications #709

Merged
merged 18 commits into from
Apr 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = {
FrederikBolding marked this conversation as resolved.
Show resolved Hide resolved
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)) {
mcmire marked this conversation as resolved.
Show resolved Hide resolved
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)) {
mcmire marked this conversation as resolved.
Show resolved Hide resolved
state.notifications[id].readDate = Date.now();
}
}
});
}
}