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

OP OpenVibers committed 27 Aug 2026 · 59e8657a0c · 13 files, +1080 −430


Changes

ddiff --git a/packages/openvibe-shared/notification-ui.js b/packages/openvibe-shared/notification-ui.js
iindex 68c54a55..5ce5f219 100644
---- a/packages/openvibe-shared/notification-ui.js
++++ b/packages/openvibe-shared/notification-ui.js
@@@ -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 = sticky
const 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 / seen
let _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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
++ }
++ 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">&times;</button>
-- `;
--
-- // Progress bar for auto-dismiss
-- const duration = TOAST_DURATION[notification.priority] || 5000;
++ <button class="openvibe-toast-close" aria-label="Dismiss">&times;</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">&times;</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">&times;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-- .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
++ // ── 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; },
};
-- // Export
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;
else root.OpenVibeNotifications = OpenVibeNotifications;
ddiff --git a/packages/openvibe-shared/notifications.js b/packages/openvibe-shared/notifications.js
iindex 252f7710..24e15cc7 100644
---- a/packages/openvibe-shared/notifications.js
++++ b/packages/openvibe-shared/notifications.js
@@@ -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' },
// Economy
COINS_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,
ddiff --git a/packages/openvibe-shared/openvibe-sw.js b/packages/openvibe-shared/openvibe-sw.js
nnew file mode 100644
iindex 00000000..2de4f373
---- /dev/null
++++ b/packages/openvibe-shared/openvibe-sw.js
@@@ -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);
++ })());
++});
ddiff --git a/public/my.html b/public/my.html
iindex ff220819..d3f14c28 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -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 navbar
OpenVibeNavbar.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); loadAcco
function 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
++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
const CATEGORIES = ['social','chat','game','stream','economy','achievement','moderation','system','service','admin'];
@@@ -1015,9 +1086,13 @@ async function loadNotifPrefs() {
// Clear existing rows
grid.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>
ddiff --git a/server/auth/email-verify.js b/server/auth/email-verify.js
nnew file mode 100644
iindex 00000000..8ec4c88d
---- /dev/null
++++ b/server/auth/email-verify.js
@@@ -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 };
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex a54bade7..2e42297f 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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 notification
try {
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) });
ddiff --git a/server/db/database.js b/server/db/database.js
iindex f3044b58..d5f667ce 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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)).
ddiff --git a/server/index.js b/server/index.js
iindex 55eb3be0..4879055a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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');
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex a244af3a..85bcface 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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_at
FROM 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_at
FROM 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 color
FROM 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" notifications
const allLiveRows = db.prepare(
ddiff --git a/server/notifications/email-service.js b/server/notifications/email-service.js
iindex 1019302a..489a453c 100644
---- a/server/notifications/email-service.js
++++ b/server/notifications/email-service.js
@@@ -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>
ddiff --git a/server/notifications/notification-service.js b/server/notifications/notification-service.js
iindex 1271f353..cd245aaf 100644
---- a/server/notifications/notification-service.js
++++ b/server/notifications/notification-service.js
@@@ -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 n
JOIN users u ON u.id = n.user_id
WHERE n.is_emailed = 0 AND n.is_dismissed = 0
AND 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 disabled
const 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 emails
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 ──────────────────────────────────────────────
ddiff --git a/server/notifications/resend-webhook.js b/server/notifications/resend-webhook.js
nnew file mode 100644
iindex 00000000..69095b42
---- /dev/null
++++ b/server/notifications/resend-webhook.js
@@@ -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;
++};
ddiff --git a/server/notifications/routes.js b/server/notifications/routes.js
iindex ec7ea273..c4d36929 100644
---- a/server/notifications/routes.js
++++ b/server/notifications/routes.js
@@@ -19,10 +19,14 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
const 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, requ
router.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' });
}