/**
* When to offer push notifications — and what to remember about the offer.
*
* The mechanism (permission, subscription, the Android bridge) lives in
* `usePush.js`; this is the policy on top of it: offer it to a signed-in user
* once per browser session, stop for good after an explicit block, and never
* flip back on a switch the user turned off in Settings.
*
* Two things force the shape of it:
*
* — A bare `Notification.requestPermission()` on load does nothing. Firefox
* (since 72) and Safari ignore a call without a user gesture, and Chrome
* answers with a struck-through bell instead of a dialog. So the offer is a
* banner and the click on its button *is* the gesture.
* — The SSO callback returns to `return_to` with no marker, so a fresh login
* and a reload are indistinguishable. "Once per session" is therefore
* sessionStorage, not "once per login".
*
* The banner itself is a candidate for gnexus-ui-kit — see the record in
* gnexus-ui-kit/docs/tasks/gn-permission-banner.md. Until it ships there, this
* file and `components/ui/NotificationPromptBanner.vue` are navi's workaround;
* the kit is asked for the banner only, never for this policy.
*/
import { computed, ref } from 'vue'
import { t } from '@/i18n/index.js'
import { usePush } from '@/composables/usePush.js'
/** Set on an explicit block: this device is never offered push again. */
export const PROMPT_BLOCKED_KEY = 'navi.notifications.promptBlocked'
/** Set the moment the banner appears: at most once per browser session. */
export const PROMPT_SHOWN_KEY = 'navi.notifications.promptShown'
// A private window, or a browser with site data blocked, throws on every
// access — neither reading nor writing may take the interface down with it.
function read(storage, key) {
try {
return storage.getItem(key)
} catch {
return null
}
}
function write(storage, key, value) {
try {
storage.setItem(key, value)
} catch { /* memory only */ }
}
function drop(storage, key) {
try {
storage.removeItem(key)
} catch { /* nothing to forget */ }
}
export function useNotificationPrompt() {
const { supported, bridgeMode, permission, subscribed, busy, enable, syncState } = usePush()
const visible = ref(false)
function blocked() {
return read(localStorage, PROMPT_BLOCKED_KEY) === '1'
}
function shownThisSession() {
return read(sessionStorage, PROMPT_SHOWN_KEY) === '1'
}
/** Every reason not to offer, in one place. */
function canPrompt() {
if (!supported.value) return false
if (busy.value) return false
if (blocked()) return false
if (shownThisSession()) return false
// 'granted' fails this gate, and that is what protects a switch the user
// turned off in Settings: turning it off unsubscribes but leaves the
// permission granted, so this never re-subscribes them behind their back.
if (permission.value !== 'default') return false
return !subscribed.value
}
/** Offer the banner, if this session and this device are still eligible. */
async function maybeShow() {
try {
await syncState()
} catch { /* state unknown — the gates below decide on what we have */ }
// Blocked in the browser itself: remember it on the device so the offer
// does not come back on every reload. Only a real denial counts — an
// unsupported browser reports 'denied' too, and that is not a refusal.
if (supported.value && !bridgeMode.value && permission.value === 'denied') {
write(localStorage, PROMPT_BLOCKED_KEY, '1')
}
if (!canPrompt()) return false
// At show time, not at click time: reloading with the banner open must not
// bring it back, or "once per session" would not hold.
write(sessionStorage, PROMPT_SHOWN_KEY, '1')
visible.value = true
return true
}
/**
* The banner's CTA. Nothing may be awaited before `enable()`: the click's
* user gesture is what lets the browser open its own prompt, and an await
* can spend it.
*/
async function accept() {
const ok = await enable()
if (ok === true) {
visible.value = false
return
}
// Classified after the await, because `false` means several things: the
// permission was refused, the VAPID key is missing, the call was ignored
// for want of a gesture, or the system dialog was dismissed unread.
if (bridgeMode.value) {
// Android closes its permission dialog with a verdict and nothing else,
// so "no" here is an answer. (The bridge's 30 s fail-closed timeout
// answers "no" as well — the Settings switch stays the way back.)
write(localStorage, PROMPT_BLOCKED_KEY, '1')
} else if (permission.value === 'denied') {
write(localStorage, PROMPT_BLOCKED_KEY, '1')
}
visible.value = false
}
/** "Not now" — an answer for this session only; nothing is written down. */
function decline() {
visible.value = false
}
/** Forget both flags. For the manual checks and the tests. */
function reset() {
drop(localStorage, PROMPT_BLOCKED_KEY)
drop(sessionStorage, PROMPT_SHOWN_KEY)
visible.value = false
}
// Computed, not plain strings: a module-level value would freeze the labels
// in the language that happened to be active at import.
const title = computed(() => t('ui.notificationsPromptTitle'))
const text = computed(() => t('ui.notificationsPromptText'))
const acceptText = computed(() => t('ui.notificationsPromptAllow'))
const dismissText = computed(() => t('ui.notificationsPromptLater'))
return {
visible,
busy,
canPrompt,
maybeShow,
accept,
decline,
reset,
title,
text,
acceptText,
dismissText,
}
}