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 15 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
176 changes: 176 additions & 0 deletions src/notification/NotificationController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { ControllerMessenger } from '../ControllerMessenger';
import {
ControllerActions,
NotificationController,
NotificationControllerMessenger,
NotificationControllerStateChange,
NotificationType,
} 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,
ControllerActions['type'],
never
>({
name,
allowedActions: ['NotificationController:show'],
}) as NotificationControllerMessenger;
}

const origin = 'snap_test';
const message = 'foo';

describe('NotificationController', () => {
it('action: NotificationController:show native notifications', async () => {
const unrestricted = getUnrestrictedMessenger();
const messenger = getRestrictedMessenger(unrestricted);

const showNativeNotification = jest.fn();
new NotificationController({
showNativeNotification,
messenger,
});

expect(
await unrestricted.call('NotificationController:show', origin, {
type: NotificationType.Native,
message,
}),
).toBeUndefined();
expect(showNativeNotification).toHaveBeenCalledTimes(1);
expect(showNativeNotification).toHaveBeenCalledWith(origin, message);
});

it('action: NotificationController:show in-app notifications', async () => {
const unrestricted = getUnrestrictedMessenger();
const messenger = getRestrictedMessenger(unrestricted);

const showNativeNotification = jest.fn();
const controller = new NotificationController({
showNativeNotification,
messenger,
});

expect(
await unrestricted.call('NotificationController:show', origin, {
type: NotificationType.InApp,
message,
}),
).toBeUndefined();
expect(showNativeNotification).toHaveBeenCalledTimes(0);
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,
type: NotificationType.InApp,
});
});

it('action: NotificationController:markViewed', async () => {
const unrestricted = getUnrestrictedMessenger();
const messenger = getRestrictedMessenger(unrestricted);

const showNativeNotification = jest.fn();
const controller = new NotificationController({
showNativeNotification,
messenger,
});

expect(
await unrestricted.call('NotificationController:show', origin, {
type: NotificationType.InApp,
message,
}),
).toBeUndefined();
expect(showNativeNotification).toHaveBeenCalledTimes(0);
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 showNativeNotification = jest.fn();
const controller = new NotificationController({
showNativeNotification,
messenger,
});

expect(
await unrestricted.call('NotificationController:show', origin, {
type: NotificationType.InApp,
message,
}),
).toBeUndefined();
expect(showNativeNotification).toHaveBeenCalledTimes(0);
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);
});

it('uses showNativeNotification to show a notification', () => {
const messenger = getRestrictedMessenger();

const showNativeNotification = jest.fn();
const controller = new NotificationController({
showNativeNotification,
messenger,
});
expect(
controller.show(origin, {
type: NotificationType.Native,
message,
}),
).toBeUndefined();
expect(showNativeNotification).toHaveBeenCalledWith(origin, message);
});
});
215 changes: 215 additions & 0 deletions src/notification/NotificationController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
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 type - The notification type
* @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;
type: NotificationType;
origin: string;
createdDate: number;
readDate: number | null;
message: string;
};

export enum NotificationType {
Native = 'native',
FrederikBolding marked this conversation as resolved.
Show resolved Hide resolved
InApp = 'in-app',
}

export interface NotificationArgs {
/**
* Enum type to determine notification type.
*/
type: NotificationType;

/**
* A message to show on the notification.
*/
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
> {
private showNativeNotification;

/**
* 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.
* @param options.showNativeNotification - Function that shows a native notification in the consumer
*/
constructor({
messenger,
state,
showNativeNotification,
}: {
messenger: NotificationControllerMessenger;
state?: Partial<NotificationControllerState>;
showNativeNotification: (origin: string, message: string) => void;
}) {
super({
name,
metadata,
messenger,
state: { ...defaultState, ...state },
});
this.showNativeNotification = showNativeNotification;

this.messagingSystem.registerActionHandler(
`${name}:show` as const,
(origin: string, args: NotificationArgs) => this.show(origin, args),
);

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 args - Notification arguments, containing the notification message etc.
*/
show(origin: string, args: NotificationArgs) {
switch (args.type) {
case NotificationType.Native:
this.showNativeNotification(origin, args.message);
break;
case NotificationType.InApp:
this.add(origin, args.message);
break;
default:
throw new Error('Invalid notification type');
}
}

private add(origin: string, message: string) {
const id = nanoid();
const notification = {
id,
type: NotificationType.InApp,
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();
}
}
});
}
}