openvibers/openvibe.shared notification-ui.js

notification-ui.js

main 114.7 KB · 1536 lines · javascript

1// ═══════════════════════════════════════════════════════════════
2// OpenVibe — Notification UI Client (shared across every OpenVibe site)
3//
4// One store (openvibe.network) → one bell everywhere. This file renders the bell + its panel, toasts, a full-page
5// inbox, and handles Web Push subscription.
6//
7// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>
8// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })
9// OpenVibeNotifications.createBell(mountEl)
10// OpenVibeNotifications.renderInbox(containerEl) // full-page inbox
11//
12// Reads: GET /api/notifications?limit&offset&unread_only&tab&q&since
13// GET /api/notifications/unread-count, /unread-by-tab (the counts on the bell's tabs)
14// Realtime (init({ realtime: true }), off by default): notification-live.js, loaded from beside this file, hears the
15// person's network.notification.created over OpenVibe.Events with a ticket from POST /api/v1/realtime/ticket;
16// each event or gap re-reads the count. Polling stays: every 15 s without a stream, every 2 min with one.
17// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}
18//
19// Notifications 2.0 (2026-10-28, the owner's screenshot of three alert toasts drawn over the open menu):
20// • a toast is an opaque, themed card: a line glyph for its category (never an emoji) or the sender's avatar with
21// the glyph as a badge, a one-line title, at most two lines of text, chips, up to two actions, a timeout bar;
22// • toasts never draw over an open menu, panel or dialog: they wait (timers paused) and show when it closes;
23// • phones show one at a time under the bar (with "+N more" and swipe to dismiss), desktops up to three at the
24// bottom right;
25// • the same notice arriving again within 15 minutes joins the card that is showing (a count, site chips);
26// • operator alerts (OPERATOR_ALERT, or rich_content.kind "ops_alert") read as System cards: "Browser check
27// failed · events · release 80b7955", a status link, and the fix command behind a copy button in the bell —
28// never raw shell text in the body;
29// • the ✦ beside the bell (4.31.0, desktop) opens the Action Center (action-center.js, loaded from beside this file
30// on the first click): every agent task in one place; on phones the bell's Agents tab links to it;
31// • the bell (4.30.0) is a 420 px popover on desktop and a full-height sheet on phones: tabs (All · Mentions · Live ·
32// Deals & alerts · System · Agents) counted by Network's unread-by-tab, an Unread filter, day headers, thumbnails,
33// unread dots and grouped repeats; on touch screens a row swipes right to read and left to clear.
34// ═══════════════════════════════════════════════════════════════
35
36(function (root) {
37 'use strict';
38
39 const POLL_INTERVAL = 15_000;
40 const REALTIME_POLL = 120_000; // with an open realtime stream the poll is only a safety net (reads made on other sites)
41 const SELF_SRC = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
42 const PAGE_SIZE = 30;
43 const TOAST_DURATION = { low: 0, normal: 5200, high: 8000, critical: 0 }; // 0 = sticky
44 const MAX_TOASTS = 3; // desktop; phones show one and count the rest
45 const GROUP_WINDOW = 15 * 60 * 1000;
46 const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };
47 const CATEGORY_LABELS = {
48 stream: 'Live', social: 'Social', chat: 'Chat', game: 'Games', economy: 'Money & deals',
49 achievement: 'Achievements', service: 'Services', moderation: 'Moderation', system: 'System', admin: 'Announcements',
50 };
51 const SERVICE_LABELS = {
52 live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', host: 'Host', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', chat: 'Chat',
53 actor: 'Agents', watch: 'Watch', connect: 'Connect', link: 'Link', community: 'Community', billing: 'Wallet', blog: 'Blog', design: 'Design', ai: 'AI', events: 'Events',
54 };
55
56 // Line glyphs (24×24, stroked like the open-loop mark): one per kind of notice.
57 const GLYPH = {
58 live: '<circle cx="12" cy="12" r="2.6"/><path d="M6.4 6.4a8 8 0 0 0 0 11.2M17.6 6.4a8 8 0 0 1 0 11.2M9.2 9.2a4 4 0 0 0 0 5.6M14.8 9.2a4 4 0 0 1 0 5.6"/>',
59 people: '<circle cx="9" cy="8.5" r="3.2"/><path d="M3.5 19.5a5.5 5.5 0 0 1 11 0"/><circle cx="17.2" cy="9.4" r="2.4"/><path d="M15.6 14.4a4.5 4.5 0 0 1 5.4 4.6"/>',
60 chat: '<path d="M4.5 5.5h15v10.5H10l-5.5 4z"/>',
61 game: '<path d="M6.5 8.5h11a3.5 3.5 0 0 1 3.5 3.5v2.6a2.9 2.9 0 0 1-5.1 1.9L14.2 14.6H9.8L8.1 16.5A2.9 2.9 0 0 1 3 14.6V12a3.5 3.5 0 0 1 3.5-3.5z"/><path d="M8.3 10.6v3M6.8 12.1h3"/><path d="M15.6 11.4h.01M17.2 12.9h.01"/>',
62 coin: '<circle cx="12" cy="12" r="8.2"/><path d="M14.6 9.6c-.6-.9-1.5-1.3-2.6-1.3-1.5 0-2.6.8-2.6 1.9s1.1 1.6 2.6 1.9 2.6.8 2.6 2-1.1 1.9-2.6 1.9c-1.1 0-2.1-.5-2.6-1.3M12 6.6v1.7M12 15.7v1.7"/>',
63 tag: '<path d="M4 4.5h7.2l8.8 8.8-6.7 6.7-8.8-8.8z"/><circle cx="8.4" cy="8.9" r="1.3"/>',
64 trophy: '<path d="M8 4h8v5a4 4 0 0 1-8 0zM8 6H5.2a3 3 0 0 0 3 3.6M16 6h2.8a3 3 0 0 1-3 3.6M12 13v3.6M8.6 20h6.8"/>',
65 shield: '<path d="M12 3.2l7 3v5.2c0 4.3-2.9 7.8-7 9.6-4.1-1.8-7-5.3-7-9.6V6.2z"/>',
66 gear: '<circle cx="12" cy="12" r="3"/><path d="M12 3.5v2.2M12 18.3v2.2M3.5 12h2.2M18.3 12h2.2M6 6l1.6 1.6M16.4 16.4L18 18M6 18l1.6-1.6M16.4 7.6L18 6"/>',
67 grid: '<rect x="4" y="4" width="6.6" height="6.6" rx="1.6"/><rect x="13.4" y="4" width="6.6" height="6.6" rx="1.6"/><rect x="4" y="13.4" width="6.6" height="6.6" rx="1.6"/><rect x="13.4" y="13.4" width="6.6" height="6.6" rx="1.6"/>',
68 pulse: '<path d="M3 12.5h4.2l2.3-5.5 4.2 10.5 2.3-5h4.9"/>',
69 check: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',
70 bell: '<path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0"/>',
71 // The set's solid bell (openvibe-shared/glyphs): shown where the person prefers solid icons (4.71.0).
72 bellSolid: '<path d="M5.8 16.2V11a6.2 6.2 0 0 1 12.4 0v5.2l1.7 2.3H4.1ZM9.9 19.6h4.2a2.1 2.1 0 0 1-4.2 0Z"/>',
73 key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',
74 spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
75 agent: '<rect x="5" y="8.2" width="14" height="10.8" rx="3.6"/><path d="M12 8.2V5.4M9.4 13.4h.01M14.6 13.4h.01M3 12.6v2.6M21 12.6v2.6"/><circle cx="12" cy="4.4" r="1"/>',
76 eye: '<path d="M2.8 12s3.4-6.2 9.2-6.2 9.2 6.2 9.2 6.2-3.4 6.2-9.2 6.2S2.8 12 2.8 12z"/><circle cx="12" cy="12" r="2.8"/>',
77 screen: '<rect x="3.5" y="4.5" width="17" height="11.5" rx="2.2"/><path d="M9 20h6M12 16v4"/>',
78 };
79 const SERVICE_GLYPH = { actor: 'agent', watch: 'eye', connect: 'screen', link: 'screen', billing: 'coin' };
80 const TYPE_GLYPH = { WELCOME: 'spark', AGENT_TASK_DONE: 'agent', AGENT_TASK_FAILED: 'agent', AGENT_NEEDS_YOU: 'agent' };
81 const CATEGORY_GLYPH = { stream: 'live', social: 'people', chat: 'chat', game: 'game', economy: 'coin', achievement: 'trophy', moderation: 'shield', system: 'gear', service: 'grid', admin: 'bell' };
82 const TONE = {
83 live: 'var(--live-red, #e5484d)', danger: 'var(--live-red, #e5484d)', warn: 'var(--warning, #f5a524)', ok: 'var(--success, #30a46c)',
84 gold: 'var(--gold, #e3b341)', accent: 'var(--accent, #3b82f6)', muted: 'var(--text-secondary, #a0a0ab)',
85 };
86 const CATEGORY_TONE = { stream: 'live', social: 'accent', chat: 'accent', game: 'accent', economy: 'gold', achievement: 'gold', moderation: 'warn', system: 'muted', service: 'accent', admin: 'accent' };
87 // The bell's tabs. Network defines what each holds (server/notifications/tabs.js, ?tab= and /unread-by-tab).
88 const TABS = [['all', 'All'], ['mentions', 'Mentions'], ['live', 'Live'], ['alerts', 'Deals & alerts'], ['system', 'System'], ['agents', 'Agents']];
89 const TAB_EMPTY = {
90 all: ['No notifications yet', 'When something happens across OpenVibe, it lands here.'],
91 mentions: ['No mentions yet', 'When someone mentions you, replies to you or messages you, it shows here.'],
92 live: ['Nobody has gone live yet', 'When someone you follow goes live, it shows here.'],
93 alerts: ['No alerts yet', 'Deal, coupon, price and trade watches you set tell you here.'],
94 system: ['Nothing from the system', 'Sign-ins, security notices and announcements show here.'],
95 agents: ['No agent work yet', 'When an agent needs your approval or finishes a task, it shows here.'],
96 };
97
98 let _config = { token: null, apiBase: 'https://openvibe.network', scopes: null, soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };
99 let _pollTimer = null;
100 let _lastSeenAt = null; // newest created_at we have toasted / seen
101 let _unreadCount = 0;
102 let _msgs = 0; // new messages (navbar.js setMessages): on the bell's badge with the notifications, never in the list's own count
103 let _toastContainer = null;
104 let _panelEl = null;
105 let _bellEl = null;
106 let _audioCache = {};
107 let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };
108 let _channel = null; // BroadcastChannel for same-origin tab sync
109 let _live = null; // OVNotificationLive feed (realtime on)
110 let _lastPoll = 0;
111 let _soon = null; let _soonAll = false;
112 const _views = new Map(); // container → view state (panel + inboxes)
113 const _queue = []; // toast cards waiting for a free screen (a menu open, or a phone already showing one)
114 let _menuOpen = false; // the OpenVibe menu (search.js) says so through its 'openvibe-menu' event
115
116 // ── Styles ──────────────────────────────────────────────
117 function injectStyles() {
118 if (document.getElementById('openvibe-notif-styles')) return;
119 const style = document.createElement('style');
120 style.id = 'openvibe-notif-styles';
121 style.textContent = `
122 @keyframes ovn-in-up { from { transform: translateY(14px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }
123 @keyframes ovn-in-down { from { transform: translateY(-14px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }
124 @keyframes ovn-out { to { transform: translateX(24px); opacity: 0; } }
125 @keyframes ovn-badge-pop { 0%,100% { transform: scale(1); } 50% { transform: scale(1.22); } }
126 @keyframes ovn-bell-ring { 0%{ transform: rotate(0); } 12%{ transform: rotate(13deg); } 24%{ transform: rotate(-12deg); } 36%{ transform: rotate(8deg); } 48%{ transform: rotate(-4deg); } 60%{ transform: rotate(0); } }
127 @keyframes ovn-panel-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }
128 @keyframes ovn-sheet-up { from { transform: translateY(48px); opacity: .5; } to { transform: none; opacity: 1; } }
129 @keyframes ovn-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }
130
131 /* Toasts. Opaque cards on the theme's raised surface; held (hidden, timers paused) while a menu is open. */
132 .openvibe-toast-container { position: fixed; z-index: 2147482600; display: flex; flex-direction: column; gap: 10px; pointer-events: none;
133 right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px))); width: min(380px, calc(100vw - 32px));
134 font-family: inherit; transition: opacity .18s ease; }
135 .openvibe-toast-container.ovn-held { opacity: 0; visibility: hidden; }
136 .openvibe-toast-container.ovn-phone { top: var(--ovn-top, 64px); bottom: auto; left: max(8px, env(safe-area-inset-left, 0px)); right: max(8px, env(safe-area-inset-right, 0px)); width: auto; }
137 .openvibe-toast { pointer-events: auto; position: relative; overflow: hidden; display: grid; grid-template-columns: 38px 1fr; gap: 12px; align-items: start;
138 padding: 12px 34px 12px 12px; border-radius: 16px; cursor: pointer; touch-action: pan-y;
139 background: var(--bg-elevated, var(--bg-card, #1f2129)); color: var(--text-primary, #ececf1);
140 border: 1px solid var(--border, rgba(255,255,255,.1)); box-shadow: 0 18px 40px rgba(0,0,0,.34), 0 2px 6px rgba(0,0,0,.2);
141 animation: ovn-in-up .26s cubic-bezier(.2,.8,.2,1); }
142 .ovn-phone .openvibe-toast { animation-name: ovn-in-down; }
143 .openvibe-toast::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--ovn-tone, var(--accent, #3b82f6)); }
144 .openvibe-toast:hover, .openvibe-toast:focus-visible { border-color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 55%, var(--border, #333)); outline: none; }
145 .openvibe-toast.removing { animation: ovn-out .24s ease forwards; }
146 .openvibe-toast-body { min-width: 0; }
147 .openvibe-toast-title { display: flex; align-items: baseline; gap: 6px; min-width: 0; font-size: 14px; font-weight: 650; line-height: 1.3; }
148 .openvibe-toast-title .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
149 .openvibe-toast-title .n { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 16%, transparent); }
150 .openvibe-toast-sub { margin-top: 1px; font-size: 11.5px; color: var(--text-muted, #8b8b96); display: flex; gap: 6px; align-items: center; }
151 .openvibe-toast-msg { margin-top: 4px; font-size: 13px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
152 .openvibe-toast-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
153 .openvibe-toast-actions button, .openvibe-toast-actions a { font: inherit; font-size: 12.5px; font-weight: 650; padding: 7px 13px; border-radius: 999px; cursor: pointer; text-decoration: none; line-height: 1;
154 border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); }
155 .openvibe-toast-actions .primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }
156 .openvibe-toast-close { position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border-radius: 50%; border: none; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; font-size: 16px; line-height: 1; }
157 .openvibe-toast-close:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }
158 .openvibe-toast-progress { position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; transform-origin: left; background: var(--ovn-tone, var(--accent, #3b82f6)); opacity: .7; }
159 .openvibe-toast-more { margin-left: auto; flex: none; font: inherit; font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 999px; cursor: pointer;
160 border: 1px solid color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 40%, var(--border, #333)); background: transparent; color: var(--text-secondary, #b6b6c0); }
161 .openvibe-toast.priority-critical { border-color: color-mix(in srgb, var(--ovn-tone) 60%, var(--border, #333)); }
162
163 /* The glyph (or avatar with a glyph badge), shared by toasts and the list. */
164 .ovn-glyph { position: relative; width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; flex: none;
165 color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 68%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }
166 .ovn-glyph svg { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
167 .ovn-glyph.ovn-avatar { border-radius: 50%; background: var(--bg-tertiary, #2a2b33); }
168 .ovn-glyph.ovn-avatar img { width: 38px; height: 38px; border-radius: 50%; object-fit: cover; display: block; }
169 .ovn-glyph .ovn-badge { position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center;
170 background: var(--ovn-tone, var(--accent, #3b82f6)); color: var(--bg-elevated, var(--bg-card, #1f2129)); box-shadow: 0 0 0 2px var(--bg-elevated, var(--bg-card, #1f2129)); }
171 .ovn-glyph .ovn-badge svg { width: 11px; height: 11px; stroke-width: 2.4; }
172 .ovn-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
173 .ovn-chip { font-size: 11px; font-weight: 600; line-height: 1; padding: 4px 8px; border-radius: 999px; color: var(--text-secondary, #b6b6c0); background: var(--bg-tertiary, rgba(255,255,255,.06)); border: 1px solid var(--border, rgba(255,255,255,.08)); }
174 .ovn-chip.sev { color: color-mix(in srgb, var(--ovn-tone) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone) 14%, transparent); border-color: transparent; text-transform: uppercase; letter-spacing: .03em; font-size: 10px; }
175
176 .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; }
177 html[data-ov-icons=solid] .openvibe-bell svg.has-s { stroke-width: 0; } html[data-ov-icons=solid] .openvibe-bell svg > .ovg-s { display: inline; fill: currentColor; stroke: none; } .openvibe-bell svg > .ovg-s { display: none; }
178 .openvibe-bell svg { color: var(--text-secondary, #b0b0b8); width: 22px; height: 22px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; transition: stroke .2s; }
179 .openvibe-bell:hover svg, .openvibe-bell.open svg { stroke: var(--accent-light, var(--accent, #60a5fa)); }
180 .openvibe-bell:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
181 .openvibe-bell .badge { position: absolute; top: 1px; right: 0; min-width: 17px; height: 17px; padding: 0 5px; box-sizing: border-box; background: var(--live-red, #e5484d); color: #fff; border-radius: 999px;
182 font-size: 10px; font-weight: 750; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; box-shadow: 0 0 0 2px var(--bg-primary, #111);
183 font-variant-numeric: tabular-nums; animation: ovn-badge-pop .36s ease; }
184 .openvibe-bell .badge.hidden { display: none; }
185 .openvibe-bell.ringing svg { animation: ovn-bell-ring .7s ease; transform-origin: 50% 15%; }
186 /* The ✦ beside the bell: the Action Center (desktop; phones reach it from the bell's Agents tab). */
187 .openvibe-actions-btn { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; }
188 .openvibe-actions-btn svg { width: 21px; height: 21px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: stroke .2s; }
189 .openvibe-actions-btn:hover svg, .openvibe-actions-btn.open svg { stroke: var(--accent-light, var(--accent, #60a5fa)); }
190 .openvibe-actions-btn:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
191 @media (max-width: 640px) { .openvibe-actions-btn { display: none; } }
192 .ovn-ac-link { display: flex; align-items: center; gap: 10px; width: calc(100% - 12px); margin: 6px; padding: 10px 14px; border-radius: 14px; border: 1px dashed color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #333));
193 background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; text-align: left; }
194 .ovn-ac-link svg { width: 18px; height: 18px; flex: none; fill: none; stroke: var(--accent-light, var(--accent, #60a5fa)); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
195
196 /* The panel (desktop popover, phone sheet) and the full inbox share one list view. */
197 .openvibe-notif-panel { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden;
198 top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));
199 max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px));
200 background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));
201 border-radius: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); animation: ovn-panel-in .2s cubic-bezier(.2,.8,.2,1); font-family: inherit; }
202 .openvibe-notif-panel.open { display: flex; }
203 .openvibe-notif-panel, .openvibe-notif-inbox, .openvibe-notif-dock { --ovn-bg: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }
204 .openvibe-notif-dock { display: flex; flex-direction: column; min-height: 0; flex: 1; color: var(--text-primary, #ececf1); }
205 .openvibe-notif-dock .openvibe-nv-head { display: none; }
206 /* 4.225.0 (the owner, 11-12: "scrolling is very buggy … on mobile … especially in notifications"): inside the menu
207 the list is not a scroll box of its own. It never overflowed there, but as a scroll box that holds its
208 overscroll it took every swipe that began on a notice and passed none of it to the menu, so nothing moved.
209 One scroller per layer: the menu's own list. */
210 .openvibe-notif-dock .openvibe-nv-list { flex: none; overflow: visible; overscroll-behavior: auto; }
211 .openvibe-notif-dock .openvibe-nv-tools { padding-top: 10px; }
212.openvibe-notif-dock.is-preview .openvibe-nv-tools, .openvibe-notif-dock.is-preview .openvibe-nv-foot, .openvibe-notif-dock.is-preview .openvibe-nv-head, .openvibe-notif-dock.is-preview .openvibe-nv-more { display: none; }
213 .openvibe-notif-dock .openvibe-nv-foot { gap: 12px; }
214 .openvibe-notif-dock .openvibe-nv-foot [data-role="status"] { margin-right: auto; }
215 .ovn-foot-read { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0; border: 0; background: none; font: inherit; font-weight: 700; color: var(--text-secondary, #b6b6c0); cursor: pointer; }
216 .ovn-foot-read:hover { color: var(--text-primary, #ececf1); }
217 .ovn-foot-read svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
218 .openvibe-notif-inbox { display: flex; flex-direction: column; overflow: hidden; min-height: 420px; max-height: 78vh; border-radius: 18px;
219 background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1)); font-family: inherit; }
220
221 .openvibe-nv-head { padding: 14px 14px 10px 16px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
222 .openvibe-nv-head h3 { font-size: 17px; font-weight: 750; letter-spacing: -.01em; margin: 0; display: flex; align-items: center; gap: 8px; }
223 .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 750; background: var(--live-red, #e5484d); color: #fff; border-radius: 999px; padding: 2px 7px; font-variant-numeric: tabular-nums; }
224 .openvibe-nv-head h3 .count.zero { display: none; }
225 .openvibe-nv-actions { display: flex; gap: 2px; align-items: center; }
226 .openvibe-nv-actions button, .openvibe-nv-actions a { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; border: none; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; text-decoration: none; }
227 .openvibe-nv-actions button > span, .openvibe-nv-actions a > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
228 .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }
229 .openvibe-nv-actions button.on { color: var(--accent-light, var(--accent, #60a5fa)); }
230 .openvibe-nv-actions button:disabled { opacity: .4; cursor: default; }
231 .openvibe-nv-actions svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
232
233 .openvibe-nv-tools { padding: 0 12px; display: flex; flex-direction: column; gap: 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
234 .openvibe-nv-search { position: relative; }
235 .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 9px 30px 9px 34px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: var(--bg-input, var(--bg-tertiary, rgba(255,255,255,.04))); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; outline: none; }
236 .openvibe-nv-search input:focus { border-color: var(--accent, #3b82f6); }
237 .openvibe-nv-search > svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; fill: none; stroke: var(--text-muted, #8b8b96); stroke-width: 1.8; stroke-linecap: round; pointer-events: none; }
238 .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #8b8b96); cursor: pointer; font-size: 16px; display: none; }
239 .openvibe-nv-search.has-q .clear { display: block; }
240 .openvibe-nv-find { display: flex; gap: 8px; align-items: center; }
241 .openvibe-nv-find .openvibe-nv-search { flex: 1; min-width: 0; }
242 .openvibe-nv-unread { flex: none; display: inline-flex; align-items: center; gap: 6px; height: 37px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }
243 .openvibe-nv-unread:hover { color: var(--text-primary, #ececf1); }
244 .openvibe-nv-unread[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }
245 .openvibe-nv-unread svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
246 .openvibe-nv-tabs { display: flex; overflow-x: auto; scrollbar-width: none; margin: 0 -12px; padding: 0 6px; }
247 .openvibe-nv-tabs::-webkit-scrollbar { display: none; }
248 .openvibe-nv-tabs.is-more { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent); }
249 .openvibe-nv-tabs button { flex: none; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 10px; border: 0; background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
250 .openvibe-nv-tabs button:hover, .openvibe-nv-tabs button[aria-selected="true"] { color: var(--text-primary, #ececf1); }
251 .openvibe-nv-tabs button[aria-selected="true"]::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: 0; height: 2.5px; border-radius: 2px 2px 0 0; background: var(--accent, #3b82f6); }
252 .openvibe-nv-tabs button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -3px; border-radius: 10px; }
253 .openvibe-nv-tabs .n { min-width: 18px; padding: 2px 5px; box-sizing: border-box; border-radius: 999px; font-size: 10.5px; font-weight: 750; line-height: 1.2; text-align: center; font-variant-numeric: tabular-nums;
254 color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); }
255 .openvibe-nv-tabs .n:empty { display: none; }
256 .openvibe-nv-tabs.is-more { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 36px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 36px), transparent); }
257
258 .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 2px 0 6px; overscroll-behavior: contain; }
259 .openvibe-nv-day { position: sticky; top: 0; z-index: 1; padding: 10px 16px 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #8b8b96); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }
260 /* A row: the opaque .ovn-row over .ovn-under, which a swipe uncovers (Read on the left, Clear on the right). */
261 .openvibe-notif-item { position: relative; margin: 0 6px; border-radius: 14px; overflow: hidden; cursor: pointer; }
262 .openvibe-notif-item .ovn-row { position: relative; z-index: 1; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; padding: 10px 14px 10px 16px; border-radius: 14px;
263 background: var(--ovn-bg, var(--bg-elevated, #1d1f27)); transition: background .15s, transform .2s ease; touch-action: pan-y; outline: none; }
264 .openvibe-notif-item .ovn-row.has-thumb { grid-template-columns: 38px minmax(0, 1fr) 56px; }
265 .openvibe-notif-item .ovn-row:focus-visible { box-shadow: inset 0 0 0 2px var(--accent, #3b82f6); }
266 .openvibe-notif-item:hover .ovn-row { background: color-mix(in srgb, var(--text-primary, #ececf1) 5%, var(--ovn-bg, #1d1f27)); }
267 .openvibe-notif-item.unread .ovn-row { background: color-mix(in srgb, var(--accent, #3b82f6) 8%, var(--ovn-bg, #1d1f27)); }
268 .openvibe-notif-item.unread:hover .ovn-row { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, var(--ovn-bg, #1d1f27)); }
269 .openvibe-notif-item.swiping .ovn-row { transition: none; }
270 .openvibe-notif-item .thumb { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; display: block; background: var(--bg-tertiary, #2a2b33); }
271 .openvibe-notif-item .ovn-under { position: absolute; inset: 0; display: none; align-items: center; justify-content: space-between; padding: 0 22px; font-size: 13px; font-weight: 750; }
272 .openvibe-notif-item[data-swipe] .ovn-under { display: flex; }
273 .openvibe-notif-item[data-swipe="read"] .ovn-under { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
274 .openvibe-notif-item[data-swipe="clear"] .ovn-under { background: var(--danger-strong, #c42b30); color: #fff; justify-content: flex-end; }
275 .openvibe-notif-item[data-swipe="read"] .ovn-under .clear, .openvibe-notif-item[data-swipe="clear"] .ovn-under .read { display: none; }
276 .openvibe-notif-item .ovn-under span { display: inline-flex; align-items: center; gap: 6px; }
277 .openvibe-notif-item .ovn-under svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
278 .openvibe-notif-item .content { min-width: 0; }
279 /* 4.223.0: the title and its count are one run of text (the count overlapped the words when the title was a box). */
280 .openvibe-notif-item .title { font-size: 13.5px; font-weight: 550; line-height: 1.32; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
281 .openvibe-notif-item .title .t { display: inline; }
282 .openvibe-notif-item .title .count { display: inline-block; margin-left: 6px; vertical-align: 1px; }
283 .openvibe-notif-item.unread .title { font-weight: 700; }
284 .openvibe-notif-item .title .count { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 50%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }
285 .openvibe-notif-item .msg { margin-top: 2px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); line-height: 1.42; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
286 .openvibe-notif-item .meta { font-size: 11.5px; color: var(--text-muted, #8b8b96); margin-top: 5px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
287 .openvibe-notif-item .meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .6; }
288 .openvibe-notif-item .unread-dot { position: absolute; left: 5px; top: 26px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); }
289 .openvibe-notif-item .row-actions { margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity .15s; }
290 .openvibe-notif-item:hover .row-actions, .openvibe-notif-item:focus-within .row-actions { opacity: 1; }
291 /* The row's buttons sit on its time line, never over its text; touch screens always show them. */
292 @media (hover: none), (pointer: coarse) { .openvibe-notif-item .row-actions { opacity: 1; } }
293 .openvibe-notif-item .row-actions button { width: 28px; height: 28px; border-radius: 8px; border: none; background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }
294 .openvibe-notif-item .row-actions button:hover { color: var(--text-primary, #ececf1); background: var(--bg-hover, rgba(255,255,255,.08)); }
295 .openvibe-notif-item .row-actions svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
296 .openvibe-notif-item .copy { font: inherit; font-size: 11.5px; font-weight: 650; margin-top: 7px; padding: 5px 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }
297 .openvibe-notif-item .copy:hover { color: var(--text-primary, #ececf1); }
298 .openvibe-nv-empty { text-align: center; padding: 48px 24px 56px; color: var(--text-muted, #8b8b96); font-size: 13.5px; }
299 .openvibe-nv-empty svg { width: 64px; height: 64px; display: block; margin: 0 auto 12px; fill: none; stroke: var(--accent, #3b82f6); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; opacity: .75; }
300 .openvibe-nv-empty b { display: block; font-size: 15px; color: var(--text-primary, #ececf1); margin-bottom: 4px; }
301 .openvibe-nv-more { display: flex; justify-content: center; padding: 12px; }
302 .openvibe-nv-more button { border: 1px solid var(--border, rgba(255,255,255,.1)); background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12.5px; padding: 7px 16px; border-radius: 999px; cursor: pointer; }
303 .openvibe-nv-more .spinner { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovn-hop 1s ease-in-out .15s infinite; }
304 .openvibe-nv-more .spinner::before, .openvibe-nv-more .spinner::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovn-hop 1s ease-in-out infinite; }
305 .openvibe-nv-more .spinner::before { left: -10px; } .openvibe-nv-more .spinner::after { left: 10px; animation-delay: .3s; }
306 .openvibe-nv-foot { padding: 10px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #8b8b96); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
307 .openvibe-nv-foot a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; font-weight: 650; }
308 .openvibe-nv-note { font-size: 12px; color: var(--text-secondary, #b6b6c0); padding: 8px 16px; background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }
309
310 /* Phones: the panel is a full-height sheet over a scrim; its handle drags down to close, as do × and a tap outside. */
311 .openvibe-nv-actions [data-act="close"] { display: none; }
312 @media (max-width: 640px) {
313 .openvibe-notif-panel { top: calc(env(safe-area-inset-top, 0px) + 10px); left: 0; right: 0; bottom: 0; width: auto; max-height: none; border-radius: 22px 22px 0 0; border-bottom: 0;
314 padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: 0 0 0 100vmax rgba(0,0,0,.5), 0 -12px 40px rgba(0,0,0,.35); animation-name: ovn-sheet-up; animation-duration: .28s; }
315 .openvibe-notif-panel .openvibe-nv-head { position: relative; padding-top: 20px; touch-action: none; }
316 .openvibe-notif-panel .openvibe-nv-head::before { content: ''; position: absolute; top: 8px; left: 50%; width: 40px; height: 4px; margin-left: -20px; border-radius: 2px; background: var(--border-light, var(--border, rgba(255,255,255,.22))); }
317 .openvibe-notif-panel .openvibe-nv-actions [data-act="close"] { display: grid; }
318 .openvibe-notif-panel .openvibe-nv-actions [data-act="inbox"] { display: none; }
319 .openvibe-nv-actions button, .openvibe-nv-actions a { width: 40px; height: 40px; }
320 .openvibe-notif-item .row-actions { opacity: 1; }
321 }
322 html.ovn-sheet-open, html.ovn-sheet-open body { overflow: hidden; }
323 @media (max-height: 520px) { .openvibe-notif-item .msg { -webkit-line-clamp: 1; } }
324 html[data-ov-motion=off] .openvibe-toast.removing{opacity: 0}html[data-ov-motion=off] .openvibe-toast-progress{display: none}
325 `;
326 document.head.appendChild(style);
327 }
328
329 // ── Helpers ─────────────────────────────────────────────
330 function esc(v) {
331 if (v == null) return '';
332 return String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
333 }
334 function richOf(n) {
335 const rc = n && (n.rich_content ?? n.richContent);
336 if (!rc) return {};
337 if (typeof rc === 'object') return rc;
338 try { return JSON.parse(rc) || {}; } catch { return {}; }
339 }
340 function urlOf(n) { return (n && n.url) || richOf(n).url || null; }
341 function isRead(n) { return !!(n && (n.is_read === 1 || n.is_read === true || n.read === true)); }
342 function parseDate(v) {
343 if (!v) return null;
344 // SQLite "YYYY-MM-DD HH:MM:SS" is UTC without a zone marker.
345 const s = String(v);
346 const d = new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? s.replace(' ', 'T') + 'Z' : s);
347 return isNaN(d.getTime()) ? null : d;
348 }
349 function timeAgo(v) {
350 const d = parseDate(v); if (!d) return '';
351 const diff = (Date.now() - d.getTime()) / 1000;
352 if (diff < 45) return 'just now';
353 if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))} min ago`;
354 if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
355 if (diff < 604800) return `${Math.floor(diff / 86400)} d ago`;
356 return d.toLocaleDateString();
357 }
358 /** "Today", "Yesterday", a weekday this week, else the date: the list's day headers. */
359 function dayLabel(v) {
360 const d = parseDate(v); if (!d) return 'Earlier';
361 const start = new Date(); start.setHours(0, 0, 0, 0);
362 const day = new Date(d.getTime()); day.setHours(0, 0, 0, 0);
363 const days = Math.round((start - day) / 86400000);
364 if (days <= 0) return 'Today';
365 if (days === 1) return 'Yesterday';
366 if (days < 7) { try { return d.toLocaleDateString(undefined, { weekday: 'long' }); } catch { return 'This week'; } }
367 try { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return 'Earlier'; }
368 }
369 function inboxUrl() {
370 if (_config.inboxUrl) return _config.inboxUrl;
371 try { return new URL('/notifications', _config.apiBase).toString(); } catch { return 'https://openvibe.network/notifications'; }
372 }
373 function navigateTo(url) {
374 if (!url) return;
375 try {
376 const u = new URL(url, window.location.href);
377 window.location.href = u.toString();
378 } catch { window.location.href = url; }
379 }
380 const svg = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
381 function isPhone() {
382 try { return !!(root.matchMedia && root.matchMedia('(max-width: 640px)').matches); } catch { return false; }
383 }
384
385 // ── What a notification says (one presentation for toasts and the list) ─────
386 /** OpenVibeBrowserCheckFailed → "Browser check failed". */
387 function humanize(name) {
388 const s = String(name || '').replace(/^OpenVibe(?=[A-Z])/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim();
389 return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Alert';
390 }
391 function isOpsAlert(n, rich) { return !!n && (n.type === 'OPERATOR_ALERT' || rich.kind === 'ops_alert'); }
392 /**
393 * An operator alert, from its structured fields (rich_content.kind "ops_alert": name, state, severity, sites,
394 * release, summary, command) or, for older notices, read out of its title and message.
395 */
396 function presentOps(n, rich) {
397 const title = String(n.title || '');
398 const m = /^(Alert|Still firing|Resolved):\s*(.+)$/i.exec(title);
399 const state = rich.state || (m ? (m[1].toLowerCase() === 'resolved' ? 'resolved' : (m[1].toLowerCase() === 'still firing' ? 'reminded' : 'firing')) : 'firing');
400 const name = rich.name || (m ? m[2] : title);
401 const raw = String(n.message || '');
402 const command = rich.command || ((/run:\s*(sudo [^;]+?)(?:\.\s|;|\.$|$)/.exec(raw) || [])[1]) || null;
403 const sites = Array.isArray(rich.sites) && rich.sites.length ? rich.sites.slice(0, 6)
404 : [...new Set(((raw.match(/(?:^|\.\s+)([a-z0-9][a-z0-9.-]{1,30}):\s/g) || []).map(s => s.replace(/^\.\s+/, '').replace(/:\s$/, ''))))].slice(0, 6);
405 const release = rich.release || ((/release ([0-9a-f]{7,40})/i.exec(raw) || [])[1] || null);
406 const failing = (/\((\d+) failing check/i.exec(raw) || [])[1];
407 let summary = rich.summary || raw
408 .replace(/run:\s*sudo [^;]+?(?:\.\s|;|\.$|$)/g, '')
409 .replace(/Firing since [^.]+\.?/g, '')
410 .replace(/(?:^|\.\s+)[a-z0-9][a-z0-9.-]{1,30}:\s/g, ' ')
411 .replace(/\s+/g, ' ').trim();
412 if (release || failing) summary = [release ? `Release ${String(release).slice(0, 7)}` : null, failing ? `${failing} failing check${failing === '1' ? '' : 's'}` : null].filter(Boolean).join(' · ');
413 const severity = rich.severity || (n.priority === 'critical' ? 'critical' : n.priority === 'high' ? 'warning' : 'info');
414 const tone = state === 'resolved' ? 'ok' : severity === 'critical' ? 'danger' : 'warn';
415 return {
416 kind: 'ops', key: `ops:${name}:${state === 'resolved' ? 'resolved' : 'open'}`, tone, glyph: state === 'resolved' ? 'check' : 'pulse',
417 title: (state === 'resolved' ? 'Resolved: ' : state === 'reminded' ? 'Still failing: ' : '') + humanize(name),
418 message: summary.slice(0, 220), chips: sites, severity: state === 'resolved' ? 'resolved' : severity, command,
419 label: 'System', url: rich.runbook_url || urlOf(n), actionLabel: 'Open status',
420 };
421 }
422 // JSON in a message (a watch sends a feed entry, cut short): its title and summary, never code.
423 function readableMessage(raw) {
424 const s = String(raw || '').trim();
425 if (!/^[{[]\s*"/.test(s)) return s;
426 const field = (name) => {
427 const m = new RegExp(`"${name}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`).exec(s);
428 if (!m) return '';
429 try { return JSON.parse(`"${m[1].replace(/\\$/, '')}"`); } catch { return m[1]; }
430 };
431 const title = field('title') || field('name');
432 const summary = field('summary') || field('description');
433 return [title, summary].filter(Boolean).join(' — ').replace(/\s+/g, ' ').trim();
434 }
435 const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
436 // What kind of step it is, as a label (a noun, so it never reads as a button).
437 const ASK_CHIP = { approve: 'Approval', confirm: 'Approval', answer: 'Question', choose: 'Choice', pick: 'Choice', review: 'Review', 'sign in': 'Sign-in', 'log in': 'Sign-in' };
438 // An agent's notice: the task, not its wording ("Done: x" → x; "approve: step (task)" → step + chip).
439 function agentText(n, rich) {
440 const t = String(n.type || '');
441 let msg = String(n.message || '').trim();
442 const chips = [];
443 if (t === 'AGENT_TASK_DONE') msg = rich.task_title || msg.replace(/^Done:\s*/i, '');
444 else if (t === 'AGENT_TASK_FAILED') msg = rich.task_title ? `${rich.task_title}${rich.detail ? ` — ${rich.detail}` : ''}` : msg.replace(/^Not finished:\s*/i, '');
445 else if (t === 'AGENT_NEEDS_YOU') {
446 if (rich.step) msg = String(rich.step);
447 else if (/\)$/.test(msg) && msg.lastIndexOf(' (') > 0) msg = msg.slice(0, msg.lastIndexOf(' ('));
448 const m = /^(approve|answer|confirm|choose|pick|review|sign in|log in)\s*:\s*/i.exec(msg);
449 const k = (m ? m[1] : String(rich.kind || '')).toLowerCase();
450 if (m) msg = msg.slice(m[0].length);
451 if (ASK_CHIP[k]) chips.push(ASK_CHIP[k]);
452 }
453 return { message: cap(msg.trim()), chips };
454 }
455 function present(n) {
456 const rich = richOf(n);
457 if (isOpsAlert(n, rich)) return presentOps(n, rich);
458 const cat = n.category || 'system';
459 const t = String(n.type || '');
460 const agent = /^AGENT_/.test(t);
461 const glyph = /DEAL|COUPON|PRICE|TRADE/.test(t) ? 'tag' : /CONFIRM|APPROV|SECUR|SIGN/.test(t) ? 'key'
462 : (TYPE_GLYPH[t] || SERVICE_GLYPH[n.service] || CATEGORY_GLYPH[cat] || 'bell');
463 const tone = n.priority === 'critical' ? 'danger'
464 : t === 'AGENT_TASK_DONE' ? 'ok' : t === 'AGENT_TASK_FAILED' ? 'warn' : (CATEGORY_TONE[cat] || 'accent');
465 const said = agent ? agentText(n, rich) : { message: readableMessage(n.message), chips: [] };
466 const chips = said.chips.concat(Array.isArray(rich.chips) ? rich.chips.filter(c => typeof c === 'string') : []).slice(0, 4);
467 return {
468 kind: 'plain', key: [t, n.title || '', n.service || '', cat, rich.task_id || ''].join('\u0001'), tone, glyph,
469 title: String(n.title || ''), message: said.message, chips, severity: null, command: null,
470 label: SERVICE_LABELS[n.service] || (n.service && n.service !== 'network' ? n.service : '') || CATEGORY_LABELS[cat] || '',
471 url: urlOf(n), actionLabel: null,
472 };
473 }
474 function avatarOf(n) {
475 const rich = richOf(n);
476 const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);
477 return img && /^https?:\/\//.test(String(img)) ? String(img) : null;
478 }
479 /** A picture for the right of a row (rich_content.image, thumbnail_url or thumbnail), unless it is the avatar. */
480 function thumbOf(n) {
481 const rich = richOf(n);
482 const img = rich.image || rich.thumbnail_url || rich.thumbnail;
483 if (!img || !/^https:\/\//.test(String(img))) return null;
484 return String(img) === avatarOf(n) ? null : String(img);
485 }
486 function glyphHtml(n, p) {
487 const tone = TONE[p.tone] || TONE.accent;
488 const img = p.kind === 'ops' ? null : avatarOf(n);
489 if (img) return `<span class="ovn-glyph ovn-avatar" style="--ovn-tone:${tone}"><img src="${esc(img)}" alt="" loading="lazy" onerror="this.remove()"><span class="ovn-badge">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span></span>`;
490 return `<span class="ovn-glyph" style="--ovn-tone:${tone}">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span>`;
491 }
492 function chipsHtml(p, extra = []) {
493 const sev = p.severity ? [`<span class="ovn-chip sev" style="--ovn-tone:${TONE[p.tone] || TONE.accent}">${esc(p.severity)}</span>`] : [];
494 const all = sev.concat((p.chips || []).concat(extra).map(c => `<span class="ovn-chip">${esc(c)}</span>`));
495 return all.length ? `<div class="ovn-chips">${all.join('')}</div>` : '';
496 }
497
498 // ── API ─────────────────────────────────────────────────
499 function isCrossOrigin() {
500 try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }
501 }
502 /** The token may be a string or a function (the extension refreshes it); resolve it per request. */
503 async function tokenValue() {
504 return typeof _config.token === 'function' ? await _config.token() : _config.token;
505 }
506 async function apiFetch(path, opts = {}) {
507 const headers = { ...(opts.headers || {}) };
508 const token = await tokenValue();
509 if (token) headers['Authorization'] = `Bearer ${token}`;
510 if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
511 headers['Content-Type'] = 'application/json';
512 opts = { ...opts, body: JSON.stringify(opts.body) };
513 }
514 return fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: isCrossOrigin() ? 'omit' : 'include' });
515 }
516 async function apiJson(path, opts) {
517 const res = await apiFetch(path, opts);
518 let data = null; try { data = await res.json(); } catch { /* */ }
519 if (!res.ok) { const e = new Error((data && data.error) || `HTTP ${res.status}`); e.status = res.status; e.body = data; throw e; }
520 return data || {};
521 }
522 function hasSession() { return !!_config.token || !isCrossOrigin(); }
523
524 // Quiet hours (OpenVibe.Network GET /api/notifications/quiet-hours, 4.30.0): during the person's own window, in their
525 // time zone, a toast and its sound wait: the bell and the badge still fill, and a critical notice still shows. Read
526 // once when the layer starts and again every ten minutes; a Network that does not answer means no quiet hours.
527 let _quiet = null, _quietAt = 0;
528 function loadQuiet() {
529 if (!hasSession() || Date.now() - _quietAt < 10 * 60000) return;
530 _quietAt = Date.now();
531 apiJson('/api/notifications/quiet-hours').then((q) => { _quiet = q && typeof q.start === 'string' ? q : null; }).catch(() => { _quiet = null; });
532 }
533 const minutesOf = (s) => { const m = /^(\d{1,2}):(\d{2})$/.exec(String(s || '')); return m ? Number(m[1]) * 60 + Number(m[2]) : null; };
534 function inQuiet() {
535 loadQuiet();
536 const q = _quiet;
537 if (!q || !q.enabled) return false;
538 const a = minutesOf(q.start), b = minutesOf(q.end);
539 if (a == null || b == null || a === b) return false;
540 let now;
541 try {
542 const parts = new Intl.DateTimeFormat('en-GB', { timeZone: q.tz || 'UTC', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date());
543 now = Number(parts.find((x) => x.type === 'hour').value) * 60 + Number(parts.find((x) => x.type === 'minute').value);
544 } catch { const d = new Date(); now = d.getUTCHours() * 60 + d.getUTCMinutes(); }
545 return a < b ? now >= a && now < b : now >= a || now < b;
546 }
547 const held = (n) => (n && n.priority) !== 'critical' && inQuiet();
548
549 // ── Cross-tab sync (same origin) ────────────────────────
550 function channel() {
551 if (_channel !== null) return _channel || null;
552 try { _channel = new BroadcastChannel('openvibe-notifications'); _channel.onmessage = (e) => onSyncMessage(e.data || {}); }
553 catch { _channel = false; }
554 return _channel || null;
555 }
556 function broadcast(msg) { try { channel()?.postMessage(msg); } catch { /* */ } }
557 function onSyncMessage(msg) {
558 if (msg.type === 'unread') setBadge(msg.count, { quiet: true });
559 else if (msg.type === 'read') { for (const v of _views.values()) applyReadLocally(v, msg.ids || [], msg.all); }
560 }
561 function announceCount() {
562 try { document.dispatchEvent(new CustomEvent('openvibe-notifications-count', { detail: { count: _unreadCount } })); } catch { /* */ }
563 }
564
565 // ── Sound ───────────────────────────────────────────────
566 function playSound(priority) {
567 if (!_preferences.sound) return;
568 if (priority !== 'critical' && inQuiet()) return;
569 const file = SOUNDS[priority]; if (!file) return;
570 const url = `${_config.soundBase}/${file}`;
571 try {
572 if (!_audioCache[url]) _audioCache[url] = new Audio(url);
573 const a = _audioCache[url]; a.currentTime = 0; a.volume = priority === 'critical' ? 0.8 : 0.5; a.play().catch(() => {});
574 } catch { /* */ }
575 }
576
577 // ── Layers: toasts never draw over an open menu, panel or dialog ─────────
578 // What counts as open, read from the page itself: this file loads late (after the bell mounts), so an
579 // 'openvibe-menu' event fired before it is missed; the menu's own class and every bar toggle's aria-expanded are not.
580 const OPEN_LAYERS = '.ovs.is-open, .openvibe-navbar [aria-expanded="true"], .openvibe-notif-panel.open, dialog[open], [aria-modal="true"]:not([hidden])';
581 function layerBusy() {
582 if (_menuOpen) return true;
583 try { if (root.OpenVibePanels && typeof root.OpenVibePanels.anyOpen === 'function' && root.OpenVibePanels.anyOpen()) return true; } catch { /* */ }
584 if (document.body && document.body.classList && document.body.classList.contains('ov-panel-open')) return true;
585 // Only what is drawn counts: a closed menu may keep aria-modal while it sits at display: none.
586 try { for (const el of document.querySelectorAll(OPEN_LAYERS)) if (el.getClientRects().length) return true; } catch { /* */ }
587 return false;
588 }
589 let _layerWatch = false;
590 function watchLayers() {
591 if (_layerWatch || typeof document === 'undefined') return;
592 _layerWatch = true;
593 document.addEventListener('openvibe-menu', (e) => { _menuOpen = !!(e && e.detail && e.detail.open); syncLayer(); });
594 try {
595 // aria-expanded and open change only when something opens or closes, so watching them page-wide is cheap.
596 if (typeof root.MutationObserver === 'function' && document.body) new root.MutationObserver(() => syncLayer()).observe(document.body, { attributes: true, subtree: true, attributeFilter: ['aria-expanded', 'open', 'aria-modal'] });
597 if (typeof root.MutationObserver === 'function' && document.body) new root.MutationObserver(() => syncLayer()).observe(document.body, { attributes: true, attributeFilter: ['class'] });
598 } catch { /* */ }
599 // Without an observer, any click may have opened or closed something: check right after it.
600 document.addEventListener('click', () => Promise.resolve().then(syncLayer), true);
601 }
602 function syncLayer() {
603 if (!_toastContainer) return;
604 const busy = layerBusy();
605 const was = _toastContainer.classList.contains('ovn-held');
606 if (busy === was) return;
607 _toastContainer.classList.toggle('ovn-held', busy);
608 for (const el of _toastContainer.querySelectorAll('.openvibe-toast')) busy ? pauseToast(el) : resumeToast(el);
609 if (!busy) flushQueue();
610 }
611
612 // ── Toasts ──────────────────────────────────────────────
613 function ensureToastContainer() {
614 if (_toastContainer) return;
615 _toastContainer = document.createElement('div');
616 _toastContainer.className = 'openvibe-toast-container';
617 _toastContainer.setAttribute('role', 'region');
618 _toastContainer.setAttribute('aria-label', 'Notifications');
619 _toastContainer.setAttribute('aria-live', 'polite');
620 document.body.appendChild(_toastContainer);
621 watchLayers();
622 }
623 function placeContainer() {
624 const phone = isPhone();
625 _toastContainer.classList.toggle('ovn-phone', phone);
626 if (phone) {
627 // Under whatever bar the site has; above it when the bar is hidden on scroll.
628 const bar = document.querySelector('nav.openvibe-navbar, nav.navbar, header.navbar, .navbar');
629 const r = bar && bar.getBoundingClientRect ? bar.getBoundingClientRect() : null;
630 const top = r && r.bottom > 0 ? Math.round(r.bottom) + 8 : 10;
631 _toastContainer.style.setProperty('--ovn-top', top + 'px');
632 }
633 }
634 function visibleToasts() { return _toastContainer ? [..._toastContainer.querySelectorAll('.openvibe-toast:not(.removing)')] : []; }
635 function maxVisible() { return isPhone() ? 1 : MAX_TOASTS; }
636
637 // Urgent first: a full stack makes room by letting a lower-priority card go, never a higher one.
638 const RANK = { critical: 3, high: 2, normal: 1, low: 0 };
639 const rank = (n) => RANK[(n && n.priority) || 'normal'] ?? 1;
640 const _shownIds = new Set(); // one notification id is one card, however many times a poll or a push brings it
641 function showToast(n) {
642 if (!_preferences.toasts) return;
643 if (held(n)) return;
644 if (_preferences.muted_categories.includes(n.category)) return;
645 const id = n && n.id != null ? String(n.id) : null;
646 if (id) {
647 if (_shownIds.has(id)) return;
648 _shownIds.add(id);
649 if (_shownIds.size > 500) _shownIds.delete(_shownIds.values().next().value);
650 }
651 ensureToastContainer();
652 const p = present(n);
653 // The same notice again within the window joins the card already showing (or waiting).
654 const now = Date.now();
655 const live = visibleToasts().find(el => el.__ovn && el.__ovn.key === p.key && now - el.__ovn.at < GROUP_WINDOW);
656 if (live) { mergeToast(live, n, p); return; }
657 const queued = _queue.find(q => q.p.key === p.key);
658 if (queued) { queued.count += 1; queued.n = rank(n) >= rank(queued.n) ? n : queued.n; queued.chips = [...new Set(queued.chips.concat(p.chips))]; return; }
659 _queue.push({ n, p, count: 1, chips: p.chips.slice() });
660 _queue.sort((a, b) => rank(b.n) - rank(a.n)); // stable: arrival order within a priority
661 flushQueue();
662 }
663 function flushQueue() {
664 if (!_toastContainer || layerBusy()) { if (_toastContainer) _toastContainer.classList.toggle('ovn-held', layerBusy()); updateMore(); return; }
665 placeContainer();
666 while (_queue.length) {
667 const vis = visibleToasts();
668 if (vis.length < maxVisible()) { renderToast(_queue.shift()); continue; }
669 const lowest = vis.reduce((a, el) => (rank(el.__ovn.n) < rank(a.__ovn.n) ? el : a), vis[0]);
670 if (rank(_queue[0].n) > rank(lowest.__ovn.n)) { dismissToast(lowest); continue; }
671 break;
672 }
673 updateMore();
674 }
675 /** "+N more" lives inside the card that is showing (a phone shows one at a time), never floating over the page. */
676 function updateMore() {
677 if (!_toastContainer) return;
678 const waiting = _queue.length;
679 const first = visibleToasts()[0];
680 _toastContainer.querySelectorAll('.openvibe-toast-more').forEach((m) => { if (!waiting || !isPhone() || !first || !first.contains(m)) m.remove(); });
681 if (!waiting || !isPhone() || !first) return;
682 let more = first.querySelector('.openvibe-toast-more');
683 if (!more) {
684 more = document.createElement('button');
685 more.type = 'button'; more.className = 'openvibe-toast-more';
686 more.addEventListener('click', (e) => { e.stopPropagation(); const f = visibleToasts()[0]; if (f) dismissToast(f); else flushQueue(); });
687 const sub = first.querySelector('.openvibe-toast-sub');
688 (sub || first.querySelector('.openvibe-toast-body') || first).appendChild(more);
689 }
690 more.textContent = `+${waiting} more`;
691 more.setAttribute('aria-label', `${waiting} more notification${waiting === 1 ? '' : 's'}: show the next`);
692 }
693 function renderToast(q) {
694 const { n, p } = q;
695 const toast = document.createElement('div');
696 toast.className = `openvibe-toast priority-${esc(n.priority || 'normal')}`;
697 toast.dataset.notifId = n.id || '';
698 toast.tabIndex = 0;
699 toast.style.setProperty('--ovn-tone', TONE[p.tone] || TONE.accent);
700 if (n.priority === 'critical') toast.setAttribute('role', 'alert');
701 toast.__ovn = { key: p.key, at: Date.now(), count: q.count, chips: q.chips.slice(), ids: n.id ? [n.id] : [], n };
702 const rich = richOf(n);
703 const custom = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input' && a.label).slice(0, 2) : [];
704 const actions = custom.length ? custom : (p.url ? [{ id: 'open', label: p.actionLabel || 'View', url: p.url, style: 'primary' }] : []);
705 toast.innerHTML = `
706 ${glyphHtml(n, p)}
707 <div class="openvibe-toast-body">
708 <div class="openvibe-toast-title"><span class="t">${esc(p.title)}</span><span class="n" data-role="n"${q.count > 1 ? '' : ' hidden'}>×${q.count}</span></div>
709 <div class="openvibe-toast-sub">${esc(p.label || '')}${p.label ? '<span aria-hidden="true">·</span>' : ''}<span>now</span></div>
710 ${p.message ? `<div class="openvibe-toast-msg" data-role="msg">${esc(p.message)}</div>` : ''}
711 <div data-role="chips">${chipsHtml({ ...p, chips: q.chips })}</div>
712 ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => a.url
713 ? `<a href="${esc(a.url)}" data-action="${esc(a.id || 'open')}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</a>`
714 : `<button type="button" data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}
715 </div>
716 <button type="button" class="openvibe-toast-close" aria-label="Dismiss">&times;</button>`;
717 const duration = TOAST_DURATION[n.priority] ?? 5200;
718 toast.__ovnDuration = duration;
719 if (duration > 0) {
720 const bar = document.createElement('div');
721 bar.className = 'openvibe-toast-progress';
722 toast.appendChild(bar);
723 startTimer(toast, duration);
724 }
725 toast.querySelector('.openvibe-toast-close').addEventListener('click', (e) => { e.stopPropagation(); dismissToast(toast); });
726 toast.addEventListener('click', (e) => {
727 if (e.target.closest && e.target.closest('[data-action]')) return;
728 const ids = toast.__ovn.ids; if (ids.length) markRead(ids);
729 dismissToast(toast);
730 if (p.url) navigateTo(p.url);
731 });
732 toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') dismissToast(toast); });
733 toast.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', (e) => {
734 e.stopPropagation();
735 const ids = toast.__ovn.ids; if (ids.length) markRead(ids);
736 const a = actions.find(x => String(x.id || 'open') === btn.dataset.action);
737 if (a && a.url) { e.preventDefault(); dismissToast(toast); navigateTo(a.url); return; }
738 if (_config.onAction) _config.onAction(n.id, btn.dataset.action, n);
739 dismissToast(toast);
740 }));
741 // Hover or focus holds the timer; a swipe sideways (or up, on a phone) dismisses.
742 toast.addEventListener('mouseenter', () => pauseToast(toast));
743 toast.addEventListener('mouseleave', () => resumeToast(toast));
744 toast.addEventListener('focusin', () => pauseToast(toast));
745 toast.addEventListener('focusout', () => resumeToast(toast));
746 attachSwipe(toast);
747 _toastContainer.appendChild(toast);
748 }
749 function mergeToast(el, n, p) {
750 const st = el.__ovn;
751 if (n.id && st.ids.includes(n.id)) return;
752 st.count += 1; st.at = Date.now(); st.n = rank(n) >= rank(st.n) ? n : st.n;
753 if (n.id) st.ids.push(n.id);
754 st.chips = [...new Set(st.chips.concat(p.chips))].slice(0, 8);
755 const c = el.querySelector('[data-role="n"]'); if (c) { c.textContent = `×${st.count}`; c.hidden = false; }
756 const m = el.querySelector('[data-role="msg"]'); if (m && p.message) m.textContent = p.message;
757 const ch = el.querySelector('[data-role="chips"]'); if (ch) ch.innerHTML = chipsHtml({ ...p, chips: st.chips });
758 if (el.__ovnDuration > 0) startTimer(el, el.__ovnDuration);
759 }
760 function startTimer(el, ms) {
761 clearTimeout(el.__ovnTimer);
762 el.__ovnRemaining = ms; el.__ovnStarted = Date.now();
763 const bar = el.querySelector('.openvibe-toast-progress');
764 if (bar) { bar.style.transition = 'none'; bar.style.transform = 'scaleX(1)'; const go = () => { bar.style.transition = `transform ${ms}ms linear`; bar.style.transform = 'scaleX(0)'; }; (root.requestAnimationFrame || ((f) => f()))(go); }
765 if (_toastContainer && _toastContainer.classList.contains('ovn-held')) { pauseToast(el); return; }
766 el.__ovnTimer = setTimeout(() => dismissToast(el), ms);
767 }
768 function pauseToast(el) {
769 if (!el || !el.__ovnTimer) return;
770 clearTimeout(el.__ovnTimer); el.__ovnTimer = null;
771 el.__ovnRemaining = Math.max(800, (el.__ovnRemaining || 0) - (Date.now() - (el.__ovnStarted || Date.now())));
772 const bar = el.querySelector('.openvibe-toast-progress');
773 if (bar && root.getComputedStyle) { try { const t = root.getComputedStyle(bar).transform; bar.style.transition = 'none'; bar.style.transform = t && t !== 'none' ? t : 'scaleX(1)'; } catch { /* */ } }
774 }
775 function resumeToast(el) {
776 if (!el || el.__ovnTimer || !(el.__ovnDuration > 0) || el.classList.contains('removing')) return;
777 if (_toastContainer && _toastContainer.classList.contains('ovn-held')) return;
778 startTimer(el, el.__ovnRemaining || el.__ovnDuration);
779 }
780 function attachSwipe(el) {
781 if (typeof root.PointerEvent !== 'function') return;
782 let x0 = null, y0 = null, dx = 0, dy = 0;
783 el.addEventListener('pointerdown', (e) => { x0 = e.clientX; y0 = e.clientY; dx = dy = 0; });
784 el.addEventListener('pointermove', (e) => {
785 if (x0 == null) return;
786 dx = e.clientX - x0; dy = e.clientY - y0;
787 if (Math.abs(dx) > 8 || dy < -8) { el.style.transition = 'none'; el.style.transform = Math.abs(dx) >= Math.abs(dy) ? `translateX(${dx}px)` : `translateY(${Math.min(0, dy)}px)`; el.style.opacity = String(Math.max(0.2, 1 - Math.max(Math.abs(dx), -dy) / 220)); }
788 });
789 const end = () => {
790 if (x0 == null) return;
791 const gone = Math.abs(dx) > 90 || (isPhone() && dy < -40);
792 x0 = y0 = null;
793 if (gone) { el.__ovnSwiped = true; dismissToast(el); return; }
794 el.style.transition = 'transform .2s ease, opacity .2s ease'; el.style.transform = ''; el.style.opacity = '';
795 };
796 el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);
797 // A swipe is not a click on the card.
798 el.addEventListener('click', (e) => { if (el.__ovnSwiped) { e.stopImmediatePropagation(); e.preventDefault(); } }, true);
799 }
800 function dismissToast(el) {
801 if (!el || !el.parentNode || el.classList.contains('removing')) return;
802 clearTimeout(el.__ovnTimer);
803 el.classList.add('removing');
804 setTimeout(() => { el.remove(); flushQueue(); }, 240);
805 }
806
807 // ── The ✦: the Action Center, loaded from beside this file on the first click ──
808 let _acLoading = null;
809 function actionCenterSrc() {
810 if (_config.actionCenterSrc) return _config.actionCenterSrc;
811 const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);
812 return `${m ? m[1] : 'https://openvibe.network/shared/'}action-center.js`;
813 }
814 function loadActionCenter() {
815 if (root.OpenVibeActionCenter) return Promise.resolve(root.OpenVibeActionCenter);
816 return _acLoading || (_acLoading = new Promise((ok) => {
817 const sc = document.createElement('script');
818 sc.src = actionCenterSrc(); sc.async = true;
819 sc.onload = () => ok(root.OpenVibeActionCenter || null);
820 sc.onerror = () => { _acLoading = null; ok(null); };
821 document.head.appendChild(sc);
822 }));
823 }
824 function openActions(anchor) {
825 loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => tokenValue(), apiBase: _config.apiBase, scopes: _config.scopes }); });
826 }
827 function createActionsEl() {
828 const b = document.createElement('div');
829 b.className = 'openvibe-actions-btn';
830 b.setAttribute('role', 'button'); b.setAttribute('aria-label', 'Agents'); b.setAttribute('aria-haspopup', 'dialog'); b.setAttribute('aria-expanded', 'false');
831 b.title = 'Agents: everything your agents are doing'; b.tabIndex = 0;
832 b.innerHTML = svg(GLYPH.spark);
833 b.addEventListener('click', () => openActions(b));
834 b.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openActions(b); } });
835 return b;
836 }
837
838 // ── Bell / badge ────────────────────────────────────────
839 let _bellAction = null;
840 function createBellEl() {
841 const bell = document.createElement('div');
842 bell.className = 'openvibe-bell';
843 bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;
844 bell.innerHTML = `<svg class="ovnav-ovg has-s" viewBox="0 0 24 24" aria-hidden="true">${GLYPH.bell}<g class="ovg-s">${GLYPH.bellSolid}</g></svg><span class="badge hidden">0</span>`;
845 // With the Dock on the page, its Activity tab is this list: the bell opens it there (4.150.0, the owner: the bell
846 // "doesn't actually open the notifications … you have to open the chat button and click this manually"); the
847 // panel opened under the Dock's sheet, so the tap seemed to do nothing.
848 const press = () => { if (_bellAction) { try { if (_bellAction() === true) return; } catch { /* the panel then */ } } togglePanel(); };
849 bell.addEventListener('click', press);
850 bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); press(); } });
851 _bellEl = bell;
852 return bell;
853 }
854 // The bell's own number: notifications and new messages together, each named in its label.
855 function paintBell(ring) {
856 if (!_bellEl) return;
857 const badge = _bellEl.querySelector('.badge'), n = _unreadCount + _msgs;
858 if (n > 0) {
859 badge.textContent = n > 99 ? '99+' : String(n);
860 badge.classList.remove('hidden');
861 if (ring) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 700); }
862 } else badge.classList.add('hidden');
863 const parts = [_unreadCount ? `${_unreadCount} unread` : '', _msgs ? `${_msgs} new ${_msgs === 1 ? 'message' : 'messages'}` : ''].filter(Boolean);
864 _bellEl.setAttribute('aria-label', parts.length ? `Notifications, ${parts.join(', ')}` : 'Notifications');
865 }
866 function setBadge(count, { quiet = false } = {}) {
867 const prev = _unreadCount;
868 _unreadCount = Math.max(0, Number(count) || 0);
869 paintBell(!quiet && _unreadCount > prev);
870 for (const v of _views.values()) {
871 const c = v.root.querySelector('.openvibe-nv-head .count');
872 if (c) { c.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount); c.classList.toggle('zero', _unreadCount === 0); }
873 }
874 if (!quiet) broadcast({ type: 'unread', count: _unreadCount });
875 announceCount();
876 if (_unreadCount !== prev && listShowing()) loadTabCounts();
877 }
878
879 // ── List views (panel + inbox share this) ───────────────
880 const ICON = {
881 search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
882 filter: '<path d="M4 6.5h16M7 12h10M10 17.5h4"/>',
883 checkAll: '<path d="M2.5 12.5l4.5 4.5L17 7"/><path d="M11.5 16.5l.5.5L22 7"/>',
884 bellOn: GLYPH.bell,
885 bellOff: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 9.6-4.4M17.8 10.4v5.8l1.6 2H6"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0M3.5 3.5l17 17"/>',
886 gear: GLYPH.gear,
887 open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',
888 read: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',
889 close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
890 empty: '<path d="M18 37V26a14 14 0 0 1 28 0v11l4 5H14z"/><path d="M27 49a5 5 0 0 0 10 0"/><path d="M50 14l4-4M53 22h5M44 8l1-5"/>',
891 };
892
893 function buildView(root, { mode }) {
894 const v = { root, mode, tab: 'all', unread: false, q: '', items: [], offset: 0, hasMore: false, loading: false, seq: 0, ids: new Set() };
895 root.innerHTML = `
896 <div class="openvibe-nv-head">
897 <h3>Notifications <span class="count zero">0</span></h3>
898 <div class="openvibe-nv-actions">
899 <button type="button" data-act="read-all" title="Mark all as read">${svg(ICON.checkAll)}<span>Mark all read</span></button>
900 <button type="button" data-act="push" title="Push alerts on this device">${svg(ICON.bellOff)}<span>Push alerts</span></button>
901 <a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification settings">${svg(ICON.gear)}<span>Settings</span></a>
902 ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open the full inbox">${svg(ICON.open)}<span>Open inbox</span></a><button type="button" data-act="close" title="Close">${svg(ICON.close)}<span>Close</span></button>` : ''}
903 </div>
904 </div>
905 <div class="openvibe-nv-note" data-role="note" style="display:none"></div>
906 <div class="openvibe-nv-tools">
907 <div class="openvibe-nv-find">
908 <div class="openvibe-nv-search">${svg(ICON.search)}<input type="search" placeholder="Search notifications" aria-label="Search notifications" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">&times;</button></div>
909 <button type="button" class="openvibe-nv-unread" aria-pressed="false" title="Show only unread">${svg(ICON.filter)}Unread</button>
910 </div>
911 <div class="openvibe-nv-tabs" role="tablist" aria-label="Notifications">
912 ${TABS.map(([id, label]) => `<button type="button" role="tab" data-tab="${id}" aria-selected="${id === 'all'}" tabindex="${id === 'all' ? '0' : '-1'}">${esc(label)}<span class="n" data-n="${id}"></span></button>`).join('')}
913 </div>
914 </div>
915 <div class="openvibe-nv-list" data-role="list" role="tabpanel" aria-label="All"></div>
916 <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'dock' ? `<button type="button" class="ovn-foot-read" data-act="read-all-foot">${svg(ICON.checkAll)}Mark all read</button>` : ''}${mode === 'panel' || mode === 'dock' ? `<a href="${esc(inboxUrl())}">Full inbox →</a>` : ''}</div>`;
917 const list = root.querySelector('[data-role="list"]');
918 const input = root.querySelector('input[type="search"]');
919 let qTimer = null;
920 input.addEventListener('input', () => {
921 root.querySelector('.openvibe-nv-search').classList.toggle('has-q', !!input.value);
922 clearTimeout(qTimer); qTimer = setTimeout(() => { v.q = input.value.trim(); reload(v); }, 260);
923 });
924 root.querySelector('.openvibe-nv-search .clear').addEventListener('click', () => { input.value = ''; v.q = ''; root.querySelector('.openvibe-nv-search').classList.remove('has-q'); reload(v); });
925 // Tabs: a click or the arrow keys (Home, End) pick one; the list reloads with Network's ?tab=.
926 const tabs = [...root.querySelectorAll('.openvibe-nv-tabs [role="tab"]')];
927 const selectTab = (btn, focus) => {
928 tabs.forEach((b) => { const on = b === btn; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
929 if (focus) btn.focus();
930 try { btn.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch { /* */ }
931 v.tab = btn.dataset.tab;
932 list.setAttribute('aria-label', (TABS.find(([id]) => id === v.tab) || ['', 'All'])[1]);
933 reload(v);
934 };
935 tabs.forEach((btn) => btn.addEventListener('click', () => selectTab(btn)));
936 const tabRow = root.querySelector('.openvibe-nv-tabs');
937 // More filters past the edge: a fade says the row scrolls (4.150.0; "System" was cut off with no sign of more).
938 if (tabRow && !tabRow.__more) {
939 tabRow.__more = true;
940 const more = () => tabRow.classList.toggle('is-more', tabRow.scrollWidth - tabRow.clientWidth - tabRow.scrollLeft > 4);
941 tabRow.addEventListener('scroll', more, { passive: true });
942 if (typeof ResizeObserver === 'function') new ResizeObserver(more).observe(tabRow);
943 setTimeout(more, 0);
944 }
945 const fade = () => { tabRow.classList.toggle('is-more', tabRow.scrollLeft + tabRow.clientWidth < tabRow.scrollWidth - 4); };
946 tabRow.addEventListener('scroll', fade, { passive: true });
947 v.fade = fade;
948 root.querySelector('.openvibe-nv-tabs').addEventListener('keydown', (e) => {
949 const i = tabs.indexOf(document.activeElement);
950 if (i < 0) return;
951 const j = e.key === 'ArrowRight' ? (i + 1) % tabs.length : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : -1;
952 if (j >= 0) { e.preventDefault(); selectTab(tabs[j], true); }
953 });
954 const unreadBtn = root.querySelector('.openvibe-nv-unread');
955 unreadBtn.addEventListener('click', () => { v.unread = !v.unread; unreadBtn.setAttribute('aria-pressed', String(v.unread)); reload(v); });
956 root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());
957 const footRead = root.querySelector('[data-act="read-all-foot"]'); if (footRead) footRead.addEventListener('click', () => markAllRead());
958 root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));
959 const closeBtn = root.querySelector('[data-act="close"]');
960 if (closeBtn) closeBtn.addEventListener('click', () => closePanel());
961 // Infinite scroll: load the next page when the user nears the bottom.
962 list.addEventListener('scroll', () => {
963 if (v.loading || !v.hasMore) return;
964 if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);
965 });
966 list.addEventListener('click', (e) => {
967 if (e.target.closest('[data-act="ac"]')) { const anchor = _bellEl; closePanel(); openActions(anchor); return; }
968 const item = e.target.closest('.openvibe-notif-item');
969 if (!item || (item.__ovnSwipedAt && Date.now() - item.__ovnSwipedAt < 450)) return;
970 const id = item.dataset.id;
971 const ids = (item.dataset.ids || id).split(',').filter(Boolean);
972 const act = e.target.closest('[data-row]');
973 if (act) {
974 e.stopPropagation();
975 if (act.dataset.row === 'dismiss') ids.forEach(x => dismiss(v, x));
976 else if (act.dataset.row === 'read') markRead(ids);
977 else if (act.dataset.row === 'copy') copyText(act.dataset.cmd || '', act);
978 return;
979 }
980 const n = v.items.find(x => String(x.id) === id);
981 const unreadIds = ids.filter(x => { const it = v.items.find(y => String(y.id) === x); return it && !isRead(it); });
982 if (unreadIds.length) markRead(unreadIds);
983 const u = n && present(n).url;
984 if (u) navigateTo(u);
985 });
986 list.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.classList && e.target.classList.contains('ovn-row')) { e.preventDefault(); e.target.click(); } });
987 // A thumbnail or avatar that does not load leaves its row (no inline handlers: a site's CSP may forbid them).
988 list.addEventListener('error', (e) => {
989 const img = e.target;
990 if (!img || img.tagName !== 'IMG') return;
991 const row = img.closest('.ovn-row');
992 if (img.classList.contains('thumb') && row) row.classList.remove('has-thumb');
993 img.remove();
994 }, true);
995 attachRowSwipe(v, list);
996 refreshPushButton(v);
997 v.destroy = () => { clearTimeout(qTimer); v.seq++; };
998 _views.set(root, v);
999 paintTabCounts();
1000 return v;
1001 }
1002 /**
1003 * Touch: a row follows the finger sideways. Past a third of its width (at most 96 px), right marks it read and left
1004 * clears it (Network's dismiss); short of that it springs back. A vertical move is the list scrolling, not a swipe.
1005 */
1006 function attachRowSwipe(v, list) {
1007 if (typeof root.PointerEvent !== 'function') return;
1008 let s = null;
1009 list.addEventListener('pointerdown', (e) => {
1010 if (e.pointerType === 'mouse') return;
1011 const row = e.target.closest && e.target.closest('.ovn-row');
1012 if (!row || e.target.closest('button, a')) return;
1013 s = { row, item: row.parentNode, x0: e.clientX, y0: e.clientY, dx: 0, on: false, id: e.pointerId };
1014 });
1015 list.addEventListener('pointermove', (e) => {
1016 if (!s || e.pointerId !== s.id) return;
1017 const dx = e.clientX - s.x0, dy = e.clientY - s.y0;
1018 if (!s.on) {
1019 if (Math.abs(dy) > 10 && Math.abs(dy) > Math.abs(dx)) { s = null; return; }
1020 if (Math.abs(dx) < 12) return;
1021 s.on = true; s.item.classList.add('swiping');
1022 try { s.row.setPointerCapture(e.pointerId); } catch { /* */ }
1023 }
1024 // Already read: a short give to the right, nothing to do.
1025 s.dx = dx > 0 && !s.item.classList.contains('unread') ? Math.min(dx, 24) : dx;
1026 s.item.dataset.swipe = s.dx > 0 ? 'read' : 'clear';
1027 s.row.style.transform = `translateX(${s.dx}px)`;
1028 });
1029 const end = () => {
1030 if (!s) return;
1031 const { row, item, dx, on } = s; s = null;
1032 item.classList.remove('swiping');
1033 if (!on) return;
1034 item.__ovnSwipedAt = Date.now();
1035 const ids = (item.dataset.ids || item.dataset.id || '').split(',').filter(Boolean);
1036 const w = row.offsetWidth || 320;
1037 const far = Math.min(96, w / 3);
1038 const settle = () => setTimeout(() => { if (!item.classList.contains('swiping')) delete item.dataset.swipe; }, 220);
1039 if (dx < -far) { row.style.transform = `translateX(${-w}px)`; setTimeout(() => ids.forEach((x) => dismiss(v, x)), 180); return; }
1040 row.style.transform = '';
1041 if (dx > far && item.classList.contains('unread')) markRead(ids);
1042 settle();
1043 };
1044 list.addEventListener('pointerup', end);
1045 list.addEventListener('pointercancel', end);
1046 }
1047 function copyText(text, btn) {
1048 const done = () => { if (btn) { const was = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = was; }, 1400); } };
1049 try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, () => {}); return; } } catch { /* */ }
1050 done();
1051 }
1052
1053 function renderItems(v, { append = false } = {}) {
1054 const list = v.root.querySelector('[data-role="list"]');
1055 // The same notice repeated back to back ("X is live!" six times; three browser checks failing) reads as one
1056 // row with a count and the sites it covers.
1057 const groups = [];
1058 for (const n of v.items) {
1059 const p = present(n);
1060 const g = groups[groups.length - 1];
1061 if (g && g.p.key === p.key) {
1062 g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true;
1063 g.chips = [...new Set(g.chips.concat(p.chips))].slice(0, 8);
1064 if (!g.cmd && p.command) g.cmd = p.command;
1065 } else groups.push({ n, p, ids: [String(n.id)], unread: !isRead(n), chips: p.chips.slice(), cmd: p.command });
1066 }
1067 let lastDay = append ? v.lastDay : null;
1068 const html = groups.map(({ n, p, ids, unread, chips, cmd }) => {
1069 try {
1070 const day = dayLabel(n.created_at);
1071 const head = day !== lastDay ? `<div class="openvibe-nv-day">${esc(day)}</div>` : '';
1072 lastDay = day;
1073 const when = esc(timeAgo(n.created_at));
1074 const label = p.label ? `<span class="dot"></span><span>${esc(p.label)}</span>` : '';
1075 const thumb = p.kind === 'ops' ? null : thumbOf(n);
1076 return `${head}<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" style="--ovn-tone:${TONE[p.tone] || TONE.accent}">
1077 <div class="ovn-under" aria-hidden="true"><span class="read">${svg(ICON.read)}Read</span><span class="clear">Clear${svg(ICON.close)}</span></div>
1078 <div class="ovn-row${thumb ? ' has-thumb' : ''}" tabindex="0">
1079 ${unread ? '<span class="unread-dot" role="img" aria-label="Unread"></span>' : ''}
1080 ${glyphHtml(n, p)}
1081 <div class="content">
1082 <div class="title"><span class="t">${esc(p.title)}</span>${ids.length > 1 ? `<span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>
1083 ${p.message ? `<div class="msg">${esc(p.message)}</div>` : ''}
1084 ${chipsHtml({ ...p, chips })}
1085 ${cmd ? `<button type="button" class="copy" data-row="copy" data-cmd="${esc(cmd)}">Copy fix command</button>` : ''}
1086 <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${when}</span>${label}<div class="row-actions"><button type="button" data-row="dismiss" title="Clear this notice" aria-label="Clear this notice">${svg(ICON.close)}</button></div></div>
1087 </div>
1088 ${thumb ? `<img class="thumb" src="${esc(thumb)}" alt="" loading="lazy">` : ''}
1089 </div>
1090 </div>`;
1091 } catch { return ''; }
1092 }).join('');
1093 v.lastDay = lastDay;
1094 if (!append) list.innerHTML = v.tab === 'agents' ? `<button type="button" class="ovn-ac-link" data-act="ac">${svg(GLYPH.spark)}<span>Everything your agents are doing: running, waiting for you, done</span></button>` : '';
1095 if (!v.items.length) {
1096 const [t0, s0] = TAB_EMPTY[v.tab] || TAB_EMPTY.all;
1097 const acLink = v.tab === 'agents' ? `<button type="button" class="ovn-ac-link" data-act="ac">${svg(GLYPH.spark)}<span>Everything your agents are doing: running, waiting for you, done</span></button>` : '';
1098 const title = v.q ? 'Nothing matches that search' : (v.unread ? 'You\'re all caught up' : t0);
1099 const sub = v.q ? 'Try other words.' : (v.unread ? 'Nothing unread here.' : s0);
1100 list.innerHTML = `${acLink}<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${esc(title)}</b>${esc(sub)}</div>`;
1101 } else {
1102 const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();
1103 list.insertAdjacentHTML('beforeend', html);
1104 if (v.hasMore) list.insertAdjacentHTML('beforeend', `<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>`);
1105 const more = list.querySelector('[data-act="more"]'); if (more) more.addEventListener('click', () => loadMore(v));
1106 }
1107 const st = v.root.querySelector('[data-role="status"]');
1108 if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.unread ? ' unread' : ''}` : '';
1109 }
1110
1111 async function fetchPage(v, offset) {
1112 const params = new URLSearchParams({ limit: String(v.pageSize || PAGE_SIZE), offset: String(offset) });
1113 if (v.unread) params.set('unread_only', '1');
1114 if (v.tab && v.tab !== 'all') params.set('tab', v.tab);
1115 if (v.q) params.set('q', v.q);
1116 return apiJson(`/api/notifications?${params}`);
1117 }
1118 async function reload(v) {
1119 const seq = ++v.seq;
1120 loadTabCounts();
1121 if (v.fade) setTimeout(v.fade, 0);
1122 v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null; v.lastDay = null;
1123 const list = v.root.querySelector('[data-role="list"]');
1124 list.innerHTML = '<div class="openvibe-nv-more"><span class="spinner"></span></div>';
1125 try {
1126 const data = await fetchPage(v, 0);
1127 if (seq !== v.seq) return;
1128 v.items = data.notifications || []; v.items.forEach(n => v.ids.add(String(n.id)));
1129 v.hasMore = !!data.has_more; v.total = data.total ?? null; v.offset = v.items.length;
1130 if (typeof v.onCount === 'function') { try { v.onCount(v.total); } catch { /* */ } }
1131 renderItems(v);
1132 } catch (e) {
1133 if (seq !== v.seq) return;
1134 list.innerHTML = `<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</b>${e.status === 401 ? '' : 'Check your connection and try again.'}</div>`;
1135 } finally { if (seq === v.seq) v.loading = false; }
1136 }
1137 async function loadMore(v) {
1138 if (v.loading || !v.hasMore) return;
1139 const seq = v.seq; v.loading = true;
1140 const more = v.root.querySelector('.openvibe-nv-more'); if (more) more.innerHTML = '<span class="spinner"></span>';
1141 try {
1142 const data = await fetchPage(v, v.offset);
1143 if (seq !== v.seq) return;
1144 const fresh = (data.notifications || []).filter(n => !v.ids.has(String(n.id)));
1145 fresh.forEach(n => v.ids.add(String(n.id)));
1146 v.items = v.items.concat(fresh); v.hasMore = !!data.has_more; v.offset += (data.notifications || []).length; v.total = data.total ?? v.total;
1147 // Re-render only the tail to keep the scroll position.
1148 const old = v.root.querySelector('.openvibe-nv-more'); if (old) old.remove();
1149 if (fresh.length) { const tail = { ...v, items: fresh }; renderItems(tail, { append: true }); v.lastDay = tail.lastDay; }
1150 else if (v.hasMore) { const list = v.root.querySelector('[data-role="list"]'); list.insertAdjacentHTML('beforeend', '<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>'); list.querySelector('[data-act="more"]').addEventListener('click', () => loadMore(v)); }
1151 const st = v.root.querySelector('[data-role="status"]');
1152 if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.unread ? ' unread' : ''}` : '';
1153 } catch { /* keep what we have */ }
1154 finally { v.loading = false; }
1155 }
1156 function applyReadLocally(v, ids, all) {
1157 const set = new Set((ids || []).map(String));
1158 for (const n of v.items) if (all || set.has(String(n.id))) n.is_read = 1;
1159 v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => {
1160 const elIds = (el.dataset.ids || el.dataset.id || '').split(',');
1161 if (all || elIds.some(x => set.has(x))) { el.classList.remove('unread'); el.querySelector('.unread-dot')?.remove(); el.querySelector('[data-row="read"]')?.remove(); }
1162 });
1163 }
1164
1165 // The counts on the tabs (Network GET /api/notifications/unread-by-tab, 4.30.0): read when a list loads, after a read
1166 // or a clear, and when the badge changes with a list showing; a Network without the route shows none.
1167 let _tabCounts = null, _tabAt = 0;
1168 function loadTabCounts(force) {
1169 if (!hasSession() || !_views.size) return;
1170 if (!force && Date.now() - _tabAt < 4000) return;
1171 _tabAt = Date.now();
1172 apiJson('/api/notifications/unread-by-tab').then((d) => { _tabCounts = (d && d.tabs) || null; paintTabCounts(); }).catch(() => { _tabCounts = null; paintTabCounts(); });
1173 }
1174 function paintTabCounts() {
1175 for (const v of _views.values()) {
1176 for (const [id, label] of TABS) {
1177 const el = v.root.querySelector(`[data-n="${id}"]`);
1178 if (!el) continue;
1179 const n = id === 'all' || !_tabCounts ? 0 : Math.max(0, Number(_tabCounts[id]) || 0);
1180 el.textContent = n ? (n > 99 ? '99+' : String(n)) : '';
1181 el.parentNode.setAttribute('aria-label', n ? `${label}, ${n} unread` : label);
1182 }
1183 }
1184 }
1185 function listShowing() { return [..._views.values()].some((v) => v.mode !== 'panel' || (_panelEl && _panelEl.classList.contains('open'))); }
1186
1187 // ── Mutations ───────────────────────────────────────────
1188 async function markRead(ids) {
1189 ids = (ids || []).map(String).filter(Boolean);
1190 if (!ids.length) return;
1191 for (const v of _views.values()) applyReadLocally(v, ids, false);
1192 broadcast({ type: 'read', ids });
1193 try {
1194 const data = ids.length === 1
1195 ? await apiJson(`/api/notifications/${encodeURIComponent(ids[0])}/read`, { method: 'POST' })
1196 : await apiJson('/api/notifications/read-batch', { method: 'POST', body: { ids } });
1197 if (data && typeof data.unread === 'number') setBadge(data.unread, { quiet: true }), broadcast({ type: 'unread', count: data.unread });
1198 else pollUnread();
1199 } catch { pollUnread(); }
1200 loadTabCounts(true);
1201 }
1202 async function markAllRead() {
1203 for (const v of _views.values()) applyReadLocally(v, [], true);
1204 setBadge(0);
1205 if (_tabCounts) { _tabCounts = Object.fromEntries(Object.keys(_tabCounts).map((k) => [k, 0])); paintTabCounts(); }
1206 broadcast({ type: 'read', all: true });
1207 try { await apiJson('/api/notifications/read-all', { method: 'POST', body: {} }); } catch { pollUnread(); }
1208 loadTabCounts(true);
1209 }
1210 async function dismiss(v, id) {
1211 const n = v.items.find(x => String(x.id) === String(id));
1212 v.items = v.items.filter(x => String(x.id) !== String(id));
1213 const row = v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`);
1214 if (row) {
1215 const rest = (row.dataset.ids || '').split(',').filter(x => x && x !== String(id));
1216 if (rest.length) { row.dataset.ids = rest.join(','); row.dataset.id = rest[0]; const c = row.querySelector('.title .count'); if (c) { if (rest.length > 1) c.textContent = `×${rest.length}`; else c.remove(); } }
1217 else row.remove();
1218 }
1219 if (v.total != null) v.total = Math.max(0, v.total - 1);
1220 if (!v.items.length) renderItems(v);
1221 try {
1222 await apiJson(`/api/notifications/${encodeURIComponent(id)}/dismiss`, { method: 'POST' });
1223 if (n && !isRead(n)) { setBadge(Math.max(0, _unreadCount - 1), { quiet: true }); loadTabCounts(true); }
1224 } catch { /* */ }
1225 }
1226
1227 // ── Polling / toasts ────────────────────────────────────
1228 let _polling = false;
1229 async function pollUnread() {
1230 if (_polling || !hasSession()) return;
1231 _polling = true;
1232 _lastPoll = Date.now();
1233 try {
1234 const data = await apiJson('/api/notifications/unread-count');
1235 const prev = _unreadCount;
1236 const count = Number(data.count) || 0;
1237 setBadge(count);
1238 if (count > prev && _lastSeenAt) {
1239 // Something new arrived: fetch what's newer than the last thing we saw.
1240 const params = new URLSearchParams({ unread_only: '1', limit: '5', since: _lastSeenAt });
1241 const nd = await apiJson(`/api/notifications?${params}`);
1242 // Only what is unread and newer than the last thing seen, whatever the server answered.
1243 const since = _lastSeenAt;
1244 const fresh = (nd.notifications || []).filter(n => n && !n.is_read && (!since || String(n.created_at || '') > since)).slice().reverse();
1245 for (const n of fresh) { showToast(n); playSound(n.priority); }
1246 // Any open list should reflect the new items too.
1247 for (const v of _views.values()) if (v.mode === 'inbox' || (_panelEl && _panelEl.classList.contains('open'))) reload(v);
1248 }
1249 const newest = await newestCreatedAt();
1250 if (newest) _lastSeenAt = newest;
1251 } catch { /* offline / signed out — try again next tick */ }
1252 finally { _polling = false; }
1253 }
1254 async function newestCreatedAt() {
1255 try { const d = await apiJson('/api/notifications/newest'); const c = d.notification && d.notification.created_at; return c ? String(c) : null; } catch { return null; }
1256 }
1257 function startPolling() {
1258 stopPolling();
1259 if (!hasSession()) return;
1260 newestCreatedAt().then(c => { _lastSeenAt = c || new Date().toISOString(); pollUnread(); });
1261 _pollTimer = setInterval(() => { if (!liveOpen() || Date.now() - _lastPoll >= REALTIME_POLL) pollUnread(); }, POLL_INTERVAL);
1262 }
1263 function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }
1264
1265 // notification-live.js's content hash (scripts/sibling-revs.js writes it), so a release gives it a new URL.
1266 const NOTIFY_REV = { 'notification-live.js': 'cd2d64cc77d0' };
1267 // ── Realtime (notification-live.js) ─────────────────────
1268 function liveOpen() { return !!(_live && _live.state().state === 'open'); }
1269 /** One re-read for a burst of events (a go-live fan-out, a replay after a reconnect); a gap re-reads open lists too. */
1270 function soon(all) {
1271 _soonAll = _soonAll || all;
1272 if (_soon) return;
1273 _soon = setTimeout(() => {
1274 _soon = null;
1275 if (_polling) { soon(false); return; } // a poll in flight may predate the event: read again after it
1276 const lists = _soonAll; _soonAll = false;
1277 pollUnread();
1278 if (lists) for (const v of _views.values()) reload(v);
1279 }, 300);
1280 }
1281 function liveSrc() {
1282 if (_config.liveSrc) return _config.liveSrc;
1283 const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);
1284 return `${m ? m[1] : 'https://openvibe.network/shared/'}notification-live.js${NOTIFY_REV['notification-live.js'] ? `?v=${NOTIFY_REV['notification-live.js']}` : ''}`;
1285 }
1286 let _liveLoading = null;
1287 function loadLive() {
1288 if (root.OVNotificationLive) return Promise.resolve(root.OVNotificationLive);
1289 return _liveLoading || (_liveLoading = new Promise((ok) => {
1290 const sc = document.createElement('script');
1291 sc.src = liveSrc(); sc.async = true;
1292 sc.onload = () => ok(root.OVNotificationLive || null);
1293 sc.onerror = () => { _liveLoading = null; ok(null); };
1294 document.head.appendChild(sc);
1295 }));
1296 }
1297 function startRealtime() {
1298 if (!_config.realtime || !hasSession() || typeof root.EventSource !== 'function') return;
1299 if (_live) { _live.restart(); return; }
1300 loadLive().then((L) => {
1301 if (!L || _live || !_config.realtime || !hasSession()) return;
1302 _live = L.create({
1303 ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,
1304 token: () => tokenValue(),
1305 credentials: isCrossOrigin() ? 'omit' : 'include',
1306 onNotification: () => soon(false),
1307 onGap: () => soon(true),
1308 });
1309 _live.start();
1310 });
1311 }
1312 function stopRealtime() { if (_live) _live.stop(); }
1313 document.addEventListener('visibilitychange', () => { if (!document.hidden) pollUnread(); });
1314
1315 // ── Panel ───────────────────────────────────────────────
1316 function createPanel() {
1317 const panel = document.createElement('div');
1318 panel.className = 'openvibe-notif-panel';
1319 panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Notifications');
1320 document.body.appendChild(panel);
1321 buildView(panel, { mode: 'panel' });
1322 attachSheetDrag(panel);
1323 document.addEventListener('click', (e) => {
1324 if (panel.classList.contains('open') && !panel.contains(e.target) && !(_bellEl && _bellEl.contains(e.target))) closePanel();
1325 });
1326 document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel.classList.contains('open')) closePanel(); });
1327 _panelEl = panel;
1328 return panel;
1329 }
1330 function closePanel() {
1331 if (_panelEl && _panelEl.contains(document.activeElement) && _bellEl) { try { _bellEl.focus({ preventScroll: true }); } catch { /* */ } }
1332 _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); _bellEl?.setAttribute('aria-expanded', 'false');
1333 if (_panelEl) _panelEl.style.transform = '';
1334 document.documentElement.classList.remove('ovn-sheet-open');
1335 syncLayer();
1336 }
1337 /** Phones: the sheet's head drags down; past 110 px it closes, short of that it springs back. */
1338 function attachSheetDrag(panel) {
1339 const head = panel.querySelector('.openvibe-nv-head');
1340 if (!head || typeof root.PointerEvent !== 'function') return;
1341 let y0 = null, dy = 0;
1342 head.addEventListener('pointerdown', (e) => {
1343 if (!isPhone() || e.pointerType === 'mouse' || e.target.closest('button, a')) return;
1344 y0 = e.clientY; dy = 0; panel.style.transition = 'none';
1345 try { head.setPointerCapture(e.pointerId); } catch { /* */ }
1346 });
1347 head.addEventListener('pointermove', (e) => { if (y0 == null) return; dy = Math.max(0, e.clientY - y0); panel.style.transform = `translateY(${dy}px)`; });
1348 const end = () => {
1349 if (y0 == null) return;
1350 y0 = null;
1351 panel.style.transition = 'transform .2s ease';
1352 if (dy > 110) closePanel(); else panel.style.transform = '';
1353 setTimeout(() => { panel.style.transition = ''; }, 220);
1354 };
1355 head.addEventListener('pointerup', end);
1356 head.addEventListener('pointercancel', end);
1357 }
1358 function togglePanel() {
1359 if (!_panelEl) createPanel();
1360 const opening = !_panelEl.classList.contains('open');
1361 if (opening && window.OpenVibePanels && !_panelEl.__registered) { _panelEl.__registered = true; window.OpenVibePanels.register({ el: _panelEl, openClass: 'open', id: 'notifications', close: () => { if (_panelEl.classList.contains('open')) togglePanel(); } }); window.OpenVibePanels.closeAll(_panelEl); }
1362 if (opening) {
1363 // Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).
1364 const bar = _bellEl && _bellEl.closest('nav, header, .navbar');
1365 const bottom = bar ? bar.getBoundingClientRect().bottom : (_bellEl ? _bellEl.getBoundingClientRect().bottom : 56);
1366 _panelEl.style.setProperty('--ovn-top', Math.max(8, Math.round(bottom) + 6) + 'px');
1367 if (!_panelEl.__esc) { _panelEl.__esc = (e) => { if (e.key === 'Escape' && _panelEl.classList.contains('open')) togglePanel(); }; document.addEventListener('keydown', _panelEl.__esc); }
1368 }
1369 if (!opening) { closePanel(); return; }
1370 _panelEl.classList.add('open');
1371 _bellEl?.classList.add('open');
1372 _bellEl?.setAttribute('aria-expanded', 'true');
1373 document.documentElement.classList.toggle('ovn-sheet-open', isPhone());
1374 syncLayer();
1375 reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread();
1376 // Focus the chosen tab, so the keyboard starts in the panel (Escape and the bell bring it back).
1377 setTimeout(() => { try { _panelEl.querySelector('[role="tab"][aria-selected="true"]')?.focus({ preventScroll: true }); } catch { /* */ } }, 0);
1378 }
1379
1380 // ── Web Push ────────────────────────────────────────────
1381 function pushSupported() { return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window && window.isSecureContext; }
1382 function urlBase64ToUint8Array(b64) {
1383 const padding = '='.repeat((4 - (b64.length % 4)) % 4);
1384 const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');
1385 const raw = window.atob(base64); const out = new Uint8Array(raw.length);
1386 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
1387 return out;
1388 }
1389 async function currentSubscription() {
1390 if (!pushSupported()) return null;
1391 try { const reg = await navigator.serviceWorker.getRegistration(_config.swPath); return reg ? await reg.pushManager.getSubscription() : null; } catch { return null; }
1392 }
1393 async function pushStatus() {
1394 const supported = pushSupported();
1395 const sub = supported ? await currentSubscription() : null;
1396 return { supported, permission: supported ? Notification.permission : 'unsupported', subscribed: !!sub };
1397 }
1398 async function enablePush() {
1399 if (!pushSupported()) throw new Error('This browser does not support push notifications');
1400 if (!hasSession()) throw new Error('Sign in first');
1401 const perm = await Notification.requestPermission();
1402 if (perm !== 'granted') throw new Error(perm === 'denied' ? 'Notifications are blocked for this site in your browser settings' : 'Permission not granted');
1403 const reg = await navigator.serviceWorker.register(_config.swPath, { scope: '/' });
1404 await navigator.serviceWorker.ready;
1405 const { publicKey } = await apiJson('/api/push/vapid-key');
1406 if (!publicKey) throw new Error('Push is not configured on the server');
1407 let sub = await reg.pushManager.getSubscription();
1408 if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });
1409 await apiJson('/api/push/subscribe', { method: 'POST', body: { subscription: { ...sub.toJSON(), userAgent: navigator.userAgent.slice(0, 200) } } });
1410 try { localStorage.setItem('ov_push_enabled', '1'); } catch { /* */ }
1411 return true;
1412 }
1413 async function disablePush() {
1414 const sub = await currentSubscription();
1415 if (sub) {
1416 try { await apiJson('/api/push/unsubscribe', { method: 'POST', body: { endpoint: sub.endpoint } }); } catch { /* */ }
1417 try { await sub.unsubscribe(); } catch { /* */ }
1418 }
1419 try { localStorage.removeItem('ov_push_enabled'); } catch { /* */ }
1420 return true;
1421 }
1422 async function refreshPushButton(v) {
1423 const btn = v && v.root.querySelector('[data-act="push"]');
1424 if (!btn) return;
1425 const st = await pushStatus();
1426 if (!st.supported) { btn.style.display = 'none'; return; }
1427 btn.style.display = '';
1428 btn.classList.toggle('on', st.subscribed);
1429 btn.innerHTML = st.subscribed ? `${svg(ICON.bellOn)}<span>Push on</span>` : `${svg(ICON.bellOff)}<span>Enable push</span>`;
1430 btn.title = st.subscribed ? 'Push alerts are on for this device: select to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get alerts on this device even when the site is closed');
1431 btn.disabled = st.permission === 'denied' && !st.subscribed;
1432 }
1433 async function togglePush(v) {
1434 const note = v.root.querySelector('[data-role="note"]');
1435 const say = (msg, ms = 4000) => { if (!note) return; note.textContent = msg; note.style.display = ''; clearTimeout(note._t); note._t = setTimeout(() => { note.style.display = 'none'; }, ms); };
1436 try {
1437 const st = await pushStatus();
1438 if (st.subscribed) { await disablePush(); say('Push alerts are off for this device.'); }
1439 else { await enablePush(); say('Push alerts are on: you\'ll get them here even when this site is closed.'); }
1440 } catch (e) { say(e.message || 'Could not change push setting', 6000); }
1441 for (const view of _views.values()) refreshPushButton(view);
1442 }
1443
1444 // ── Public API ──────────────────────────────────────────
1445 const OpenVibeNotifications = {
1446 init(opts = {}) {
1447 Object.assign(_config, opts);
1448 _quietAt = 0; loadQuiet(); // quiet hours before the first notice can arrive
1449 injectStyles();
1450 if (opts.preferences) Object.assign(_preferences, opts.preferences);
1451 channel();
1452 startPolling();
1453 startRealtime();
1454 // If the user enabled push on this device before, make sure the worker is registered.
1455 try { if (pushSupported() && localStorage.getItem('ov_push_enabled') === '1') navigator.serviceWorker.register(_config.swPath, { scope: '/' }).catch(() => {}); } catch { /* */ }
1456 },
1457 destroy() {
1458 stopPolling();
1459 stopRealtime();
1460 _toastContainer?.remove(); _panelEl?.remove();
1461 _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null; _queue.length = 0;
1462 },
1463 /** What a tap on the bell does instead of its own panel: fn() returns true when it handled it (navbar.js: the Dock). */
1464 setBellAction(fn) { _bellAction = typeof fn === 'function' ? fn : null; },
1465 /** New messages, counted on the bell with the notifications (navbar.js): 0 takes them off. */
1466 setMessages(count) { const prev = _msgs; _msgs = Math.max(0, Number(count) || 0); paintBell(_msgs > prev); },
1467 /** How many notifications are unread (not counting messages). */
1468 unread() { return _unreadCount; },
1469 /** Create and inject the bell icon. Returns the DOM element. */
1470 createBell(container) {
1471 injectStyles();
1472 const bell = createBellEl();
1473 if (container && _config.actions !== false && !container.querySelector('.openvibe-actions-btn')) container.appendChild(createActionsEl());
1474 if (container) container.appendChild(bell);
1475 pollUnread();
1476 return bell;
1477 },
1478 /** Open the Action Center (the ✦) from a site's own control. */
1479 openActions(anchor) { injectStyles(); openActions(anchor || null); },
1480 /** Full-page inbox (same list, filters, search, infinite scroll) rendered into a container. */
1481 renderInbox(container, opts = {}) {
1482 injectStyles();
1483 if (!container) return null;
1484 container.classList.add('openvibe-notif-inbox');
1485 const v = buildView(container, { mode: 'inbox' });
1486 if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => { b.classList.toggle('active', b.dataset.tab === opts.tab); b.setAttribute('aria-selected', String(b.dataset.tab === opts.tab)); }); }
1487 reload(v);
1488 return v;
1489 },
1490 /** The You tab's preview (4.211.0): the newest three, no tools; Show all opens the whole list in place. */
1491 renderMenuNotifications(container, onCount) {
1492 injectStyles();
1493 if (!container) return null;
1494 container.classList.add('openvibe-notif-dock', 'is-preview');
1495 const v = buildView(container, { mode: 'dock' });
1496 v.pageSize = 3; v.onCount = onCount;
1497 reload(v);
1498 return { showAll() { container.classList.remove('is-preview'); v.pageSize = PAGE_SIZE; reload(v); } };
1499 },
1500 unmountDock(container) {
1501 const v = _views.get(container);
1502 if (!v || v.mode !== 'dock') return;
1503 if (v.destroy) v.destroy();
1504 _views.delete(container);
1505 container.classList.remove('openvibe-notif-dock');
1506 },
1507 /** Programmatically show a toast (real-time events). Marks nothing on the server. */
1508 push(notification) {
1509 showToast(notification);
1510 playSound(notification.priority);
1511 if (!notification.id) return;
1512 setBadge(_unreadCount + 1);
1513 },
1514 /** Re-fetch the badge and any open lists (call after an in-page action). */
1515 refresh() { pollUnread(); for (const v of _views.values()) reload(v); },
1516 markRead(ids) { return markRead(Array.isArray(ids) ? ids : [ids]); },
1517 markAllRead,
1518 setPreferences(prefs) { Object.assign(_preferences, prefs); },
1519 setToken(token) {
1520 _config.token = token;
1521 if (token || !isCrossOrigin()) { startPolling(); startRealtime(); } else { stopPolling(); stopRealtime(); setBadge(0, { quiet: true }); }
1522 },
1523 /** The realtime feed's state ({ state: 'off' | 'ticket' | 'connecting' | 'open' | 'backoff' | 'hidden' | 'blocked' | 'failed' | 'signed-out' | 'unavailable', … }), or null when off. */
1524 realtimeState() { return _live ? _live.state() : null; },
1525 /** How a notification reads (title, message, chips, tone, glyph, group key): the same for toasts and the list. */
1526 present,
1527 togglePanel, closePanel,
1528 enablePush, disablePush, pushStatus,
1529 get unreadCount() { return _unreadCount; },
1530 };
1531
1532 if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;
1533 else root.OpenVibeNotifications = OpenVibeNotifications;
1534
1535})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
1536