/** * Auto-mount the SSE notification stream once the app is up AND the user is * authenticated. Each event becomes an in-app toast via Nuxt UI's useToast(). * * This is the primary in-app notification surface β€” independent of the OS * web-push pipeline (sw-notifications.js), which stays as a best-effort * outside-the-app supplement. As long as the browser tab is open the toast * is delivered regardless of macOS Focus mode / notification permissions. * * Auth gate: the stream needs a valid logship_it cookie. Opening it on the * login page (no cookie yet) just yields a 401 + a wasteful 30s retry loop and * noisy console errors. We therefore only connect when logged in, mirroring * the chat plugin (04.chat-socket.client.ts). Login is an SPA navigation * (router.push('/'), no full reload β€” see signin.vue), so a one-time boot check * would never start the stream post-login; we re-evaluate on every navigation * instead, and read document.cookie directly because a useCookie() ref does not * reliably react to the cookie being set server-side via Set-Cookie at login. */ // Tab-title badge: when notifications arrive while the tab is not focused, // mutate document.title to "πŸ”” (N) " so the browser tab visibly // surfaces unread count. Clear on focus / visibility change to visible. // // stripBadge handles the case where the title changes mid-flight (page // navigation, page-set titles) β€” always re-derive the base from the current // title rather than caching once at boot. const BADGE_RE = /^πŸ”” \(\d+\) / const stripBadge = (t: string) => t.replace(BADGE_RE, '') export default defineNuxtPlugin((nuxtApp) => { if (!process.client) return // Capture useToast() now, while we're still in Nuxt context. The toast // callback runs later and has no current Nuxt instance, which makes // useToast() return a no-op (silently β€” no toast renders even though // .add() resolves). Holding the handle from out here avoids that trap. const nuxtToast = useToast() const { start, stop } = useNotificationStream() // Tab-title badge state. Bump on each unseen notification, clear on focus. let unread = 0 const applyBadge = () => { const base = stripBadge(document.title) document.title = unread > 0 ? `πŸ”” (${unread}) ${base}` : base } const clearBadge = () => { if (unread === 0) return unread = 0 applyBadge() } // Both events: visibilitychange catches background-tab β†’ focused-tab, focus // catches alt-tabbing back into the browser window itself. document.addEventListener('visibilitychange', () => { if (!document.hidden) clearBadge() }) window.addEventListener('focus', clearBadge) const onNotification = (data: any) => { const url = data?.url || '/' const title = data?.title || 'Benachrichtigung' const description = (data?.body || '').toString() // The bus payload reuses the web-push payload's `icon` field, which // contains a URL (e.g. '/favicon.ico'). Nuxt UI's toast `icon` prop only // accepts Iconify identifiers (e.g. 'i-heroicons-bell-alert'), so a URL // value triggers a failed icon load. Map anything that looks URL-ish // back to the default iconify name. const rawIcon = data?.icon const icon = (typeof rawIcon === 'string' && rawIcon.startsWith('i-')) ? rawIcon : 'i-heroicons-bell-alert' console.log('[NotifStreamPlugin] toast β†’', title) // Only badge the title if the tab is currently NOT visible β€” if the // user is already looking at LogShip the toast itself is enough. if (document.hidden) { unread++ applyBadge() } const open = () => { clearBadge() try { navigateTo(url) } catch { window.location.href = url } } // Run inside the captured Nuxt context so any composables nuxtToast // depends on (useState, useRouter for navigation, etc.) still resolve. nuxtApp.runWithContext(() => { nuxtToast.add({ // Nuxt UI v2 used `timeout`, v3 uses `duration`. We're on ^3.1.3 but // the rest of the codebase still uses v2-style keys (see // pages/sales/orders/[id]/edit.vue:1421). Pass both β€” extra keys // are ignored and one of them will stick. duration: 12000, timeout: 12000, title, description, icon, // Nuxt UI v3 expects semantic colour names (primary/success/error/...) // rather than CSS colours. Use 'primary' so we work on both. color: 'primary', actions: [{ label: 'Γ–ffnen', click: open, onClick: open }] } as any) }) } // Freshest possible client-side read of the login cookie (see header note on // why we don't use a useCookie() ref here). Empty value (logship_user_id=) β†’ false. const isLoggedIn = () => document.cookie.split('; ').some(c => c.startsWith('logship_user_id=') && c.slice(c.indexOf('=') + 1) !== '') // Start when authenticated, stop when not. Idempotent: start() guards against // a double connection, and we only stop() what we started. let started = false const sync = () => { if (isLoggedIn()) { if (!started) { started = true start(onNotification) } } else if (started) { started = false stop() } } // afterEach covers SPA navigations β€” crucially the post-login router.push('/') // and the logout redirect β€” but does NOT fire for the initial hydrated route, // so a delayed initial sync() handles a fresh load onto an already-authed page. useRouter().afterEach(() => sync()) setTimeout(sync, 1500) })