pgkiln 0.83.0 → 0.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +2 -1
- package/docs/guide/03-builder.md +2 -0
- package/docs/guide/04-pages-and-regions.md +42 -0
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +1 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +7 -0
- package/package.json +1 -1
- package/public/app.css +29 -0
- package/public/app.js +78 -0
- package/public/sw.js +5 -1
- package/src/app.ts +2 -0
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +10 -6
- package/src/builder/i18n/de.ts +8 -0
- package/src/builder/i18n/en.ts +8 -0
- package/src/builder/i18n/es.ts +8 -0
- package/src/builder/i18n/fr.ts +8 -0
- package/src/builder/i18n/it.ts +8 -0
- package/src/builder/i18n/ja.ts +8 -0
- package/src/builder/i18n/ko.ts +8 -0
- package/src/builder/i18n/nl.ts +8 -0
- package/src/builder/i18n/pt.ts +8 -0
- package/src/builder/i18n/zh-cn.ts +8 -0
- package/src/builder/i18n/zh-tw.ts +8 -0
- package/src/builder/notification-settings.ts +27 -0
- package/src/builder/routes.ts +3 -0
- package/src/cli/replace.ts +2 -0
- package/src/i18n/ar.ts +9 -0
- package/src/i18n/cs.ts +9 -0
- package/src/i18n/da.ts +9 -0
- package/src/i18n/de.ts +9 -0
- package/src/i18n/el.ts +9 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/fi.ts +9 -0
- package/src/i18n/fr.ts +9 -0
- package/src/i18n/he.ts +9 -0
- package/src/i18n/it.ts +9 -0
- package/src/i18n/ja.ts +9 -0
- package/src/i18n/ko.ts +9 -0
- package/src/i18n/nb.ts +9 -0
- package/src/i18n/pl.ts +9 -0
- package/src/i18n/pt.ts +9 -0
- package/src/i18n/ru.ts +9 -0
- package/src/i18n/sv.ts +9 -0
- package/src/i18n/tr.ts +9 -0
- package/src/i18n/uk.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/i18n.ts +18 -0
- package/src/metadata.ts +9 -3
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/notifications.ts +237 -0
- package/src/runtime/processes.ts +45 -1
- package/src/runtime/regions.ts +11 -15
- package/src/runtime/render.ts +12 -3
- package/src/runtime/routes.ts +12 -4
- package/src/runtime/signin.ts +2 -1
- package/src/session.ts +13 -4
package/src/i18n/pl.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const pl: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "Przeglądarki, w których wybrano „Nie wylogowuj mnie”: {count}.",
|
|
311
311
|
'account.forget_devices': "Wyloguj na wszystkich urządzeniach",
|
|
312
312
|
'account.devices_forgotten': "Wylogowano na wszystkich urządzeniach.",
|
|
313
|
+
'notify.title': "Powiadomienia",
|
|
314
|
+
'notify.none': "Brak powiadomień.",
|
|
315
|
+
'notify.mark_all_read': "Oznacz wszystkie jako przeczytane",
|
|
316
|
+
'notify.all': "Wszystkie powiadomienia",
|
|
317
|
+
'notify.marked': "Powiadomienia oznaczono jako przeczytane.",
|
|
318
|
+
'notify.gone': "To powiadomienie już nie istnieje.",
|
|
319
|
+
'notify.bell_one': "Powiadomienia: 1 nieprzeczytane",
|
|
320
|
+
'notify.bell_n': "Powiadomienia: nieprzeczytane: {n}",
|
|
321
|
+
'notify.unread_one': "Nieprzeczytane",
|
|
313
322
|
'push.title': "Powiadomienia",
|
|
314
323
|
'push.help': "Otrzymuj powiadomienia z tej aplikacji na tym urządzeniu, także gdy aplikacja jest zamknięta.",
|
|
315
324
|
'push.devices': "Urządzenia z powiadomieniami: {n}.",
|
package/src/i18n/pt.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const pt: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "Navegadores onde escolheu “Manter a sessão iniciada”: {count}.",
|
|
311
311
|
'account.forget_devices': "Terminar sessão em todos os dispositivos",
|
|
312
312
|
'account.devices_forgotten': "Sessão terminada em todos os dispositivos.",
|
|
313
|
+
'notify.title': "Notificações",
|
|
314
|
+
'notify.none': "Sem notificações.",
|
|
315
|
+
'notify.mark_all_read': "Marcar todas como lidas",
|
|
316
|
+
'notify.all': "Todas as notificações",
|
|
317
|
+
'notify.marked': "Notificações marcadas como lidas.",
|
|
318
|
+
'notify.gone': "Esta notificação já não existe.",
|
|
319
|
+
'notify.bell_one': "Notificações: 1 por ler",
|
|
320
|
+
'notify.bell_n': "Notificações: {n} por ler",
|
|
321
|
+
'notify.unread_one': "Por ler",
|
|
313
322
|
'push.title': "Notificações",
|
|
314
323
|
'push.help': "Receba notificações desta aplicação neste dispositivo, mesmo com a aplicação fechada.",
|
|
315
324
|
'push.devices': "Dispositivos com notificações: {n}.",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const ru: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "Браузеров, где выбрано «Не выходить»: {count}.",
|
|
311
311
|
'account.forget_devices': "Выйти на всех устройствах",
|
|
312
312
|
'account.devices_forgotten': "Выход выполнен на всех устройствах.",
|
|
313
|
+
'notify.title': "Уведомления",
|
|
314
|
+
'notify.none': "Уведомлений нет.",
|
|
315
|
+
'notify.mark_all_read': "Отметить все как прочитанные",
|
|
316
|
+
'notify.all': "Все уведомления",
|
|
317
|
+
'notify.marked': "Уведомления отмечены как прочитанные.",
|
|
318
|
+
'notify.gone': "Этого уведомления больше нет.",
|
|
319
|
+
'notify.bell_one': "Уведомления: 1 непрочитанное",
|
|
320
|
+
'notify.bell_n': "Уведомления: непрочитанных — {n}",
|
|
321
|
+
'notify.unread_one': "Непрочитанное",
|
|
313
322
|
'push.title': "Уведомления",
|
|
314
323
|
'push.help': "Получайте уведомления этого приложения на этом устройстве, даже когда приложение закрыто.",
|
|
315
324
|
'push.devices': "Устройства с уведомлениями: {n}.",
|
package/src/i18n/sv.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const sv: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "Webbläsare där du valde ”Håll mig inloggad”: {count}.",
|
|
311
311
|
'account.forget_devices': "Logga ut på alla enheter",
|
|
312
312
|
'account.devices_forgotten': "Utloggad på alla enheter.",
|
|
313
|
+
'notify.title': "Aviseringar",
|
|
314
|
+
'notify.none': "Inga aviseringar.",
|
|
315
|
+
'notify.mark_all_read': "Markera alla som lästa",
|
|
316
|
+
'notify.all': "Alla aviseringar",
|
|
317
|
+
'notify.marked': "Aviseringarna har markerats som lästa.",
|
|
318
|
+
'notify.gone': "Den här aviseringen finns inte längre.",
|
|
319
|
+
'notify.bell_one': "Aviseringar: 1 oläst",
|
|
320
|
+
'notify.bell_n': "Aviseringar: {n} olästa",
|
|
321
|
+
'notify.unread_one': "Oläst",
|
|
313
322
|
'push.title': "Aviseringar",
|
|
314
323
|
'push.help': "Få aviseringar från den här appen på den här enheten, även när appen är stängd.",
|
|
315
324
|
'push.devices': "Enheter med aviseringar: {n}.",
|
package/src/i18n/tr.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const tr: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "“Oturumum açık kalsın” seçtiğiniz tarayıcılar: {count}.",
|
|
311
311
|
'account.forget_devices': "Tüm cihazlarda oturumu kapat",
|
|
312
312
|
'account.devices_forgotten': "Tüm cihazlarda oturum kapatıldı.",
|
|
313
|
+
'notify.title': "Bildirimler",
|
|
314
|
+
'notify.none': "Bildirim yok.",
|
|
315
|
+
'notify.mark_all_read': "Tümünü okundu olarak işaretle",
|
|
316
|
+
'notify.all': "Tüm bildirimler",
|
|
317
|
+
'notify.marked': "Bildirimler okundu olarak işaretlendi.",
|
|
318
|
+
'notify.gone': "Bu bildirim artık yok.",
|
|
319
|
+
'notify.bell_one': "Bildirimler: 1 okunmamış",
|
|
320
|
+
'notify.bell_n': "Bildirimler: {n} okunmamış",
|
|
321
|
+
'notify.unread_one': "Okunmamış",
|
|
313
322
|
'push.title': "Bildirimler",
|
|
314
323
|
'push.help': "Uygulama kapalıyken de bu cihazda bu uygulamanın bildirimlerini alın.",
|
|
315
324
|
'push.devices': "Bildirim alan cihazlar: {n}.",
|
package/src/i18n/uk.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const uk: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "Браузерів, де вибрано «Не виходити»: {count}.",
|
|
311
311
|
'account.forget_devices': "Вийти на всіх пристроях",
|
|
312
312
|
'account.devices_forgotten': "Вихід виконано на всіх пристроях.",
|
|
313
|
+
'notify.title': "Сповіщення",
|
|
314
|
+
'notify.none': "Сповіщень немає.",
|
|
315
|
+
'notify.mark_all_read': "Позначити всі як прочитані",
|
|
316
|
+
'notify.all': "Усі сповіщення",
|
|
317
|
+
'notify.marked': "Сповіщення позначено як прочитані.",
|
|
318
|
+
'notify.gone': "Цього сповіщення більше немає.",
|
|
319
|
+
'notify.bell_one': "Сповіщення: 1 непрочитане",
|
|
320
|
+
'notify.bell_n': "Сповіщення: непрочитаних — {n}",
|
|
321
|
+
'notify.unread_one': "Непрочитане",
|
|
313
322
|
'push.title': "Сповіщення",
|
|
314
323
|
'push.help': "Отримуйте сповіщення цього застосунку на цьому пристрої, навіть коли застосунок закрито.",
|
|
315
324
|
'push.devices': "Пристрої зі сповіщеннями: {n}.",
|
package/src/i18n/zh.ts
CHANGED
|
@@ -310,6 +310,15 @@ export const zh: Record<MessageKey, string> = {
|
|
|
310
310
|
'account.devices_help': "您选择了“保持登录”的浏览器:{count}。",
|
|
311
311
|
'account.forget_devices': "在所有设备上退出登录",
|
|
312
312
|
'account.devices_forgotten': "已在所有设备上退出登录。",
|
|
313
|
+
'notify.title': "通知",
|
|
314
|
+
'notify.none': "没有通知。",
|
|
315
|
+
'notify.mark_all_read': "全部标为已读",
|
|
316
|
+
'notify.all': "所有通知",
|
|
317
|
+
'notify.marked': "通知已标为已读。",
|
|
318
|
+
'notify.gone': "此通知已不存在。",
|
|
319
|
+
'notify.bell_one': "通知:1 条未读",
|
|
320
|
+
'notify.bell_n': "通知:{n} 条未读",
|
|
321
|
+
'notify.unread_one': "未读",
|
|
313
322
|
'push.title': "通知",
|
|
314
323
|
'push.help': "在此设备上接收此应用的通知,即使应用已关闭。",
|
|
315
324
|
'push.devices': "接收通知的设备:{n}。",
|
package/src/i18n.ts
CHANGED
|
@@ -337,6 +337,15 @@ const en = {
|
|
|
337
337
|
'account.devices_help': 'Browsers where you chose "Keep me signed in": {count}.',
|
|
338
338
|
'account.forget_devices': 'Sign out on all devices',
|
|
339
339
|
'account.devices_forgotten': 'Signed out on all devices.',
|
|
340
|
+
'notify.title': "Notifications",
|
|
341
|
+
'notify.none': "No notifications.",
|
|
342
|
+
'notify.mark_all_read': "Mark all read",
|
|
343
|
+
'notify.all': "All notifications",
|
|
344
|
+
'notify.marked': "Notifications marked as read.",
|
|
345
|
+
'notify.gone': "This notification no longer exists.",
|
|
346
|
+
'notify.bell_one': "Notifications: 1 unread",
|
|
347
|
+
'notify.bell_n': "Notifications: {n} unread",
|
|
348
|
+
'notify.unread_one': "Unread",
|
|
340
349
|
'push.title': "Notifications",
|
|
341
350
|
'push.help': "Get notifications from this app on this device, also when the app is closed.",
|
|
342
351
|
'push.devices': "Devices with notifications: {n}.",
|
|
@@ -1247,6 +1256,15 @@ const nl: Record<MessageKey, string> = {
|
|
|
1247
1256
|
'account.devices_help': 'Browsers waarin je "Ingelogd blijven" koos: {count}.',
|
|
1248
1257
|
'account.forget_devices': 'Overal afmelden',
|
|
1249
1258
|
'account.devices_forgotten': 'Op alle apparaten afgemeld.',
|
|
1259
|
+
'notify.title': "Meldingen",
|
|
1260
|
+
'notify.none': "Geen meldingen.",
|
|
1261
|
+
'notify.mark_all_read': "Alles gelezen",
|
|
1262
|
+
'notify.all': "Alle meldingen",
|
|
1263
|
+
'notify.marked': "Meldingen als gelezen gemarkeerd.",
|
|
1264
|
+
'notify.gone': "Deze melding bestaat niet meer.",
|
|
1265
|
+
'notify.bell_one': "Meldingen: 1 ongelezen",
|
|
1266
|
+
'notify.bell_n': "Meldingen: {n} ongelezen",
|
|
1267
|
+
'notify.unread_one': "Ongelezen",
|
|
1250
1268
|
'push.title': "Meldingen",
|
|
1251
1269
|
'push.help': "Ontvang meldingen van deze app op dit apparaat, ook als de app gesloten is.",
|
|
1252
1270
|
'push.devices': "Apparaten met meldingen: {n}.",
|
package/src/metadata.ts
CHANGED
|
@@ -112,6 +112,10 @@ export interface App {
|
|
|
112
112
|
availability_users: string[];
|
|
113
113
|
/** (084) users can send feedback from the navigation bar */
|
|
114
114
|
feedback: boolean;
|
|
115
|
+
/** (154) the notification bell in the header */
|
|
116
|
+
notifications: boolean;
|
|
117
|
+
/** (154) refresh the badges every so many seconds (null: never) */
|
|
118
|
+
live_badges_s: number | null;
|
|
115
119
|
/** (107) the application's version (Release 1.0), shown in its pages' footer */
|
|
116
120
|
version: string | null;
|
|
117
121
|
/** (085) schema.function(jsonb) returns jsonb that rewrites error messages; seconds of the session timeout warning */
|
|
@@ -322,7 +326,7 @@ export interface DynamicAction {
|
|
|
322
326
|
/** (149) */
|
|
323
327
|
| 'double_click' | 'key_down' | 'key_press' | 'mouse_enter' | 'mouse_leave' | 'select' | 'resize' | 'scroll' | 'page_unload' | 'before_refresh' | 'facets_change'
|
|
324
328
|
/** (153) component events: trigger = the grid, calendar, map or tree region */
|
|
325
|
-
| 'grid_row_init' | 'grid_save' | 'calendar_date_select' | 'calendar_view_change' | 'map_object_click' | 'map_change' | 'tree_selection_change';
|
|
329
|
+
| 'grid_row_init' | 'grid_save' | 'calendar_date_select' | 'calendar_view_change' | 'map_object_click' | 'map_change' | 'tree_selection_change' | 'timer';
|
|
326
330
|
trigger_element: string | null;
|
|
327
331
|
condition_type: 'equals' | 'not_equals' | 'in_list' | 'is_null' | 'is_not_null' | 'greater' | 'greater_equal' | 'less' | 'less_equal' | 'not_in_list' | null;
|
|
328
332
|
condition_value: string | null;
|
|
@@ -383,7 +387,9 @@ export interface Process {
|
|
|
383
387
|
type: 'form_dml' | 'grid_dml' | 'sql' | 'data_load' | 'invoke_api' | 'download' | 'chain' | 'workflow' | 'ai_generate' | 'plugin' | 'send_push' | 'send_email'
|
|
384
388
|
/** (150) */
|
|
385
389
|
| 'close_dialog' | 'clear_session_state' | 'reset_pagination' | 'user_preferences' | 'server_geocoding' | 'print_report'
|
|
386
|
-
| 'human_task_create' | 'human_task_manage' | 'form_init'
|
|
390
|
+
| 'human_task_create' | 'human_task_manage' | 'form_init'
|
|
391
|
+
/** (154) */
|
|
392
|
+
| 'send_notification';
|
|
387
393
|
region_id: number | null;
|
|
388
394
|
code: string | null;
|
|
389
395
|
config: Record<string, unknown> | null;
|
|
@@ -443,7 +449,7 @@ const agg = (table: string, fk: string, parent: string, appId: string) =>
|
|
|
443
449
|
export async function loadApp(alias: string) {
|
|
444
450
|
return runtime.one<App>(
|
|
445
451
|
`select a.id, a.alias, a.name, a.version, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.mfa, a.mfa_roles, a.mfa_email, a.api_self_tokens, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
|
|
446
|
-
a.availability, a.availability_message, a.availability_users, a.feedback, a.error_function, a.session_warning_seconds, a.component_settings,
|
|
452
|
+
a.availability, a.availability_message, a.availability_users, a.feedback, a.notifications, a.live_badges_s, a.error_function, a.session_warning_seconds, a.component_settings,
|
|
447
453
|
a.language, a.languages, a.language_from, a.date_format, a.timestamp_format, a.time_zone, a.time_zone_auto, a.currency,
|
|
448
454
|
coalesce((select jsonb_agg(jsonb_build_object('name', l.name, 'query', l.query, 'rest_source', l.rest_source)) from meta.lov l where l.app_id = a.id), '[]') as lovs,
|
|
449
455
|
coalesce((select jsonb_agg(jsonb_build_object('page_no', p.page_no, 'name', p.name, 'title', p.title,
|
package/src/runtime/engine.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { parseDateMask } from './format.ts';
|
|
|
18
18
|
import { conditionHolds, functionBody } from './logic.ts';
|
|
19
19
|
import { runPluginProcess } from './plugins.ts';
|
|
20
20
|
import { runDeclarative } from './process-types.ts';
|
|
21
|
-
import { BACKGROUND_TYPES, downloadFile, sendEmailProcess, sendPushProcess, workflowProcess, type ChainConfig } from './processes.ts';
|
|
21
|
+
import { BACKGROUND_TYPES, downloadFile, sendEmailProcess, sendNotificationProcess, sendPushProcess, workflowProcess, type ChainConfig } from './processes.ts';
|
|
22
22
|
import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, toState, type Errors, type PageContext } from './context.ts';
|
|
23
23
|
|
|
24
24
|
const ident = pg.escapeIdentifier;
|
|
@@ -621,6 +621,7 @@ async function runOneStep(ctx: PageContext, p: Process, names: Set<string>, dept
|
|
|
621
621
|
case 'invoke_api': return await invokeApi(ctx, p, names);
|
|
622
622
|
case 'workflow': return await workflowProcess(ctx, p, names);
|
|
623
623
|
case 'send_push': return await sendPushProcess(ctx, p);
|
|
624
|
+
case 'send_notification': return await sendNotificationProcess(ctx, p);
|
|
624
625
|
case 'send_email': return await sendEmailProcess(ctx, p);
|
|
625
626
|
case 'ai_generate': return (await runAiProcess(ctx, p, names)).message;
|
|
626
627
|
case 'plugin': return await runPluginProcess(ctx, p.config, p.name);
|
package/src/runtime/feedback.ts
CHANGED
|
@@ -29,7 +29,7 @@ export function feedbackNode(ctx: PageContext): ListNode | null {
|
|
|
29
29
|
if (!ctx.app.feedback || ctx.dialog) return null;
|
|
30
30
|
return {
|
|
31
31
|
label: ctx.locale.t('feedback.link'), href: `${ctx.base}/${ctx.page.page_no}/feedback`, modal: false, external: false,
|
|
32
|
-
icon: 'chat', badge: null, description: null, current: false, inTrail: false, children: [],
|
|
32
|
+
icon: 'chat', badge: null, key: '__feedback', description: null, current: false, inTrail: false, children: [],
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
|
package/src/runtime/lists.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface ListNode {
|
|
|
28
28
|
external: boolean;
|
|
29
29
|
icon: string | null;
|
|
30
30
|
badge: string | null;
|
|
31
|
+
/** (154) the entry's id (a query's id column, else its label): finds its badge again for a live refresh */
|
|
32
|
+
key: string;
|
|
31
33
|
description: string | null;
|
|
32
34
|
/** the entry's page is the current page */
|
|
33
35
|
current: boolean;
|
|
@@ -50,6 +52,8 @@ interface Entry {
|
|
|
50
52
|
url: string | null;
|
|
51
53
|
icon: string | null;
|
|
52
54
|
badge: string | null;
|
|
55
|
+
/** (154) a SELECT whose first value is the badge (static entries; wins over badge) */
|
|
56
|
+
badge_query?: string | null;
|
|
53
57
|
description: string | null;
|
|
54
58
|
condition?: string | null;
|
|
55
59
|
authz?: string | null;
|
|
@@ -105,7 +109,7 @@ async function entriesOf(ctx: PageContext, name: string): Promise<Entry[] | null
|
|
|
105
109
|
}
|
|
106
110
|
const rows = (
|
|
107
111
|
await runtime.query(
|
|
108
|
-
`select id, parent_id, label, target_page, target_items, target_url, icon, badge, description, condition, authz
|
|
112
|
+
`select id, parent_id, label, target_page, target_items, target_url, icon, badge, badge_query, description, condition, authz
|
|
109
113
|
from meta.list_entry where app_id = $1 and list_name = $2 and meta.build_option_on(app_id, build_option)
|
|
110
114
|
order by seq, id`,
|
|
111
115
|
[ctx.app.id, name.toUpperCase()],
|
|
@@ -113,10 +117,28 @@ async function entriesOf(ctx: PageContext, name: string): Promise<Entry[] | null
|
|
|
113
117
|
).rows;
|
|
114
118
|
return rows.map((r) => ({
|
|
115
119
|
id: r.id, parent_id: r.parent_id, label: ctx.locale.tr(r.label), page: r.target_page, items: r.target_items ?? {}, url: r.target_url,
|
|
116
|
-
icon: r.icon, badge: r.badge, description: r.description ? ctx.locale.tr(r.description) : null, condition: r.condition, authz: r.authz,
|
|
120
|
+
icon: r.icon, badge: r.badge, badge_query: r.badge_query, description: r.description ? ctx.locale.tr(r.description) : null, condition: r.condition, authz: r.authz,
|
|
117
121
|
}));
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
/**
|
|
125
|
+
* (029, 154) A badge from SQL: the first value of the query's first row, run as the application's
|
|
126
|
+
* role with the page's binds; an error is a page error and no badge. Empty values give no badge.
|
|
127
|
+
*/
|
|
128
|
+
export async function queryBadge(ctx: PageContext, query: string, what: string): Promise<string | null> {
|
|
129
|
+
const c = ctx.client;
|
|
130
|
+
if (!c) return null;
|
|
131
|
+
try {
|
|
132
|
+
const sql = stripSemicolon(applyBinds(query, bindValues(ctx)));
|
|
133
|
+
const res = await savepoint(c, () => c.query({ text: sql, rowMode: 'array' }));
|
|
134
|
+
const v = res.rows[0]?.[0];
|
|
135
|
+
return v === null || v === undefined ? null : String(v).trim().slice(0, 100) || null;
|
|
136
|
+
} catch (e) {
|
|
137
|
+
ctx.errors.page.push(await publicError(ctx, e, what));
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
120
142
|
/** The current page and its parent pages (for "in trail"). */
|
|
121
143
|
function trail(ctx: PageContext) {
|
|
122
144
|
const pages = new Set<number>();
|
|
@@ -156,7 +178,10 @@ export async function listTree(ctx: PageContext, name: string): Promise<ListNode
|
|
|
156
178
|
modal: !!e.page && isModal(ctx, e.page),
|
|
157
179
|
external,
|
|
158
180
|
icon: e.icon,
|
|
159
|
-
badge: e.
|
|
181
|
+
badge: e.badge_query?.trim()
|
|
182
|
+
? await queryBadge(ctx, e.badge_query, `badge of list entry "${e.label}"`)
|
|
183
|
+
: e.badge === null ? null : substitute(e.badge, ctx, (x) => x),
|
|
184
|
+
key: String(e.id ?? e.label),
|
|
160
185
|
description: e.description === null ? null : substitute(e.description, ctx, (x) => x),
|
|
161
186
|
current: isCurrent,
|
|
162
187
|
inTrail: isCurrent || (e.page !== null && current.has(e.page)) || children.some((c) => c.inTrail),
|
|
@@ -179,15 +204,22 @@ export async function listTree(ctx: PageContext, name: string): Promise<ListNode
|
|
|
179
204
|
|
|
180
205
|
const linkAttrs = (n: ListNode, cls?: string) =>
|
|
181
206
|
html`href="${n.href}"${cls ? html` class="${cls}"` : ''}${n.current ? raw(' aria-current="page"') : ''}${n.modal ? raw(' data-dialog') : ''}${n.external ? raw(' rel="noopener noreferrer"') : ''}`;
|
|
182
|
-
|
|
207
|
+
/**
|
|
208
|
+
* An entry's badge. live: the prefix of the badge's data-badge key when the application refreshes
|
|
209
|
+
* badges (154); an empty badge is then a hidden placeholder the refresh can fill.
|
|
210
|
+
*/
|
|
211
|
+
const badge = (n: ListNode, live?: string) =>
|
|
212
|
+
n.badge !== null
|
|
213
|
+
? html` <span class="badge"${live ? html` data-badge="${live}${n.key}"` : ''}>${n.badge}</span>`
|
|
214
|
+
: live ? html` <span class="badge" data-badge="${live}${n.key}" hidden></span>` : '';
|
|
183
215
|
|
|
184
216
|
/** Entries as the navigation menu (the same markup as navigation entries). */
|
|
185
|
-
export function navMarkup(nodes: ListNode[], topNav: boolean): Raw[] {
|
|
217
|
+
export function navMarkup(nodes: ListNode[], topNav: boolean, live?: string): Raw[] {
|
|
186
218
|
return nodes.map((n) => {
|
|
187
|
-
const label = html`${icon(n.icon ?? 'chevron')}<span>${n.label}</span>${badge(n)}`;
|
|
219
|
+
const label = html`${icon(n.icon ?? 'chevron')}<span>${n.label}</span>${badge(n, live)}`;
|
|
188
220
|
if (n.children.length)
|
|
189
221
|
return html`<li><details${n.inTrail && !topNav ? raw(' open') : ''}><summary class="${n.inTrail ? 'in-trail' : null}">${label}</summary><ul>${
|
|
190
|
-
n.href ? html`<li><a ${linkAttrs(n)}>${label}</a></li>` : ''}${navMarkup(n.children, topNav)}</ul></details></li>`;
|
|
222
|
+
n.href ? html`<li><a ${linkAttrs(n)}>${label}</a></li>` : ''}${navMarkup(n.children, topNav, live)}</ul></details></li>`;
|
|
191
223
|
return html`<li><a ${linkAttrs(n, !n.current && n.inTrail ? 'in-trail' : undefined)}>${label}</a></li>`;
|
|
192
224
|
});
|
|
193
225
|
}
|
|
@@ -195,13 +227,13 @@ export function navMarkup(nodes: ListNode[], topNav: boolean): Raw[] {
|
|
|
195
227
|
const hasIcon = (n: ListNode) => isIcon(n.icon);
|
|
196
228
|
|
|
197
229
|
/** Entries as the navigation bar in the header: links, with a menu for entries with children (labels of entries with an icon hide on small screens). */
|
|
198
|
-
export function navbarMarkup(nodes: ListNode[], label: string): Raw {
|
|
230
|
+
export function navbarMarkup(nodes: ListNode[], label: string, live?: string): Raw {
|
|
199
231
|
if (!nodes.length) return raw('');
|
|
200
232
|
return html`<nav class="t-navbar" aria-label="${label}"><ul>${nodes.map((n) =>
|
|
201
233
|
n.children.length
|
|
202
|
-
? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon" aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
|
|
203
|
-
[...(n.href ? [n] : []), ...n.children].map((c) => html`<li><a ${linkAttrs(c)}>${icon(c.icon)}<span>${c.label}</span>${badge(c)}</a></li>`)}</ul></div></details></li>`
|
|
204
|
-
: html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}${hasIcon(n) ? html` aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</a></li>`)}</ul></nav>`;
|
|
234
|
+
? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon" aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
|
|
235
|
+
[...(n.href ? [n] : []), ...n.children].map((c) => html`<li><a ${linkAttrs(c)}>${icon(c.icon)}<span>${c.label}</span>${badge(c, live)}</a></li>`)}</ul></div></details></li>`
|
|
236
|
+
: html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}${hasIcon(n) ? html` aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</a></li>`)}</ul></nav>`;
|
|
205
237
|
}
|
|
206
238
|
|
|
207
239
|
function linksMarkup(nodes: ListNode[]): Raw {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { appTx, runtime } from '../db.ts';
|
|
3
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { checkPageAccess, computeVisibility, Forbidden } from './authz.ts';
|
|
6
|
+
import { publicError, type PageContext } from './context.ts';
|
|
7
|
+
import { listTree, queryBadge, type ListNode } from './lists.ts';
|
|
8
|
+
import { buttonBadgeKey } from './regions.ts';
|
|
9
|
+
import { chrome, clientTexts } from './render.ts';
|
|
10
|
+
import { safeNext } from './signin.ts';
|
|
11
|
+
import { saveState, takeFlash } from '../session.ts';
|
|
12
|
+
import { loadContext, txContext, type Req } from './routes.ts';
|
|
13
|
+
|
|
14
|
+
// (sprint 165, migration 154) In-app notifications and live badges (APEX: a notification bell in the
|
|
15
|
+
// header, navigation menu badges, badges refreshed with dynamic actions and JavaScript).
|
|
16
|
+
//
|
|
17
|
+
// Application code adds notifications with meta.notify() or the send_notification process. With
|
|
18
|
+
// meta.app.notifications on, signed-in users get a bell in the header: the number of unread
|
|
19
|
+
// notifications and the newest ones. Opening one (a POST with the session's CSRF token) marks it read
|
|
20
|
+
// and goes to its page (a link signed for the recipient when it was made). /a/<alias>/notifications
|
|
21
|
+
// lists them all. Only the user's own notifications of this application are ever read or changed:
|
|
22
|
+
// every query has the application's id and the session's user name.
|
|
23
|
+
//
|
|
24
|
+
// With meta.app.live_badges_s set, the page asks GET /a/<alias>/<page>/live every so many seconds for
|
|
25
|
+
// the badges of its navigation menu and bar (list entries), of its buttons with a badge query, and for
|
|
26
|
+
// the bell. These requests don't keep an idle session alive (loadContext touch: false).
|
|
27
|
+
|
|
28
|
+
/** Newest notifications in the bell's menu. */
|
|
29
|
+
const BELL_ROWS = 8;
|
|
30
|
+
/** Notifications on the notifications page. */
|
|
31
|
+
const PAGE_ROWS = 100;
|
|
32
|
+
|
|
33
|
+
interface Row {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
body: string | null;
|
|
37
|
+
url: string | null;
|
|
38
|
+
icon: string | null;
|
|
39
|
+
created_at: Date | string;
|
|
40
|
+
read_at: Date | string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const signedIn = (ctx: PageContext) => ctx.app.authentication !== 'none' && ctx.user !== 'nobody';
|
|
44
|
+
/** The bell is shown: notifications on, a signed-in user, a page with the header. */
|
|
45
|
+
export const hasBell = (ctx: PageContext) => !!ctx.app.notifications && signedIn(ctx);
|
|
46
|
+
|
|
47
|
+
async function rows(ctx: PageContext, limit: number): Promise<Row[]> {
|
|
48
|
+
return (
|
|
49
|
+
await runtime.query<Row>(
|
|
50
|
+
`select id::text, title, body, url, icon, created_at, read_at from meta.notification
|
|
51
|
+
where app_id = $1 and lower(username) = lower($2) order by id desc limit $3`,
|
|
52
|
+
[ctx.app.id, ctx.user, limit],
|
|
53
|
+
)
|
|
54
|
+
).rows;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function unreadCount(ctx: PageContext): Promise<number> {
|
|
58
|
+
const r = await runtime.one<{ n: number }>(
|
|
59
|
+
'select count(*)::int as n from meta.notification where app_id = $1 and lower(username) = lower($2) and read_at is null',
|
|
60
|
+
[ctx.app.id, ctx.user],
|
|
61
|
+
);
|
|
62
|
+
return r?.n ?? 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const shown = (n: number) => (n > 99 ? '99+' : String(n));
|
|
66
|
+
|
|
67
|
+
/** A notification's time in the user's language and time zone (the pool gives timestamps as text). */
|
|
68
|
+
function when(ctx: PageContext, value: Date | string) {
|
|
69
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
70
|
+
if (Number.isNaN(d.getTime())) return String(value);
|
|
71
|
+
try {
|
|
72
|
+
return new Intl.DateTimeFormat(ctx.locale.lang, { dateStyle: 'medium', timeStyle: 'short', timeZone: ctx.locale.timeZone ?? undefined }).format(d);
|
|
73
|
+
} catch {
|
|
74
|
+
return d.toISOString().slice(0, 16).replace('T', ' ');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A notification as a button that opens it (a form: it marks the notification read). */
|
|
79
|
+
function entry(ctx: PageContext, r: Row, cls: string): Raw {
|
|
80
|
+
const t = ctx.locale.t;
|
|
81
|
+
return html`<li class="${cls}${r.read_at ? '' : ' is-unread'}"><form method="post" action="${ctx.base}/notifications/${r.id}/open">
|
|
82
|
+
<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
|
|
83
|
+
<button class="t-notification">${icon(r.icon ?? 'bell')}<span class="t-notification-text">
|
|
84
|
+
<span class="t-notification-title">${r.read_at ? '' : html`<span class="sr-only">${t('notify.unread_one')}: </span>`}${r.title}</span>
|
|
85
|
+
${r.body ? html`<span class="t-notification-body">${r.body}</span>` : ''}
|
|
86
|
+
<span class="t-notification-time">${when(ctx, r.created_at)}</span></span></button>
|
|
87
|
+
</form></li>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** "Mark all read", back to next. */
|
|
91
|
+
function markAllForm(ctx: PageContext, next: string): Raw {
|
|
92
|
+
return html`<form method="post" action="${ctx.base}/notifications/read" class="t-bell-read">
|
|
93
|
+
<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}"><input type="hidden" name="next" value="${next}">
|
|
94
|
+
<button class="link-button">${icon('check')} ${ctx.locale.t('notify.mark_all_read')}</button></form>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The bell's menu: the newest notifications, Mark all read, All notifications. */
|
|
98
|
+
async function bellPanel(ctx: PageContext, unread: number): Promise<Raw> {
|
|
99
|
+
const t = ctx.locale.t;
|
|
100
|
+
const list = await rows(ctx, BELL_ROWS);
|
|
101
|
+
return html`<div class="menu-section t-bell-head"><strong>${t('notify.title')}</strong>${unread ? markAllForm(ctx, `${ctx.base}/${ctx.page.page_no}`) : ''}</div>
|
|
102
|
+
${list.length ? html`<ul class="t-bell-list">${list.map((r) => entry(ctx, r, 't-bell-entry'))}</ul>` : html`<p class="muted t-bell-none">${t('notify.none')}</p>`}
|
|
103
|
+
<div class="menu-section"><a href="${ctx.base}/notifications">${icon('arrow-right')} ${t('notify.all')}</a></div>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const bellLabel = (ctx: PageContext, unread: number) =>
|
|
107
|
+
unread ? ctx.locale.t(unread === 1 ? 'notify.bell_one' : 'notify.bell_n', { n: unread }) : ctx.locale.t('notify.title');
|
|
108
|
+
|
|
109
|
+
/** The bell in the header (empty when the application has no notifications or nobody is signed in). */
|
|
110
|
+
export async function bellMarkup(ctx: PageContext): Promise<Raw | ''> {
|
|
111
|
+
if (!hasBell(ctx)) return '';
|
|
112
|
+
const unread = await unreadCount(ctx);
|
|
113
|
+
return html`<details class="menu t-bell" data-bell>
|
|
114
|
+
<summary class="icon-button" title="${bellLabel(ctx, unread)}" aria-label="${bellLabel(ctx, unread)}">${icon('bell')}<span class="t-bell-count" data-badge="bell"${unread ? '' : raw(' hidden')}>${unread ? shown(unread) : ''}</span></summary>
|
|
115
|
+
<div class="menu-panel align-right t-bell-panel" data-bell-panel>${await bellPanel(ctx, unread)}</div>
|
|
116
|
+
</details>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Every node of a list tree, depth first. */
|
|
120
|
+
const allNodes = (nodes: ListNode[]): ListNode[] => nodes.flatMap((n) => [n, ...allNodes(n.children)]);
|
|
121
|
+
|
|
122
|
+
export interface LiveState {
|
|
123
|
+
/** data-badge key → its text (null: no badge) */
|
|
124
|
+
badges: Record<string, string | null>;
|
|
125
|
+
bell: { unread: number; label: string; panel: string } | null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The page's badges now (navigation menu and bar, buttons with a badge query) and the bell. */
|
|
129
|
+
export async function liveState(ctx: PageContext): Promise<LiveState> {
|
|
130
|
+
const badges: Record<string, string | null> = {};
|
|
131
|
+
for (const [list, prefix] of [[ctx.app.nav_list, 'n:'], [ctx.app.navbar_list, 'b:']] as const) {
|
|
132
|
+
if (!list) continue;
|
|
133
|
+
for (const n of allNodes((await listTree(ctx, list)) ?? [])) badges[`${prefix}${n.key}`] = n.badge;
|
|
134
|
+
}
|
|
135
|
+
for (const b of ctx.vis!.buttons.values())
|
|
136
|
+
if (b.badge_query?.trim() && b.id > 0) badges[buttonBadgeKey(b)] = await queryBadge(ctx, b.badge_query, `badge of button ${b.name}`);
|
|
137
|
+
return { badges, bell: null };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function notificationRoutes(app: FastifyInstance) {
|
|
141
|
+
/** A signed-in user of an application with notifications on (else a 404). */
|
|
142
|
+
const context = async (req: Req, reply: FastifyReply) => {
|
|
143
|
+
const ctx = await loadContext(req, reply, { pageNo: 'home' });
|
|
144
|
+
if (!ctx) return null;
|
|
145
|
+
if (!hasBell(ctx)) {
|
|
146
|
+
reply.code(404).type('text/plain').send('Not found');
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return ctx;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
app.get('/a/:alias/notifications', async (req: Req, reply) => {
|
|
153
|
+
const ctx = await context(req, reply);
|
|
154
|
+
if (!ctx) return;
|
|
155
|
+
const t = ctx.locale.t;
|
|
156
|
+
const list = await rows(ctx, PAGE_ROWS);
|
|
157
|
+
const unread = list.some((r) => !r.read_at);
|
|
158
|
+
const flash = takeFlash(ctx.session);
|
|
159
|
+
const main = html`<div class="t-titlebar"><h1>${t('notify.title')}</h1></div>
|
|
160
|
+
<div class="t-content notifications-page">
|
|
161
|
+
<div class="messages" aria-live="polite">${flash ? html`<div class="alert alert-success" role="status">${flash}</div>` : ''}</div>
|
|
162
|
+
<section class="region region-standard"><div class="region-body">
|
|
163
|
+
${unread ? html`<div class="buttons u-mt0">${markAllForm(ctx, `${ctx.base}/notifications`)}</div>` : ''}
|
|
164
|
+
${list.length ? html`<ul class="t-notification-list">${list.map((r) => entry(ctx, r, 't-notification-entry'))}</ul>` : html`<p class="muted">${t('notify.none')}</p>`}
|
|
165
|
+
</div></section>
|
|
166
|
+
</div>
|
|
167
|
+
<script type="application/json" id="pgkiln-meta">${raw(JSON.stringify({ csrf: ctx.session.csrf_token, das: [], texts: clientTexts(ctx) }).replace(/</g, '\\u003c'))}</script>`;
|
|
168
|
+
ctx.vis = { regions: new Set(), items: new Set(), editable: new Set(), buttons: new Map(), dynamicActions: new Set() };
|
|
169
|
+
const body = await appTx(txContext(ctx), async (c) => {
|
|
170
|
+
ctx.client = c;
|
|
171
|
+
return chrome(ctx, main, t('notify.title'));
|
|
172
|
+
});
|
|
173
|
+
await saveState(ctx.session);
|
|
174
|
+
return reply.header('cache-control', 'private, no-store').type('text/html').send(body);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// open a notification: mark it read, go to its page (or the list when it has none)
|
|
178
|
+
app.post('/a/:alias/notifications/:id/open', async (req: Req, reply) => {
|
|
179
|
+
const ctx = await context(req, reply);
|
|
180
|
+
if (!ctx) return;
|
|
181
|
+
if (req.body?.__csrf !== ctx.session.csrf_token) return reply.redirect(`${ctx.base}/notifications`, 303);
|
|
182
|
+
const id = /^[1-9][0-9]{0,17}$/.test(String(req.params.id)) ? String(req.params.id) : null;
|
|
183
|
+
const row = id
|
|
184
|
+
? await runtime.one<{ url: string | null }>(
|
|
185
|
+
`update meta.notification set read_at = coalesce(read_at, now())
|
|
186
|
+
where id = $1 and app_id = $2 and lower(username) = lower($3) returning url`,
|
|
187
|
+
[id, ctx.app.id, ctx.user],
|
|
188
|
+
)
|
|
189
|
+
: null;
|
|
190
|
+
if (!row) {
|
|
191
|
+
ctx.session.state.__FLASH = ctx.locale.t('notify.gone');
|
|
192
|
+
await saveState(ctx.session);
|
|
193
|
+
return reply.redirect(`${ctx.base}/notifications`, 303);
|
|
194
|
+
}
|
|
195
|
+
// the link was made under the application's alias then; it goes to the same page now
|
|
196
|
+
const rest = row.url?.replace(/^\/a\/[^/]+\//, '');
|
|
197
|
+
return reply.redirect(rest ? `${ctx.base}/${rest}` : `${ctx.base}/notifications`, 303);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
app.post('/a/:alias/notifications/read', async (req: Req, reply) => {
|
|
201
|
+
const ctx = await context(req, reply);
|
|
202
|
+
if (!ctx) return;
|
|
203
|
+
const next = safeNext(ctx.app, typeof req.body?.next === 'string' ? req.body.next : `${ctx.base}/notifications`);
|
|
204
|
+
if (req.body?.__csrf !== ctx.session.csrf_token) return reply.redirect(next, 303);
|
|
205
|
+
await runtime.query(
|
|
206
|
+
'update meta.notification set read_at = now() where app_id = $1 and lower(username) = lower($2) and read_at is null',
|
|
207
|
+
[ctx.app.id, ctx.user],
|
|
208
|
+
);
|
|
209
|
+
ctx.session.state.__FLASH = ctx.locale.t('notify.marked');
|
|
210
|
+
await saveState(ctx.session);
|
|
211
|
+
return reply.redirect(next, 303);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// live badges: what the page shows now; never extends the session
|
|
215
|
+
app.get('/a/:alias/:page/live', async (req: Req, reply) => {
|
|
216
|
+
const ctx = await loadContext(req, reply, { json: true, touch: false });
|
|
217
|
+
if (!ctx) return;
|
|
218
|
+
reply.header('cache-control', 'private, no-store');
|
|
219
|
+
if (!ctx.app.live_badges_s) return reply.code(404).send({ error: 'not found' });
|
|
220
|
+
try {
|
|
221
|
+
const state = await appTx(txContext(ctx), async (c) => {
|
|
222
|
+
ctx.client = c;
|
|
223
|
+
await checkPageAccess(ctx);
|
|
224
|
+
ctx.vis = await computeVisibility(ctx);
|
|
225
|
+
return liveState(ctx);
|
|
226
|
+
});
|
|
227
|
+
if (hasBell(ctx)) {
|
|
228
|
+
const unread = await unreadCount(ctx);
|
|
229
|
+
state.bell = { unread, label: bellLabel(ctx, unread), panel: (await bellPanel(ctx, unread)).toString() };
|
|
230
|
+
}
|
|
231
|
+
return reply.send(state);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (e instanceof Forbidden) return reply.code(403).send({ error: e.message });
|
|
234
|
+
return reply.code(400).send({ error: await publicError(ctx, e, 'live badges') });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|