diff --git a/frontend/src/i18n/locales/de/errors.json b/frontend/src/i18n/locales/de/errors.json index 799a006..8b09923 100644 --- a/frontend/src/i18n/locales/de/errors.json +++ b/frontend/src/i18n/locales/de/errors.json @@ -3,6 +3,10 @@ "generic": "Der Server konnte die Aktion nicht ausführen. Bitte erneut versuchen.", "server": "Serverfehler", "ok": "OK", + "offline": { + "title": "Verbindung verloren", + "body": "Server nicht erreichbar — er startet möglicherweise gerade neu. Dieser Hinweis verschwindet, sobald die Verbindung wieder steht." + }, "boundary": { "title": "Etwas ist schiefgelaufen.", "subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.", diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index f22b32a..56f38c7 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -3,6 +3,10 @@ "generic": "The server couldn't carry out that action. Please try again.", "server": "Server error", "ok": "OK", + "offline": { + "title": "Connection lost", + "body": "Couldn't reach the server — it may be restarting. This notice will clear once the connection is back." + }, "boundary": { "title": "Something went wrong.", "subtitle": "The app hit an unexpected error.", diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index b3c7bc3..43f5311 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -3,6 +3,10 @@ "generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.", "server": "Szerverhiba", "ok": "OK", + "offline": { + "title": "Megszakadt a kapcsolat", + "body": "Nem sikerült elérni a szervert — lehet, hogy épp újraindul. Ez az üzenet eltűnik, amint helyreáll a kapcsolat." + }, "boundary": { "title": "Hiba történt.", "subtitle": "Az alkalmazás váratlan hibába ütközött.", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 06ca59c..be31fd8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,4 @@ -import { notify } from "./notifications"; +import { notify, remove } from "./notifications"; import i18n from "../i18n"; import { reportError } from "./errorDialog"; @@ -163,14 +163,24 @@ class HttpError extends Error { } } -// Collapse bursts of connection failures (e.g. while the server restarts) into a -// single notification rather than one per failed request. -let lastErrorNotifiedAt = 0; -function notifyErrorThrottled(title: string, message: string): void { - const now = Date.now(); - if (now - lastErrorNotifiedAt < 30_000) return; - lastErrorNotifiedAt = now; - notify({ level: "error", title, message }); +// A single, self-resolving "connection lost" status: while the server is unreachable we +// show one sticky, transient (non-persisted) notice; the moment any request reaches the +// server again we remove it. This makes good on its "clears once it's back" copy and keeps +// bursts of concurrent failures from stacking up — there's only ever one live handle. +let connectivityNotifId: number | null = null; +function markConnectivityLost(): void { + if (connectivityNotifId !== null) return; + connectivityNotifId = notify({ + level: "error", + title: i18n.t("errors.offline.title"), + message: i18n.t("errors.offline.body"), + transient: true, + }); +} +function markConnectivityRestored(): void { + if (connectivityNotifId === null) return; + remove(connectivityNotifId); + connectivityNotifId = null; } // Gateway statuses that mean "the upstream connection failed", not "the app rejected the @@ -206,10 +216,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr await delay(250); continue; } - notifyErrorThrottled( - "Connection lost", - "Couldn't reach the server — it may be restarting. This will clear once it's back." - ); + markConnectivityLost(); throw e; } @@ -221,6 +228,10 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr continue; } + // The server answered (anything that isn't a gateway error means it's reachable) — + // clear a standing "connection lost" status if one is showing. + if (!RETRIABLE_GATEWAY.has(r.status)) markConnectivityRestored(); + if (!r.ok) { // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react. let detail: string | undefined; @@ -236,10 +247,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr // A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow. if (RETRIABLE_GATEWAY.has(r.status)) { - notifyErrorThrottled( - "Connection lost", - "Couldn't reach the server — it may be restarting. This will clear once it's back." - ); + markConnectivityLost(); } else if (r.status >= 500) { reportError(detail || `${i18n.t("errors.server")} (${r.status})`); } else if (r.status === 400 || r.status === 409 || r.status === 422) { diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 184d708..717533b 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -38,6 +38,10 @@ export interface Notification { ts: number; read: boolean; dismissed: boolean; // transient toast surface closed (still kept in history) + // Live session-only status (e.g. "connection lost"): sticky toast (no auto-dismiss + // timer) that the producer removes itself when the condition resolves. Never persisted + // to history, so a reload can't orphan it with no live handle to clear it. + transient: boolean; } export interface NotifyInput { @@ -48,6 +52,7 @@ export interface NotifyInput { meta?: NotifMeta; requiresInteraction?: boolean; sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast + transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient) } const HISTORY_KEY = "subfeed.notifications"; @@ -122,7 +127,7 @@ function load(): Notification[] { // resurrect as active toasts on reload — their auto-dismiss timers aren't // re-armed across a reload, so otherwise they'd stick forever. Also drop the // live `action` callback, which can't survive serialization. - return raw.map((n) => ({ ...n, action: undefined, dismissed: true }) as Notification); + return raw.map((n) => ({ ...n, action: undefined, dismissed: true, transient: false }) as Notification); } catch { return []; } @@ -130,7 +135,9 @@ function load(): Notification[] { function persist() { try { - const slim = items.map(({ action: _action, ...n }) => n); + // Transient status notices are live-session only — never write them to history, + // so a reload can't leave one stranded with no producer left to clear it. + const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n); localStorage.setItem(HISTORY_KEY, JSON.stringify(slim)); } catch { /* ignore quota / serialization errors */ @@ -164,7 +171,9 @@ export function notify(input: NotifyInput): number { const requiresInteraction = input.requiresInteraction ?? false; const level = input.level ?? "info"; // config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed). - const duration = requiresInteraction + // A transient status stays put until its producer clears it (no auto-dismiss timer), + // same as an interaction-awaiting notice. + const duration = requiresInteraction || input.transient ? undefined : level === "error" || level === "fatal" ? ERROR_TTL @@ -185,6 +194,7 @@ export function notify(input: NotifyInput): number { ts: Date.now(), read: false, dismissed: false, + transient: input.transient ?? false, }, ]; emit();