openvibers/openvibe.shared notification-ui.js
notification-ui.js
| 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 | ; |
| 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); |
| 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">×</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">×</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 |