Files
webmail/lib/plugin-sandbox/host-api-notify.ts
T

67 lines
3.2 KiB
TypeScript

// Host side of api.notifications.show: a system notification when the user
// has allowed them for the webmail, otherwise a toast. Every one names the
// plugin that sent it, and a plugin may send only a few a minute.
import type { InstalledPlugin, NotificationInput } from '../plugin-types';
import { toast as appToast } from '@/stores/toast-store';
import { toastHooks } from '../plugin-hooks';
import { readObject, readString, withinRate } from './host-api-input';
import { recordNotification } from '@/stores/notification-center-store';
export const MAX_NOTIFICATION_TITLE = 120;
export const MAX_NOTIFICATION_BODY = 300;
export const NOTIFICATIONS_PER_MINUTE = 5;
const TAG_RE = /^[A-Za-z0-9._:-]{1,64}$/;
// eslint-disable-next-line no-control-regex -- line breaks and other control characters become spaces
const oneLine = (text: string) => text.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
/**
* Show a notification from a plugin. Resolves to 'system' when the browser
* showed it, 'toast' when the app did. Clicking a system notification
* brings the webmail to the front and fires onNotificationClick for this
* plugin only, with the tag it passed.
*/
export function doNotificationsShow(plugin: InstalledPlugin, input: unknown): 'system' | 'toast' {
const method = 'notifications.show';
const data = readObject(input, method, 'notification', ['title', 'body', 'tag']) as NotificationInput & Record<string, unknown>;
const title = oneLine(readString(data.title, method, 'title', MAX_NOTIFICATION_TITLE, { min: 1 }));
if (!title) throw new Error(`${method}: title must not be empty`);
const body = data.body === undefined ? '' : oneLine(readString(data.body, method, 'body', MAX_NOTIFICATION_BODY));
let tag: string | undefined;
if (data.tag !== undefined) {
if (typeof data.tag !== 'string' || !TAG_RE.test(data.tag)) {
throw new Error(`${method}: tag must be 1-64 letters, digits or . _ : -`);
}
tag = data.tag;
}
if (!withinRate('notifications', plugin.id, NOTIFICATIONS_PER_MINUTE)) {
throw new Error(`${method}: at most ${NOTIFICATIONS_PER_MINUTE} notifications a minute`);
}
const source = plugin.name || plugin.id;
// Also listed in the app's notification panel (the bell), where a click
// reaches the plugin the same way as a click on the system notification.
recordNotification({ level: 'info', title, body, pluginId: plugin.id, pluginName: source, ...(tag ? { tag } : {}) });
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
try {
const notification = new Notification(title, {
body: body ? `${body}\n${source}` : source,
// Scoped to the plugin, so one plugin's tag can't replace another's notification.
...(tag ? { tag: `plugin:${plugin.id}:${tag}` } : {}),
});
notification.onclick = () => {
try { window.focus(); } catch { /* ignore */ }
notification.close();
void toastHooks.onNotificationClick.emitTo(plugin.id, { tag: tag ?? '' });
};
return 'system';
} catch {
// Some browsers (Chrome on Android) only allow notifications from a
// service worker; the toast below still tells the user.
}
}
appToast.info(title, { message: body ? `${body} (${source})` : source });
return 'toast';
}