fix(web): make the 'connection lost' notice self-resolving

The notice promised 'this will clear once it's back', but nothing ever
removed it: the toast auto-hid on a 15s timer (not on recovery) and the
v0.9.0 unified inbox kept every notify() in persistent history, so the
notice lingered there forever.

Model it as a single live status instead: one sticky, transient (never
persisted) notice while the server is unreachable, removed the moment any
request reaches the server again. Adds a 'transient' notification flag
(sticky toast + excluded from history so a reload can't orphan it) and
replaces the throttled-toast helper with a connectivity lost/restored pair.
i18n the strings (errors.offline.*) in EN/HU/DE — they were hardcoded English.
This commit is contained in:
npeter83 2026-06-18 23:17:32 +02:00
parent 4f7aac7b92
commit 1278d079e2
5 changed files with 50 additions and 20 deletions

View file

@ -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) {

View file

@ -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();