openvibers/openvibe.network Commits
Notifications overhaul: composable list filters + search + paging, go-live fan-in by network ids, email verification + Resend bounce webhook + abuse caps, go-live email default, HTML-escaped mail, shared bell UI rewrite (read state, infinite scroll, filters, search, push), shared service worker, full inbox on /notifications, internal color column fix
Changes
@@@ -1,30 +1,46 @@// ═══════════════════════════════════════════════════════════════--// OpenVibe — Notification UI Client--// Drop-in vanilla JS library for toast notifications, bell badge,--// notification panel, sounds, and real-time polling.++// OpenVibe — Notification UI Client (shared across every OpenVibe site)++//++// One store (openvibe.network) → one bell everywhere. This file renders the bell +++// dropdown panel, toasts, a full-page inbox, and handles Web Push subscription.++//// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>--// OpenVibeNotifications.init({ token, apiBase, onAction })++// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })++// OpenVibeNotifications.createBell(mountEl)++// OpenVibeNotifications.renderInbox(containerEl) // full-page inbox++//++// Reads: GET /api/notifications?limit&offset&unread_only&category&q&since++// GET /api/notifications/unread-count++// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}// ═══════════════════════════════════════════════════════════════(function (root) {'use strict';-- const POLL_INTERVAL = 15_000; // 15s for unread count-- const TOAST_DURATION = { low: 0, normal: 3800, high: 6000, critical: 0 }; // 0 = sticky++ const POLL_INTERVAL = 15_000;++ const PAGE_SIZE = 30;++ const TOAST_DURATION = { low: 0, normal: 4200, high: 6500, critical: 0 }; // 0 = stickyconst MAX_TOASTS = 3;const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };++ const CATEGORY_LABELS = {++ social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams', economy: 'Economy',++ achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements',++ };++ const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network' };-- let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', onAction: null };++ let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };let _pollTimer = null;-- let _lastCheck = null;++ let _lastSeenAt = null; // newest created_at we have toasted / seenlet _unreadCount = 0;let _toastContainer = null;let _panelEl = null;let _bellEl = null;let _audioCache = {};let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };++ let _channel = null; // BroadcastChannel for same-origin tab sync++ const _views = new Map(); // container → view state (panel + inboxes)-- // ── Styles (injected once) ───────────────────────────────++ // ── Styles ──────────────────────────────────────────────function injectStyles() {if (document.getElementById('openvibe-notif-styles')) return;const style = document.createElement('style');@@@ -35,504 +51,625 @@@keyframes openvibe-badge-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }@keyframes openvibe-bell-ring { 0%{ transform: rotate(0); } 10%{ transform: rotate(14deg); } 20%{ transform: rotate(-14deg); } 30%{ transform: rotate(10deg); } 40%{ transform: rotate(-6deg); } 50%{ transform: rotate(0); } }@keyframes openvibe-slide-down { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }-- @keyframes openvibe-fade-in { from { opacity: 0; } to { opacity: 1; } }++ @keyframes openvibe-spin { to { transform: rotate(360deg); } }-- .openvibe-toast-container {-- position: fixed; top: 82px; right: 14px; z-index: 100000;-- display: flex; flex-direction: column; gap: 6px;-- pointer-events: none; max-width: 320px; width: 100%;-- }-- .openvibe-toast {-- pointer-events: all;-- background: color-mix(in srgb, var(--bg-card, #22222c) 92%, transparent);-- border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent);-- border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start;-- box-shadow: 0 10px 24px rgba(0,0,0,0.28);-- animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1);-- cursor: pointer; position: relative; overflow: hidden;-- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;-- color: var(--text-primary, #e0e0e0);-- transition: border-color .2s;-- }++ .openvibe-toast-container { position: fixed; top: 82px; right: 14px; z-index: 100000; display: flex; flex-direction: column; gap: 6px; pointer-events: none; max-width: 340px; width: 100%; }++ .openvibe-toast { pointer-events: all; background: color-mix(in srgb, var(--bg-card, #22222c) 94%, transparent); border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent); border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start; box-shadow: 0 10px 24px rgba(0,0,0,0.28); animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1); cursor: pointer; position: relative; overflow: hidden; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); transition: border-color .2s; }.openvibe-toast:hover { border-color: var(--accent, #8b5cf6); }.openvibe-toast.removing { animation: openvibe-toast-out .3s ease forwards; }.openvibe-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; margin-top: 1px; }++ .openvibe-toast-icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }.openvibe-toast-body { flex: 1; min-width: 0; }.openvibe-toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }-- .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: rgba(192,150,92,0.15); color: var(--accent-light, #a78bfa); font-weight: 500; }++ .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: color-mix(in srgb, var(--accent, #8b5cf6) 18%, transparent); color: var(--accent-light, #a78bfa); font-weight: 500; }.openvibe-toast-msg { font-size: 11px; color: var(--text-secondary, #b0b0b8); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.openvibe-toast-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; padding: 2px; line-height: 1; }.openvibe-toast-close:hover { color: var(--text-primary, #e0e0e0); }-- .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #8b5cf6); border-radius: 0 0 0 10px; }++ .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #8b5cf6); }.openvibe-toast.priority-high { border-left: 3px solid var(--warning, #f39c12); }.openvibe-toast.priority-critical { border-left: 3px solid var(--live-red, #e74c3c); background: rgba(231,76,60,0.06); }.openvibe-toast-actions { display: flex; gap: 6px; margin-top: 8px; }-- .openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; transition: all .15s; }-- .openvibe-toast-actions button:hover { background: var(--accent-dark, #a07840); color: #fff; border-color: var(--accent-dark, #a07840); }++ .openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; }.openvibe-toast-actions button.primary { background: var(--accent, #8b5cf6); color: #fff; border-color: var(--accent, #8b5cf6); }-- .openvibe-toast-user { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }-- .openvibe-toast-user img { width: 24px; height: 24px; border-radius: 50%; }-- .openvibe-toast-user .username { font-weight: 600; font-size: 12px; cursor: pointer; }-- /* ── Bell ─────────────────────────────────────── */.openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; }.openvibe-bell svg { width: 22px; height: 22px; fill: var(--text-secondary, #b0b0b8); transition: fill .2s; }-- .openvibe-bell:hover svg { fill: var(--accent-light, #a78bfa); }-- .openvibe-bell .badge {-- position: absolute; top: 0; right: 0;-- min-width: 16px; height: 16px; padding: 0 4px;-- background: var(--live-red, #e74c3c); color: #fff;-- border-radius: 8px; font-size: 10px; font-weight: 700;-- display: flex; align-items: center; justify-content: center;-- line-height: 1; pointer-events: none;-- animation: openvibe-badge-pulse .4s ease;-- }++ .openvibe-bell:hover svg, .openvibe-bell.open svg { fill: var(--accent-light, #a78bfa); }++ .openvibe-bell .badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; background: var(--live-red, #e74c3c); color: #fff; border-radius: 8px; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; animation: openvibe-badge-pulse .4s ease; }.openvibe-bell .badge.hidden { display: none; }.openvibe-bell.ringing svg { animation: openvibe-bell-ring .6s ease; }-- /* ── Panel ────────────────────────────────────── */-- .openvibe-notif-panel {-- position: fixed; top: 62px; right: 16px;-- width: 380px; max-height: calc(100vh - 86px);-- background: var(--bg-secondary, #252530);-- border: 1px solid var(--border, #333340);-- border-radius: 12px;-- box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));-- z-index: 99999; display: none; flex-direction: column;-- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;-- color: var(--text-primary, #e0e0e0);-- animation: openvibe-slide-down .2s ease;-- }++ .openvibe-notif-panel { position: fixed; top: 62px; right: 16px; width: 400px; max-height: calc(100vh - 86px); background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5)); z-index: 99999; display: none; flex-direction: column; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); animation: openvibe-slide-down .2s ease; overflow: hidden; }.openvibe-notif-panel.open { display: flex; }-- .openvibe-notif-panel-header {-- padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);-- display: flex; align-items: center; justify-content: space-between;-- }-- .openvibe-notif-panel-header h3 { font-size: 15px; font-weight: 600; margin: 0; }-- .openvibe-notif-panel-header .actions { display: flex; gap: 8px; }-- .openvibe-notif-panel-header .actions button { background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 4px 8px; border-radius: 4px; transition: all .15s; }-- .openvibe-notif-panel-header .actions button:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }-- .openvibe-notif-panel-tabs { display: flex; border-bottom: 1px solid var(--border, #333340); }-- .openvibe-notif-panel-tabs button { flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-muted, #707080); font-size: 12px; font-weight: 500; cursor: pointer; transition: all .15s; }-- .openvibe-notif-panel-tabs button.active { color: var(--accent-light, #a78bfa); border-bottom-color: var(--accent, #8b5cf6); }-- .openvibe-notif-panel-list { flex: 1; overflow-y: auto; padding: 4px 0; }-- .openvibe-notif-item {-- padding: 12px 16px; display: flex; gap: 10px; align-items: flex-start;-- cursor: pointer; transition: background .15s; border-left: 3px solid transparent;-- }++ .openvibe-notif-inbox { display: flex; flex-direction: column; background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; overflow: hidden; color: var(--text-primary, #e0e0e0); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; min-height: 420px; max-height: 78vh; }++++ .openvibe-nv-head { padding: 12px 14px; border-bottom: 1px solid var(--border, #333340); display: flex; align-items: center; justify-content: space-between; gap: 8px; }++ .openvibe-nv-head h3 { font-size: 15px; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 8px; }++ .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 600; background: var(--live-red, #e74c3c); color: #fff; border-radius: 9px; padding: 1px 7px; }++ .openvibe-nv-head h3 .count.zero { display: none; }++ .openvibe-nv-actions { display: flex; gap: 4px; align-items: center; }++ .openvibe-nv-actions button, .openvibe-nv-actions a { background: none; border: 1px solid transparent; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 5px 8px; border-radius: 6px; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; line-height: 1; }++ .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }++ .openvibe-nv-actions button.on { color: var(--accent-light, #a78bfa); border-color: color-mix(in srgb, var(--accent, #8b5cf6) 40%, transparent); }++ .openvibe-nv-actions button:disabled { opacity: .45; cursor: default; }++ .openvibe-nv-actions svg { width: 14px; height: 14px; fill: currentColor; }++++ .openvibe-nv-tools { padding: 8px 12px; border-bottom: 1px solid var(--border, #333340); display: flex; flex-direction: column; gap: 8px; }++ .openvibe-nv-search { position: relative; }++ .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 7px 28px 7px 30px; border-radius: 8px; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-primary, #e0e0e0); font-size: 12px; outline: none; }++ .openvibe-nv-search input:focus { border-color: var(--accent, #8b5cf6); }++ .openvibe-nv-search svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; fill: var(--text-muted, #707080); pointer-events: none; }++ .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; display: none; }++ .openvibe-nv-search.has-q .clear { display: block; }++ .openvibe-nv-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }++ .openvibe-nv-chips button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 10px; border-radius: 999px; cursor: pointer; line-height: 1.2; }++ .openvibe-nv-chips button:hover { border-color: var(--accent, #8b5cf6); color: var(--text-primary, #e0e0e0); }++ .openvibe-nv-chips button.active { background: color-mix(in srgb, var(--accent, #8b5cf6) 18%, transparent); border-color: var(--accent, #8b5cf6); color: var(--accent-light, #a78bfa); }++ .openvibe-nv-chips select { margin-left: auto; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 8px; border-radius: 8px; cursor: pointer; }++++ .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 4px 0; overscroll-behavior: contain; }++ .openvibe-notif-item { padding: 10px 12px 10px 14px; display: flex; gap: 10px; align-items: flex-start; cursor: pointer; transition: background .15s; border-left: 3px solid transparent; position: relative; }.openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }-- .openvibe-notif-item.unread { background: rgba(192,150,92,0.04); border-left-color: var(--accent, #8b5cf6); }-- .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }++ .openvibe-notif-item.unread { background: color-mix(in srgb, var(--accent, #8b5cf6) 7%, transparent); border-left-color: var(--accent, #8b5cf6); }++ .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; width: 32px; text-align: center; }++ .openvibe-notif-item .icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }.openvibe-notif-item .content { flex: 1; min-width: 0; }-- .openvibe-notif-item .content .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; }-- .openvibe-notif-item .content .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }-- .openvibe-notif-item .content .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; }-- .openvibe-notif-item .content .meta .service-tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }-- .openvibe-notif-panel-empty { text-align: center; padding: 48px 24px; color: var(--text-muted, #707080); }-- .openvibe-notif-panel-empty .icon { font-size: 36px; margin-bottom: 8px; display: block; opacity: .5; }---- @media (max-width: 440px) {++ .openvibe-notif-item .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; padding-right: 18px; }++ .openvibe-notif-item.unread .title { font-weight: 650; }++ .openvibe-notif-item .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++ .openvibe-notif-item .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }++ .openvibe-notif-item .meta .tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }++ .openvibe-notif-item .dismiss { position: absolute; top: 8px; right: 8px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 15px; line-height: 1; padding: 2px 4px; border-radius: 4px; opacity: 0; transition: opacity .15s; }++ .openvibe-notif-item:hover .dismiss { opacity: 1; }++ .openvibe-notif-item .dismiss:hover { color: var(--live-red, #e74c3c); background: var(--bg-tertiary, #2a2a38); }++ .openvibe-nv-empty { text-align: center; padding: 44px 24px; color: var(--text-muted, #707080); font-size: 13px; }++ .openvibe-nv-empty .icon { font-size: 34px; margin-bottom: 8px; display: block; opacity: .5; }++ .openvibe-nv-more { display: flex; justify-content: center; padding: 10px; }++ .openvibe-nv-more button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 12px; padding: 6px 14px; border-radius: 8px; cursor: pointer; }++ .openvibe-nv-more .spinner { width: 16px; height: 16px; border: 2px solid var(--border, #333340); border-top-color: var(--accent, #8b5cf6); border-radius: 50%; animation: openvibe-spin .8s linear infinite; }++ .openvibe-nv-foot { padding: 8px 12px; border-top: 1px solid var(--border, #333340); font-size: 11px; color: var(--text-muted, #707080); display: flex; justify-content: space-between; align-items: center; gap: 8px; }++ .openvibe-nv-foot a { color: var(--accent-light, #a78bfa); text-decoration: none; }++ .openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }++++ @media (max-width: 460px) {.openvibe-toast-container { right: 8px; left: 8px; max-width: none; }-- .openvibe-notif-panel { right: 8px; left: 8px; width: auto; }++ .openvibe-notif-panel { right: 6px; left: 6px; width: auto; top: 56px; max-height: calc(100vh - 66px); }}`;document.head.appendChild(style);}-- // ── Sound ────────────────────────────────────────────────++ // ── Helpers ─────────────────────────────────────────────++ function esc(v) {++ if (v == null) return '';++ return String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');++ }++ function richOf(n) {++ const rc = n && (n.rich_content ?? n.richContent);++ if (!rc) return {};++ if (typeof rc === 'object') return rc;++ try { return JSON.parse(rc) || {}; } catch { return {}; }++ }++ function urlOf(n) { return (n && n.url) || richOf(n).url || null; }++ function isRead(n) { return !!(n && (n.is_read === 1 || n.is_read === true || n.read === true)); }++ function parseDate(v) {++ if (!v) return null;++ // SQLite "YYYY-MM-DD HH:MM:SS" is UTC without a zone marker.++ const s = String(v);++ const d = new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? s.replace(' ', 'T') + 'Z' : s);++ return isNaN(d.getTime()) ? null : d;++ }++ function timeAgo(v) {++ const d = parseDate(v); if (!d) return '';++ const diff = (Date.now() - d.getTime()) / 1000;++ if (diff < 45) return 'just now';++ if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m ago`;++ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;++ if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;++ return d.toLocaleDateString();++ }++ function inboxUrl() {++ if (_config.inboxUrl) return _config.inboxUrl;++ try { return new URL('/notifications', _config.apiBase).toString(); } catch { return 'https://openvibe.network/notifications'; }++ }++ function iconHtml(n) {++ const rich = richOf(n);++ const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);++ if (img && /^https?:\/\//.test(String(img))) return `<img src="${esc(img)}" alt="" loading="lazy" onerror="this.replaceWith(document.createTextNode('${esc(n.icon || '🔔')}'))">`;++ return esc(n.icon || '🔔');++ }++ function navigateTo(url) {++ if (!url) return;++ try {++ const u = new URL(url, window.location.href);++ window.location.href = u.toString();++ } catch { window.location.href = url; }++ }++++ // ── API ─────────────────────────────────────────────────++ function isCrossOrigin() {++ try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }++ }++ function apiFetch(path, opts = {}) {++ const headers = { ...(opts.headers || {}) };++ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;++ if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {++ headers['Content-Type'] = 'application/json';++ opts = { ...opts, body: JSON.stringify(opts.body) };++ }++ return fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: isCrossOrigin() ? 'omit' : 'include' });++ }++ async function apiJson(path, opts) {++ const res = await apiFetch(path, opts);++ let data = null; try { data = await res.json(); } catch { /* */ }++ if (!res.ok) { const e = new Error((data && data.error) || `HTTP ${res.status}`); e.status = res.status; e.body = data; throw e; }++ return data || {};++ }++ function hasSession() { return !!_config.token || !isCrossOrigin(); }++++ // ── Cross-tab sync (same origin) ────────────────────────++ function channel() {++ if (_channel !== null) return _channel || null;++ try { _channel = new BroadcastChannel('openvibe-notifications'); _channel.onmessage = (e) => onSyncMessage(e.data || {}); }++ catch { _channel = false; }++ return _channel || null;++ }++ function broadcast(msg) { try { channel()?.postMessage(msg); } catch { /* */ } }++ function onSyncMessage(msg) {++ if (msg.type === 'unread') setBadge(msg.count, { quiet: true });++ else if (msg.type === 'read') { for (const v of _views.values()) applyReadLocally(v, msg.ids || [], msg.all); }++ }++++ // ── Sound ───────────────────────────────────────────────function playSound(priority) {if (!_preferences.sound) return;-- const file = SOUNDS[priority];-- if (!file) return;++ const file = SOUNDS[priority]; if (!file) return;const url = `${_config.soundBase}/${file}`;try {if (!_audioCache[url]) _audioCache[url] = new Audio(url);-- const audio = _audioCache[url];-- audio.currentTime = 0;-- audio.volume = priority === 'critical' ? 0.8 : 0.5;-- audio.play().catch(() => {});-- } catch {}++ const a = _audioCache[url]; a.currentTime = 0; a.volume = priority === 'critical' ? 0.8 : 0.5; a.play().catch(() => {});++ } catch { /* */ }}-- // ── Toast Container ──────────────────────────────────────++ // ── Toasts ──────────────────────────────────────────────function ensureToastContainer() {if (_toastContainer) return;_toastContainer = document.createElement('div');_toastContainer.className = 'openvibe-toast-container';document.body.appendChild(_toastContainer);}---- function showToast(notification) {++ function showToast(n) {if (!_preferences.toasts) return;-- if (_preferences.muted_categories.includes(notification.category)) return;++ if (_preferences.muted_categories.includes(n.category)) return;ensureToastContainer();---- // Limit visible toasts-- while (_toastContainer.children.length >= MAX_TOASTS) {-- const oldest = _toastContainer.firstChild;-- oldest.remove();-- }--++ while (_toastContainer.children.length >= MAX_TOASTS) _toastContainer.firstChild.remove();++ const rich = richOf(n);const toast = document.createElement('div');-- toast.className = `openvibe-toast priority-${notification.priority}`;-- toast.dataset.notifId = notification.id;---- const rich = notification.richContent || {};-- let userHtml = '';-- if (rich.user) {-- const u = rich.user;-- const nameStyle = u.profile_color ? `color:${u.profile_color}` : '';-- const nameClass = u.name_effect ? `openvibe-name-fx-${u.name_effect}` : '';-- userHtml = `<div class="openvibe-toast-user">-- ${u.avatar_url ? `<img src="${u.avatar_url}" alt="">` : ''}-- <span class="username ${nameClass}" style="${nameStyle}">${u.display_name || u.username}</span>-- </div>`;-- }---- let actionsHtml = '';-- if (rich.actions && rich.actions.length) {-- actionsHtml = '<div class="openvibe-toast-actions">' +-- rich.actions.filter(a => a.type !== 'input').map(a =>-- `<button data-action="${a.id}" class="${a.style === 'primary' ? 'primary' : ''}">${a.label}</button>`-- ).join('') + '</div>';-- }---- const serviceLabel = notification.service && notification.service !== 'network'-- ? `<span class="service-badge">${notification.service}</span>` : '';--++ toast.className = `openvibe-toast priority-${esc(n.priority || 'normal')}`;++ toast.dataset.notifId = n.id || '';++ const serviceLabel = n.service && n.service !== 'network' ? `<span class="service-badge">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';++ const actions = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input') : [];toast.innerHTML = `-- <div class="openvibe-toast-icon">${notification.icon || '🔔'}</div>++ <div class="openvibe-toast-icon">${iconHtml(n)}</div><div class="openvibe-toast-body">-- ${userHtml}-- <div class="openvibe-toast-title">${notification.title}${serviceLabel}</div>-- <div class="openvibe-toast-msg">${notification.message}</div>-- ${actionsHtml}++ <div class="openvibe-toast-title">${esc(n.title)}${serviceLabel}</div>++ <div class="openvibe-toast-msg">${esc(n.message || '')}</div>++ ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => `<button data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}</div>-- <button class="openvibe-toast-close">×</button>-- `;---- // Progress bar for auto-dismiss-- const duration = TOAST_DURATION[notification.priority] || 5000;++ <button class="openvibe-toast-close" aria-label="Dismiss">×</button>`;++ const duration = TOAST_DURATION[n.priority] ?? 5000;if (duration > 0) {const bar = document.createElement('div');-- bar.className = 'openvibe-toast-progress';-- bar.style.width = '100%';-- bar.style.transition = `width ${duration}ms linear`;++ bar.className = 'openvibe-toast-progress'; bar.style.width = '100%'; bar.style.transition = `width ${duration}ms linear`;toast.appendChild(bar);requestAnimationFrame(() => { bar.style.width = '0%'; });++ setTimeout(() => dismissToast(toast), duration);}---- // Events-- toast.querySelector('.openvibe-toast-close').addEventListener('click', e => {-- e.stopPropagation();-- dismissToast(toast);-- });--++ toast.querySelector('.openvibe-toast-close').addEventListener('click', (e) => { e.stopPropagation(); dismissToast(toast); });toast.addEventListener('click', () => {-- if (rich.url) window.open(rich.url, '_blank');-- markRead(notification.id);++ if (n.id) markRead([n.id]);dismissToast(toast);++ const u = urlOf(n); if (u) navigateTo(u);});---- // Action buttons-- toast.querySelectorAll('[data-action]').forEach(btn => {-- btn.addEventListener('click', e => {-- e.stopPropagation();-- const actionId = btn.dataset.action;-- const action = rich.actions.find(a => a.id === actionId);-- if (action?.url) { window.open(action.url, '_blank'); return; }-- if (_config.onAction) _config.onAction(notification.id, actionId, notification);-- dismissToast(toast);-- });-- });--++ toast.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', (e) => {++ e.stopPropagation();++ const a = actions.find(x => String(x.id) === btn.dataset.action);++ if (a && a.url) { navigateTo(a.url); return; }++ if (_config.onAction) _config.onAction(n.id, btn.dataset.action, n);++ dismissToast(toast);++ }));_toastContainer.appendChild(toast);---- // Auto dismiss-- if (duration > 0) {-- setTimeout(() => dismissToast(toast), duration);-- }}--function dismissToast(el) {if (!el || !el.parentNode) return;el.classList.add('removing');setTimeout(() => el.remove(), 300);}-- // ── Bell / Badge ─────────────────────────────────────────-- function createBell() {++ // ── Bell / badge ────────────────────────────────────────++ function createBellEl() {const bell = document.createElement('div');bell.className = 'openvibe-bell';-- bell.innerHTML = `-- <svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>-- <span class="badge hidden">0</span>-- `;++ bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;++ bell.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg><span class="badge hidden">0</span>`;bell.addEventListener('click', () => togglePanel());++ bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });_bellEl = bell;return bell;}---- function updateBadge(count) {-- _unreadCount = count;-- if (!_bellEl) return;-- const badge = _bellEl.querySelector('.badge');-- if (count > 0) {-- badge.textContent = count > 99 ? '99+' : count;-- badge.classList.remove('hidden');-- _bellEl.classList.add('ringing');-- setTimeout(() => _bellEl.classList.remove('ringing'), 600);-- } else {-- badge.classList.add('hidden');++ function setBadge(count, { quiet = false } = {}) {++ const prev = _unreadCount;++ _unreadCount = Math.max(0, Number(count) || 0);++ if (_bellEl) {++ const badge = _bellEl.querySelector('.badge');++ if (_unreadCount > 0) {++ badge.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount);++ badge.classList.remove('hidden');++ if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 600); }++ } else badge.classList.add('hidden');}++ for (const v of _views.values()) {++ const c = v.root.querySelector('.openvibe-nv-head .count');++ if (c) { c.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount); c.classList.toggle('zero', _unreadCount === 0); }++ }++ if (!quiet) broadcast({ type: 'unread', count: _unreadCount });}-- // ── Panel ────────────────────────────────────────────────-- function createPanel() {-- const panel = document.createElement('div');-- panel.className = 'openvibe-notif-panel';-- panel.innerHTML = `-- <div class="openvibe-notif-panel-header">-- <h3>Notifications</h3>-- <div class="actions">-- <button data-action="mark-all-read">Mark all read</button>-- <button data-action="settings">⚙️</button>++ // ── List views (panel + inbox share this) ───────────────++ const SVG = {++ search: '<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>',++ check: '<svg viewBox="0 0 24 24"><path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z"/></svg>',++ bellOn: '<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>',++ bellOff: '<svg viewBox="0 0 24 24"><path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31 0 0-.01 0-.01.01L18 14.68z"/></svg>',++ gear: '<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>',++ open: '<svg viewBox="0 0 24 24"><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>',++ };++++ function buildView(root, { mode }) {++ const v = { root, mode, tab: 'all', category: '', q: '', items: [], offset: 0, hasMore: false, loading: false, seq: 0, ids: new Set() };++ root.innerHTML = `++ <div class="openvibe-nv-head">++ <h3>Notifications <span class="count zero">0</span></h3>++ <div class="openvibe-nv-actions">++ <button type="button" data-act="read-all" title="Mark all as read">${SVG.check}<span>Mark all read</span></button>++ <button type="button" data-act="push" title="Desktop / mobile push alerts on this device">${SVG.bellOff}</button>++ ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open inbox">${SVG.open}</a>` : `<a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification preferences">${SVG.gear}</a>`}</div></div>-- <div class="openvibe-notif-panel-tabs">-- <button class="active" data-tab="all">All</button>-- <button data-tab="unread">Unread</button>-- <button data-tab="social">Social</button>-- <button data-tab="game">Game</button>-- <button data-tab="system">System</button>++ <div class="openvibe-nv-note" data-role="note" style="display:none"></div>++ <div class="openvibe-nv-tools">++ <div class="openvibe-nv-search">${SVG.search}<input type="search" placeholder="Search notifications…" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">×</button></div>++ <div class="openvibe-nv-chips">++ <button type="button" data-tab="all" class="active">All</button>++ <button type="button" data-tab="unread">Unread</button>++ <select data-role="category"><option value="">All categories</option>${Object.entries(CATEGORY_LABELS).map(([k, l]) => `<option value="${k}">${l}</option>`).join('')}</select>++ </div></div>-- <div class="openvibe-notif-panel-list"></div>-- `;---- panel.querySelector('[data-action="mark-all-read"]').addEventListener('click', markAllRead);-- panel.querySelectorAll('[data-tab]').forEach(btn => {-- btn.addEventListener('click', () => {-- panel.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active'));-- btn.classList.add('active');-- loadNotifications(btn.dataset.tab);-- });++ <div class="openvibe-nv-list" data-role="list"></div>++ <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'panel' ? `<a href="${esc(inboxUrl())}">Open full inbox →</a>` : ''}</div>`;++ const list = root.querySelector('[data-role="list"]');++ const input = root.querySelector('input[type="search"]');++ let qTimer = null;++ input.addEventListener('input', () => {++ root.querySelector('.openvibe-nv-search').classList.toggle('has-q', !!input.value);++ clearTimeout(qTimer); qTimer = setTimeout(() => { v.q = input.value.trim(); reload(v); }, 260);});---- document.addEventListener('click', e => {-- if (_panelEl?.classList.contains('open') && !_panelEl.contains(e.target) && !_bellEl?.contains(e.target)) {-- _panelEl.classList.remove('open');-- }++ root.querySelector('.openvibe-nv-search .clear').addEventListener('click', () => { input.value = ''; v.q = ''; root.querySelector('.openvibe-nv-search').classList.remove('has-q'); reload(v); });++ root.querySelectorAll('[data-tab]').forEach(btn => btn.addEventListener('click', () => {++ root.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active')); btn.classList.add('active');++ v.tab = btn.dataset.tab; reload(v);++ }));++ root.querySelector('[data-role="category"]').addEventListener('change', (e) => { v.category = e.target.value; reload(v); });++ root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());++ root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));++ // Infinite scroll: load the next page when the user nears the bottom.++ list.addEventListener('scroll', () => {++ if (v.loading || !v.hasMore) return;++ if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);});---- _panelEl = panel;-- document.body.appendChild(panel);-- return panel;++ list.addEventListener('click', (e) => {++ const dismissBtn = e.target.closest('.dismiss');++ const item = e.target.closest('.openvibe-notif-item');++ if (!item) return;++ const id = item.dataset.id;++ if (dismissBtn) { e.stopPropagation(); dismiss(v, id); return; }++ const n = v.items.find(x => String(x.id) === id);++ if (n && !isRead(n)) markRead([id]);++ const u = urlOf(n);++ if (u) navigateTo(u);++ });++ refreshPushButton(v);++ _views.set(root, v);++ return v;}-- function togglePanel() {-- if (!_panelEl) createPanel();-- const opening = !_panelEl.classList.contains('open');-- _panelEl.classList.toggle('open');-- if (opening) loadNotifications('all');++ function renderItems(v, { append = false } = {}) {++ const list = v.root.querySelector('[data-role="list"]');++ const html = v.items.map(n => {++ try {++ const cat = CATEGORY_LABELS[n.category] || n.category || '';++ const service = n.service && n.service !== 'network' ? `<span class="tag">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';++ return `<div class="openvibe-notif-item ${isRead(n) ? '' : 'unread'}" data-id="${esc(n.id)}" title="${esc(urlOf(n) ? 'Open' : '')}">++ <span class="icon">${iconHtml(n)}</span>++ <div class="content">++ <div class="title">${esc(n.title)}</div>++ ${n.message ? `<div class="msg">${esc(n.message)}</div>` : ''}++ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${esc(timeAgo(n.created_at))}</span>${service}${cat ? `<span class="tag">${esc(cat)}</span>` : ''}</div>++ </div>++ <button type="button" class="dismiss" title="Dismiss" aria-label="Dismiss">×</button>++ </div>`;++ } catch { return ''; }++ }).join('');++ if (!append) list.innerHTML = '';++ if (!v.items.length) {++ list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">${v.q ? '🔎' : '🔔'}</span>${v.q ? 'Nothing matches that search' : (v.tab === 'unread' ? 'You\'re all caught up' : 'No notifications yet')}</div>`;++ } else {++ const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();++ list.insertAdjacentHTML('beforeend', html);++ if (v.hasMore) list.insertAdjacentHTML('beforeend', `<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>`);++ const more = list.querySelector('[data-act="more"]'); if (more) more.addEventListener('click', () => loadMore(v));++ }++ const st = v.root.querySelector('[data-role="status"]');++ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';}-- async function loadNotifications(tab = 'all') {-- if (!_panelEl) return;-- const list = _panelEl.querySelector('.openvibe-notif-panel-list');-- list.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-muted)">Loading...</div>';--++ async function fetchPage(v, offset) {++ const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) });++ if (v.tab === 'unread') params.set('unread_only', '1');++ if (v.category) params.set('category', v.category);++ if (v.q) params.set('q', v.q);++ return apiJson(`/api/notifications?${params}`);++ }++ async function reload(v) {++ const seq = ++v.seq;++ v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null;++ const list = v.root.querySelector('[data-role="list"]');++ list.innerHTML = '<div class="openvibe-nv-more"><span class="spinner"></span></div>';try {-- const params = new URLSearchParams({ limit: '50' });-- if (tab === 'unread') params.set('unread', '1');-- else if (tab !== 'all') params.set('category', tab);---- const res = await apiFetch(`/api/notifications?${params}`);-- const data = await res.json();---- if (!data.notifications || data.notifications.length === 0) {-- list.innerHTML = `<div class="openvibe-notif-panel-empty"><span class="icon">🔔</span>No notifications yet</div>`;-- return;-- }---- list.innerHTML = data.notifications.map(n => {-- // Render each item in isolation — one malformed notification must-- // never blank the whole dropdown ("Failed to load").-- try {-- const ago = timeAgo(n.created_at);-- const serviceTag = n.service && n.service !== 'network'-- ? `<span class="service-tag">${esc(n.service)}</span>` : '';-- return `<div class="openvibe-notif-item ${n.read ? '' : 'unread'}" data-id="${esc(n.id)}">-- <span class="icon">${esc(n.icon || '🔔')}</span>-- <div class="content">-- <div class="title">${esc(n.title)}</div>-- <div class="msg">${esc(n.message)}</div>-- <div class="meta"><span>${esc(ago)}</span>${serviceTag}</div>-- </div>-- </div>`;-- } catch { return ''; }-- }).join('');---- list.querySelectorAll('.openvibe-notif-item').forEach(item => {-- item.addEventListener('click', () => {-- const id = item.dataset.id;-- markRead(id);-- item.classList.remove('unread');-- const notif = data.notifications.find(n => String(n.id) === id);-- const rich = richOf(notif);-- if (rich.url) window.open(rich.url, '_blank');-- });-- });-- } catch {-- list.innerHTML = '<div class="openvibe-notif-panel-empty"><span class="icon">⚠️</span>Failed to load</div>';-- }++ const data = await fetchPage(v, 0);++ if (seq !== v.seq) return;++ v.items = data.notifications || []; v.items.forEach(n => v.ids.add(String(n.id)));++ v.hasMore = !!data.has_more; v.total = data.total ?? null; v.offset = v.items.length;++ renderItems(v);++ } catch (e) {++ if (seq !== v.seq) return;++ list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">⚠️</span>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</div>`;++ } finally { if (seq === v.seq) v.loading = false; }}---- // ── API Helpers ──────────────────────────────────────────-- function isCrossOrigin() {-- try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }++ async function loadMore(v) {++ if (v.loading || !v.hasMore) return;++ const seq = v.seq; v.loading = true;++ const more = v.root.querySelector('.openvibe-nv-more'); if (more) more.innerHTML = '<span class="spinner"></span>';++ try {++ const data = await fetchPage(v, v.offset);++ if (seq !== v.seq) return;++ const fresh = (data.notifications || []).filter(n => !v.ids.has(String(n.id)));++ fresh.forEach(n => v.ids.add(String(n.id)));++ v.items = v.items.concat(fresh); v.hasMore = !!data.has_more; v.offset += (data.notifications || []).length; v.total = data.total ?? v.total;++ // Re-render only the tail to keep the scroll position.++ const old = v.root.querySelector('.openvibe-nv-more'); if (old) old.remove();++ if (fresh.length) renderItems({ ...v, items: fresh }, { append: true });++ 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)); }++ const st = v.root.querySelector('[data-role="status"]');++ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';++ } catch { /* keep what we have */ }++ finally { v.loading = false; }}---- function apiFetch(path, opts = {}) {-- const headers = { ...opts.headers };-- if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;-- const crossOrigin = isCrossOrigin();-- return fetch(`${_config.apiBase}${path}`, {-- ...opts,-- headers,-- // Only send credentials for same-origin requests; cross-origin uses Authorization header-- credentials: crossOrigin ? 'omit' : 'include',-- });++ function applyReadLocally(v, ids, all) {++ const set = new Set((ids || []).map(String));++ for (const n of v.items) if (all || set.has(String(n.id))) n.is_read = 1;++ v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => { if (all || set.has(el.dataset.id)) el.classList.remove('unread'); });}-- async function markRead(id) {-- try { await apiFetch(`/api/notifications/${id}/read`, { method: 'POST' }); } catch {}-- _unreadCount = Math.max(0, _unreadCount - 1);-- updateBadge(_unreadCount);++ // ── Mutations ───────────────────────────────────────────++ async function markRead(ids) {++ ids = (ids || []).map(String).filter(Boolean);++ if (!ids.length) return;++ for (const v of _views.values()) applyReadLocally(v, ids, false);++ broadcast({ type: 'read', ids });++ try {++ const data = ids.length === 1++ ? await apiJson(`/api/notifications/${encodeURIComponent(ids[0])}/read`, { method: 'POST' })++ : await apiJson('/api/notifications/read-batch', { method: 'POST', body: { ids } });++ if (data && typeof data.unread === 'number') setBadge(data.unread, { quiet: true }), broadcast({ type: 'unread', count: data.unread });++ else pollUnread();++ } catch { pollUnread(); }}--async function markAllRead() {-- try { await apiFetch('/api/notifications/read-all', { method: 'POST' }); } catch {}-- updateBadge(0);-- if (_panelEl?.classList.contains('open')) {-- _panelEl.querySelectorAll('.openvibe-notif-item.unread').forEach(el => el.classList.remove('unread'));-- }++ for (const v of _views.values()) applyReadLocally(v, [], true);++ setBadge(0);++ broadcast({ type: 'read', all: true });++ try { await apiJson('/api/notifications/read-all', { method: 'POST', body: {} }); } catch { pollUnread(); }++ }++ async function dismiss(v, id) {++ const n = v.items.find(x => String(x.id) === String(id));++ v.items = v.items.filter(x => String(x.id) !== String(id));++ v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`)?.remove();++ if (v.total != null) v.total = Math.max(0, v.total - 1);++ if (!v.items.length) renderItems(v);++ try {++ await apiJson(`/api/notifications/${encodeURIComponent(id)}/dismiss`, { method: 'POST' });++ if (n && !isRead(n)) setBadge(Math.max(0, _unreadCount - 1), { quiet: true });++ } catch { /* */ }}++ // ── Polling / toasts ────────────────────────────────────++ let _polling = false;async function pollUnread() {++ if (_polling || !hasSession()) return;++ _polling = true;try {-- const res = await apiFetch('/api/notifications/unread-count');-- const data = await res.json();++ const data = await apiJson('/api/notifications/unread-count');const prev = _unreadCount;-- updateBadge(data.count || 0);---- // If new notifications arrived, fetch and toast the latest-- if (data.count > prev && _preferences.toasts) {-- const newest = await apiFetch(`/api/notifications?unread=1&limit=${data.count - prev}&since=${_lastCheck || ''}`);-- const nd = await newest.json();-- if (nd.notifications) {-- for (const n of nd.notifications.slice(0, 3)) {-- const rich = n.rich_content ? JSON.parse(n.rich_content) : {};-- showToast({ ...n, richContent: rich });-- playSound(n.priority);-- }-- }++ const count = Number(data.count) || 0;++ setBadge(count);++ if (count > prev && _lastSeenAt) {++ // Something new arrived: fetch what's newer than the last thing we saw.++ const params = new URLSearchParams({ unread_only: '1', limit: '5', since: _lastSeenAt });++ const nd = await apiJson(`/api/notifications?${params}`);++ const fresh = (nd.notifications || []).slice().reverse();++ for (const n of fresh) { showToast(n); playSound(n.priority); }++ // Any open list should reflect the new items too.++ for (const v of _views.values()) if (v.mode === 'inbox' || (_panelEl && _panelEl.classList.contains('open'))) reload(v);}-- _lastCheck = new Date().toISOString();-- } catch {}++ const newest = await newestCreatedAt();++ if (newest) _lastSeenAt = newest;++ } catch { /* offline / signed out — try again next tick */ }++ finally { _polling = false; }}++ async function newestCreatedAt() {++ try { const d = await apiJson('/api/notifications/newest'); const c = d.notification && d.notification.created_at; return c ? String(c) : null; } catch { return null; }++ }++ function startPolling() {++ stopPolling();++ if (!hasSession()) return;++ newestCreatedAt().then(c => { _lastSeenAt = c || new Date().toISOString(); pollUnread(); });++ _pollTimer = setInterval(pollUnread, POLL_INTERVAL);++ }++ function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }++ document.addEventListener('visibilitychange', () => { if (!document.hidden) pollUnread(); });-- // ── Helpers ──────────────────────────────────────────────-- // Escape untrusted text before inserting into innerHTML. Notification-- // titles/messages can be attacker-controlled (e.g. abusive broadcasts), so-- // they must never be treated as HTML.-- function esc(v) {-- if (v == null) return '';-- return String(v)-- .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')-- .replace(/"/g, '"').replace(/'/g, ''');++ // ── Panel ───────────────────────────────────────────────++ function createPanel() {++ const panel = document.createElement('div');++ panel.className = 'openvibe-notif-panel';++ document.body.appendChild(panel);++ buildView(panel, { mode: 'panel' });++ document.addEventListener('click', (e) => {++ if (panel.classList.contains('open') && !panel.contains(e.target) && !(_bellEl && _bellEl.contains(e.target))) closePanel();++ });++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel.classList.contains('open')) closePanel(); });++ _panelEl = panel;++ return panel;}-- // rich_content arrives already parsed from the API, but tolerate a raw JSON-- // string too. Never throw — a bad value just yields {}.-- function richOf(n) {-- const rc = n && n.rich_content;-- if (!rc) return {};-- if (typeof rc === 'object') return rc;-- try { return JSON.parse(rc) || {}; } catch { return {}; }++ function closePanel() { _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); }++ function togglePanel() {++ if (!_panelEl) createPanel();++ const opening = !_panelEl.classList.contains('open');++ _panelEl.classList.toggle('open', opening);++ _bellEl?.classList.toggle('open', opening);++ if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }}-- // ── Time Formatting ──────────────────────────────────────-- function timeAgo(dateStr) {-- const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;-- if (diff < 60) return 'just now';-- if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;-- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;-- if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;-- return new Date(dateStr).toLocaleDateString();++ // ── Web Push ────────────────────────────────────────────++ function pushSupported() { return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window && window.isSecureContext; }++ function urlBase64ToUint8Array(b64) {++ const padding = '='.repeat((4 - (b64.length % 4)) % 4);++ const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');++ const raw = window.atob(base64); const out = new Uint8Array(raw.length);++ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);++ return out;++ }++ async function currentSubscription() {++ if (!pushSupported()) return null;++ try { const reg = await navigator.serviceWorker.getRegistration(_config.swPath); return reg ? await reg.pushManager.getSubscription() : null; } catch { return null; }++ }++ async function pushStatus() {++ const supported = pushSupported();++ const sub = supported ? await currentSubscription() : null;++ return { supported, permission: supported ? Notification.permission : 'unsupported', subscribed: !!sub };++ }++ async function enablePush() {++ if (!pushSupported()) throw new Error('This browser does not support push notifications');++ if (!hasSession()) throw new Error('Sign in first');++ const perm = await Notification.requestPermission();++ if (perm !== 'granted') throw new Error(perm === 'denied' ? 'Notifications are blocked for this site in your browser settings' : 'Permission not granted');++ const reg = await navigator.serviceWorker.register(_config.swPath, { scope: '/' });++ await navigator.serviceWorker.ready;++ const { publicKey } = await apiJson('/api/push/vapid-key');++ if (!publicKey) throw new Error('Push is not configured on the server');++ let sub = await reg.pushManager.getSubscription();++ if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });++ await apiJson('/api/push/subscribe', { method: 'POST', body: { subscription: { ...sub.toJSON(), userAgent: navigator.userAgent.slice(0, 200) } } });++ try { localStorage.setItem('ov_push_enabled', '1'); } catch { /* */ }++ return true;++ }++ async function disablePush() {++ const sub = await currentSubscription();++ if (sub) {++ try { await apiJson('/api/push/unsubscribe', { method: 'POST', body: { endpoint: sub.endpoint } }); } catch { /* */ }++ try { await sub.unsubscribe(); } catch { /* */ }++ }++ try { localStorage.removeItem('ov_push_enabled'); } catch { /* */ }++ return true;++ }++ async function refreshPushButton(v) {++ const btn = v && v.root.querySelector('[data-act="push"]');++ if (!btn) return;++ const st = await pushStatus();++ if (!st.supported) { btn.style.display = 'none'; return; }++ btn.style.display = '';++ btn.classList.toggle('on', st.subscribed);++ btn.innerHTML = st.subscribed ? `${SVG.bellOn}<span>Push on</span>` : `${SVG.bellOff}<span>Enable push</span>`;++ btn.title = st.subscribed ? 'Push alerts are on for this device — click to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get desktop / mobile alerts on this device even when the tab is closed');++ btn.disabled = st.permission === 'denied' && !st.subscribed;++ }++ async function togglePush(v) {++ const note = v.root.querySelector('[data-role="note"]');++ 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); };++ try {++ const st = await pushStatus();++ if (st.subscribed) { await disablePush(); say('Push alerts turned off for this device.'); }++ else { await enablePush(); say('Push alerts enabled — you\'ll get alerts here even when this site is closed.'); }++ } catch (e) { say(e.message || 'Could not change push setting', 6000); }++ for (const view of _views.values()) refreshPushButton(view);}-- // ── Public API ───────────────────────────────────────────++ // ── Public API ──────────────────────────────────────────const OpenVibeNotifications = {init(opts = {}) {Object.assign(_config, opts);injectStyles();if (opts.preferences) Object.assign(_preferences, opts.preferences);-- _lastCheck = new Date().toISOString();---- // Start polling-- pollUnread();-- _pollTimer = setInterval(pollUnread, POLL_INTERVAL);++ channel();++ startPolling();++ // If the user enabled push on this device before, make sure the worker is registered.++ try { if (pushSupported() && localStorage.getItem('ov_push_enabled') === '1') navigator.serviceWorker.register(_config.swPath, { scope: '/' }).catch(() => {}); } catch { /* */ }},--destroy() {-- clearInterval(_pollTimer);-- _toastContainer?.remove();-- _panelEl?.remove();-- _toastContainer = null;-- _panelEl = null;++ stopPolling();++ _toastContainer?.remove(); _panelEl?.remove();++ _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null;},--/** Create and inject the bell icon. Returns the DOM element. */createBell(container) {-- const bell = createBell();++ injectStyles();++ const bell = createBellEl();if (container) container.appendChild(bell);pollUnread();return bell;},---- /** Programmatically push a toast (for real-time WebSocket events). */++ /** Full-page inbox (same list, filters, search, infinite scroll) rendered into a container. */++ renderInbox(container, opts = {}) {++ injectStyles();++ if (!container) return null;++ container.classList.add('openvibe-notif-inbox');++ const v = buildView(container, { mode: 'inbox' });++ if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b.dataset.tab === opts.tab)); }++ reload(v);++ return v;++ },++ /** Programmatically show a toast (real-time events). Marks nothing on the server. */push(notification) {showToast(notification);playSound(notification.priority);-- _unreadCount++;-- updateBadge(_unreadCount);-- },---- /** Update preferences at runtime. */-- setPreferences(prefs) {-- Object.assign(_preferences, prefs);++ if (!notification.id) return;++ setBadge(_unreadCount + 1);},---- /** Update auth token (e.g., after account switch). */++ /** Re-fetch the badge and any open lists (call after an in-page action). */++ refresh() { pollUnread(); for (const v of _views.values()) reload(v); },++ markRead(ids) { return markRead(Array.isArray(ids) ? ids : [ids]); },++ markAllRead,++ setPreferences(prefs) { Object.assign(_preferences, prefs); },setToken(token) {_config.token = token;-- if (token) {-- pollUnread();-- if (!_pollTimer) _pollTimer = setInterval(pollUnread, POLL_INTERVAL);-- } else {-- clearInterval(_pollTimer);-- _pollTimer = null;-- updateBadge(0);-- }++ if (token || !isCrossOrigin()) startPolling(); else { stopPolling(); setBadge(0, { quiet: true }); }},---- /** Open/close the notification panel. */-- togglePanel,---- /** Get current unread count. */++ togglePanel, closePanel,++ enablePush, disablePush, pushStatus,get unreadCount() { return _unreadCount; },};-- // Exportif (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;else root.OpenVibeNotifications = OpenVibeNotifications;@@@ -54,6 +54,11 @@ const TYPES = Object.freeze({STREAM_LIVE: { category: 'stream', priority: 'high', icon: '🔴', title: 'Stream Live' },RAID: { category: 'stream', priority: 'high', icon: '🚀', title: 'Incoming Raid' },CLIP_CREATED: { category: 'stream', priority: 'normal', icon: '🎬', title: 'Clip Created' },++ VC_CALL_INVITE: { category: 'chat', priority: 'high', icon: '📞', title: 'Call Invite' },++ CONTENT_COMMENT: { category: 'social', priority: 'normal', icon: '💬', title: 'New Comment' },++ CONTENT_REPLY: { category: 'social', priority: 'normal', icon: '↩️', title: 'New Reply' },++ TIP_RECEIVED: { category: 'economy', priority: 'normal', icon: '💜', title: 'Tip Received' },++ SUBSCRIPTION: { category: 'economy', priority: 'normal', icon: '⭐', title: 'New Subscriber' },// EconomyCOINS_RECEIVED: { category: 'economy', priority: 'normal', icon: '🪙', title: 'Coins Received' },@@@ -92,6 +97,11 @@ const EMAIL_ELIGIBLE_CATEGORIES = new Set(['system', 'moderation', 'admin',]);++// Types that email BY DEFAULT (no explicit per-category opt-in needed) — as long as the++// account's address is verified and not bouncing. Go-live alerts are the headline case:++// followers expect them in their inbox unless they turn the 'stream' category's email off.++const EMAIL_DEFAULT_TYPES = new Set(['STREAM_LIVE']);++function isEmailEligible(notification) {return notification.priority === PRIORITY.CRITICAL&& EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);@@@ -172,6 +182,7 @@ const DEFAULT_NOTIFICATION_PREFS = Object.freeze({});module.exports = {++ EMAIL_DEFAULT_TYPES,PRIORITY,CATEGORY,TYPES,nnew file mode 100644@@@ -0,0 +1,41 @@++/* OpenVibe — shared Web Push service worker.++ * Served by every OpenVibe site at /openvibe-sw.js (must be same-origin, scope "/").++ * Payload (from openvibe.network push-service): { title, body, icon, url, tag } */++self.addEventListener('install', () => self.skipWaiting());++self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));++++self.addEventListener('push', (event) => {++ let data = {};++ try { data = event.data ? event.data.json() : {}; } catch { data = { title: 'OpenVibe', body: event.data ? event.data.text() : '' }; }++ const title = data.title || 'OpenVibe';++ const options = {++ body: data.body || '',++ icon: data.icon || 'https://openvibe.network/assets/logo-192.png',++ badge: data.badge || 'https://openvibe.network/assets/logo-72.png',++ tag: data.tag || 'openvibe',++ renotify: !!data.renotify,++ data: { url: data.url || 'https://openvibe.network/notifications', id: data.id || null },++ requireInteraction: false,++ };++ event.waitUntil(self.registration.showNotification(title, options));++});++++self.addEventListener('notificationclick', (event) => {++ event.notification.close();++ const url = (event.notification.data && event.notification.data.url) || 'https://openvibe.network/notifications';++ event.waitUntil((async () => {++ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });++ let target = null;++ try { target = new URL(url); } catch { /* */ }++ for (const c of all) {++ try {++ if (target && new URL(c.url).origin === target.origin && 'focus' in c) {++ await c.focus();++ if ('navigate' in c) { try { await c.navigate(url); } catch { /* cross-origin navigate blocked */ } }++ return;++ }++ } catch { /* */ }++ }++ await self.clients.openWindow(url);++ })());++});@@@ -329,9 +329,18 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa<!-- Notifications Section --><div class="section" id="sec-notifications">++ <div class="card" id="notif-verify-card" style="display:none">++ <h3><span class="icon"><i class="fa-solid fa-envelope-circle-check"></i></span> Email alerts</h3>++ <div id="notif-verify-body" style="font-size:13px;line-height:1.5"></div>++ </div><div class="card">++ <h3><span class="icon"><i class="fa-solid fa-inbox"></i></span> Inbox</h3>++ <p style="margin-bottom:12px;font-size:13px">Everything from every OpenVibe site, in one place. Search, filter, and scroll — it's the same inbox behind the bell on Live, Tools and Games. Turn on <strong>push</strong> to get alerts on this device even when no OpenVibe tab is open.</p>++ <div id="notif-inbox"></div>++ </div>++ <div class="card" id="prefs"><h3><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Preferences</h3>-- <p style="margin-bottom:16px">Control which notifications you receive and how they are delivered, including optional email alerts by category.</p>++ <p style="margin-bottom:16px">Control which notifications you receive and how they are delivered. <strong>Streams</strong> emails you when a streamer you follow goes live (on by default, once your email is verified); every other category is email-off unless you turn it on.</p><div class="notif-grid" id="notif-prefs"><div style="font-weight:700;font-size:11px;color:var(--text-muted)">Category</div><div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center">Enabled</div>@@@ -764,7 +773,9 @@ async function resetCustomColors(btn) {}function openInitialSection() {-- const routeSection = window.location.pathname === '/themes' ? 'themes' : window.location.hash.replace(/^#/, '') || 'profile';++ const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/profile': 'profile' }[window.location.pathname];++ const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';++ if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);if (isAnonSession() && ['notifications','linked','security'].includes(routeSection)) {return showSection('anon');}@@@ -855,15 +866,17 @@ async function loadUser() {loadLinked();loadSessions();loadAnonInfo();++ loadEmailStatus();// Init navbarOpenVibeNavbar.init({ service: 'network', token: authToken, user: currentUser });const bellMount = OpenVibeNavbar.getBellMount();if (bellMount && window.OpenVibeNotifications && authToken) {-- OpenVibeNotifications.init({ token: authToken, apiBase: API });++ OpenVibeNotifications.init({ token: authToken, apiBase: API, swPath: '/openvibe-sw.js' });const bell = OpenVibeNotifications.createBell();bellMount.replaceChildren(bell);}++ renderNotifInbox();}} catch (err) {console.error(err);@@@ -1002,6 +1015,64 @@ function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAccofunction addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }function signOutAll() { OpenVibeAccountSwitcher.logoutAll(); }++// Small helpers (my.html has no global toast/esc of its own).++if (typeof window.esc !== 'function') window.esc = (v) => String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');++if (typeof window.toast !== 'function') window.toast = (msg, type = 'info') => {++ let c = document.getElementById('my-toasts');++ if (!c) { c = document.createElement('div'); c.id = 'my-toasts'; c.style.cssText = 'position:fixed;top:78px;right:16px;z-index:100001;display:flex;flex-direction:column;gap:8px;max-width:360px'; document.body.appendChild(c); }++ const t = document.createElement('div');++ t.style.cssText = `padding:10px 14px;border-radius:10px;font-size:13px;color:#fff;box-shadow:0 8px 24px rgba(0,0,0,.35);background:${type === 'error' ? '#dc2626' : type === 'success' ? '#16a34a' : '#4c4c5e'}`;++ t.textContent = msg; c.appendChild(t);++ setTimeout(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 4500);++};++++// ── Email verification status (gates opt-in email like go-live alerts) ──++async function loadEmailStatus() {++ if (isAnonSession()) return;++ const card = document.getElementById('notif-verify-card'), body = document.getElementById('notif-verify-body');++ if (!card || !body) return;++ try {++ const st = await apiFetch('/api/auth/email/status');++ card.style.display = '';++ if (!st.email) {++ body.innerHTML = `<p>No email address on your account, so you can't receive go-live emails. Add one in <a href="#" onclick="showSection('profile');return false">Profile</a> — we'll send a confirmation link.</p>`;++ } else if (st.bounced) {++ body.innerHTML = `<p><span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-triangle-exclamation"></i> Mail to <strong>${esc(st.email)}</strong> is bouncing</span>${st.bounce_reason ? ` (${esc(st.bounce_reason)})` : ''}. Emails are paused until you update the address in <a href="#" onclick="showSection('profile');return false">Profile</a> and confirm it.</p>`;++ } else if (st.verified) {++ body.innerHTML = `<p><span style="color:var(--success,#22c55e)"><i class="fa-solid fa-circle-check"></i> <strong>${esc(st.email)}</strong> is verified.</span> You'll get an email when a streamer you follow goes live (turn this off under Streams → Email below).</p>`;++ } else {++ body.innerHTML = `<p><i class="fa-solid fa-envelope"></i> <strong>${esc(st.email)}</strong> isn't confirmed yet — go-live and other opt-in emails stay off until it is.</p>++ <button class="btn btn-primary" id="notif-verify-btn" onclick="resendVerification(this)" ${st.can_resend && st.email_enabled ? '' : 'disabled'}><i class="fa-solid fa-paper-plane"></i> ${st.email_enabled ? 'Send verification email' : 'Email delivery not configured'}</button>++ ${!st.can_resend && st.email_enabled ? '<div class="muted" style="font-size:12px;margin-top:6px">A link was sent moments ago — check your inbox and spam folder.</div>' : ''}`;++ }++ } catch (e) { card.style.display = 'none'; }++}++async function resendVerification(btn) {++ if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }++ try { const r = await apiFetch('/api/auth/email/send-verification', { method: 'POST' }); toast(r.message || 'Sent', 'success'); }++ catch (e) { toast(e.message, 'error'); }++ loadEmailStatus();++}++async function handleVerifyEmailToken() {++ const token = new URLSearchParams(window.location.search).get('token');++ if (!token) return;++ history.replaceState(null, '', '/notifications');++ try {++ const r = await fetchWithTimeout(API + '/api/auth/email/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) });++ const data = await r.json().catch(() => ({}));++ if (r.ok) toast(`${data.email || 'Your email'} is verified — go-live alerts will reach your inbox.`, 'success');++ else toast(data.error || 'Verification failed', 'error');++ } catch { toast('Verification failed — try again from your account page.', 'error'); }++ loadEmailStatus();++}++let _inboxRendered = false;++function renderNotifInbox() {++ if (_inboxRendered || isAnonSession() || !window.OpenVibeNotifications) return;++ const el = document.getElementById('notif-inbox'); if (!el) return;++ _inboxRendered = true;++ OpenVibeNotifications.renderInbox(el, { tab: 'all' });++}++// Notification preferencesconst CATEGORIES = ['social','chat','game','stream','economy','achievement','moderation','system','service','admin'];@@@ -1015,9 +1086,13 @@ async function loadNotifPrefs() {// Clear existing rowsgrid.querySelectorAll('.pref-row').forEach(r => r.remove());for (const cat of CATEGORIES) {-- const p = prefsMap[cat] || { enabled: 1, sound: 1, toasts: 1, email: 0 };++ const p = prefsMap[cat] || { enabled: 1, sound: 1, toasts: 1, email: null };++ // email NULL = no explicit choice: go-live (stream) alerts email by default.++ const emailOn = (p.email === null || p.email === undefined) ? cat === 'stream' : !!p.email;++ p.email = emailOn ? 1 : 0;++ const label = { social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams (go-live)', economy: 'Economy', achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements' }[cat] || cat;const html = `-- <div class="cat pref-row">${cat}</div>++ <div class="cat pref-row" title="${cat}">${label}</div><button class="toggle pref-row ${p.enabled ? 'on' : 'off'}" data-cat="${cat}" data-field="enabled" onclick="togglePref(this)"></button><button class="toggle pref-row ${p.sound ? 'on' : 'off'}" data-cat="${cat}" data-field="sound" onclick="togglePref(this)"></button><button class="toggle pref-row col-toast ${p.toasts ? 'on' : 'off'}" data-cat="${cat}" data-field="toasts" onclick="togglePref(this)"></button>nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// Email verification — proves the user controls the address before OpenVibe will send++// opt-in mail (go-live alerts etc.) to it. Tokens are random, hashed at rest, single-use,++// bound to the exact address they were issued for, and rate-limited per user + globally++// so the endpoint can't be used to spray a stranger's inbox or burn the email budget.++// ═══════════════════════════════════════════════════════════════++const express = require('express');++const crypto = require('crypto');++++const TOKEN_TTL_MIN = 60;++++function hashToken(t) { return crypto.createHash('sha256').update(String(t)).digest('hex'); }++++function baseUrl(req) {++ try { return require('./reset-tokens').buildBaseUrl(req); } catch { /* */ }++ return 'https://openvibe.network';++}++++/**++ * Issue + send a verification email. Returns { ok, reason }.++ * reasons: no-email, already-verified, email-disabled, rate-limited, cap++ */++async function sendVerification(req, user, { force = false } = {}) {++ const db = req.app.locals.db;++ const emailService = req.app.locals.emailService;++ if (!user?.email) return { ok: false, reason: 'no-email' };++ if (user.email_verified && !force) return { ok: false, reason: 'already-verified' };++ if (!emailService?.isEnabled) return { ok: false, reason: 'email-disabled' };++++ // Per-user: 1 per 3 minutes, N per day (site_settings email_verify_user_daily_cap, default 6).++ const recent = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(user.id)?.c || 0;++ if (recent > 0) return { ok: false, reason: 'rate-limited' };++ const dayCap = parseInt(db.getSetting('email_verify_user_daily_cap'), 10) || 6;++ const today = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-1 day')").get(user.id)?.c || 0;++ if (today >= dayCap) return { ok: false, reason: 'cap' };++ // Same address hammered from many accounts (abuse) — 5/day per address.++ const perAddr = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE LOWER(email) = LOWER(?) AND created_at > datetime('now','-1 day')").get(user.email)?.c || 0;++ if (perAddr >= 5) return { ok: false, reason: 'cap' };++ // A bouncing address gets no more mail until the user changes it.++ if (user.email_bounced_at) return { ok: false, reason: 'bounced' };++++ const raw = crypto.randomBytes(32).toString('base64url');++ db.prepare(`INSERT INTO email_verification_tokens (token_hash, user_id, email, expires_at) VALUES (?, ?, ?, datetime('now', '+${TOKEN_TTL_MIN} minutes'))`)++ .run(hashToken(raw), user.id, user.email);++ const verifyUrl = `${baseUrl(req)}/verify-email?token=${encodeURIComponent(raw)}`;++ const sent = await emailService.sendVerificationEmail({ to: user.email, username: user.display_name || user.username, verifyUrl, expiresMinutes: TOKEN_TTL_MIN });++ return sent ? { ok: true } : { ok: false, reason: 'send-failed' };++}++++/** Consume a token. Returns { ok, error? , user? }. */++function consumeToken(db, raw) {++ if (!raw) return { ok: false, error: 'Missing token' };++ const row = db.prepare("SELECT * FROM email_verification_tokens WHERE token_hash = ?").get(hashToken(raw));++ if (!row) return { ok: false, error: 'This verification link is invalid.' };++ if (row.used_at) return { ok: false, error: 'This verification link was already used.' };++ if (new Date(String(row.expires_at).replace(' ', 'T') + 'Z').getTime() < Date.now()) return { ok: false, error: 'This verification link has expired — request a new one from your account page.' };++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);++ if (!user) return { ok: false, error: 'Account not found.' };++ if (!user.email || user.email.toLowerCase() !== String(row.email).toLowerCase()) {++ return { ok: false, error: 'Your email address changed since this link was sent — request a new one.' };++ }++ const tx = db.transaction(() => {++ db.prepare("UPDATE email_verification_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?").run(row.token_hash);++ db.prepare("UPDATE users SET email_verified = 1, email_verified_at = CURRENT_TIMESTAMP, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?").run(user.id);++ });++ tx();++ return { ok: true, user };++}++++/** Called when a user sets/changes their address: unverify + (best-effort) send a new link. */++function onEmailChanged(req, userId) {++ const db = req.app.locals.db;++ db.prepare('UPDATE users SET email_verified = 0, email_verified_at = NULL, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?').run(userId);++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);++ if (user?.email) sendVerification(req, user).catch(() => {});++}++++function routes(requireAuth) {++ const router = express.Router();++++ // Status for the account page: { email, verified, bounced, can_resend }++ router.get('/email/status', requireAuth, (req, res) => {++ const db = req.app.locals.db;++ const u = db.prepare('SELECT email, email_verified, email_verified_at, email_bounced_at, email_bounce_reason FROM users WHERE id = ?').get(req.user.id) || {};++ const recent = db.prepare("SELECT MAX(created_at) AS t FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(req.user.id)?.t || null;++ res.json({++ ok: true,++ email: u.email || null,++ verified: !!u.email_verified,++ verified_at: u.email_verified_at || null,++ bounced: !!u.email_bounced_at,++ bounce_reason: u.email_bounce_reason || null,++ email_enabled: !!req.app.locals.emailService?.isEnabled,++ can_resend: !!u.email && !u.email_verified && !recent,++ });++ });++++ router.post('/email/send-verification', requireAuth, async (req, res) => {++ try {++ const db = req.app.locals.db;++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);++ const r = await sendVerification(req, user);++ if (r.ok) return res.json({ ok: true, message: `Verification email sent to ${user.email}. Check your inbox (and spam).` });++ const msg = {++ 'no-email': 'Add an email address to your profile first.',++ 'already-verified': 'Your email is already verified.',++ 'email-disabled': 'Email delivery is not configured on this site yet.',++ 'rate-limited': 'A verification email was sent moments ago — give it a few minutes.',++ 'cap': 'Too many verification emails today. Try again tomorrow.',++ 'bounced': 'That address bounced. Update your email address, then verify the new one.',++ 'send-failed': 'The email could not be sent. Try again later.',++ }[r.reason] || 'Could not send verification email.';++ res.status(r.reason === 'rate-limited' || r.reason === 'cap' ? 429 : 400).json({ ok: false, error: msg, reason: r.reason });++ } catch (err) {++ console.error('[EmailVerify] send error:', err);++ res.status(500).json({ ok: false, error: 'Failed to send verification email' });++ }++ });++++ // JSON verify (used by the /verify-email page)++ router.post('/email/verify', (req, res) => {++ const db = req.app.locals.db;++ const r = consumeToken(db, String(req.body?.token || ''));++ if (!r.ok) return res.status(400).json({ ok: false, error: r.error });++ try {++ req.app.locals.notificationService?.create({ user_id: r.user.id, type: 'EMAIL_VERIFIED', title: 'Email verified', message: `${r.user.email} is confirmed. Go-live alerts from streamers you follow will now reach your inbox.`, service: 'network', url: 'https://openvibe.network/notifications' });++ } catch { /* */ }++ res.json({ ok: true, email: r.user.email });++ });++++ return router;++}++++module.exports = { routes, sendVerification, consumeToken, onEmailChanged };@@@ -182,6 +182,9 @@ router.post('/register', (req, res) => {console.log(`[Auth] New user registered: ${username} (id: ${user.id})${reserved ? ' [verification key redeemed]' : ''}`);++ // Confirm the address before it can receive opt-in mail (best-effort, rate-limited).++ if (user.email) { try { require('./email-verify').sendVerification(req, user).catch(() => {}); } catch { /* */ } }++// Send welcome notificationtry {const notifService = req.app.locals.notificationService;@@@ -412,14 +415,28 @@ router.put('/profile', requireAuth, (req, res) => {}if (bio !== undefined) { updates.push('bio = ?'); params.push(bio.slice(0, 500)); }if (avatar_url !== undefined) { updates.push('avatar_url = ?'); params.push(avatar_url); }-- if (email !== undefined) { updates.push('email = ?'); params.push(email || null); }++ let emailChanged = false;++ if (email !== undefined) {++ const next = (email || '').trim() || null;++ if (next && !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(next)) return res.status(400).json({ error: 'That email address does not look valid' });++ if (next && next.length > 254) return res.status(400).json({ error: 'Email address too long' });++ emailChanged = (next || '').toLowerCase() !== String(req.user.email || '').toLowerCase();++ updates.push('email = ?'); params.push(next);++ }if (profile_color !== undefined) { updates.push('profile_color = ?'); params.push(profile_color); }if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });updates.push('updated_at = CURRENT_TIMESTAMP');params.push(req.user.id);-- db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);++ try {++ db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);++ } catch (e) {++ if (/UNIQUE/.test(e.message)) return res.status(409).json({ error: 'That email address is already used by another account' });++ throw e;++ }++ // A new address is unverified until confirmed; kick off the confirmation email.++ if (emailChanged) { try { require('./email-verify').onEmailChanged(req, req.user.id); } catch { /* */ } }const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);res.json({ user: sanitizeUser(user) });@@@ -481,6 +481,11 @@ function initDb(dbPath) {{ table: 'users', column: 'name_effect', sql: "ALTER TABLE users ADD COLUMN name_effect TEXT" },{ table: 'users', column: 'particle_effect', sql: "ALTER TABLE users ADD COLUMN particle_effect TEXT" },{ table: 'anon_users', column: 'ip', sql: "ALTER TABLE anon_users ADD COLUMN ip TEXT" },++ // Email deliverability state — verification gates opt-in mail, bounces suppress it.++ { table: 'users', column: 'email_verified', sql: "ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0" },++ { table: 'users', column: 'email_verified_at', sql: "ALTER TABLE users ADD COLUMN email_verified_at DATETIME" },++ { table: 'users', column: 'email_bounced_at', sql: "ALTER TABLE users ADD COLUMN email_bounced_at DATETIME" },++ { table: 'users', column: 'email_bounce_reason', sql: "ALTER TABLE users ADD COLUMN email_bounce_reason TEXT" },];for (const m of migrations) {const cols = db.prepare(`PRAGMA table_info(${m.table})`).all();@@@ -490,6 +495,36 @@ function initDb(dbPath) {}}++ // ── Email verification tokens + preference default semantics ──++ try {++ db.exec(`++ CREATE TABLE IF NOT EXISTS email_verification_tokens (++ token_hash TEXT PRIMARY KEY,++ user_id INTEGER NOT NULL,++ email TEXT NOT NULL,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ expires_at DATETIME NOT NULL,++ used_at DATETIME,++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++ );++ CREATE INDEX IF NOT EXISTS idx_email_verify_user ON email_verification_tokens(user_id, created_at DESC);++ `);++ // notification_preferences.email: 0 used to be the implicit default written whenever a++ // user toggled anything else in the row. It now means an EXPLICIT "no email"; NULL is++ // "no choice → defaults apply" (go-live alerts email by default). One-time reset.++ const flagged = db.prepare("SELECT value FROM site_settings WHERE key = 'migr_pref_email_null'").get();++ if (!flagged) {++ const n = db.prepare('UPDATE notification_preferences SET email = NULL WHERE email = 0').run().changes;++ db.prepare("INSERT OR REPLACE INTO site_settings (key, value, type) VALUES ('migr_pref_email_null', '1', 'boolean')").run();++ if (n) console.log(`[DB] notification_preferences: ${n} email=0 rows reset to default (NULL)`);++ }++ const seed = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');++ seed.run('email_user_daily_cap', '30', 'number');++ seed.run('email_daily_cap', '2000', 'number');++ seed.run('email_verify_user_daily_cap', '6', 'number');++ seed.run('resend_webhook_secret', '', 'string');++ } catch (e) { console.warn('[DB] email verification migration:', e.message); }++// ── Migration: one linked account per (user, service) ────────// The link-account upsert uses ON CONFLICT(user_id, service), which needs a// matching unique index (the table only declared UNIQUE(service, service_user_id)).@@@ -135,6 +135,9 @@ app.use(helmet({crossOriginEmbedderPolicy: false,}));app.use(cookieParser());++// Provider webhooks (Resend bounces/complaints) — mounted BEFORE the JSON body parser so++// the route sees the raw bytes it must verify the Svix signature over.++app.use('/api/webhooks', require('./notifications/resend-webhook')());app.use(express.json({ limit: '1mb' }));app.use(express.urlencoded({ extended: true }));@@@ -392,6 +395,9 @@ app.get('/api/brand', (_req, res) => res.json(BRAND));// Auth routes (SSO provider)app.use('/api/auth', authRoutes);++// Email verification (status / resend / consume)++app.use('/api/auth', require('./auth/email-verify').routes(requireAuth));++// Discord account linking (OAuth2 flow)app.use('/api/auth/discord', requireAuth, require('./auth/discord-link'));@@@ -528,7 +534,7 @@ app.get(['/', '/index.html'], (req, res) => {// Account hub (my.html) — the apex hosts the account hub under /my// plus its client-routed sections.--app.get(['/my', '/my.html', '/themes', '/notifications', '/linked', '/security', '/profile', '/billing', '/preferences'], (req, res) => {++app.get(['/my', '/my.html', '/themes', '/notifications', '/linked', '/security', '/profile', '/billing', '/preferences', '/verify-email'], (req, res) => {if (req.path === '/my.html') return redirectWithoutHtml(req, res, '/my');return sendMyAccountApp(res);});@@@ -541,6 +547,15 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {},}));++// Web-push service worker: must be served from THIS origin (scope /), so each site++// exposes the shared worker at /openvibe-sw.js rather than loading it from Network.++app.get('/openvibe-sw.js', (req, res) => {++ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');++ res.setHeader('Service-Worker-Allowed', '/');++ res.setHeader('Cache-Control', 'no-cache');++ res.sendFile(path.resolve(__dirname, '..', 'packages', 'openvibe-shared', 'openvibe-sw.js'));++});++// Serve openvibe-shared client-side libs (notification-ui.js, navbar.js, etc.)// Accessible at https://openvibe.network/shared/notification-ui.js etc.const sharedPath = path.resolve(__dirname, '..', 'packages', 'openvibe-shared');@@@ -47,7 +47,7 @@ router.post('/verify-token', (req, res) => {issuer: config.jwt.issuer});const db = getDb(req);-- const user = db.prepare('SELECT id, username, display_name, role, avatar_url, color FROM users WHERE id = ?').get(decoded.sub || decoded.id);++ const user = db.prepare('SELECT id, username, display_name, role, avatar_url, profile_color AS color FROM users WHERE id = ?').get(decoded.sub || decoded.id);res.json({ valid: true, decoded, user: user || null });} catch (err) {res.json({ valid: false, error: err.message });@@@ -58,7 +58,7 @@ router.post('/verify-token', (req, res) => {router.get('/users/:id', (req, res) => {const db = getDb(req);const user = db.prepare(`-- SELECT id, username, display_name, role, avatar_url, color, bio, created_at++ SELECT id, username, display_name, role, avatar_url, profile_color AS color, bio, created_atFROM users WHERE id = ?`).get(req.params.id);if (!user) return res.status(404).json({ error: 'User not found' });@@@ -69,7 +69,7 @@ router.get('/users/:id', (req, res) => {router.get('/users/by-username/:username', (req, res) => {const db = getDb(req);const user = db.prepare(`-- SELECT id, username, display_name, role, avatar_url, color, bio, created_at++ SELECT id, username, display_name, role, avatar_url, profile_color AS color, bio, created_atFROM users WHERE username = ?`).get(req.params.username.toLowerCase());if (!user) return res.status(404).json({ error: 'User not found' });@@@ -85,7 +85,7 @@ router.post('/users/bulk', (req, res) => {const db = getDb(req);const placeholders = ids.map(() => '?').join(',');const users = db.prepare(`-- SELECT id, username, display_name, role, avatar_url, color++ SELECT id, username, display_name, role, avatar_url, profile_color AS colorFROM users WHERE id IN (${placeholders})`).all(...ids);res.json({ users });@@@ -220,7 +220,7 @@ router.post('/coins/transfer', (req, res) => {// ═══════════════════════════════════════════════════════════════router.post('/events/stream-live', async (req, res) => {-- const { streamer, stream } = req.body;++ const { streamer, stream, follower_network_ids } = req.body;if (!streamer?.username || !stream?.id) {return res.status(400).json({ error: 'streamer and stream objects required' });}@@@ -263,11 +263,23 @@ router.post('/events/stream-live', async (req, res) => {},};-- // Find followers of this streamer in openvibe.network-- const followerRows = db.prepare(-- 'SELECT follower_id FROM follows WHERE followed_id = ?'-- ).all(streamer.id);-- const followerIds = followerRows.map(r => r.follower_id);++ // The streaming follow graph lives in OpenVibe.Live, keyed by LIVE user ids; Live++ // translates its followers to NETWORK ids via linked_accounts and sends them here.++ // (Network's own `follows` table is a separate, tiny social graph and `streamer.id`++ // is a Live id — the old lookup below silently addressed the wrong accounts.)++ let followerIds = Array.isArray(follower_network_ids)++ ? follower_network_ids.map(Number).filter(n => Number.isInteger(n) && n > 0).slice(0, 20000)++ : [];++ if (!Array.isArray(follower_network_ids)) {++ // Legacy caller: fall back to Network's graph, resolving the streamer's NETWORK id first.++ const link = streamer.id ? db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;++ if (link) followerIds = db.prepare('SELECT follower_id FROM follows WHERE followed_id = ?').all(link.user_id).map(r => r.follower_id);++ }++ // sender_id must be the streamer's NETWORK id for dedupe + "who is this" lookups.++ try {++ const link = streamer.id ? db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;++ if (link) notifData.sender_id = link.user_id;++ } catch { /* keep Live id */ }// Find users who opted into "all live" notificationsconst allLiveRows = db.prepare(@@@ -220,6 +220,25 @@ class EmailService {return sent;}++ /** Email-address verification link. */++ async sendVerificationEmail({ to, username, verifyUrl, expiresMinutes = 60 }) {++ const u = this._escapeHtml(username || 'there');++ const url = this._escapeHtml(verifyUrl);++ const htmlBody = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>++<body style="margin:0;padding:0;background:#1a1a24;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">++<div style="max-width:560px;margin:0 auto;padding:24px"><div style="background:#22222c;border-radius:12px;border:1px solid #333340;overflow:hidden">++<div style="background:linear-gradient(135deg,#2a2a38,#1a1a24);padding:24px;text-align:center;border-bottom:2px solid #8b5cf6"><div style="font-size:32px">✉️</div><h1 style="margin:0;color:#e0e0e0;font-size:18px">Confirm your email</h1></div>++<div style="padding:24px;color:#b0b0b8;font-size:14px;line-height:1.6">++<div style="color:#e0e0e0;font-weight:600;margin-bottom:12px">Hey ${u},</div>++<p>Confirm this address to receive OpenVibe email alerts — like when a streamer you follow goes live.</p>++<a href="${url}" style="display:inline-block;background:#8b5cf6;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:700;margin:16px 0">Verify email →</a>++<p style="color:#707080;font-size:12px">This link expires in ${expiresMinutes} minutes. If you didn't add this address to an OpenVibe account, ignore this email — nothing will be sent to you again.</p>++<p style="color:#707080;font-size:12px;word-break:break-all">${url}</p>++</div><div style="padding:16px 24px;text-align:center;color:#505060;font-size:11px;border-top:1px solid #333340"><a href="https://openvibe.network" style="color:#8b5cf6;text-decoration:none">openvibe.network</a></div></div></div></body></html>`;++ const textBody = [`Hey ${username || 'there'},`, '', 'Confirm this address to receive OpenVibe email alerts.', `Verify: ${verifyUrl}`, '', `This link expires in ${expiresMinutes} minutes. If you didn't add this address to an OpenVibe account, ignore this email.`].join('\n');++ return this._sendEmail({ to, subject: 'OpenVibe — confirm your email address', htmlBody, textBody, emailType: 'email_verification', metadata: { expiresMinutes } });++ }++async sendPasswordResetEmail({ to, username, resetUrl, expiresMinutes = 60 }) {const htmlBody = this._buildPasswordResetHtml({ username, resetUrl, expiresMinutes });const textBody = this._buildPasswordResetText({ username, resetUrl, expiresMinutes });@@@ -250,19 +269,31 @@ class EmailService {console.log(`[Email] Processing ${pending.length} pending email(s)...`);++ let skipped = 0;for (const notif of pending) {if (!notifService.shouldEmail(notif)) {notifService.markEmailed(notif.id);continue;}++ const why = notifService.emailGuard ? notifService.emailGuard(notif) : null;++ if (why) {++ // Caps: leave the row un-emailed only if the cap may lift (it won't for stale/dup).++ if (why === 'stale' || why === 'dup') notifService.markEmailed(notif.id);++ skipped++;++ continue;++ }++ const subject = notif.type === 'STREAM_LIVE'++ ? `🔴 ${notif.sender_name || 'A streamer you follow'} is live${notif.message ? ': ' + String(notif.message).slice(0, 80) : ''}`++ : `🔥 OpenVibe — ${notif.title}`;const sent = await this.sendNotificationEmail({to: notif.email,username: notif.display_name || notif.username,-- subject: `🔥 OpenVibe — ${notif.title}`,++ subject,notification: notif,});if (sent) notifService.markEmailed(notif.id);}++ if (skipped) console.log(`[Email] ${skipped} notification email(s) held back by guards (stale/dup/caps)`);}// ─── Email Templates ───────────────────────────────────────@@@ -294,15 +325,15 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B<body><div class="container"><div class="card"><div class="header"><div class="flame">🔥</div><h1>OpenVibe</h1></div><div class="body">--<div class="greeting">Hey ${username || 'there'},</div>++<div class="greeting">Hey ${this._escapeHtml(username || 'there')},</div><p>You have an important notification:</p><div class="notif-box">--<div class="icon">${notification.icon || '🔔'}</div>--<div class="title">${notification.title}</div>--${notification.message ? `<div class="message">${notification.message}</div>` : ''}--<div class="meta">${notification.service ? `From ${notification.service} · ` : ''}${notification.priority?.toUpperCase()} priority</div>++<div class="icon">${this._escapeHtml(notification.icon || '🔔')}</div>++<div class="title">${this._escapeHtml(notification.title)}</div>++${notification.message ? `<div class="message">${this._escapeHtml(notification.message)}</div>` : ''}++<div class="meta">${notification.service ? `From ${this._escapeHtml(notification.service)} · ` : ''}${notification.priority?.toUpperCase()} priority</div></div>--${notification.url ? `<a class="cta" href="${notification.url}">View Details →</a>` : ''}++${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}">View Details →</a>` : ''}</div><div class="footer"><p>You're receiving this because it's a critical notification.</p>@@@ -7,7 +7,7 @@// ═══════════════════════════════════════════════════════════════const { v4: uuidv4 } = require('uuid');--const { TYPES, PRIORITY, EMAIL_ELIGIBLE_CATEGORIES } = require('openvibe-shared/notifications');++const { TYPES, PRIORITY, EMAIL_ELIGIBLE_CATEGORIES, EMAIL_DEFAULT_TYPES } = require('openvibe-shared/notifications');class NotificationService {constructor(db) {@@@ -80,12 +80,32 @@ class NotificationService {this._deletePrefs = db.prepare('DELETE FROM notification_preferences WHERE user_id = ? AND category = ?');this._getPendingEmails = db.prepare(`-- SELECT n.*, u.email, u.username, u.display_name FROM notifications n++ SELECT n.*, u.email, u.username, u.display_name, u.email_verified, u.email_bounced_at FROM notifications nJOIN users u ON u.id = n.user_idWHERE n.is_emailed = 0 AND n.is_dismissed = 0AND u.email IS NOT NULL AND u.email != ''ORDER BY n.created_at ASC-- LIMIT 100++ LIMIT 200++ `);++ // Dedupe: an unread go-live from the same streamer within the hour means the viewer++ // already has the alert — reconnects/restarts must not stack ten of them.++ this._recentFromSender = db.prepare(`++ SELECT id FROM notifications++ WHERE user_id = ? AND type = ? AND sender_id = ? AND is_dismissed = 0++ AND created_at > datetime('now', '-60 minutes')++ LIMIT 1++ `);++ this._recentEmailFromSender = db.prepare(`++ SELECT 1 FROM email_delivery_log l JOIN notifications n ON n.id = l.notification_id++ WHERE n.user_id = ? AND n.type = ? AND n.sender_id = ? AND l.status = 'sent'++ AND l.created_at > datetime('now', '-60 minutes')++ LIMIT 1++ `);++ this._emailsSentToUserToday = db.prepare(`++ SELECT COUNT(*) AS c FROM email_delivery_log WHERE user_id = ? AND status = 'sent' AND created_at > datetime('now', '-1 day')++ `);++ this._emailsSentToday = db.prepare(`++ SELECT COUNT(*) AS c FROM email_delivery_log WHERE status = 'sent' AND created_at > datetime('now', '-1 day')`);this._newestForUser = db.prepare(`@@@ -113,6 +133,10 @@ class NotificationService {// Check user's preferences — skip if disabledconst pref = this._getPrefByCategory.get(data.user_id, category);if (pref && !pref.enabled) return null;++ // Go-live dedupe (see _recentFromSender).++ if (data.type === 'STREAM_LIVE' && data.sender_id != null) {++ try { if (this._recentFromSender.get(data.user_id, 'STREAM_LIVE', data.sender_id)) return null; } catch { /* */ }++ }const richContent = data.rich_content ? JSON.stringify(data.rich_content) : null;@@@ -162,16 +186,31 @@ class NotificationService {return n;}-- getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false } = {}) {-- let rows;-- if (category) {-- rows = this._getByCategory.all(userId, category, limit, offset);-- } else if (unreadOnly) {-- rows = this._getUnreadForUser.all(userId, limit, offset);-- } else {-- rows = this._getForUser.all(userId, limit, offset);++ /**++ * List notifications with composable filters. Returns { notifications, has_more, total }.++ * - category + unreadOnly combine (they used to be either/or)++ * - q: case-insensitive substring over title/message/sender_name++ * - since: ISO timestamp, only rows created after it (toasts)++ * Fetches limit+1 so the client knows whether another page exists.++ */++ getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false, q = null, since = null, type = null } = {}) {++ const where = ['user_id = ?', 'is_dismissed = 0'];++ const params = [userId];++ if (category) { where.push('category = ?'); params.push(category); }++ if (type) { where.push('type = ?'); params.push(type); }++ if (unreadOnly) where.push('is_read = 0');++ if (since) { where.push('created_at > ?'); params.push(String(since).replace('T', ' ').replace(/Z$/, '').slice(0, 19)); }++ if (q) {++ const like = `%${String(q).replace(/[%_\\]/g, c => '\\' + c).slice(0, 80)}%`;++ where.push("(title LIKE ? ESCAPE '\\' OR message LIKE ? ESCAPE '\\' OR sender_name LIKE ? ESCAPE '\\')");++ params.push(like, like, like);}-- return rows.map(n => {++ const sql = `SELECT * FROM notifications WHERE ${where.join(' AND ')} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`;++ const rows = this.db.prepare(sql).all(...params, limit + 1, offset);++ const has_more = rows.length > limit;++ if (has_more) rows.length = limit;++ const total = this.db.prepare(`SELECT COUNT(*) AS c FROM notifications WHERE ${where.join(' AND ')}`).get(...params)?.c || 0;++ const notifications = rows.map(n => {// Defensive: a single corrupt row must never 500 the whole dropdown.if (n.rich_content) {try { n.rich_content = JSON.parse(n.rich_content); }@@@ -179,6 +218,16 @@ class NotificationService {}return n;});++ return { notifications, has_more, total };++ }++++ markReadMany(ids, userId) {++ if (!Array.isArray(ids) || !ids.length) return 0;++ const stmt = this.db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ? AND is_read = 0');++ let n = 0;++ const tx = this.db.transaction(() => { for (const id of ids.slice(0, 500)) n += stmt.run(String(id), userId).changes; });++ tx();++ return n;}getUnreadCount(userId) {@@@ -234,13 +283,13 @@ class NotificationService {enabled: 1,sound: 1,toasts: 1,-- email: 0,++ email: null, // NULL = no explicit choice → shouldEmail() applies the defaults};const next = {enabled: updates.enabled !== undefined ? (updates.enabled ? 1 : 0) : current.enabled,sound: updates.sound !== undefined ? (updates.sound ? 1 : 0) : current.sound,toasts: updates.toasts !== undefined ? (updates.toasts ? 1 : 0) : current.toasts,-- email: updates.email !== undefined ? (updates.email ? 1 : 0) : current.email,++ email: updates.email !== undefined ? (updates.email === null ? null : (updates.email ? 1 : 0)) : current.email,};this._upsertPref.run(userId,@@@ -279,10 +328,39 @@ class NotificationService {if (pref && !pref.enabled) return false;// LOW priority never emailsif (notification.priority === PRIORITY.LOW) return false;-- // User explicitly opted into email for this category-- if (pref && pref.email) return true;-- // Built-in: CRITICAL + eligible categories always email-- return notification.priority === PRIORITY.CRITICAL && EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);++ const critical = notification.priority === PRIORITY.CRITICAL && EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);++ // Deliverability gate: a bouncing address never gets mail; an UNVERIFIED address++ // only gets critical security/moderation mail (password changed, ban) — never++ // opt-in style alerts, so nobody can point our sender at a stranger's inbox.++ if (notification.email_bounced_at) return false;++ if (!notification.email_verified && !critical) return false;++ // Explicit per-category choice wins (email column: NULL = no choice made).++ if (pref && pref.email != null) return !!pref.email;++ // Defaults: go-live alerts email unless turned off; critical eligible categories email.++ if (EMAIL_DEFAULT_TYPES.has(notification.type)) return true;++ return critical;++ }++++ /**++ * Abuse/cost guards evaluated right before a send. Returns a reason string to skip, or null.++ * - stale: a go-live alert older than 2h is useless in an inbox++ * - dup: same streamer already emailed this user within the hour++ * - user cap / global cap: site_settings email_user_daily_cap (default 30),++ * email_daily_cap (default 2000)++ */++ emailGuard(notification) {++ try {++ if (notification.type === 'STREAM_LIVE') {++ const ageMs = Date.now() - new Date(String(notification.created_at).replace(' ', 'T') + 'Z').getTime();++ if (ageMs > 2 * 3600 * 1000) return 'stale';++ if (notification.sender_id != null && this._recentEmailFromSender.get(notification.user_id, 'STREAM_LIVE', notification.sender_id)) return 'dup';++ }++ const userCap = parseInt(this.db.getSetting?.('email_user_daily_cap'), 10) || 30;++ if ((this._emailsSentToUserToday.get(notification.user_id)?.c || 0) >= userCap) return 'user-cap';++ const globalCap = parseInt(this.db.getSetting?.('email_daily_cap'), 10) || 2000;++ if ((this._emailsSentToday.get()?.c || 0) >= globalCap) return 'global-cap';++ } catch { /* guards are best-effort */ }++ return null;}// ─── Cleanup ──────────────────────────────────────────────nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// Resend delivery webhooks (Svix-signed). Bounces and complaints suppress further mail++// to the address; a hard bounce also clears the verified flag so the user has to++// re-confirm after fixing it. Configure in Resend: Webhooks → https://openvibe.network/api/webhooks/resend++// and paste the signing secret (whsec_…) into site_settings.resend_webhook_secret.++// ═══════════════════════════════════════════════════════════════++const express = require('express');++const crypto = require('crypto');++++function verifySvix(secret, headers, rawBody) {++ const id = headers['svix-id'], ts = headers['svix-timestamp'], sig = headers['svix-signature'];++ if (!id || !ts || !sig) return false;++ if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5-min replay window++ const key = Buffer.from(String(secret).replace(/^whsec_/, ''), 'base64');++ const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');++ return String(sig).split(' ').some(part => {++ const v = part.split(',')[1];++ if (!v || v.length !== expected.length) return false;++ try { return crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)); } catch { return false; }++ });++}++++module.exports = function resendWebhook() {++ const router = express.Router();++ router.post('/resend', express.raw({ type: '*/*', limit: '256kb' }), (req, res) => {++ const db = req.app.locals.db;++ const secret = db.getSetting('resend_webhook_secret');++ const raw = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body || '');++ if (!secret) { console.warn('[Resend webhook] received but resend_webhook_secret is not configured — ignored'); return res.status(503).json({ ok: false }); }++ if (!verifySvix(secret, req.headers, raw)) return res.status(401).json({ ok: false, error: 'bad signature' });++ let evt; try { evt = JSON.parse(raw); } catch { return res.status(400).json({ ok: false }); }++ const type = String(evt?.type || '');++ const to = [].concat(evt?.data?.to || []).map(s => String(s).toLowerCase());++ if (!to.length) return res.json({ ok: true });++ if (type === 'email.bounced' || type === 'email.complained') {++ const reason = type === 'email.complained' ? 'complaint' : (evt?.data?.bounce?.message || evt?.data?.bounce?.type || 'bounce');++ const stmt = db.prepare("UPDATE users SET email_bounced_at = CURRENT_TIMESTAMP, email_bounce_reason = ?, email_verified = CASE WHEN ? = 'complaint' THEN email_verified ELSE 0 END WHERE LOWER(email) = ?");++ let n = 0;++ for (const addr of to) n += stmt.run(String(reason).slice(0, 200), type === 'email.complained' ? 'complaint' : 'bounce', addr).changes;++ console.warn(`[Resend webhook] ${type} for ${to.join(',')} — ${n} account(s) suppressed`);++ try { db.prepare("INSERT INTO email_delivery_log (email_type, recipient, subject, status, error_message, metadata) VALUES (?, ?, ?, 'failed', ?, ?)").run(`webhook:${type}`, to[0], null, String(reason).slice(0, 200), JSON.stringify({ event: type })); } catch { /* */ }++ }++ res.json({ ok: true });++ });++ return router;++};@@@ -19,10 +19,14 @@ module.exports = function createNotificationRoutes(db, notificationService, requconst limit = Math.min(parseInt(req.query.limit) || 50, 200);const offset = parseInt(req.query.offset) || 0;const category = req.query.category || null;-- const unreadOnly = req.query.unread_only === '1' || req.query.unread_only === 'true';---- const notifications = notificationService.getForUser(userId, { limit, offset, category, unreadOnly });-- res.json({ ok: true, notifications });++ const type = req.query.type || null;++ // Accept both spellings — the shared UI shipped `unread=1` for a while.++ const unreadOnly = ['1', 'true'].includes(String(req.query.unread_only ?? req.query.unread ?? ''));++ const q = req.query.q ? String(req.query.q).trim() : null;++ const since = req.query.since ? String(req.query.since) : null;++++ const out = notificationService.getForUser(userId, { limit, offset, category, unreadOnly, q, since, type });++ res.json({ ok: true, ...out, limit, offset });} catch (err) {console.error('[Notifications] GET / error:', err);res.status(500).json({ ok: false, error: 'Failed to fetch notifications' });@@@ -63,7 +67,19 @@ module.exports = function createNotificationRoutes(db, notificationService, requrouter.post('/:id/read', requireAuth, (req, res) => {try {const ok = notificationService.markRead(req.params.id, req.user.id);-- res.json({ ok });++ res.json({ ok, unread: notificationService.getUnreadCount(req.user.id) });++ } catch (err) {++ res.status(500).json({ ok: false, error: 'Failed to mark read' });++ }++ });++++ // ─── POST /api/notifications/read-batch ────────────────++ // Body: { ids: [] } — the inbox marks everything it rendered as seen in one call.++ router.post('/read-batch', requireAuth, (req, res) => {++ try {++ const ids = Array.isArray(req.body?.ids) ? req.body.ids : [];++ const changed = notificationService.markReadMany(ids, req.user.id);++ res.json({ ok: true, changed, unread: notificationService.getUnreadCount(req.user.id) });} catch (err) {res.status(500).json({ ok: false, error: 'Failed to mark read' });}
@@ -1,30 +1,46 @@
@@ -1,30 +1,46 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-// OpenVibe — Notification UI Client
-// Drop-in vanilla JS library for toast notifications, bell badge,
-// notification panel, sounds, and real-time polling.
+// OpenVibe — Notification UI Client (shared across every OpenVibe site)
+//
+// One store (openvibe.network) → one bell everywhere. This file renders the bell +
+// dropdown panel, toasts, a full-page inbox, and handles Web Push subscription.
+//
// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>
// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>
-// OpenVibeNotifications.init({ token, apiBase, onAction })
+// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })
+// OpenVibeNotifications.createBell(mountEl)
+// OpenVibeNotifications.renderInbox(containerEl) // full-page inbox
+//
+// Reads: GET /api/notifications?limit&offset&unread_only&category&q&since
+// GET /api/notifications/unread-count
+// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
(function (root) {
(function (root) {
'use strict';
'use strict';
- const POLL_INTERVAL = 15_000; // 15s for unread count
- const TOAST_DURATION = { low: 0, normal: 3800, high: 6000, critical: 0 }; // 0 = sticky
+ const POLL_INTERVAL = 15_000;
+ const PAGE_SIZE = 30;
+ const TOAST_DURATION = { low: 0, normal: 4200, high: 6500, critical: 0 }; // 0 = sticky
const MAX_TOASTS = 3;
const MAX_TOASTS = 3;
const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };
const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };
+ const CATEGORY_LABELS = {
+ social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams', economy: 'Economy',
+ achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements',
+ };
+ const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network' };
- let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', onAction: null };
+ let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };
let _pollTimer = null;
let _pollTimer = null;
- let _lastCheck = null;
+ let _lastSeenAt = null; // newest created_at we have toasted / seen
let _unreadCount = 0;
let _unreadCount = 0;
let _toastContainer = null;
let _toastContainer = null;
let _panelEl = null;
let _panelEl = null;
let _bellEl = null;
let _bellEl = null;
let _audioCache = {};
let _audioCache = {};
let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };
let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };
+ let _channel = null; // BroadcastChannel for same-origin tab sync
+ const _views = new Map(); // container → view state (panel + inboxes)
- // ── Styles (injected once) ───────────────────────────────
+ // ── Styles ──────────────────────────────────────────────
function injectStyles() {
function injectStyles() {
if (document.getElementById('openvibe-notif-styles')) return;
if (document.getElementById('openvibe-notif-styles')) return;
const style = document.createElement('style');
const style = document.createElement('style');
@@ -35,504 +51,625 @@
@@ -35,504 +51,625 @@
@keyframes openvibe-badge-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }
@keyframes openvibe-badge-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }
@keyframes openvibe-bell-ring { 0%{ transform: rotate(0); } 10%{ transform: rotate(14deg); } 20%{ transform: rotate(-14deg); } 30%{ transform: rotate(10deg); } 40%{ transform: rotate(-6deg); } 50%{ transform: rotate(0); } }
@keyframes openvibe-bell-ring { 0%{ transform: rotate(0); } 10%{ transform: rotate(14deg); } 20%{ transform: rotate(-14deg); } 30%{ transform: rotate(10deg); } 40%{ transform: rotate(-6deg); } 50%{ transform: rotate(0); } }
@keyframes openvibe-slide-down { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes openvibe-slide-down { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
- @keyframes openvibe-fade-in { from { opacity: 0; } to { opacity: 1; } }
+ @keyframes openvibe-spin { to { transform: rotate(360deg); } }
- .openvibe-toast-container {
- position: fixed; top: 82px; right: 14px; z-index: 100000;
- display: flex; flex-direction: column; gap: 6px;
- pointer-events: none; max-width: 320px; width: 100%;
- }
- .openvibe-toast {
- pointer-events: all;
- background: color-mix(in srgb, var(--bg-card, #22222c) 92%, transparent);
- border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent);
- border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start;
- box-shadow: 0 10px 24px rgba(0,0,0,0.28);
- animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1);
- cursor: pointer; position: relative; overflow: hidden;
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
- color: var(--text-primary, #e0e0e0);
- transition: border-color .2s;
- }
+ .openvibe-toast-container { position: fixed; top: 82px; right: 14px; z-index: 100000; display: flex; flex-direction: column; gap: 6px; pointer-events: none; max-width: 340px; width: 100%; }
+ .openvibe-toast { pointer-events: all; background: color-mix(in srgb, var(--bg-card, #22222c) 94%, transparent); border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent); border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start; box-shadow: 0 10px 24px rgba(0,0,0,0.28); animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1); cursor: pointer; position: relative; overflow: hidden; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); transition: border-color .2s; }
.openvibe-toast:hover { border-color: var(--accent, #8b5cf6); }
.openvibe-toast:hover { border-color: var(--accent, #8b5cf6); }
.openvibe-toast.removing { animation: openvibe-toast-out .3s ease forwards; }
.openvibe-toast.removing { animation: openvibe-toast-out .3s ease forwards; }
.openvibe-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; margin-top: 1px; }
.openvibe-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; margin-top: 1px; }
+ .openvibe-toast-icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }
.openvibe-toast-body { flex: 1; min-width: 0; }
.openvibe-toast-body { flex: 1; min-width: 0; }
.openvibe-toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }
.openvibe-toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }
- .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: rgba(192,150,92,0.15); color: var(--accent-light, #a78bfa); font-weight: 500; }
+ .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: color-mix(in srgb, var(--accent, #8b5cf6) 18%, transparent); color: var(--accent-light, #a78bfa); font-weight: 500; }
.openvibe-toast-msg { font-size: 11px; color: var(--text-secondary, #b0b0b8); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.openvibe-toast-msg { font-size: 11px; color: var(--text-secondary, #b0b0b8); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.openvibe-toast-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; padding: 2px; line-height: 1; }
.openvibe-toast-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; padding: 2px; line-height: 1; }
.openvibe-toast-close:hover { color: var(--text-primary, #e0e0e0); }
.openvibe-toast-close:hover { color: var(--text-primary, #e0e0e0); }
- .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #8b5cf6); border-radius: 0 0 0 10px; }
+ .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #8b5cf6); }
.openvibe-toast.priority-high { border-left: 3px solid var(--warning, #f39c12); }
.openvibe-toast.priority-high { border-left: 3px solid var(--warning, #f39c12); }
.openvibe-toast.priority-critical { border-left: 3px solid var(--live-red, #e74c3c); background: rgba(231,76,60,0.06); }
.openvibe-toast.priority-critical { border-left: 3px solid var(--live-red, #e74c3c); background: rgba(231,76,60,0.06); }
.openvibe-toast-actions { display: flex; gap: 6px; margin-top: 8px; }
.openvibe-toast-actions { display: flex; gap: 6px; margin-top: 8px; }
- .openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; transition: all .15s; }
- .openvibe-toast-actions button:hover { background: var(--accent-dark, #a07840); color: #fff; border-color: var(--accent-dark, #a07840); }
+ .openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; }
.openvibe-toast-actions button.primary { background: var(--accent, #8b5cf6); color: #fff; border-color: var(--accent, #8b5cf6); }
.openvibe-toast-actions button.primary { background: var(--accent, #8b5cf6); color: #fff; border-color: var(--accent, #8b5cf6); }
- .openvibe-toast-user { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
- .openvibe-toast-user img { width: 24px; height: 24px; border-radius: 50%; }
- .openvibe-toast-user .username { font-weight: 600; font-size: 12px; cursor: pointer; }
- /* ── Bell ─────────────────────────────────────── */
.openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; }
.openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; }
.openvibe-bell svg { width: 22px; height: 22px; fill: var(--text-secondary, #b0b0b8); transition: fill .2s; }
.openvibe-bell svg { width: 22px; height: 22px; fill: var(--text-secondary, #b0b0b8); transition: fill .2s; }
- .openvibe-bell:hover svg { fill: var(--accent-light, #a78bfa); }
- .openvibe-bell .badge {
- position: absolute; top: 0; right: 0;
- min-width: 16px; height: 16px; padding: 0 4px;
- background: var(--live-red, #e74c3c); color: #fff;
- border-radius: 8px; font-size: 10px; font-weight: 700;
- display: flex; align-items: center; justify-content: center;
- line-height: 1; pointer-events: none;
- animation: openvibe-badge-pulse .4s ease;
- }
+ .openvibe-bell:hover svg, .openvibe-bell.open svg { fill: var(--accent-light, #a78bfa); }
+ .openvibe-bell .badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; background: var(--live-red, #e74c3c); color: #fff; border-radius: 8px; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; animation: openvibe-badge-pulse .4s ease; }
.openvibe-bell .badge.hidden { display: none; }
.openvibe-bell .badge.hidden { display: none; }
.openvibe-bell.ringing svg { animation: openvibe-bell-ring .6s ease; }
.openvibe-bell.ringing svg { animation: openvibe-bell-ring .6s ease; }
- /* ── Panel ────────────────────────────────────── */
- .openvibe-notif-panel {
- position: fixed; top: 62px; right: 16px;
- width: 380px; max-height: calc(100vh - 86px);
- background: var(--bg-secondary, #252530);
- border: 1px solid var(--border, #333340);
- border-radius: 12px;
- box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
- z-index: 99999; display: none; flex-direction: column;
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
- color: var(--text-primary, #e0e0e0);
- animation: openvibe-slide-down .2s ease;
- }
+ .openvibe-notif-panel { position: fixed; top: 62px; right: 16px; width: 400px; max-height: calc(100vh - 86px); background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5)); z-index: 99999; display: none; flex-direction: column; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); animation: openvibe-slide-down .2s ease; overflow: hidden; }
.openvibe-notif-panel.open { display: flex; }
.openvibe-notif-panel.open { display: flex; }
- .openvibe-notif-panel-header {
- padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);
- display: flex; align-items: center; justify-content: space-between;
- }
- .openvibe-notif-panel-header h3 { font-size: 15px; font-weight: 600; margin: 0; }
- .openvibe-notif-panel-header .actions { display: flex; gap: 8px; }
- .openvibe-notif-panel-header .actions button { background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 4px 8px; border-radius: 4px; transition: all .15s; }
- .openvibe-notif-panel-header .actions button:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
- .openvibe-notif-panel-tabs { display: flex; border-bottom: 1px solid var(--border, #333340); }
- .openvibe-notif-panel-tabs button { flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-muted, #707080); font-size: 12px; font-weight: 500; cursor: pointer; transition: all .15s; }
- .openvibe-notif-panel-tabs button.active { color: var(--accent-light, #a78bfa); border-bottom-color: var(--accent, #8b5cf6); }
- .openvibe-notif-panel-list { flex: 1; overflow-y: auto; padding: 4px 0; }
- .openvibe-notif-item {
- padding: 12px 16px; display: flex; gap: 10px; align-items: flex-start;
- cursor: pointer; transition: background .15s; border-left: 3px solid transparent;
- }
+ .openvibe-notif-inbox { display: flex; flex-direction: column; background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; overflow: hidden; color: var(--text-primary, #e0e0e0); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; min-height: 420px; max-height: 78vh; }
+
+ .openvibe-nv-head { padding: 12px 14px; border-bottom: 1px solid var(--border, #333340); display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+ .openvibe-nv-head h3 { font-size: 15px; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 8px; }
+ .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 600; background: var(--live-red, #e74c3c); color: #fff; border-radius: 9px; padding: 1px 7px; }
+ .openvibe-nv-head h3 .count.zero { display: none; }
+ .openvibe-nv-actions { display: flex; gap: 4px; align-items: center; }
+ .openvibe-nv-actions button, .openvibe-nv-actions a { background: none; border: 1px solid transparent; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 5px 8px; border-radius: 6px; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; line-height: 1; }
+ .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
+ .openvibe-nv-actions button.on { color: var(--accent-light, #a78bfa); border-color: color-mix(in srgb, var(--accent, #8b5cf6) 40%, transparent); }
+ .openvibe-nv-actions button:disabled { opacity: .45; cursor: default; }
+ .openvibe-nv-actions svg { width: 14px; height: 14px; fill: currentColor; }
+
+ .openvibe-nv-tools { padding: 8px 12px; border-bottom: 1px solid var(--border, #333340); display: flex; flex-direction: column; gap: 8px; }
+ .openvibe-nv-search { position: relative; }
+ .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 7px 28px 7px 30px; border-radius: 8px; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-primary, #e0e0e0); font-size: 12px; outline: none; }
+ .openvibe-nv-search input:focus { border-color: var(--accent, #8b5cf6); }
+ .openvibe-nv-search svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; fill: var(--text-muted, #707080); pointer-events: none; }
+ .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; display: none; }
+ .openvibe-nv-search.has-q .clear { display: block; }
+ .openvibe-nv-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
+ .openvibe-nv-chips button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 10px; border-radius: 999px; cursor: pointer; line-height: 1.2; }
+ .openvibe-nv-chips button:hover { border-color: var(--accent, #8b5cf6); color: var(--text-primary, #e0e0e0); }
+ .openvibe-nv-chips button.active { background: color-mix(in srgb, var(--accent, #8b5cf6) 18%, transparent); border-color: var(--accent, #8b5cf6); color: var(--accent-light, #a78bfa); }
+ .openvibe-nv-chips select { margin-left: auto; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 8px; border-radius: 8px; cursor: pointer; }
+
+ .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 4px 0; overscroll-behavior: contain; }
+ .openvibe-notif-item { padding: 10px 12px 10px 14px; display: flex; gap: 10px; align-items: flex-start; cursor: pointer; transition: background .15s; border-left: 3px solid transparent; position: relative; }
.openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }
.openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }
- .openvibe-notif-item.unread { background: rgba(192,150,92,0.04); border-left-color: var(--accent, #8b5cf6); }
- .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }
+ .openvibe-notif-item.unread { background: color-mix(in srgb, var(--accent, #8b5cf6) 7%, transparent); border-left-color: var(--accent, #8b5cf6); }
+ .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; width: 32px; text-align: center; }
+ .openvibe-notif-item .icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }
.openvibe-notif-item .content { flex: 1; min-width: 0; }
.openvibe-notif-item .content { flex: 1; min-width: 0; }
- .openvibe-notif-item .content .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; }
- .openvibe-notif-item .content .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .openvibe-notif-item .content .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; }
- .openvibe-notif-item .content .meta .service-tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }
- .openvibe-notif-panel-empty { text-align: center; padding: 48px 24px; color: var(--text-muted, #707080); }
- .openvibe-notif-panel-empty .icon { font-size: 36px; margin-bottom: 8px; display: block; opacity: .5; }
-
- @media (max-width: 440px) {
+ .openvibe-notif-item .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; padding-right: 18px; }
+ .openvibe-notif-item.unread .title { font-weight: 650; }
+ .openvibe-notif-item .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+ .openvibe-notif-item .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+ .openvibe-notif-item .meta .tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }
+ .openvibe-notif-item .dismiss { position: absolute; top: 8px; right: 8px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 15px; line-height: 1; padding: 2px 4px; border-radius: 4px; opacity: 0; transition: opacity .15s; }
+ .openvibe-notif-item:hover .dismiss { opacity: 1; }
+ .openvibe-notif-item .dismiss:hover { color: var(--live-red, #e74c3c); background: var(--bg-tertiary, #2a2a38); }
+ .openvibe-nv-empty { text-align: center; padding: 44px 24px; color: var(--text-muted, #707080); font-size: 13px; }
+ .openvibe-nv-empty .icon { font-size: 34px; margin-bottom: 8px; display: block; opacity: .5; }
+ .openvibe-nv-more { display: flex; justify-content: center; padding: 10px; }
+ .openvibe-nv-more button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 12px; padding: 6px 14px; border-radius: 8px; cursor: pointer; }
+ .openvibe-nv-more .spinner { width: 16px; height: 16px; border: 2px solid var(--border, #333340); border-top-color: var(--accent, #8b5cf6); border-radius: 50%; animation: openvibe-spin .8s linear infinite; }
+ .openvibe-nv-foot { padding: 8px 12px; border-top: 1px solid var(--border, #333340); font-size: 11px; color: var(--text-muted, #707080); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
+ .openvibe-nv-foot a { color: var(--accent-light, #a78bfa); text-decoration: none; }
+ .openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }
+
+ @media (max-width: 460px) {
.openvibe-toast-container { right: 8px; left: 8px; max-width: none; }
.openvibe-toast-container { right: 8px; left: 8px; max-width: none; }
- .openvibe-notif-panel { right: 8px; left: 8px; width: auto; }
+ .openvibe-notif-panel { right: 6px; left: 6px; width: auto; top: 56px; max-height: calc(100vh - 66px); }
}
}
`;
`;
document.head.appendChild(style);
document.head.appendChild(style);
}
}
- // ── Sound ────────────────────────────────────────────────
+ // ── Helpers ─────────────────────────────────────────────
+ function esc(v) {
+ if (v == null) return '';
+ return String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
+ }
+ function richOf(n) {
+ const rc = n && (n.rich_content ?? n.richContent);
+ if (!rc) return {};
+ if (typeof rc === 'object') return rc;
+ try { return JSON.parse(rc) || {}; } catch { return {}; }
+ }
+ function urlOf(n) { return (n && n.url) || richOf(n).url || null; }
+ function isRead(n) { return !!(n && (n.is_read === 1 || n.is_read === true || n.read === true)); }
+ function parseDate(v) {
+ if (!v) return null;
+ // SQLite "YYYY-MM-DD HH:MM:SS" is UTC without a zone marker.
+ const s = String(v);
+ const d = new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? s.replace(' ', 'T') + 'Z' : s);
+ return isNaN(d.getTime()) ? null : d;
+ }
+ function timeAgo(v) {
+ const d = parseDate(v); if (!d) return '';
+ const diff = (Date.now() - d.getTime()) / 1000;
+ if (diff < 45) return 'just now';
+ if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+ if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
+ return d.toLocaleDateString();
+ }
+ function inboxUrl() {
+ if (_config.inboxUrl) return _config.inboxUrl;
+ try { return new URL('/notifications', _config.apiBase).toString(); } catch { return 'https://openvibe.network/notifications'; }
+ }
+ function iconHtml(n) {
+ const rich = richOf(n);
+ const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);
+ if (img && /^https?:\/\//.test(String(img))) return `<img src="${esc(img)}" alt="" loading="lazy" onerror="this.replaceWith(document.createTextNode('${esc(n.icon || '🔔')}'))">`;
+ return esc(n.icon || '🔔');
+ }
+ function navigateTo(url) {
+ if (!url) return;
+ try {
+ const u = new URL(url, window.location.href);
+ window.location.href = u.toString();
+ } catch { window.location.href = url; }
+ }
+
+ // ── API ─────────────────────────────────────────────────
+ function isCrossOrigin() {
+ try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }
+ }
+ function apiFetch(path, opts = {}) {
+ const headers = { ...(opts.headers || {}) };
+ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
+ if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
+ headers['Content-Type'] = 'application/json';
+ opts = { ...opts, body: JSON.stringify(opts.body) };
+ }
+ return fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: isCrossOrigin() ? 'omit' : 'include' });
+ }
+ async function apiJson(path, opts) {
+ const res = await apiFetch(path, opts);
+ let data = null; try { data = await res.json(); } catch { /* */ }
+ if (!res.ok) { const e = new Error((data && data.error) || `HTTP ${res.status}`); e.status = res.status; e.body = data; throw e; }
+ return data || {};
+ }
+ function hasSession() { return !!_config.token || !isCrossOrigin(); }
+
+ // ── Cross-tab sync (same origin) ────────────────────────
+ function channel() {
+ if (_channel !== null) return _channel || null;
+ try { _channel = new BroadcastChannel('openvibe-notifications'); _channel.onmessage = (e) => onSyncMessage(e.data || {}); }
+ catch { _channel = false; }
+ return _channel || null;
+ }
+ function broadcast(msg) { try { channel()?.postMessage(msg); } catch { /* */ } }
+ function onSyncMessage(msg) {
+ if (msg.type === 'unread') setBadge(msg.count, { quiet: true });
+ else if (msg.type === 'read') { for (const v of _views.values()) applyReadLocally(v, msg.ids || [], msg.all); }
+ }
+
+ // ── Sound ───────────────────────────────────────────────
function playSound(priority) {
function playSound(priority) {
if (!_preferences.sound) return;
if (!_preferences.sound) return;
- const file = SOUNDS[priority];
- if (!file) return;
+ const file = SOUNDS[priority]; if (!file) return;
const url = `${_config.soundBase}/${file}`;
const url = `${_config.soundBase}/${file}`;
try {
try {
if (!_audioCache[url]) _audioCache[url] = new Audio(url);
if (!_audioCache[url]) _audioCache[url] = new Audio(url);
- const audio = _audioCache[url];
- audio.currentTime = 0;
- audio.volume = priority === 'critical' ? 0.8 : 0.5;
- audio.play().catch(() => {});
- } catch {}
+ const a = _audioCache[url]; a.currentTime = 0; a.volume = priority === 'critical' ? 0.8 : 0.5; a.play().catch(() => {});
+ } catch { /* */ }
}
}
- // ── Toast Container ──────────────────────────────────────
+ // ── Toasts ──────────────────────────────────────────────
function ensureToastContainer() {
function ensureToastContainer() {
if (_toastContainer) return;
if (_toastContainer) return;
_toastContainer = document.createElement('div');
_toastContainer = document.createElement('div');
_toastContainer.className = 'openvibe-toast-container';
_toastContainer.className = 'openvibe-toast-container';
document.body.appendChild(_toastContainer);
document.body.appendChild(_toastContainer);
}
}
-
- function showToast(notification) {
+ function showToast(n) {
if (!_preferences.toasts) return;
if (!_preferences.toasts) return;
- if (_preferences.muted_categories.includes(notification.category)) return;
+ if (_preferences.muted_categories.includes(n.category)) return;
ensureToastContainer();
ensureToastContainer();
-
- // Limit visible toasts
- while (_toastContainer.children.length >= MAX_TOASTS) {
- const oldest = _toastContainer.firstChild;
- oldest.remove();
- }
-
+ while (_toastContainer.children.length >= MAX_TOASTS) _toastContainer.firstChild.remove();
+ const rich = richOf(n);
const toast = document.createElement('div');
const toast = document.createElement('div');
- toast.className = `openvibe-toast priority-${notification.priority}`;
- toast.dataset.notifId = notification.id;
-
- const rich = notification.richContent || {};
- let userHtml = '';
- if (rich.user) {
- const u = rich.user;
- const nameStyle = u.profile_color ? `color:${u.profile_color}` : '';
- const nameClass = u.name_effect ? `openvibe-name-fx-${u.name_effect}` : '';
- userHtml = `<div class="openvibe-toast-user">
- ${u.avatar_url ? `<img src="${u.avatar_url}" alt="">` : ''}
- <span class="username ${nameClass}" style="${nameStyle}">${u.display_name || u.username}</span>
- </div>`;
- }
-
- let actionsHtml = '';
- if (rich.actions && rich.actions.length) {
- actionsHtml = '<div class="openvibe-toast-actions">' +
- rich.actions.filter(a => a.type !== 'input').map(a =>
- `<button data-action="${a.id}" class="${a.style === 'primary' ? 'primary' : ''}">${a.label}</button>`
- ).join('') + '</div>';
- }
-
- const serviceLabel = notification.service && notification.service !== 'network'
- ? `<span class="service-badge">${notification.service}</span>` : '';
-
+ toast.className = `openvibe-toast priority-${esc(n.priority || 'normal')}`;
+ toast.dataset.notifId = n.id || '';
+ const serviceLabel = n.service && n.service !== 'network' ? `<span class="service-badge">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';
+ const actions = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input') : [];
toast.innerHTML = `
toast.innerHTML = `
- <div class="openvibe-toast-icon">${notification.icon || '🔔'}</div>
+ <div class="openvibe-toast-icon">${iconHtml(n)}</div>
<div class="openvibe-toast-body">
<div class="openvibe-toast-body">
- ${userHtml}
- <div class="openvibe-toast-title">${notification.title}${serviceLabel}</div>
- <div class="openvibe-toast-msg">${notification.message}</div>
- ${actionsHtml}
+ <div class="openvibe-toast-title">${esc(n.title)}${serviceLabel}</div>
+ <div class="openvibe-toast-msg">${esc(n.message || '')}</div>
+ ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => `<button data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}
</div>
</div>
- <button class="openvibe-toast-close">×</button>
- `;
-
- // Progress bar for auto-dismiss
- const duration = TOAST_DURATION[notification.priority] || 5000;
+ <button class="openvibe-toast-close" aria-label="Dismiss">×</button>`;
+ const duration = TOAST_DURATION[n.priority] ?? 5000;
if (duration > 0) {
if (duration > 0) {
const bar = document.createElement('div');
const bar = document.createElement('div');
- bar.className = 'openvibe-toast-progress';
- bar.style.width = '100%';
- bar.style.transition = `width ${duration}ms linear`;
+ bar.className = 'openvibe-toast-progress'; bar.style.width = '100%'; bar.style.transition = `width ${duration}ms linear`;
toast.appendChild(bar);
toast.appendChild(bar);
requestAnimationFrame(() => { bar.style.width = '0%'; });
requestAnimationFrame(() => { bar.style.width = '0%'; });
+ setTimeout(() => dismissToast(toast), duration);
}
}
-
- // Events
- toast.querySelector('.openvibe-toast-close').addEventListener('click', e => {
- e.stopPropagation();
- dismissToast(toast);
- });
-
+ toast.querySelector('.openvibe-toast-close').addEventListener('click', (e) => { e.stopPropagation(); dismissToast(toast); });
toast.addEventListener('click', () => {
toast.addEventListener('click', () => {
- if (rich.url) window.open(rich.url, '_blank');
- markRead(notification.id);
+ if (n.id) markRead([n.id]);
dismissToast(toast);
dismissToast(toast);
+ const u = urlOf(n); if (u) navigateTo(u);
});
});
-
- // Action buttons
- toast.querySelectorAll('[data-action]').forEach(btn => {
- btn.addEventListener('click', e => {
- e.stopPropagation();
- const actionId = btn.dataset.action;
- const action = rich.actions.find(a => a.id === actionId);
- if (action?.url) { window.open(action.url, '_blank'); return; }
- if (_config.onAction) _config.onAction(notification.id, actionId, notification);
- dismissToast(toast);
- });
- });
-
+ toast.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const a = actions.find(x => String(x.id) === btn.dataset.action);
+ if (a && a.url) { navigateTo(a.url); return; }
+ if (_config.onAction) _config.onAction(n.id, btn.dataset.action, n);
+ dismissToast(toast);
+ }));
_toastContainer.appendChild(toast);
_toastContainer.appendChild(toast);
-
- // Auto dismiss
- if (duration > 0) {
- setTimeout(() => dismissToast(toast), duration);
- }
}
}
-
function dismissToast(el) {
function dismissToast(el) {
if (!el || !el.parentNode) return;
if (!el || !el.parentNode) return;
el.classList.add('removing');
el.classList.add('removing');
setTimeout(() => el.remove(), 300);
setTimeout(() => el.remove(), 300);
}
}
- // ── Bell / Badge ─────────────────────────────────────────
- function createBell() {
+ // ── Bell / badge ────────────────────────────────────────
+ function createBellEl() {
const bell = document.createElement('div');
const bell = document.createElement('div');
bell.className = 'openvibe-bell';
bell.className = 'openvibe-bell';
- bell.innerHTML = `
- <svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>
- <span class="badge hidden">0</span>
- `;
+ bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;
+ bell.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg><span class="badge hidden">0</span>`;
bell.addEventListener('click', () => togglePanel());
bell.addEventListener('click', () => togglePanel());
+ bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });
_bellEl = bell;
_bellEl = bell;
return bell;
return bell;
}
}
-
- function updateBadge(count) {
- _unreadCount = count;
- if (!_bellEl) return;
- const badge = _bellEl.querySelector('.badge');
- if (count > 0) {
- badge.textContent = count > 99 ? '99+' : count;
- badge.classList.remove('hidden');
- _bellEl.classList.add('ringing');
- setTimeout(() => _bellEl.classList.remove('ringing'), 600);
- } else {
- badge.classList.add('hidden');
+ function setBadge(count, { quiet = false } = {}) {
+ const prev = _unreadCount;
+ _unreadCount = Math.max(0, Number(count) || 0);
+ if (_bellEl) {
+ const badge = _bellEl.querySelector('.badge');
+ if (_unreadCount > 0) {
+ badge.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount);
+ badge.classList.remove('hidden');
+ if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 600); }
+ } else badge.classList.add('hidden');
}
}
+ for (const v of _views.values()) {
+ const c = v.root.querySelector('.openvibe-nv-head .count');
+ if (c) { c.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount); c.classList.toggle('zero', _unreadCount === 0); }
+ }
+ if (!quiet) broadcast({ type: 'unread', count: _unreadCount });
}
}
- // ── Panel ────────────────────────────────────────────────
- function createPanel() {
- const panel = document.createElement('div');
- panel.className = 'openvibe-notif-panel';
- panel.innerHTML = `
- <div class="openvibe-notif-panel-header">
- <h3>Notifications</h3>
- <div class="actions">
- <button data-action="mark-all-read">Mark all read</button>
- <button data-action="settings">⚙️</button>
+ // ── List views (panel + inbox share this) ───────────────
+ const SVG = {
+ search: '<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>',
+ check: '<svg viewBox="0 0 24 24"><path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z"/></svg>',
+ bellOn: '<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>',
+ bellOff: '<svg viewBox="0 0 24 24"><path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31 0 0-.01 0-.01.01L18 14.68z"/></svg>',
+ gear: '<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>',
+ open: '<svg viewBox="0 0 24 24"><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>',
+ };
+
+ function buildView(root, { mode }) {
+ const v = { root, mode, tab: 'all', category: '', q: '', items: [], offset: 0, hasMore: false, loading: false, seq: 0, ids: new Set() };
+ root.innerHTML = `
+ <div class="openvibe-nv-head">
+ <h3>Notifications <span class="count zero">0</span></h3>
+ <div class="openvibe-nv-actions">
+ <button type="button" data-act="read-all" title="Mark all as read">${SVG.check}<span>Mark all read</span></button>
+ <button type="button" data-act="push" title="Desktop / mobile push alerts on this device">${SVG.bellOff}</button>
+ ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open inbox">${SVG.open}</a>` : `<a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification preferences">${SVG.gear}</a>`}
</div>
</div>
</div>
</div>
- <div class="openvibe-notif-panel-tabs">
- <button class="active" data-tab="all">All</button>
- <button data-tab="unread">Unread</button>
- <button data-tab="social">Social</button>
- <button data-tab="game">Game</button>
- <button data-tab="system">System</button>
+ <div class="openvibe-nv-note" data-role="note" style="display:none"></div>
+ <div class="openvibe-nv-tools">
+ <div class="openvibe-nv-search">${SVG.search}<input type="search" placeholder="Search notifications…" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">×</button></div>
+ <div class="openvibe-nv-chips">
+ <button type="button" data-tab="all" class="active">All</button>
+ <button type="button" data-tab="unread">Unread</button>
+ <select data-role="category"><option value="">All categories</option>${Object.entries(CATEGORY_LABELS).map(([k, l]) => `<option value="${k}">${l}</option>`).join('')}</select>
+ </div>
</div>
</div>
- <div class="openvibe-notif-panel-list"></div>
- `;
-
- panel.querySelector('[data-action="mark-all-read"]').addEventListener('click', markAllRead);
- panel.querySelectorAll('[data-tab]').forEach(btn => {
- btn.addEventListener('click', () => {
- panel.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- loadNotifications(btn.dataset.tab);
- });
+ <div class="openvibe-nv-list" data-role="list"></div>
+ <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'panel' ? `<a href="${esc(inboxUrl())}">Open full inbox →</a>` : ''}</div>`;
+ const list = root.querySelector('[data-role="list"]');
+ const input = root.querySelector('input[type="search"]');
+ let qTimer = null;
+ input.addEventListener('input', () => {
+ root.querySelector('.openvibe-nv-search').classList.toggle('has-q', !!input.value);
+ clearTimeout(qTimer); qTimer = setTimeout(() => { v.q = input.value.trim(); reload(v); }, 260);
});
});
-
- document.addEventListener('click', e => {
- if (_panelEl?.classList.contains('open') && !_panelEl.contains(e.target) && !_bellEl?.contains(e.target)) {
- _panelEl.classList.remove('open');
- }
+ root.querySelector('.openvibe-nv-search .clear').addEventListener('click', () => { input.value = ''; v.q = ''; root.querySelector('.openvibe-nv-search').classList.remove('has-q'); reload(v); });
+ root.querySelectorAll('[data-tab]').forEach(btn => btn.addEventListener('click', () => {
+ root.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active')); btn.classList.add('active');
+ v.tab = btn.dataset.tab; reload(v);
+ }));
+ root.querySelector('[data-role="category"]').addEventListener('change', (e) => { v.category = e.target.value; reload(v); });
+ root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());
+ root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));
+ // Infinite scroll: load the next page when the user nears the bottom.
+ list.addEventListener('scroll', () => {
+ if (v.loading || !v.hasMore) return;
+ if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);
});
});
-
- _panelEl = panel;
- document.body.appendChild(panel);
- return panel;
+ list.addEventListener('click', (e) => {
+ const dismissBtn = e.target.closest('.dismiss');
+ const item = e.target.closest('.openvibe-notif-item');
+ if (!item) return;
+ const id = item.dataset.id;
+ if (dismissBtn) { e.stopPropagation(); dismiss(v, id); return; }
+ const n = v.items.find(x => String(x.id) === id);
+ if (n && !isRead(n)) markRead([id]);
+ const u = urlOf(n);
+ if (u) navigateTo(u);
+ });
+ refreshPushButton(v);
+ _views.set(root, v);
+ return v;
}
}
- function togglePanel() {
- if (!_panelEl) createPanel();
- const opening = !_panelEl.classList.contains('open');
- _panelEl.classList.toggle('open');
- if (opening) loadNotifications('all');
+ function renderItems(v, { append = false } = {}) {
+ const list = v.root.querySelector('[data-role="list"]');
+ const html = v.items.map(n => {
+ try {
+ const cat = CATEGORY_LABELS[n.category] || n.category || '';
+ const service = n.service && n.service !== 'network' ? `<span class="tag">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';
+ return `<div class="openvibe-notif-item ${isRead(n) ? '' : 'unread'}" data-id="${esc(n.id)}" title="${esc(urlOf(n) ? 'Open' : '')}">
+ <span class="icon">${iconHtml(n)}</span>
+ <div class="content">
+ <div class="title">${esc(n.title)}</div>
+ ${n.message ? `<div class="msg">${esc(n.message)}</div>` : ''}
+ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${esc(timeAgo(n.created_at))}</span>${service}${cat ? `<span class="tag">${esc(cat)}</span>` : ''}</div>
+ </div>
+ <button type="button" class="dismiss" title="Dismiss" aria-label="Dismiss">×</button>
+ </div>`;
+ } catch { return ''; }
+ }).join('');
+ if (!append) list.innerHTML = '';
+ if (!v.items.length) {
+ list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">${v.q ? '🔎' : '🔔'}</span>${v.q ? 'Nothing matches that search' : (v.tab === 'unread' ? 'You\'re all caught up' : 'No notifications yet')}</div>`;
+ } else {
+ const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();
+ list.insertAdjacentHTML('beforeend', html);
+ if (v.hasMore) list.insertAdjacentHTML('beforeend', `<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>`);
+ const more = list.querySelector('[data-act="more"]'); if (more) more.addEventListener('click', () => loadMore(v));
+ }
+ const st = v.root.querySelector('[data-role="status"]');
+ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';
}
}
- async function loadNotifications(tab = 'all') {
- if (!_panelEl) return;
- const list = _panelEl.querySelector('.openvibe-notif-panel-list');
- list.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-muted)">Loading...</div>';
-
+ async function fetchPage(v, offset) {
+ const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) });
+ if (v.tab === 'unread') params.set('unread_only', '1');
+ if (v.category) params.set('category', v.category);
+ if (v.q) params.set('q', v.q);
+ return apiJson(`/api/notifications?${params}`);
+ }
+ async function reload(v) {
+ const seq = ++v.seq;
+ v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null;
+ const list = v.root.querySelector('[data-role="list"]');
+ list.innerHTML = '<div class="openvibe-nv-more"><span class="spinner"></span></div>';
try {
try {
- const params = new URLSearchParams({ limit: '50' });
- if (tab === 'unread') params.set('unread', '1');
- else if (tab !== 'all') params.set('category', tab);
-
- const res = await apiFetch(`/api/notifications?${params}`);
- const data = await res.json();
-
- if (!data.notifications || data.notifications.length === 0) {
- list.innerHTML = `<div class="openvibe-notif-panel-empty"><span class="icon">🔔</span>No notifications yet</div>`;
- return;
- }
-
- list.innerHTML = data.notifications.map(n => {
- // Render each item in isolation — one malformed notification must
- // never blank the whole dropdown ("Failed to load").
- try {
- const ago = timeAgo(n.created_at);
- const serviceTag = n.service && n.service !== 'network'
- ? `<span class="service-tag">${esc(n.service)}</span>` : '';
- return `<div class="openvibe-notif-item ${n.read ? '' : 'unread'}" data-id="${esc(n.id)}">
- <span class="icon">${esc(n.icon || '🔔')}</span>
- <div class="content">
- <div class="title">${esc(n.title)}</div>
- <div class="msg">${esc(n.message)}</div>
- <div class="meta"><span>${esc(ago)}</span>${serviceTag}</div>
- </div>
- </div>`;
- } catch { return ''; }
- }).join('');
-
- list.querySelectorAll('.openvibe-notif-item').forEach(item => {
- item.addEventListener('click', () => {
- const id = item.dataset.id;
- markRead(id);
- item.classList.remove('unread');
- const notif = data.notifications.find(n => String(n.id) === id);
- const rich = richOf(notif);
- if (rich.url) window.open(rich.url, '_blank');
- });
- });
- } catch {
- list.innerHTML = '<div class="openvibe-notif-panel-empty"><span class="icon">⚠️</span>Failed to load</div>';
- }
+ const data = await fetchPage(v, 0);
+ if (seq !== v.seq) return;
+ v.items = data.notifications || []; v.items.forEach(n => v.ids.add(String(n.id)));
+ v.hasMore = !!data.has_more; v.total = data.total ?? null; v.offset = v.items.length;
+ renderItems(v);
+ } catch (e) {
+ if (seq !== v.seq) return;
+ list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">⚠️</span>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</div>`;
+ } finally { if (seq === v.seq) v.loading = false; }
}
}
-
- // ── API Helpers ──────────────────────────────────────────
- function isCrossOrigin() {
- try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }
+ async function loadMore(v) {
+ if (v.loading || !v.hasMore) return;
+ const seq = v.seq; v.loading = true;
+ const more = v.root.querySelector('.openvibe-nv-more'); if (more) more.innerHTML = '<span class="spinner"></span>';
+ try {
+ const data = await fetchPage(v, v.offset);
+ if (seq !== v.seq) return;
+ const fresh = (data.notifications || []).filter(n => !v.ids.has(String(n.id)));
+ fresh.forEach(n => v.ids.add(String(n.id)));
+ v.items = v.items.concat(fresh); v.hasMore = !!data.has_more; v.offset += (data.notifications || []).length; v.total = data.total ?? v.total;
+ // Re-render only the tail to keep the scroll position.
+ const old = v.root.querySelector('.openvibe-nv-more'); if (old) old.remove();
+ if (fresh.length) renderItems({ ...v, items: fresh }, { append: true });
+ 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)); }
+ const st = v.root.querySelector('[data-role="status"]');
+ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';
+ } catch { /* keep what we have */ }
+ finally { v.loading = false; }
}
}
-
- function apiFetch(path, opts = {}) {
- const headers = { ...opts.headers };
- if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
- const crossOrigin = isCrossOrigin();
- return fetch(`${_config.apiBase}${path}`, {
- ...opts,
- headers,
- // Only send credentials for same-origin requests; cross-origin uses Authorization header
- credentials: crossOrigin ? 'omit' : 'include',
- });
+ function applyReadLocally(v, ids, all) {
+ const set = new Set((ids || []).map(String));
+ for (const n of v.items) if (all || set.has(String(n.id))) n.is_read = 1;
+ v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => { if (all || set.has(el.dataset.id)) el.classList.remove('unread'); });
}
}
- async function markRead(id) {
- try { await apiFetch(`/api/notifications/${id}/read`, { method: 'POST' }); } catch {}
- _unreadCount = Math.max(0, _unreadCount - 1);
- updateBadge(_unreadCount);
+ // ── Mutations ───────────────────────────────────────────
+ async function markRead(ids) {
+ ids = (ids || []).map(String).filter(Boolean);
+ if (!ids.length) return;
+ for (const v of _views.values()) applyReadLocally(v, ids, false);
+ broadcast({ type: 'read', ids });
+ try {
+ const data = ids.length === 1
+ ? await apiJson(`/api/notifications/${encodeURIComponent(ids[0])}/read`, { method: 'POST' })
+ : await apiJson('/api/notifications/read-batch', { method: 'POST', body: { ids } });
+ if (data && typeof data.unread === 'number') setBadge(data.unread, { quiet: true }), broadcast({ type: 'unread', count: data.unread });
+ else pollUnread();
+ } catch { pollUnread(); }
}
}
-
async function markAllRead() {
async function markAllRead() {
- try { await apiFetch('/api/notifications/read-all', { method: 'POST' }); } catch {}
- updateBadge(0);
- if (_panelEl?.classList.contains('open')) {
- _panelEl.querySelectorAll('.openvibe-notif-item.unread').forEach(el => el.classList.remove('unread'));
- }
+ for (const v of _views.values()) applyReadLocally(v, [], true);
+ setBadge(0);
+ broadcast({ type: 'read', all: true });
+ try { await apiJson('/api/notifications/read-all', { method: 'POST', body: {} }); } catch { pollUnread(); }
+ }
+ async function dismiss(v, id) {
+ const n = v.items.find(x => String(x.id) === String(id));
+ v.items = v.items.filter(x => String(x.id) !== String(id));
+ v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`)?.remove();
+ if (v.total != null) v.total = Math.max(0, v.total - 1);
+ if (!v.items.length) renderItems(v);
+ try {
+ await apiJson(`/api/notifications/${encodeURIComponent(id)}/dismiss`, { method: 'POST' });
+ if (n && !isRead(n)) setBadge(Math.max(0, _unreadCount - 1), { quiet: true });
+ } catch { /* */ }
}
}
+ // ── Polling / toasts ────────────────────────────────────
+ let _polling = false;
async function pollUnread() {
async function pollUnread() {
+ if (_polling || !hasSession()) return;
+ _polling = true;
try {
try {
- const res = await apiFetch('/api/notifications/unread-count');
- const data = await res.json();
+ const data = await apiJson('/api/notifications/unread-count');
const prev = _unreadCount;
const prev = _unreadCount;
- updateBadge(data.count || 0);
-
- // If new notifications arrived, fetch and toast the latest
- if (data.count > prev && _preferences.toasts) {
- const newest = await apiFetch(`/api/notifications?unread=1&limit=${data.count - prev}&since=${_lastCheck || ''}`);
- const nd = await newest.json();
- if (nd.notifications) {
- for (const n of nd.notifications.slice(0, 3)) {
- const rich = n.rich_content ? JSON.parse(n.rich_content) : {};
- showToast({ ...n, richContent: rich });
- playSound(n.priority);
- }
- }
+ const count = Number(data.count) || 0;
+ setBadge(count);
+ if (count > prev && _lastSeenAt) {
+ // Something new arrived: fetch what's newer than the last thing we saw.
+ const params = new URLSearchParams({ unread_only: '1', limit: '5', since: _lastSeenAt });
+ const nd = await apiJson(`/api/notifications?${params}`);
+ const fresh = (nd.notifications || []).slice().reverse();
+ for (const n of fresh) { showToast(n); playSound(n.priority); }
+ // Any open list should reflect the new items too.
+ for (const v of _views.values()) if (v.mode === 'inbox' || (_panelEl && _panelEl.classList.contains('open'))) reload(v);
}
}
- _lastCheck = new Date().toISOString();
- } catch {}
+ const newest = await newestCreatedAt();
+ if (newest) _lastSeenAt = newest;
+ } catch { /* offline / signed out — try again next tick */ }
+ finally { _polling = false; }
}
}
+ async function newestCreatedAt() {
+ try { const d = await apiJson('/api/notifications/newest'); const c = d.notification && d.notification.created_at; return c ? String(c) : null; } catch { return null; }
+ }
+ function startPolling() {
+ stopPolling();
+ if (!hasSession()) return;
+ newestCreatedAt().then(c => { _lastSeenAt = c || new Date().toISOString(); pollUnread(); });
+ _pollTimer = setInterval(pollUnread, POLL_INTERVAL);
+ }
+ function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) pollUnread(); });
- // ── Helpers ──────────────────────────────────────────────
- // Escape untrusted text before inserting into innerHTML. Notification
- // titles/messages can be attacker-controlled (e.g. abusive broadcasts), so
- // they must never be treated as HTML.
- function esc(v) {
- if (v == null) return '';
- return String(v)
- .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
- .replace(/"/g, '"').replace(/'/g, ''');
+ // ── Panel ───────────────────────────────────────────────
+ function createPanel() {
+ const panel = document.createElement('div');
+ panel.className = 'openvibe-notif-panel';
+ document.body.appendChild(panel);
+ buildView(panel, { mode: 'panel' });
+ document.addEventListener('click', (e) => {
+ if (panel.classList.contains('open') && !panel.contains(e.target) && !(_bellEl && _bellEl.contains(e.target))) closePanel();
+ });
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel.classList.contains('open')) closePanel(); });
+ _panelEl = panel;
+ return panel;
}
}
- // rich_content arrives already parsed from the API, but tolerate a raw JSON
- // string too. Never throw — a bad value just yields {}.
- function richOf(n) {
- const rc = n && n.rich_content;
- if (!rc) return {};
- if (typeof rc === 'object') return rc;
- try { return JSON.parse(rc) || {}; } catch { return {}; }
+ function closePanel() { _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); }
+ function togglePanel() {
+ if (!_panelEl) createPanel();
+ const opening = !_panelEl.classList.contains('open');
+ _panelEl.classList.toggle('open', opening);
+ _bellEl?.classList.toggle('open', opening);
+ if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }
}
}
- // ── Time Formatting ──────────────────────────────────────
- function timeAgo(dateStr) {
- const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;
- if (diff < 60) return 'just now';
- if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
- if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
- return new Date(dateStr).toLocaleDateString();
+ // ── Web Push ────────────────────────────────────────────
+ function pushSupported() { return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window && window.isSecureContext; }
+ function urlBase64ToUint8Array(b64) {
+ const padding = '='.repeat((4 - (b64.length % 4)) % 4);
+ const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');
+ const raw = window.atob(base64); const out = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
+ return out;
+ }
+ async function currentSubscription() {
+ if (!pushSupported()) return null;
+ try { const reg = await navigator.serviceWorker.getRegistration(_config.swPath); return reg ? await reg.pushManager.getSubscription() : null; } catch { return null; }
+ }
+ async function pushStatus() {
+ const supported = pushSupported();
+ const sub = supported ? await currentSubscription() : null;
+ return { supported, permission: supported ? Notification.permission : 'unsupported', subscribed: !!sub };
+ }
+ async function enablePush() {
+ if (!pushSupported()) throw new Error('This browser does not support push notifications');
+ if (!hasSession()) throw new Error('Sign in first');
+ const perm = await Notification.requestPermission();
+ if (perm !== 'granted') throw new Error(perm === 'denied' ? 'Notifications are blocked for this site in your browser settings' : 'Permission not granted');
+ const reg = await navigator.serviceWorker.register(_config.swPath, { scope: '/' });
+ await navigator.serviceWorker.ready;
+ const { publicKey } = await apiJson('/api/push/vapid-key');
+ if (!publicKey) throw new Error('Push is not configured on the server');
+ let sub = await reg.pushManager.getSubscription();
+ if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });
+ await apiJson('/api/push/subscribe', { method: 'POST', body: { subscription: { ...sub.toJSON(), userAgent: navigator.userAgent.slice(0, 200) } } });
+ try { localStorage.setItem('ov_push_enabled', '1'); } catch { /* */ }
+ return true;
+ }
+ async function disablePush() {
+ const sub = await currentSubscription();
+ if (sub) {
+ try { await apiJson('/api/push/unsubscribe', { method: 'POST', body: { endpoint: sub.endpoint } }); } catch { /* */ }
+ try { await sub.unsubscribe(); } catch { /* */ }
+ }
+ try { localStorage.removeItem('ov_push_enabled'); } catch { /* */ }
+ return true;
+ }
+ async function refreshPushButton(v) {
+ const btn = v && v.root.querySelector('[data-act="push"]');
+ if (!btn) return;
+ const st = await pushStatus();
+ if (!st.supported) { btn.style.display = 'none'; return; }
+ btn.style.display = '';
+ btn.classList.toggle('on', st.subscribed);
+ btn.innerHTML = st.subscribed ? `${SVG.bellOn}<span>Push on</span>` : `${SVG.bellOff}<span>Enable push</span>`;
+ btn.title = st.subscribed ? 'Push alerts are on for this device — click to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get desktop / mobile alerts on this device even when the tab is closed');
+ btn.disabled = st.permission === 'denied' && !st.subscribed;
+ }
+ async function togglePush(v) {
+ const note = v.root.querySelector('[data-role="note"]');
+ 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); };
+ try {
+ const st = await pushStatus();
+ if (st.subscribed) { await disablePush(); say('Push alerts turned off for this device.'); }
+ else { await enablePush(); say('Push alerts enabled — you\'ll get alerts here even when this site is closed.'); }
+ } catch (e) { say(e.message || 'Could not change push setting', 6000); }
+ for (const view of _views.values()) refreshPushButton(view);
}
}
- // ── Public API ───────────────────────────────────────────
+ // ── Public API ──────────────────────────────────────────
const OpenVibeNotifications = {
const OpenVibeNotifications = {
init(opts = {}) {
init(opts = {}) {
Object.assign(_config, opts);
Object.assign(_config, opts);
injectStyles();
injectStyles();
if (opts.preferences) Object.assign(_preferences, opts.preferences);
if (opts.preferences) Object.assign(_preferences, opts.preferences);
- _lastCheck = new Date().toISOString();
-
- // Start polling
- pollUnread();
- _pollTimer = setInterval(pollUnread, POLL_INTERVAL);
+ channel();
+ startPolling();
+ // If the user enabled push on this device before, make sure the worker is registered.
+ try { if (pushSupported() && localStorage.getItem('ov_push_enabled') === '1') navigator.serviceWorker.register(_config.swPath, { scope: '/' }).catch(() => {}); } catch { /* */ }
},
},
-
destroy() {
destroy() {
- clearInterval(_pollTimer);
- _toastContainer?.remove();
- _panelEl?.remove();
- _toastContainer = null;
- _panelEl = null;
+ stopPolling();
+ _toastContainer?.remove(); _panelEl?.remove();
+ _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null;
},
},
-
/** Create and inject the bell icon. Returns the DOM element. */
/** Create and inject the bell icon. Returns the DOM element. */
createBell(container) {
createBell(container) {
- const bell = createBell();
+ injectStyles();
+ const bell = createBellEl();
if (container) container.appendChild(bell);
if (container) container.appendChild(bell);
pollUnread();
pollUnread();
return bell;
return bell;
},
},
-
- /** Programmatically push a toast (for real-time WebSocket events). */
+ /** Full-page inbox (same list, filters, search, infinite scroll) rendered into a container. */
+ renderInbox(container, opts = {}) {
+ injectStyles();
+ if (!container) return null;
+ container.classList.add('openvibe-notif-inbox');
+ const v = buildView(container, { mode: 'inbox' });
+ if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b.dataset.tab === opts.tab)); }
+ reload(v);
+ return v;
+ },
+ /** Programmatically show a toast (real-time events). Marks nothing on the server. */
push(notification) {
push(notification) {
showToast(notification);
showToast(notification);
playSound(notification.priority);
playSound(notification.priority);
- _unreadCount++;
- updateBadge(_unreadCount);
- },
-
- /** Update preferences at runtime. */
- setPreferences(prefs) {
- Object.assign(_preferences, prefs);
+ if (!notification.id) return;
+ setBadge(_unreadCount + 1);
},
},
-
- /** Update auth token (e.g., after account switch). */
+ /** Re-fetch the badge and any open lists (call after an in-page action). */
+ refresh() { pollUnread(); for (const v of _views.values()) reload(v); },
+ markRead(ids) { return markRead(Array.isArray(ids) ? ids : [ids]); },
+ markAllRead,
+ setPreferences(prefs) { Object.assign(_preferences, prefs); },
setToken(token) {
setToken(token) {
_config.token = token;
_config.token = token;
- if (token) {
- pollUnread();
- if (!_pollTimer) _pollTimer = setInterval(pollUnread, POLL_INTERVAL);
- } else {
- clearInterval(_pollTimer);
- _pollTimer = null;
- updateBadge(0);
- }
+ if (token || !isCrossOrigin()) startPolling(); else { stopPolling(); setBadge(0, { quiet: true }); }
},
},
-
- /** Open/close the notification panel. */
- togglePanel,
-
- /** Get current unread count. */
+ togglePanel, closePanel,
+ enablePush, disablePush, pushStatus,
get unreadCount() { return _unreadCount; },
get unreadCount() { return _unreadCount; },
};
};
- // Export
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;
else root.OpenVibeNotifications = OpenVibeNotifications;
else root.OpenVibeNotifications = OpenVibeNotifications;
@@ -54,6 +54,11 @@ const TYPES = Object.freeze({
@@ -54,6 +54,11 @@ const TYPES = Object.freeze({
STREAM_LIVE: { category: 'stream', priority: 'high', icon: '🔴', title: 'Stream Live' },
STREAM_LIVE: { category: 'stream', priority: 'high', icon: '🔴', title: 'Stream Live' },
RAID: { category: 'stream', priority: 'high', icon: '🚀', title: 'Incoming Raid' },
RAID: { category: 'stream', priority: 'high', icon: '🚀', title: 'Incoming Raid' },
CLIP_CREATED: { category: 'stream', priority: 'normal', icon: '🎬', title: 'Clip Created' },
CLIP_CREATED: { category: 'stream', priority: 'normal', icon: '🎬', title: 'Clip Created' },
+ VC_CALL_INVITE: { category: 'chat', priority: 'high', icon: '📞', title: 'Call Invite' },
+ CONTENT_COMMENT: { category: 'social', priority: 'normal', icon: '💬', title: 'New Comment' },
+ CONTENT_REPLY: { category: 'social', priority: 'normal', icon: '↩️', title: 'New Reply' },
+ TIP_RECEIVED: { category: 'economy', priority: 'normal', icon: '💜', title: 'Tip Received' },
+ SUBSCRIPTION: { category: 'economy', priority: 'normal', icon: '⭐', title: 'New Subscriber' },
// Economy
// Economy
COINS_RECEIVED: { category: 'economy', priority: 'normal', icon: '🪙', title: 'Coins Received' },
COINS_RECEIVED: { category: 'economy', priority: 'normal', icon: '🪙', title: 'Coins Received' },
@@ -92,6 +97,11 @@ const EMAIL_ELIGIBLE_CATEGORIES = new Set([
@@ -92,6 +97,11 @@ const EMAIL_ELIGIBLE_CATEGORIES = new Set([
'system', 'moderation', 'admin',
'system', 'moderation', 'admin',
]);
]);
+// Types that email BY DEFAULT (no explicit per-category opt-in needed) — as long as the
+// account's address is verified and not bouncing. Go-live alerts are the headline case:
+// followers expect them in their inbox unless they turn the 'stream' category's email off.
+const EMAIL_DEFAULT_TYPES = new Set(['STREAM_LIVE']);
+
function isEmailEligible(notification) {
function isEmailEligible(notification) {
return notification.priority === PRIORITY.CRITICAL
return notification.priority === PRIORITY.CRITICAL
&& EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);
&& EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);
@@ -172,6 +182,7 @@ const DEFAULT_NOTIFICATION_PREFS = Object.freeze({
@@ -172,6 +182,7 @@ const DEFAULT_NOTIFICATION_PREFS = Object.freeze({
});
});
module.exports = {
module.exports = {
+ EMAIL_DEFAULT_TYPES,
PRIORITY,
PRIORITY,
CATEGORY,
CATEGORY,
TYPES,
TYPES,
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+/* OpenVibe — shared Web Push service worker.
+ * Served by every OpenVibe site at /openvibe-sw.js (must be same-origin, scope "/").
+ * Payload (from openvibe.network push-service): { title, body, icon, url, tag } */
+self.addEventListener('install', () => self.skipWaiting());
+self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
+
+self.addEventListener('push', (event) => {
+ let data = {};
+ try { data = event.data ? event.data.json() : {}; } catch { data = { title: 'OpenVibe', body: event.data ? event.data.text() : '' }; }
+ const title = data.title || 'OpenVibe';
+ const options = {
+ body: data.body || '',
+ icon: data.icon || 'https://openvibe.network/assets/logo-192.png',
+ badge: data.badge || 'https://openvibe.network/assets/logo-72.png',
+ tag: data.tag || 'openvibe',
+ renotify: !!data.renotify,
+ data: { url: data.url || 'https://openvibe.network/notifications', id: data.id || null },
+ requireInteraction: false,
+ };
+ event.waitUntil(self.registration.showNotification(title, options));
+});
+
+self.addEventListener('notificationclick', (event) => {
+ event.notification.close();
+ const url = (event.notification.data && event.notification.data.url) || 'https://openvibe.network/notifications';
+ event.waitUntil((async () => {
+ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ let target = null;
+ try { target = new URL(url); } catch { /* */ }
+ for (const c of all) {
+ try {
+ if (target && new URL(c.url).origin === target.origin && 'focus' in c) {
+ await c.focus();
+ if ('navigate' in c) { try { await c.navigate(url); } catch { /* cross-origin navigate blocked */ } }
+ return;
+ }
+ } catch { /* */ }
+ }
+ await self.clients.openWindow(url);
+ })());
+});
@@ -329,9 +329,18 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -329,9 +329,18 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<!-- Notifications Section -->
<!-- Notifications Section -->
<div class="section" id="sec-notifications">
<div class="section" id="sec-notifications">
+ <div class="card" id="notif-verify-card" style="display:none">
+ <h3><span class="icon"><i class="fa-solid fa-envelope-circle-check"></i></span> Email alerts</h3>
+ <div id="notif-verify-body" style="font-size:13px;line-height:1.5"></div>
+ </div>
<div class="card">
<div class="card">
+ <h3><span class="icon"><i class="fa-solid fa-inbox"></i></span> Inbox</h3>
+ <p style="margin-bottom:12px;font-size:13px">Everything from every OpenVibe site, in one place. Search, filter, and scroll — it's the same inbox behind the bell on Live, Tools and Games. Turn on <strong>push</strong> to get alerts on this device even when no OpenVibe tab is open.</p>
+ <div id="notif-inbox"></div>
+ </div>
+ <div class="card" id="prefs">
<h3><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Preferences</h3>
<h3><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Preferences</h3>
- <p style="margin-bottom:16px">Control which notifications you receive and how they are delivered, including optional email alerts by category.</p>
+ <p style="margin-bottom:16px">Control which notifications you receive and how they are delivered. <strong>Streams</strong> emails you when a streamer you follow goes live (on by default, once your email is verified); every other category is email-off unless you turn it on.</p>
<div class="notif-grid" id="notif-prefs">
<div class="notif-grid" id="notif-prefs">
<div style="font-weight:700;font-size:11px;color:var(--text-muted)">Category</div>
<div style="font-weight:700;font-size:11px;color:var(--text-muted)">Category</div>
<div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center">Enabled</div>
<div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center">Enabled</div>
@@ -764,7 +773,9 @@ async function resetCustomColors(btn) {
@@ -764,7 +773,9 @@ async function resetCustomColors(btn) {
}
}
function openInitialSection() {
function openInitialSection() {
- const routeSection = window.location.pathname === '/themes' ? 'themes' : window.location.hash.replace(/^#/, '') || 'profile';
+ const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/profile': 'profile' }[window.location.pathname];
+ const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
+ if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
if (isAnonSession() && ['notifications','linked','security'].includes(routeSection)) {
if (isAnonSession() && ['notifications','linked','security'].includes(routeSection)) {
return showSection('anon');
return showSection('anon');
}
}
@@ -855,15 +866,17 @@ async function loadUser() {
@@ -855,15 +866,17 @@ async function loadUser() {
loadLinked();
loadLinked();
loadSessions();
loadSessions();
loadAnonInfo();
loadAnonInfo();
+ loadEmailStatus();
// Init navbar
// Init navbar
OpenVibeNavbar.init({ service: 'network', token: authToken, user: currentUser });
OpenVibeNavbar.init({ service: 'network', token: authToken, user: currentUser });
const bellMount = OpenVibeNavbar.getBellMount();
const bellMount = OpenVibeNavbar.getBellMount();
if (bellMount && window.OpenVibeNotifications && authToken) {
if (bellMount && window.OpenVibeNotifications && authToken) {
- OpenVibeNotifications.init({ token: authToken, apiBase: API });
+ OpenVibeNotifications.init({ token: authToken, apiBase: API, swPath: '/openvibe-sw.js' });
const bell = OpenVibeNotifications.createBell();
const bell = OpenVibeNotifications.createBell();
bellMount.replaceChildren(bell);
bellMount.replaceChildren(bell);
}
}
+ renderNotifInbox();
}
}
} catch (err) {
} catch (err) {
console.error(err);
console.error(err);
@@ -1002,6 +1015,64 @@ function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAcco
@@ -1002,6 +1015,64 @@ function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAcco
function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }
function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }
function signOutAll() { OpenVibeAccountSwitcher.logoutAll(); }
function signOutAll() { OpenVibeAccountSwitcher.logoutAll(); }
+// Small helpers (my.html has no global toast/esc of its own).
+if (typeof window.esc !== 'function') window.esc = (v) => String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
+if (typeof window.toast !== 'function') window.toast = (msg, type = 'info') => {
+ let c = document.getElementById('my-toasts');
+ if (!c) { c = document.createElement('div'); c.id = 'my-toasts'; c.style.cssText = 'position:fixed;top:78px;right:16px;z-index:100001;display:flex;flex-direction:column;gap:8px;max-width:360px'; document.body.appendChild(c); }
+ const t = document.createElement('div');
+ t.style.cssText = `padding:10px 14px;border-radius:10px;font-size:13px;color:#fff;box-shadow:0 8px 24px rgba(0,0,0,.35);background:${type === 'error' ? '#dc2626' : type === 'success' ? '#16a34a' : '#4c4c5e'}`;
+ t.textContent = msg; c.appendChild(t);
+ setTimeout(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 4500);
+};
+
+// ── Email verification status (gates opt-in email like go-live alerts) ──
+async function loadEmailStatus() {
+ if (isAnonSession()) return;
+ const card = document.getElementById('notif-verify-card'), body = document.getElementById('notif-verify-body');
+ if (!card || !body) return;
+ try {
+ const st = await apiFetch('/api/auth/email/status');
+ card.style.display = '';
+ if (!st.email) {
+ body.innerHTML = `<p>No email address on your account, so you can't receive go-live emails. Add one in <a href="#" onclick="showSection('profile');return false">Profile</a> — we'll send a confirmation link.</p>`;
+ } else if (st.bounced) {
+ body.innerHTML = `<p><span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-triangle-exclamation"></i> Mail to <strong>${esc(st.email)}</strong> is bouncing</span>${st.bounce_reason ? ` (${esc(st.bounce_reason)})` : ''}. Emails are paused until you update the address in <a href="#" onclick="showSection('profile');return false">Profile</a> and confirm it.</p>`;
+ } else if (st.verified) {
+ body.innerHTML = `<p><span style="color:var(--success,#22c55e)"><i class="fa-solid fa-circle-check"></i> <strong>${esc(st.email)}</strong> is verified.</span> You'll get an email when a streamer you follow goes live (turn this off under Streams → Email below).</p>`;
+ } else {
+ body.innerHTML = `<p><i class="fa-solid fa-envelope"></i> <strong>${esc(st.email)}</strong> isn't confirmed yet — go-live and other opt-in emails stay off until it is.</p>
+ <button class="btn btn-primary" id="notif-verify-btn" onclick="resendVerification(this)" ${st.can_resend && st.email_enabled ? '' : 'disabled'}><i class="fa-solid fa-paper-plane"></i> ${st.email_enabled ? 'Send verification email' : 'Email delivery not configured'}</button>
+ ${!st.can_resend && st.email_enabled ? '<div class="muted" style="font-size:12px;margin-top:6px">A link was sent moments ago — check your inbox and spam folder.</div>' : ''}`;
+ }
+ } catch (e) { card.style.display = 'none'; }
+}
+async function resendVerification(btn) {
+ if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }
+ try { const r = await apiFetch('/api/auth/email/send-verification', { method: 'POST' }); toast(r.message || 'Sent', 'success'); }
+ catch (e) { toast(e.message, 'error'); }
+ loadEmailStatus();
+}
+async function handleVerifyEmailToken() {
+ const token = new URLSearchParams(window.location.search).get('token');
+ if (!token) return;
+ history.replaceState(null, '', '/notifications');
+ try {
+ const r = await fetchWithTimeout(API + '/api/auth/email/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) });
+ const data = await r.json().catch(() => ({}));
+ if (r.ok) toast(`${data.email || 'Your email'} is verified — go-live alerts will reach your inbox.`, 'success');
+ else toast(data.error || 'Verification failed', 'error');
+ } catch { toast('Verification failed — try again from your account page.', 'error'); }
+ loadEmailStatus();
+}
+let _inboxRendered = false;
+function renderNotifInbox() {
+ if (_inboxRendered || isAnonSession() || !window.OpenVibeNotifications) return;
+ const el = document.getElementById('notif-inbox'); if (!el) return;
+ _inboxRendered = true;
+ OpenVibeNotifications.renderInbox(el, { tab: 'all' });
+}
+
// Notification preferences
// Notification preferences
const CATEGORIES = ['social','chat','game','stream','economy','achievement','moderation','system','service','admin'];
const CATEGORIES = ['social','chat','game','stream','economy','achievement','moderation','system','service','admin'];
@@ -1015,9 +1086,13 @@ async function loadNotifPrefs() {
@@ -1015,9 +1086,13 @@ async function loadNotifPrefs() {
// Clear existing rows
// Clear existing rows
grid.querySelectorAll('.pref-row').forEach(r => r.remove());
grid.querySelectorAll('.pref-row').forEach(r => r.remove());
for (const cat of CATEGORIES) {
for (const cat of CATEGORIES) {
- const p = prefsMap[cat] || { enabled: 1, sound: 1, toasts: 1, email: 0 };
+ const p = prefsMap[cat] || { enabled: 1, sound: 1, toasts: 1, email: null };
+ // email NULL = no explicit choice: go-live (stream) alerts email by default.
+ const emailOn = (p.email === null || p.email === undefined) ? cat === 'stream' : !!p.email;
+ p.email = emailOn ? 1 : 0;
+ const label = { social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams (go-live)', economy: 'Economy', achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements' }[cat] || cat;
const html = `
const html = `
- <div class="cat pref-row">${cat}</div>
+ <div class="cat pref-row" title="${cat}">${label}</div>
<button class="toggle pref-row ${p.enabled ? 'on' : 'off'}" data-cat="${cat}" data-field="enabled" onclick="togglePref(this)"></button>
<button class="toggle pref-row ${p.enabled ? 'on' : 'off'}" data-cat="${cat}" data-field="enabled" onclick="togglePref(this)"></button>
<button class="toggle pref-row ${p.sound ? 'on' : 'off'}" data-cat="${cat}" data-field="sound" onclick="togglePref(this)"></button>
<button class="toggle pref-row ${p.sound ? 'on' : 'off'}" data-cat="${cat}" data-field="sound" onclick="togglePref(this)"></button>
<button class="toggle pref-row col-toast ${p.toasts ? 'on' : 'off'}" data-cat="${cat}" data-field="toasts" onclick="togglePref(this)"></button>
<button class="toggle pref-row col-toast ${p.toasts ? 'on' : 'off'}" data-cat="${cat}" data-field="toasts" onclick="togglePref(this)"></button>
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// Email verification — proves the user controls the address before OpenVibe will send
+// opt-in mail (go-live alerts etc.) to it. Tokens are random, hashed at rest, single-use,
+// bound to the exact address they were issued for, and rate-limited per user + globally
+// so the endpoint can't be used to spray a stranger's inbox or burn the email budget.
+// ═══════════════════════════════════════════════════════════════
+const express = require('express');
+const crypto = require('crypto');
+
+const TOKEN_TTL_MIN = 60;
+
+function hashToken(t) { return crypto.createHash('sha256').update(String(t)).digest('hex'); }
+
+function baseUrl(req) {
+ try { return require('./reset-tokens').buildBaseUrl(req); } catch { /* */ }
+ return 'https://openvibe.network';
+}
+
+/**
+ * Issue + send a verification email. Returns { ok, reason }.
+ * reasons: no-email, already-verified, email-disabled, rate-limited, cap
+ */
+async function sendVerification(req, user, { force = false } = {}) {
+ const db = req.app.locals.db;
+ const emailService = req.app.locals.emailService;
+ if (!user?.email) return { ok: false, reason: 'no-email' };
+ if (user.email_verified && !force) return { ok: false, reason: 'already-verified' };
+ if (!emailService?.isEnabled) return { ok: false, reason: 'email-disabled' };
+
+ // Per-user: 1 per 3 minutes, N per day (site_settings email_verify_user_daily_cap, default 6).
+ const recent = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(user.id)?.c || 0;
+ if (recent > 0) return { ok: false, reason: 'rate-limited' };
+ const dayCap = parseInt(db.getSetting('email_verify_user_daily_cap'), 10) || 6;
+ const today = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-1 day')").get(user.id)?.c || 0;
+ if (today >= dayCap) return { ok: false, reason: 'cap' };
+ // Same address hammered from many accounts (abuse) — 5/day per address.
+ const perAddr = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE LOWER(email) = LOWER(?) AND created_at > datetime('now','-1 day')").get(user.email)?.c || 0;
+ if (perAddr >= 5) return { ok: false, reason: 'cap' };
+ // A bouncing address gets no more mail until the user changes it.
+ if (user.email_bounced_at) return { ok: false, reason: 'bounced' };
+
+ const raw = crypto.randomBytes(32).toString('base64url');
+ db.prepare(`INSERT INTO email_verification_tokens (token_hash, user_id, email, expires_at) VALUES (?, ?, ?, datetime('now', '+${TOKEN_TTL_MIN} minutes'))`)
+ .run(hashToken(raw), user.id, user.email);
+ const verifyUrl = `${baseUrl(req)}/verify-email?token=${encodeURIComponent(raw)}`;
+ const sent = await emailService.sendVerificationEmail({ to: user.email, username: user.display_name || user.username, verifyUrl, expiresMinutes: TOKEN_TTL_MIN });
+ return sent ? { ok: true } : { ok: false, reason: 'send-failed' };
+}
+
+/** Consume a token. Returns { ok, error? , user? }. */
+function consumeToken(db, raw) {
+ if (!raw) return { ok: false, error: 'Missing token' };
+ const row = db.prepare("SELECT * FROM email_verification_tokens WHERE token_hash = ?").get(hashToken(raw));
+ if (!row) return { ok: false, error: 'This verification link is invalid.' };
+ if (row.used_at) return { ok: false, error: 'This verification link was already used.' };
+ if (new Date(String(row.expires_at).replace(' ', 'T') + 'Z').getTime() < Date.now()) return { ok: false, error: 'This verification link has expired — request a new one from your account page.' };
+ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
+ if (!user) return { ok: false, error: 'Account not found.' };
+ if (!user.email || user.email.toLowerCase() !== String(row.email).toLowerCase()) {
+ return { ok: false, error: 'Your email address changed since this link was sent — request a new one.' };
+ }
+ const tx = db.transaction(() => {
+ db.prepare("UPDATE email_verification_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?").run(row.token_hash);
+ db.prepare("UPDATE users SET email_verified = 1, email_verified_at = CURRENT_TIMESTAMP, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?").run(user.id);
+ });
+ tx();
+ return { ok: true, user };
+}
+
+/** Called when a user sets/changes their address: unverify + (best-effort) send a new link. */
+function onEmailChanged(req, userId) {
+ const db = req.app.locals.db;
+ db.prepare('UPDATE users SET email_verified = 0, email_verified_at = NULL, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?').run(userId);
+ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
+ if (user?.email) sendVerification(req, user).catch(() => {});
+}
+
+function routes(requireAuth) {
+ const router = express.Router();
+
+ // Status for the account page: { email, verified, bounced, can_resend }
+ router.get('/email/status', requireAuth, (req, res) => {
+ const db = req.app.locals.db;
+ const u = db.prepare('SELECT email, email_verified, email_verified_at, email_bounced_at, email_bounce_reason FROM users WHERE id = ?').get(req.user.id) || {};
+ const recent = db.prepare("SELECT MAX(created_at) AS t FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(req.user.id)?.t || null;
+ res.json({
+ ok: true,
+ email: u.email || null,
+ verified: !!u.email_verified,
+ verified_at: u.email_verified_at || null,
+ bounced: !!u.email_bounced_at,
+ bounce_reason: u.email_bounce_reason || null,
+ email_enabled: !!req.app.locals.emailService?.isEnabled,
+ can_resend: !!u.email && !u.email_verified && !recent,
+ });
+ });
+
+ router.post('/email/send-verification', requireAuth, async (req, res) => {
+ try {
+ const db = req.app.locals.db;
+ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
+ const r = await sendVerification(req, user);
+ if (r.ok) return res.json({ ok: true, message: `Verification email sent to ${user.email}. Check your inbox (and spam).` });
+ const msg = {
+ 'no-email': 'Add an email address to your profile first.',
+ 'already-verified': 'Your email is already verified.',
+ 'email-disabled': 'Email delivery is not configured on this site yet.',
+ 'rate-limited': 'A verification email was sent moments ago — give it a few minutes.',
+ 'cap': 'Too many verification emails today. Try again tomorrow.',
+ 'bounced': 'That address bounced. Update your email address, then verify the new one.',
+ 'send-failed': 'The email could not be sent. Try again later.',
+ }[r.reason] || 'Could not send verification email.';
+ res.status(r.reason === 'rate-limited' || r.reason === 'cap' ? 429 : 400).json({ ok: false, error: msg, reason: r.reason });
+ } catch (err) {
+ console.error('[EmailVerify] send error:', err);
+ res.status(500).json({ ok: false, error: 'Failed to send verification email' });
+ }
+ });
+
+ // JSON verify (used by the /verify-email page)
+ router.post('/email/verify', (req, res) => {
+ const db = req.app.locals.db;
+ const r = consumeToken(db, String(req.body?.token || ''));
+ if (!r.ok) return res.status(400).json({ ok: false, error: r.error });
+ try {
+ req.app.locals.notificationService?.create({ user_id: r.user.id, type: 'EMAIL_VERIFIED', title: 'Email verified', message: `${r.user.email} is confirmed. Go-live alerts from streamers you follow will now reach your inbox.`, service: 'network', url: 'https://openvibe.network/notifications' });
+ } catch { /* */ }
+ res.json({ ok: true, email: r.user.email });
+ });
+
+ return router;
+}
+
+module.exports = { routes, sendVerification, consumeToken, onEmailChanged };
@@ -182,6 +182,9 @@ router.post('/register', (req, res) => {
@@ -182,6 +182,9 @@ router.post('/register', (req, res) => {
console.log(`[Auth] New user registered: ${username} (id: ${user.id})${reserved ? ' [verification key redeemed]' : ''}`);
console.log(`[Auth] New user registered: ${username} (id: ${user.id})${reserved ? ' [verification key redeemed]' : ''}`);
+ // Confirm the address before it can receive opt-in mail (best-effort, rate-limited).
+ if (user.email) { try { require('./email-verify').sendVerification(req, user).catch(() => {}); } catch { /* */ } }
+
// Send welcome notification
// Send welcome notification
try {
try {
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
@@ -412,14 +415,28 @@ router.put('/profile', requireAuth, (req, res) => {
@@ -412,14 +415,28 @@ router.put('/profile', requireAuth, (req, res) => {
}
}
if (bio !== undefined) { updates.push('bio = ?'); params.push(bio.slice(0, 500)); }
if (bio !== undefined) { updates.push('bio = ?'); params.push(bio.slice(0, 500)); }
if (avatar_url !== undefined) { updates.push('avatar_url = ?'); params.push(avatar_url); }
if (avatar_url !== undefined) { updates.push('avatar_url = ?'); params.push(avatar_url); }
- if (email !== undefined) { updates.push('email = ?'); params.push(email || null); }
+ let emailChanged = false;
+ if (email !== undefined) {
+ const next = (email || '').trim() || null;
+ if (next && !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(next)) return res.status(400).json({ error: 'That email address does not look valid' });
+ if (next && next.length > 254) return res.status(400).json({ error: 'Email address too long' });
+ emailChanged = (next || '').toLowerCase() !== String(req.user.email || '').toLowerCase();
+ updates.push('email = ?'); params.push(next);
+ }
if (profile_color !== undefined) { updates.push('profile_color = ?'); params.push(profile_color); }
if (profile_color !== undefined) { updates.push('profile_color = ?'); params.push(profile_color); }
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
updates.push('updated_at = CURRENT_TIMESTAMP');
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(req.user.id);
params.push(req.user.id);
- db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
+ try {
+ db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
+ } catch (e) {
+ if (/UNIQUE/.test(e.message)) return res.status(409).json({ error: 'That email address is already used by another account' });
+ throw e;
+ }
+ // A new address is unverified until confirmed; kick off the confirmation email.
+ if (emailChanged) { try { require('./email-verify').onEmailChanged(req, req.user.id); } catch { /* */ } }
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
res.json({ user: sanitizeUser(user) });
res.json({ user: sanitizeUser(user) });
@@ -481,6 +481,11 @@ function initDb(dbPath) {
@@ -481,6 +481,11 @@ function initDb(dbPath) {
{ table: 'users', column: 'name_effect', sql: "ALTER TABLE users ADD COLUMN name_effect TEXT" },
{ table: 'users', column: 'name_effect', sql: "ALTER TABLE users ADD COLUMN name_effect TEXT" },
{ table: 'users', column: 'particle_effect', sql: "ALTER TABLE users ADD COLUMN particle_effect TEXT" },
{ table: 'users', column: 'particle_effect', sql: "ALTER TABLE users ADD COLUMN particle_effect TEXT" },
{ table: 'anon_users', column: 'ip', sql: "ALTER TABLE anon_users ADD COLUMN ip TEXT" },
{ table: 'anon_users', column: 'ip', sql: "ALTER TABLE anon_users ADD COLUMN ip TEXT" },
+ // Email deliverability state — verification gates opt-in mail, bounces suppress it.
+ { table: 'users', column: 'email_verified', sql: "ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0" },
+ { table: 'users', column: 'email_verified_at', sql: "ALTER TABLE users ADD COLUMN email_verified_at DATETIME" },
+ { table: 'users', column: 'email_bounced_at', sql: "ALTER TABLE users ADD COLUMN email_bounced_at DATETIME" },
+ { table: 'users', column: 'email_bounce_reason', sql: "ALTER TABLE users ADD COLUMN email_bounce_reason TEXT" },
];
];
for (const m of migrations) {
for (const m of migrations) {
const cols = db.prepare(`PRAGMA table_info(${m.table})`).all();
const cols = db.prepare(`PRAGMA table_info(${m.table})`).all();
@@ -490,6 +495,36 @@ function initDb(dbPath) {
@@ -490,6 +495,36 @@ function initDb(dbPath) {
}
}
}
}
+ // ── Email verification tokens + preference default semantics ──
+ try {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS email_verification_tokens (
+ token_hash TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ email TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ expires_at DATETIME NOT NULL,
+ used_at DATETIME,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+ CREATE INDEX IF NOT EXISTS idx_email_verify_user ON email_verification_tokens(user_id, created_at DESC);
+ `);
+ // notification_preferences.email: 0 used to be the implicit default written whenever a
+ // user toggled anything else in the row. It now means an EXPLICIT "no email"; NULL is
+ // "no choice → defaults apply" (go-live alerts email by default). One-time reset.
+ const flagged = db.prepare("SELECT value FROM site_settings WHERE key = 'migr_pref_email_null'").get();
+ if (!flagged) {
+ const n = db.prepare('UPDATE notification_preferences SET email = NULL WHERE email = 0').run().changes;
+ db.prepare("INSERT OR REPLACE INTO site_settings (key, value, type) VALUES ('migr_pref_email_null', '1', 'boolean')").run();
+ if (n) console.log(`[DB] notification_preferences: ${n} email=0 rows reset to default (NULL)`);
+ }
+ const seed = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
+ seed.run('email_user_daily_cap', '30', 'number');
+ seed.run('email_daily_cap', '2000', 'number');
+ seed.run('email_verify_user_daily_cap', '6', 'number');
+ seed.run('resend_webhook_secret', '', 'string');
+ } catch (e) { console.warn('[DB] email verification migration:', e.message); }
+
// ── Migration: one linked account per (user, service) ────────
// ── Migration: one linked account per (user, service) ────────
// The link-account upsert uses ON CONFLICT(user_id, service), which needs a
// The link-account upsert uses ON CONFLICT(user_id, service), which needs a
// matching unique index (the table only declared UNIQUE(service, service_user_id)).
// matching unique index (the table only declared UNIQUE(service, service_user_id)).
@@ -135,6 +135,9 @@ app.use(helmet({
@@ -135,6 +135,9 @@ app.use(helmet({
crossOriginEmbedderPolicy: false,
crossOriginEmbedderPolicy: false,
}));
}));
app.use(cookieParser());
app.use(cookieParser());
+// Provider webhooks (Resend bounces/complaints) — mounted BEFORE the JSON body parser so
+// the route sees the raw bytes it must verify the Svix signature over.
+app.use('/api/webhooks', require('./notifications/resend-webhook')());
app.use(express.json({ limit: '1mb' }));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(express.urlencoded({ extended: true }));
@@ -392,6 +395,9 @@ app.get('/api/brand', (_req, res) => res.json(BRAND));
@@ -392,6 +395,9 @@ app.get('/api/brand', (_req, res) => res.json(BRAND));
// Auth routes (SSO provider)
// Auth routes (SSO provider)
app.use('/api/auth', authRoutes);
app.use('/api/auth', authRoutes);
+// Email verification (status / resend / consume)
+app.use('/api/auth', require('./auth/email-verify').routes(requireAuth));
+
// Discord account linking (OAuth2 flow)
// Discord account linking (OAuth2 flow)
app.use('/api/auth/discord', requireAuth, require('./auth/discord-link'));
app.use('/api/auth/discord', requireAuth, require('./auth/discord-link'));
@@ -528,7 +534,7 @@ app.get(['/', '/index.html'], (req, res) => {
@@ -528,7 +534,7 @@ app.get(['/', '/index.html'], (req, res) => {
// Account hub (my.html) — the apex hosts the account hub under /my
// Account hub (my.html) — the apex hosts the account hub under /my
// plus its client-routed sections.
// plus its client-routed sections.
-app.get(['/my', '/my.html', '/themes', '/notifications', '/linked', '/security', '/profile', '/billing', '/preferences'], (req, res) => {
+app.get(['/my', '/my.html', '/themes', '/notifications', '/linked', '/security', '/profile', '/billing', '/preferences', '/verify-email'], (req, res) => {
if (req.path === '/my.html') return redirectWithoutHtml(req, res, '/my');
if (req.path === '/my.html') return redirectWithoutHtml(req, res, '/my');
return sendMyAccountApp(res);
return sendMyAccountApp(res);
});
});
@@ -541,6 +547,15 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
@@ -541,6 +547,15 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
},
}));
}));
+// Web-push service worker: must be served from THIS origin (scope /), so each site
+// exposes the shared worker at /openvibe-sw.js rather than loading it from Network.
+app.get('/openvibe-sw.js', (req, res) => {
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
+ res.setHeader('Service-Worker-Allowed', '/');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.sendFile(path.resolve(__dirname, '..', 'packages', 'openvibe-shared', 'openvibe-sw.js'));
+});
+
// Serve openvibe-shared client-side libs (notification-ui.js, navbar.js, etc.)
// Serve openvibe-shared client-side libs (notification-ui.js, navbar.js, etc.)
// Accessible at https://openvibe.network/shared/notification-ui.js etc.
// Accessible at https://openvibe.network/shared/notification-ui.js etc.
const sharedPath = path.resolve(__dirname, '..', 'packages', 'openvibe-shared');
const sharedPath = path.resolve(__dirname, '..', 'packages', 'openvibe-shared');
@@ -47,7 +47,7 @@ router.post('/verify-token', (req, res) => {
@@ -47,7 +47,7 @@ router.post('/verify-token', (req, res) => {
issuer: config.jwt.issuer
issuer: config.jwt.issuer
});
});
const db = getDb(req);
const db = getDb(req);
- const user = db.prepare('SELECT id, username, display_name, role, avatar_url, color FROM users WHERE id = ?').get(decoded.sub || decoded.id);
+ const user = db.prepare('SELECT id, username, display_name, role, avatar_url, profile_color AS color FROM users WHERE id = ?').get(decoded.sub || decoded.id);
res.json({ valid: true, decoded, user: user || null });
res.json({ valid: true, decoded, user: user || null });
} catch (err) {
} catch (err) {
res.json({ valid: false, error: err.message });
res.json({ valid: false, error: err.message });
@@ -58,7 +58,7 @@ router.post('/verify-token', (req, res) => {
@@ -58,7 +58,7 @@ router.post('/verify-token', (req, res) => {
router.get('/users/:id', (req, res) => {
router.get('/users/:id', (req, res) => {
const db = getDb(req);
const db = getDb(req);
const user = db.prepare(`
const user = db.prepare(`
- SELECT id, username, display_name, role, avatar_url, color, bio, created_at
+ SELECT id, username, display_name, role, avatar_url, profile_color AS color, bio, created_at
FROM users WHERE id = ?
FROM users WHERE id = ?
`).get(req.params.id);
`).get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
if (!user) return res.status(404).json({ error: 'User not found' });
@@ -69,7 +69,7 @@ router.get('/users/:id', (req, res) => {
@@ -69,7 +69,7 @@ router.get('/users/:id', (req, res) => {
router.get('/users/by-username/:username', (req, res) => {
router.get('/users/by-username/:username', (req, res) => {
const db = getDb(req);
const db = getDb(req);
const user = db.prepare(`
const user = db.prepare(`
- SELECT id, username, display_name, role, avatar_url, color, bio, created_at
+ SELECT id, username, display_name, role, avatar_url, profile_color AS color, bio, created_at
FROM users WHERE username = ?
FROM users WHERE username = ?
`).get(req.params.username.toLowerCase());
`).get(req.params.username.toLowerCase());
if (!user) return res.status(404).json({ error: 'User not found' });
if (!user) return res.status(404).json({ error: 'User not found' });
@@ -85,7 +85,7 @@ router.post('/users/bulk', (req, res) => {
@@ -85,7 +85,7 @@ router.post('/users/bulk', (req, res) => {
const db = getDb(req);
const db = getDb(req);
const placeholders = ids.map(() => '?').join(',');
const placeholders = ids.map(() => '?').join(',');
const users = db.prepare(`
const users = db.prepare(`
- SELECT id, username, display_name, role, avatar_url, color
+ SELECT id, username, display_name, role, avatar_url, profile_color AS color
FROM users WHERE id IN (${placeholders})
FROM users WHERE id IN (${placeholders})
`).all(...ids);
`).all(...ids);
res.json({ users });
res.json({ users });
@@ -220,7 +220,7 @@ router.post('/coins/transfer', (req, res) => {
@@ -220,7 +220,7 @@ router.post('/coins/transfer', (req, res) => {
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
router.post('/events/stream-live', async (req, res) => {
router.post('/events/stream-live', async (req, res) => {
- const { streamer, stream } = req.body;
+ const { streamer, stream, follower_network_ids } = req.body;
if (!streamer?.username || !stream?.id) {
if (!streamer?.username || !stream?.id) {
return res.status(400).json({ error: 'streamer and stream objects required' });
return res.status(400).json({ error: 'streamer and stream objects required' });
}
}
@@ -263,11 +263,23 @@ router.post('/events/stream-live', async (req, res) => {
@@ -263,11 +263,23 @@ router.post('/events/stream-live', async (req, res) => {
},
},
};
};
- // Find followers of this streamer in openvibe.network
- const followerRows = db.prepare(
- 'SELECT follower_id FROM follows WHERE followed_id = ?'
- ).all(streamer.id);
- const followerIds = followerRows.map(r => r.follower_id);
+ // The streaming follow graph lives in OpenVibe.Live, keyed by LIVE user ids; Live
+ // translates its followers to NETWORK ids via linked_accounts and sends them here.
+ // (Network's own `follows` table is a separate, tiny social graph and `streamer.id`
+ // is a Live id — the old lookup below silently addressed the wrong accounts.)
+ let followerIds = Array.isArray(follower_network_ids)
+ ? follower_network_ids.map(Number).filter(n => Number.isInteger(n) && n > 0).slice(0, 20000)
+ : [];
+ if (!Array.isArray(follower_network_ids)) {
+ // Legacy caller: fall back to Network's graph, resolving the streamer's NETWORK id first.
+ const link = streamer.id ? db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;
+ if (link) followerIds = db.prepare('SELECT follower_id FROM follows WHERE followed_id = ?').all(link.user_id).map(r => r.follower_id);
+ }
+ // sender_id must be the streamer's NETWORK id for dedupe + "who is this" lookups.
+ try {
+ const link = streamer.id ? db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;
+ if (link) notifData.sender_id = link.user_id;
+ } catch { /* keep Live id */ }
// Find users who opted into "all live" notifications
// Find users who opted into "all live" notifications
const allLiveRows = db.prepare(
const allLiveRows = db.prepare(
@@ -220,6 +220,25 @@ class EmailService {
@@ -220,6 +220,25 @@ class EmailService {
return sent;
return sent;
}
}
+ /** Email-address verification link. */
+ async sendVerificationEmail({ to, username, verifyUrl, expiresMinutes = 60 }) {
+ const u = this._escapeHtml(username || 'there');
+ const url = this._escapeHtml(verifyUrl);
+ const htmlBody = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
+<body style="margin:0;padding:0;background:#1a1a24;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
+<div style="max-width:560px;margin:0 auto;padding:24px"><div style="background:#22222c;border-radius:12px;border:1px solid #333340;overflow:hidden">
+<div style="background:linear-gradient(135deg,#2a2a38,#1a1a24);padding:24px;text-align:center;border-bottom:2px solid #8b5cf6"><div style="font-size:32px">✉️</div><h1 style="margin:0;color:#e0e0e0;font-size:18px">Confirm your email</h1></div>
+<div style="padding:24px;color:#b0b0b8;font-size:14px;line-height:1.6">
+<div style="color:#e0e0e0;font-weight:600;margin-bottom:12px">Hey ${u},</div>
+<p>Confirm this address to receive OpenVibe email alerts — like when a streamer you follow goes live.</p>
+<a href="${url}" style="display:inline-block;background:#8b5cf6;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:700;margin:16px 0">Verify email →</a>
+<p style="color:#707080;font-size:12px">This link expires in ${expiresMinutes} minutes. If you didn't add this address to an OpenVibe account, ignore this email — nothing will be sent to you again.</p>
+<p style="color:#707080;font-size:12px;word-break:break-all">${url}</p>
+</div><div style="padding:16px 24px;text-align:center;color:#505060;font-size:11px;border-top:1px solid #333340"><a href="https://openvibe.network" style="color:#8b5cf6;text-decoration:none">openvibe.network</a></div></div></div></body></html>`;
+ const textBody = [`Hey ${username || 'there'},`, '', 'Confirm this address to receive OpenVibe email alerts.', `Verify: ${verifyUrl}`, '', `This link expires in ${expiresMinutes} minutes. If you didn't add this address to an OpenVibe account, ignore this email.`].join('\n');
+ return this._sendEmail({ to, subject: 'OpenVibe — confirm your email address', htmlBody, textBody, emailType: 'email_verification', metadata: { expiresMinutes } });
+ }
+
async sendPasswordResetEmail({ to, username, resetUrl, expiresMinutes = 60 }) {
async sendPasswordResetEmail({ to, username, resetUrl, expiresMinutes = 60 }) {
const htmlBody = this._buildPasswordResetHtml({ username, resetUrl, expiresMinutes });
const htmlBody = this._buildPasswordResetHtml({ username, resetUrl, expiresMinutes });
const textBody = this._buildPasswordResetText({ username, resetUrl, expiresMinutes });
const textBody = this._buildPasswordResetText({ username, resetUrl, expiresMinutes });
@@ -250,19 +269,31 @@ class EmailService {
@@ -250,19 +269,31 @@ class EmailService {
console.log(`[Email] Processing ${pending.length} pending email(s)...`);
console.log(`[Email] Processing ${pending.length} pending email(s)...`);
+ let skipped = 0;
for (const notif of pending) {
for (const notif of pending) {
if (!notifService.shouldEmail(notif)) {
if (!notifService.shouldEmail(notif)) {
notifService.markEmailed(notif.id);
notifService.markEmailed(notif.id);
continue;
continue;
}
}
+ const why = notifService.emailGuard ? notifService.emailGuard(notif) : null;
+ if (why) {
+ // Caps: leave the row un-emailed only if the cap may lift (it won't for stale/dup).
+ if (why === 'stale' || why === 'dup') notifService.markEmailed(notif.id);
+ skipped++;
+ continue;
+ }
+ const subject = notif.type === 'STREAM_LIVE'
+ ? `🔴 ${notif.sender_name || 'A streamer you follow'} is live${notif.message ? ': ' + String(notif.message).slice(0, 80) : ''}`
+ : `🔥 OpenVibe — ${notif.title}`;
const sent = await this.sendNotificationEmail({
const sent = await this.sendNotificationEmail({
to: notif.email,
to: notif.email,
username: notif.display_name || notif.username,
username: notif.display_name || notif.username,
- subject: `🔥 OpenVibe — ${notif.title}`,
+ subject,
notification: notif,
notification: notif,
});
});
if (sent) notifService.markEmailed(notif.id);
if (sent) notifService.markEmailed(notif.id);
}
}
+ if (skipped) console.log(`[Email] ${skipped} notification email(s) held back by guards (stale/dup/caps)`);
}
}
// ─── Email Templates ───────────────────────────────────────
// ─── Email Templates ───────────────────────────────────────
@@ -294,15 +325,15 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
@@ -294,15 +325,15 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
<body><div class="container"><div class="card">
<body><div class="container"><div class="card">
<div class="header"><div class="flame">🔥</div><h1>OpenVibe</h1></div>
<div class="header"><div class="flame">🔥</div><h1>OpenVibe</h1></div>
<div class="body">
<div class="body">
-<div class="greeting">Hey ${username || 'there'},</div>
+<div class="greeting">Hey ${this._escapeHtml(username || 'there')},</div>
<p>You have an important notification:</p>
<p>You have an important notification:</p>
<div class="notif-box">
<div class="notif-box">
-<div class="icon">${notification.icon || '🔔'}</div>
-<div class="title">${notification.title}</div>
-${notification.message ? `<div class="message">${notification.message}</div>` : ''}
-<div class="meta">${notification.service ? `From ${notification.service} · ` : ''}${notification.priority?.toUpperCase()} priority</div>
+<div class="icon">${this._escapeHtml(notification.icon || '🔔')}</div>
+<div class="title">${this._escapeHtml(notification.title)}</div>
+${notification.message ? `<div class="message">${this._escapeHtml(notification.message)}</div>` : ''}
+<div class="meta">${notification.service ? `From ${this._escapeHtml(notification.service)} · ` : ''}${notification.priority?.toUpperCase()} priority</div>
</div>
</div>
-${notification.url ? `<a class="cta" href="${notification.url}">View Details →</a>` : ''}
+${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}">View Details →</a>` : ''}
</div>
</div>
<div class="footer">
<div class="footer">
<p>You're receiving this because it's a critical notification.</p>
<p>You're receiving this because it's a critical notification.</p>
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
-const { TYPES, PRIORITY, EMAIL_ELIGIBLE_CATEGORIES } = require('openvibe-shared/notifications');
+const { TYPES, PRIORITY, EMAIL_ELIGIBLE_CATEGORIES, EMAIL_DEFAULT_TYPES } = require('openvibe-shared/notifications');
class NotificationService {
class NotificationService {
constructor(db) {
constructor(db) {
@@ -80,12 +80,32 @@ class NotificationService {
@@ -80,12 +80,32 @@ class NotificationService {
this._deletePrefs = db.prepare('DELETE FROM notification_preferences WHERE user_id = ? AND category = ?');
this._deletePrefs = db.prepare('DELETE FROM notification_preferences WHERE user_id = ? AND category = ?');
this._getPendingEmails = db.prepare(`
this._getPendingEmails = db.prepare(`
- SELECT n.*, u.email, u.username, u.display_name FROM notifications n
+ SELECT n.*, u.email, u.username, u.display_name, u.email_verified, u.email_bounced_at FROM notifications n
JOIN users u ON u.id = n.user_id
JOIN users u ON u.id = n.user_id
WHERE n.is_emailed = 0 AND n.is_dismissed = 0
WHERE n.is_emailed = 0 AND n.is_dismissed = 0
AND u.email IS NOT NULL AND u.email != ''
AND u.email IS NOT NULL AND u.email != ''
ORDER BY n.created_at ASC
ORDER BY n.created_at ASC
- LIMIT 100
+ LIMIT 200
+ `);
+ // Dedupe: an unread go-live from the same streamer within the hour means the viewer
+ // already has the alert — reconnects/restarts must not stack ten of them.
+ this._recentFromSender = db.prepare(`
+ SELECT id FROM notifications
+ WHERE user_id = ? AND type = ? AND sender_id = ? AND is_dismissed = 0
+ AND created_at > datetime('now', '-60 minutes')
+ LIMIT 1
+ `);
+ this._recentEmailFromSender = db.prepare(`
+ SELECT 1 FROM email_delivery_log l JOIN notifications n ON n.id = l.notification_id
+ WHERE n.user_id = ? AND n.type = ? AND n.sender_id = ? AND l.status = 'sent'
+ AND l.created_at > datetime('now', '-60 minutes')
+ LIMIT 1
+ `);
+ this._emailsSentToUserToday = db.prepare(`
+ SELECT COUNT(*) AS c FROM email_delivery_log WHERE user_id = ? AND status = 'sent' AND created_at > datetime('now', '-1 day')
+ `);
+ this._emailsSentToday = db.prepare(`
+ SELECT COUNT(*) AS c FROM email_delivery_log WHERE status = 'sent' AND created_at > datetime('now', '-1 day')
`);
`);
this._newestForUser = db.prepare(`
this._newestForUser = db.prepare(`
@@ -113,6 +133,10 @@ class NotificationService {
@@ -113,6 +133,10 @@ class NotificationService {
// Check user's preferences — skip if disabled
// Check user's preferences — skip if disabled
const pref = this._getPrefByCategory.get(data.user_id, category);
const pref = this._getPrefByCategory.get(data.user_id, category);
if (pref && !pref.enabled) return null;
if (pref && !pref.enabled) return null;
+ // Go-live dedupe (see _recentFromSender).
+ if (data.type === 'STREAM_LIVE' && data.sender_id != null) {
+ try { if (this._recentFromSender.get(data.user_id, 'STREAM_LIVE', data.sender_id)) return null; } catch { /* */ }
+ }
const richContent = data.rich_content ? JSON.stringify(data.rich_content) : null;
const richContent = data.rich_content ? JSON.stringify(data.rich_content) : null;
@@ -162,16 +186,31 @@ class NotificationService {
@@ -162,16 +186,31 @@ class NotificationService {
return n;
return n;
}
}
- getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false } = {}) {
- let rows;
- if (category) {
- rows = this._getByCategory.all(userId, category, limit, offset);
- } else if (unreadOnly) {
- rows = this._getUnreadForUser.all(userId, limit, offset);
- } else {
- rows = this._getForUser.all(userId, limit, offset);
+ /**
+ * List notifications with composable filters. Returns { notifications, has_more, total }.
+ * - category + unreadOnly combine (they used to be either/or)
+ * - q: case-insensitive substring over title/message/sender_name
+ * - since: ISO timestamp, only rows created after it (toasts)
+ * Fetches limit+1 so the client knows whether another page exists.
+ */
+ getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false, q = null, since = null, type = null } = {}) {
+ const where = ['user_id = ?', 'is_dismissed = 0'];
+ const params = [userId];
+ if (category) { where.push('category = ?'); params.push(category); }
+ if (type) { where.push('type = ?'); params.push(type); }
+ if (unreadOnly) where.push('is_read = 0');
+ if (since) { where.push('created_at > ?'); params.push(String(since).replace('T', ' ').replace(/Z$/, '').slice(0, 19)); }
+ if (q) {
+ const like = `%${String(q).replace(/[%_\\]/g, c => '\\' + c).slice(0, 80)}%`;
+ where.push("(title LIKE ? ESCAPE '\\' OR message LIKE ? ESCAPE '\\' OR sender_name LIKE ? ESCAPE '\\')");
+ params.push(like, like, like);
}
}
- return rows.map(n => {
+ const sql = `SELECT * FROM notifications WHERE ${where.join(' AND ')} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`;
+ const rows = this.db.prepare(sql).all(...params, limit + 1, offset);
+ const has_more = rows.length > limit;
+ if (has_more) rows.length = limit;
+ const total = this.db.prepare(`SELECT COUNT(*) AS c FROM notifications WHERE ${where.join(' AND ')}`).get(...params)?.c || 0;
+ const notifications = rows.map(n => {
// Defensive: a single corrupt row must never 500 the whole dropdown.
// Defensive: a single corrupt row must never 500 the whole dropdown.
if (n.rich_content) {
if (n.rich_content) {
try { n.rich_content = JSON.parse(n.rich_content); }
try { n.rich_content = JSON.parse(n.rich_content); }
@@ -179,6 +218,16 @@ class NotificationService {
@@ -179,6 +218,16 @@ class NotificationService {
}
}
return n;
return n;
});
});
+ return { notifications, has_more, total };
+ }
+
+ markReadMany(ids, userId) {
+ if (!Array.isArray(ids) || !ids.length) return 0;
+ const stmt = this.db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ? AND is_read = 0');
+ let n = 0;
+ const tx = this.db.transaction(() => { for (const id of ids.slice(0, 500)) n += stmt.run(String(id), userId).changes; });
+ tx();
+ return n;
}
}
getUnreadCount(userId) {
getUnreadCount(userId) {
@@ -234,13 +283,13 @@ class NotificationService {
@@ -234,13 +283,13 @@ class NotificationService {
enabled: 1,
enabled: 1,
sound: 1,
sound: 1,
toasts: 1,
toasts: 1,
- email: 0,
+ email: null, // NULL = no explicit choice → shouldEmail() applies the defaults
};
};
const next = {
const next = {
enabled: updates.enabled !== undefined ? (updates.enabled ? 1 : 0) : current.enabled,
enabled: updates.enabled !== undefined ? (updates.enabled ? 1 : 0) : current.enabled,
sound: updates.sound !== undefined ? (updates.sound ? 1 : 0) : current.sound,
sound: updates.sound !== undefined ? (updates.sound ? 1 : 0) : current.sound,
toasts: updates.toasts !== undefined ? (updates.toasts ? 1 : 0) : current.toasts,
toasts: updates.toasts !== undefined ? (updates.toasts ? 1 : 0) : current.toasts,
- email: updates.email !== undefined ? (updates.email ? 1 : 0) : current.email,
+ email: updates.email !== undefined ? (updates.email === null ? null : (updates.email ? 1 : 0)) : current.email,
};
};
this._upsertPref.run(
this._upsertPref.run(
userId,
userId,
@@ -279,10 +328,39 @@ class NotificationService {
@@ -279,10 +328,39 @@ class NotificationService {
if (pref && !pref.enabled) return false;
if (pref && !pref.enabled) return false;
// LOW priority never emails
// LOW priority never emails
if (notification.priority === PRIORITY.LOW) return false;
if (notification.priority === PRIORITY.LOW) return false;
- // User explicitly opted into email for this category
- if (pref && pref.email) return true;
- // Built-in: CRITICAL + eligible categories always email
- return notification.priority === PRIORITY.CRITICAL && EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);
+ const critical = notification.priority === PRIORITY.CRITICAL && EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);
+ // Deliverability gate: a bouncing address never gets mail; an UNVERIFIED address
+ // only gets critical security/moderation mail (password changed, ban) — never
+ // opt-in style alerts, so nobody can point our sender at a stranger's inbox.
+ if (notification.email_bounced_at) return false;
+ if (!notification.email_verified && !critical) return false;
+ // Explicit per-category choice wins (email column: NULL = no choice made).
+ if (pref && pref.email != null) return !!pref.email;
+ // Defaults: go-live alerts email unless turned off; critical eligible categories email.
+ if (EMAIL_DEFAULT_TYPES.has(notification.type)) return true;
+ return critical;
+ }
+
+ /**
+ * Abuse/cost guards evaluated right before a send. Returns a reason string to skip, or null.
+ * - stale: a go-live alert older than 2h is useless in an inbox
+ * - dup: same streamer already emailed this user within the hour
+ * - user cap / global cap: site_settings email_user_daily_cap (default 30),
+ * email_daily_cap (default 2000)
+ */
+ emailGuard(notification) {
+ try {
+ if (notification.type === 'STREAM_LIVE') {
+ const ageMs = Date.now() - new Date(String(notification.created_at).replace(' ', 'T') + 'Z').getTime();
+ if (ageMs > 2 * 3600 * 1000) return 'stale';
+ if (notification.sender_id != null && this._recentEmailFromSender.get(notification.user_id, 'STREAM_LIVE', notification.sender_id)) return 'dup';
+ }
+ const userCap = parseInt(this.db.getSetting?.('email_user_daily_cap'), 10) || 30;
+ if ((this._emailsSentToUserToday.get(notification.user_id)?.c || 0) >= userCap) return 'user-cap';
+ const globalCap = parseInt(this.db.getSetting?.('email_daily_cap'), 10) || 2000;
+ if ((this._emailsSentToday.get()?.c || 0) >= globalCap) return 'global-cap';
+ } catch { /* guards are best-effort */ }
+ return null;
}
}
// ─── Cleanup ──────────────────────────────────────────────
// ─── Cleanup ──────────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// Resend delivery webhooks (Svix-signed). Bounces and complaints suppress further mail
+// to the address; a hard bounce also clears the verified flag so the user has to
+// re-confirm after fixing it. Configure in Resend: Webhooks → https://openvibe.network/api/webhooks/resend
+// and paste the signing secret (whsec_…) into site_settings.resend_webhook_secret.
+// ═══════════════════════════════════════════════════════════════
+const express = require('express');
+const crypto = require('crypto');
+
+function verifySvix(secret, headers, rawBody) {
+ const id = headers['svix-id'], ts = headers['svix-timestamp'], sig = headers['svix-signature'];
+ if (!id || !ts || !sig) return false;
+ if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5-min replay window
+ const key = Buffer.from(String(secret).replace(/^whsec_/, ''), 'base64');
+ const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
+ return String(sig).split(' ').some(part => {
+ const v = part.split(',')[1];
+ if (!v || v.length !== expected.length) return false;
+ try { return crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)); } catch { return false; }
+ });
+}
+
+module.exports = function resendWebhook() {
+ const router = express.Router();
+ router.post('/resend', express.raw({ type: '*/*', limit: '256kb' }), (req, res) => {
+ const db = req.app.locals.db;
+ const secret = db.getSetting('resend_webhook_secret');
+ const raw = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body || '');
+ if (!secret) { console.warn('[Resend webhook] received but resend_webhook_secret is not configured — ignored'); return res.status(503).json({ ok: false }); }
+ if (!verifySvix(secret, req.headers, raw)) return res.status(401).json({ ok: false, error: 'bad signature' });
+ let evt; try { evt = JSON.parse(raw); } catch { return res.status(400).json({ ok: false }); }
+ const type = String(evt?.type || '');
+ const to = [].concat(evt?.data?.to || []).map(s => String(s).toLowerCase());
+ if (!to.length) return res.json({ ok: true });
+ if (type === 'email.bounced' || type === 'email.complained') {
+ const reason = type === 'email.complained' ? 'complaint' : (evt?.data?.bounce?.message || evt?.data?.bounce?.type || 'bounce');
+ const stmt = db.prepare("UPDATE users SET email_bounced_at = CURRENT_TIMESTAMP, email_bounce_reason = ?, email_verified = CASE WHEN ? = 'complaint' THEN email_verified ELSE 0 END WHERE LOWER(email) = ?");
+ let n = 0;
+ for (const addr of to) n += stmt.run(String(reason).slice(0, 200), type === 'email.complained' ? 'complaint' : 'bounce', addr).changes;
+ console.warn(`[Resend webhook] ${type} for ${to.join(',')} — ${n} account(s) suppressed`);
+ try { db.prepare("INSERT INTO email_delivery_log (email_type, recipient, subject, status, error_message, metadata) VALUES (?, ?, ?, 'failed', ?, ?)").run(`webhook:${type}`, to[0], null, String(reason).slice(0, 200), JSON.stringify({ event: type })); } catch { /* */ }
+ }
+ res.json({ ok: true });
+ });
+ return router;
+};
@@ -19,10 +19,14 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -19,10 +19,14 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const offset = parseInt(req.query.offset) || 0;
const offset = parseInt(req.query.offset) || 0;
const category = req.query.category || null;
const category = req.query.category || null;
- const unreadOnly = req.query.unread_only === '1' || req.query.unread_only === 'true';
-
- const notifications = notificationService.getForUser(userId, { limit, offset, category, unreadOnly });
- res.json({ ok: true, notifications });
+ const type = req.query.type || null;
+ // Accept both spellings — the shared UI shipped `unread=1` for a while.
+ const unreadOnly = ['1', 'true'].includes(String(req.query.unread_only ?? req.query.unread ?? ''));
+ const q = req.query.q ? String(req.query.q).trim() : null;
+ const since = req.query.since ? String(req.query.since) : null;
+
+ const out = notificationService.getForUser(userId, { limit, offset, category, unreadOnly, q, since, type });
+ res.json({ ok: true, ...out, limit, offset });
} catch (err) {
} catch (err) {
console.error('[Notifications] GET / error:', err);
console.error('[Notifications] GET / error:', err);
res.status(500).json({ ok: false, error: 'Failed to fetch notifications' });
res.status(500).json({ ok: false, error: 'Failed to fetch notifications' });
@@ -63,7 +67,19 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -63,7 +67,19 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
router.post('/:id/read', requireAuth, (req, res) => {
router.post('/:id/read', requireAuth, (req, res) => {
try {
try {
const ok = notificationService.markRead(req.params.id, req.user.id);
const ok = notificationService.markRead(req.params.id, req.user.id);
- res.json({ ok });
+ res.json({ ok, unread: notificationService.getUnreadCount(req.user.id) });
+ } catch (err) {
+ res.status(500).json({ ok: false, error: 'Failed to mark read' });
+ }
+ });
+
+ // ─── POST /api/notifications/read-batch ────────────────
+ // Body: { ids: [] } — the inbox marks everything it rendered as seen in one call.
+ router.post('/read-batch', requireAuth, (req, res) => {
+ try {
+ const ids = Array.isArray(req.body?.ids) ? req.body.ids : [];
+ const changed = notificationService.markReadMany(ids, req.user.id);
+ res.json({ ok: true, changed, unread: notificationService.getUnreadCount(req.user.id) });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: 'Failed to mark read' });
res.status(500).json({ ok: false, error: 'Failed to mark read' });
}
}