Newer
Older
navi-1 / webclient / src / composables / useNotificationPrompt.js
/**
 * 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,
  }
}