|
|
@@ -1,11 +1,39 @@
|
|
|
'use client'
|
|
|
|
|
|
-import { useEffect, useState } from 'react'
|
|
|
+import { useCallback, useEffect, useState } from 'react'
|
|
|
import { useTranslations } from 'next-intl'
|
|
|
import { X } from 'lucide-react'
|
|
|
import { markVersionSeen } from '@/features/users/Actions/versionActions'
|
|
|
import { BANNER_PRIORITY, useBannerSlot } from './banner-slot'
|
|
|
|
|
|
+/**
|
|
|
+ * How long the banner stays up when nobody touches it. Six hours is a full
|
|
|
+ * working day of chances to read it; past that it has stopped being a notice
|
|
|
+ * and become part of the header.
|
|
|
+ */
|
|
|
+const AUTO_DISMISS_MS = 6 * 60 * 60 * 1000
|
|
|
+
|
|
|
+/** `<version>|<epoch ms>`: when this release's banner first appeared here. */
|
|
|
+const FIRST_SEEN_KEY = 'update-banner-first-seen'
|
|
|
+
|
|
|
+function firstSeenAt(version: string): number {
|
|
|
+ try {
|
|
|
+ const [seenVersion, at] = (localStorage.getItem(FIRST_SEEN_KEY) ?? '').split('|')
|
|
|
+ return seenVersion === version ? Number(at) || 0 : 0
|
|
|
+ } catch {
|
|
|
+ // localStorage unavailable; the clock restarts on this visit
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function rememberFirstSeen(version: string, at: number) {
|
|
|
+ try {
|
|
|
+ localStorage.setItem(FIRST_SEEN_KEY, `${version}|${at}`)
|
|
|
+ } catch {
|
|
|
+ // localStorage unavailable; the banner then runs its six hours from now
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* One-time "the app was updated" notice, shown when the running APP_VERSION
|
|
|
* differs from the version stored on the user record. Dismissing (or opening
|
|
|
@@ -31,6 +59,11 @@ export function UpdateBanner({
|
|
|
currentVersion !== 'development' &&
|
|
|
lastSeenVersion !== currentVersion
|
|
|
|
|
|
+ const acknowledge = useCallback(() => {
|
|
|
+ setDismissed(true)
|
|
|
+ markVersionSeen(currentVersion)
|
|
|
+ }, [currentVersion])
|
|
|
+
|
|
|
// First load ever for this account: seed silently so a brand-new user is
|
|
|
// not greeted with "what's new" for a version they never used.
|
|
|
useEffect(() => {
|
|
|
@@ -39,17 +72,37 @@ export function UpdateBanner({
|
|
|
}
|
|
|
}, [neverSeeded, currentVersion])
|
|
|
|
|
|
+ // Hardly anyone presses the X, so the banner otherwise rides along until the
|
|
|
+ // next release. Six hours after it first appeared it acknowledges itself.
|
|
|
+ // The clock lives in this browser, but the acknowledgement is the same
|
|
|
+ // server-side write as the X, so it clears the banner on every device.
|
|
|
+ useEffect(() => {
|
|
|
+ if (!show) return
|
|
|
+
|
|
|
+ const now = Date.now()
|
|
|
+ let since = firstSeenAt(currentVersion)
|
|
|
+ if (!since) {
|
|
|
+ since = now
|
|
|
+ rememberFirstSeen(currentVersion, now)
|
|
|
+ }
|
|
|
+
|
|
|
+ const remaining = since + AUTO_DISMISS_MS - now
|
|
|
+ if (remaining <= 0) {
|
|
|
+ acknowledge()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Also covers a tab left open across the deadline.
|
|
|
+ const timer = setTimeout(acknowledge, remaining)
|
|
|
+ return () => clearTimeout(timer)
|
|
|
+ }, [show, currentVersion, acknowledge])
|
|
|
+
|
|
|
// Last in the queue. Interesting, never urgent, and it waits behind an
|
|
|
// outage notice rather than sitting under one.
|
|
|
const mine = useBannerSlot('update', BANNER_PRIORITY.update, show)
|
|
|
|
|
|
if (!show || !mine) return null
|
|
|
|
|
|
- const acknowledge = () => {
|
|
|
- setDismissed(true)
|
|
|
- markVersionSeen(currentVersion)
|
|
|
- }
|
|
|
-
|
|
|
return (
|
|
|
<div className="relative bg-amber-500 px-8 py-1.5 text-center text-xs font-medium text-amber-950">
|
|
|
{t('updated', { version: currentVersion })}{' '}
|