fix(deferred): close frontend UI/UX tails from the hygiene sweep

- player YB2: keepalive pagehide beacon (api.saveProgressBeacon) so the resume
  position isn't lost on F5/close within 5s of the last checkpoint (mirrors Plex).
- player YB4: bound consecutive unplayable items in auto-advance so an
  auto-advancing/loop=all queue can't spin over a run of dead videos.
- downloads B6 (client): localise structured quota/edit errors centrally in
  api.req() via localizeDetail() + Intl.NumberFormat (fixes HU/DE + decimal sep);
  + 3-locale download error strings.
- channels FB1: focusChannelToken bump so re-clicking the same channel re-seeds
  the search box; FB2: syncSubs invalidates feed/feed-count (set can change now).
- config CB2: reseed the ConfigPanel draft on a key-set dataVersion + explicit
  post-save token, so a mid-edit refetch can't clobber an in-progress edit.
- config AB3 (UI): a blank allow_empty field stores "" instead of resetting.
- admin SB2/SC2: confirm + success toast on demo-whitelist remove and deny-invite
  (+ trilingual strings); SC1: disable only the acted-on row (pendingId), not all;
  SB3: pause the 1s scheduler countdown ticker while the tab is hidden.
- messages CT4: only invalidate conversations/unread when the incoming-message
  count actually changed, not on every 20s poll; MB3 (client): react to the live
  unread ping (onUnread → invalidate); MC3: extract e2ee installKey (setup/unlock);
  CC2: hoist POLL_MS to messaging.ts; MB4: document the reload-scoped socket.
This commit is contained in:
npeter83 2026-07-12 05:59:25 +02:00
parent 4e80e2b39b
commit f0198f2e0a
19 changed files with 302 additions and 45 deletions

View file

@ -275,8 +275,10 @@ export default function App() {
// "Focus this channel in the manager": jump to the Channels page and seed its name filter
// so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later.
const [focusChannelName, setFocusChannelName] = useState<string | null>(null);
const [focusChannelToken, setFocusChannelToken] = useState(0);
const focusChannel = (name: string) => {
setFocusChannelName(name);
setFocusChannelToken((n) => n + 1); // re-seed even when the same channel is re-focused
setChannelsView("subscribed"); // the name filter applies to the subscriptions table
setPage("channels");
};
@ -803,6 +805,7 @@ export default function App() {
canWrite={meQuery.data!.can_write}
isAdmin={meQuery.data!.role === "admin"}
focusChannelName={focusChannelName}
focusChannelToken={focusChannelToken}
onFocusChannel={focusChannel}
statusFilter={channelFilter}
setStatusFilter={setChannelFilter}

View file

@ -52,19 +52,25 @@ function UsersRoles({ me }: { me: Me }) {
const qc = useQueryClient();
const confirm = useConfirm();
const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
// Which row currently has an action in flight, so we disable ONLY that row's button (mutations
// are single-flight, so one id suffices) rather than greying every row (SC1).
const [pendingId, setPendingId] = useState<number | null>(null);
const setRole = useMutation({
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
api.setUserRole(id, role),
onMutate: (vars) => setPendingId(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
},
onSettled: () => setPendingId(null),
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
});
const suspend = useMutation({
mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) =>
api.setUserSuspended(id, suspended),
onMutate: (vars) => setPendingId(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({
@ -74,14 +80,17 @@ function UsersRoles({ me }: { me: Me }) {
}),
});
},
onSettled: () => setPendingId(null),
// Guards (demo / self / last admin) surface via the global error dialog.
});
const del = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
onMutate: (vars) => setPendingId(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
},
onSettled: () => setPendingId(null),
});
const onToggle = async (u: AdminUserRow) => {
@ -162,7 +171,7 @@ function UsersRoles({ me }: { me: Me }) {
>
<button
onClick={() => onToggle(u)}
disabled={u.is_demo || self || setRole.isPending}
disabled={u.is_demo || self || pendingId === u.id}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
>
<Shield className="w-3.5 h-3.5" />
@ -182,7 +191,7 @@ function UsersRoles({ me }: { me: Me }) {
>
<button
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || suspend.isPending}
disabled={u.is_demo || self || pendingId === u.id}
aria-label={u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")}
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
u.is_suspended
@ -196,7 +205,7 @@ function UsersRoles({ me }: { me: Me }) {
<Tooltip hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}>
<button
onClick={() => onDelete(u)}
disabled={u.is_demo || self || del.isPending}
disabled={u.is_demo || self || pendingId === u.id}
aria-label={t("users.delete.action")}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-40 transition"
>
@ -217,6 +226,7 @@ function UsersRoles({ me }: { me: Me }) {
function AdminInvites() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
const [newEmail, setNewEmail] = useState("");
const refresh = () => {
@ -233,9 +243,21 @@ function AdminInvites() {
});
const deny = useMutation({
mutationFn: (id: number) => api.denyInvite(id),
onSuccess: refresh,
onSuccess: (_d, _id) => {
refresh();
notify({ level: "success", message: t("settings.invites.denied") });
},
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
});
const onDeny = async (i: { id: number; email: string }) => {
const ok = await confirm({
title: t("settings.invites.denyTitle"),
message: t("settings.invites.denyConfirm", { email: i.email }),
confirmLabel: t("settings.invites.deny"),
danger: true,
});
if (ok) deny.mutate(i.id);
};
const add = useMutation({
mutationFn: (email: string) => api.addInvite(email),
onSuccess: (_d, email) => {
@ -261,7 +283,7 @@ function AdminInvites() {
key={i.id}
inv={i}
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
onDeny={() => deny.mutate(i.id)}
onDeny={() => onDeny(i)}
busy={approve.isPending || deny.isPending}
/>
))}
@ -329,9 +351,21 @@ function AdminDemo() {
});
const remove = useMutation({
mutationFn: (id: number) => api.removeDemoWhitelist(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
notify({ level: "success", message: t("settings.demo.removed") });
},
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
});
const onRemove = async (r: { id: number; email: string }) => {
const ok = await confirm({
title: t("settings.demo.removeTitle"),
message: t("settings.demo.removeConfirm", { email: r.email }),
confirmLabel: t("settings.demo.remove"),
danger: true,
});
if (ok) remove.mutate(r.id);
};
const reset = useMutation({
mutationFn: () => api.resetDemo(),
onSuccess: (r: { playlists_seeded: number }) =>
@ -362,7 +396,7 @@ function AdminDemo() {
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<button
onClick={() => remove.mutate(r.id)}
onClick={() => onRemove(r)}
disabled={remove.isPending}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
aria-label={t("settings.demo.remove")}

View file

@ -48,6 +48,7 @@ export default function Channels({
onFilterByTag,
onFocusChannel,
focusChannelName,
focusChannelToken,
statusFilter,
setStatusFilter,
view,
@ -61,6 +62,9 @@ export default function Channels({
onFilterByTag: (tagId: number, name: string) => void;
onFocusChannel: (name: string) => void;
focusChannelName: string | null;
// Bumped every time a focus-channel intent is (re-)issued, so re-clicking the same channel
// re-seeds the search box even when the name is unchanged.
focusChannelToken: number;
statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void;
// The active tab lives in App so navigation intents that target the subscriptions table
@ -92,6 +96,10 @@ export default function Channels({
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
// already have videos in the shared catalog, so refresh the feed too (not just the manager).
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
};
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
@ -189,7 +197,8 @@ export default function Channels({
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
}, [focusChannelName]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusChannelToken]);
// The header's reset intent clears the search + tag chips (skip the initial mount).
const resetRef = useRef(filtersResetToken);
useEffect(() => {

View file

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock";
import { onMessage } from "../lib/messagesSocket";
import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
import ChatThread from "./ChatThread";
@ -39,6 +39,16 @@ export default function ChatDock({ meId }: { meId: number }) {
[qc, meId]
);
// Live badge sync: another tab/device read a thread → refresh this tab's unread count + list.
useEffect(
() =>
onUnread(() => {
qc.invalidateQueries({ queryKey: ["message-unread"] });
qc.invalidateQueries({ queryKey: ["conversations"] });
}),
[qc]
);
if (chats.length === 0) return null;
return (
<div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">

View file

@ -6,12 +6,10 @@ import { api, HttpError, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import { partnerPub, useKeyState } from "../lib/messaging";
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
const POLL_MS = 20000; // safety net; live updates arrive over the WebSocket
// The message list + composer for one conversation, shared by the full Messages page and the
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
// chrome (back button / minimize / close) is the caller's. Self-contained key gating: prompts
@ -66,13 +64,20 @@ export default function ChatThread({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
// Opening / refreshing the thread marks incoming messages read, so clear the badges.
// Opening the thread (or a new incoming message arriving) marks messages read server-side, so
// refresh the badges — but ONLY when the incoming-message count actually changed, not on every
// 20s poll (which would needlessly refetch /conversations + /unread_count each tick).
const lastIncoming = useRef(-1);
useEffect(() => {
if (q.dataUpdatedAt) {
if (!q.dataUpdatedAt) return;
const incoming = (q.data?.items ?? []).filter((m) => m.recipient_id === meId).length;
if (incoming !== lastIncoming.current) {
lastIncoming.current = incoming;
qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: ["message-unread"] });
}
}, [q.dataUpdatedAt, qc]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, meId, qc]);
// Scroll to the newest message when the count changes (open / new message) — NOT on `plain`,
// which gets a fresh object on every poll's decrypt pass and would yank a scrolled-up reader

View file

@ -38,15 +38,23 @@ export default function ConfigPanel() {
// clearing the field, since an empty secret field means "leave unchanged").
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
const [saveState, setSaveState] = useState<SaveState>("idle");
// Bumped after our own save so the draft re-seeds from fresh server values; a plain mid-edit
// refetch does NOT (see the effect below).
const [reseedToken, setReseedToken] = useState(0);
// One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = usePersistedTab(LS.configTab);
// Re-seed the draft whenever the server data changes (initial load + after a save/reset).
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
// load, registry change) or an explicit post-save bump — so a refetch that resolves mid-edit
// (e.g. an invalidation) can no longer clobber the admin's in-progress draft (CB2).
const dataVersion = useMemo(() => items.map((i) => i.key).join("|"), [items]);
useEffect(() => {
setDraft(baseline);
setSecretReset({});
}, [baseline]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataVersion, reseedToken]);
const isDirty = (it: ConfigItem): boolean => {
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
@ -67,12 +75,16 @@ export default function ConfigPanel() {
} else if (it.type === "bool") {
await api.setConfig(key, raw === "true");
} else if (raw === "") {
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
// allow_empty keys treat a blank as an explicit empty value (e.g. disable smtp_user /
// youtube_api_proxy) rather than resetting to the env default (AB3).
if (it.allow_empty) await api.setConfig(key, "");
else if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
} else {
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
}
}
await qc.invalidateQueries({ queryKey: ["admin-config"] });
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
} catch {

View file

@ -5,7 +5,7 @@ import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { partnerPub, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history";
import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
@ -13,8 +13,6 @@ import Avatar from "./Avatar";
import KeyGate from "./KeyGate";
import ChatThread from "./ChatThread";
const POLL_MS = 20000;
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
export default function Messages({ meId }: { meId: number }) {

View file

@ -107,6 +107,10 @@ export default function PlayerModal({
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null);
const autoMarkedRef = useRef(false);
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
// run of dead videos — after the cap we stop on the error overlay instead of skipping forever.
const MAX_CONSECUTIVE_ERRORS = 3;
const errorStreakRef = useRef(0);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
// iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
// controls; the interaction overlay below also stops the iframe stealing focus on a video
@ -502,6 +506,7 @@ export default function PlayerModal({
onStateChange: (e: any) => {
// 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) {
errorStreakRef.current = 0; // a successful play breaks any unplayable run
const p = playerRef.current;
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
if (d) setLiveData({ title: d.title, author: d.author });
@ -517,17 +522,47 @@ export default function PlayerModal({
},
// The embed couldn't play this video (embedding disabled, removed, private…).
// Surface our own message + an "Open on YouTube" escape hatch.
onError: (e: any) => setPlayerError(typeof e?.data === "number" ? e.data : -1),
onError: (e: any) => {
setPlayerError(typeof e?.data === "number" ? e.data : -1);
// The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd()
// never runs and an auto-advancing queue would freeze here. Skip forward instead — but
// bounded, so a run of dead items (or loop=all over a broken queue) can't spin. We do
// NOT mark the broken video watched (only the ended-handler does that).
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
const { autoMode: a } = modeRef.current;
const { queue: q } = navRef.current;
if (a === "off" || !q || q.length <= 1) return;
errorStreakRef.current += 1;
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
advanceOnEnd();
},
},
});
});
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
const timer = window.setInterval(persist, 5000);
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
// without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
// beacon so the POST survives the unload. Only for the library item we opened with — a navigated
// description-link video may not be in this user's library (the server would 404).
const onHide = () => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
try {
const cur = p.getCurrentTime();
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
} catch {
/* player tearing down */
}
};
window.addEventListener("pagehide", onHide);
return () => {
cancelled = true;
window.clearInterval(timer);
window.removeEventListener("pagehide", onHide);
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: ["feed"] });

View file

@ -318,11 +318,33 @@ export default function Scheduler() {
});
const data = q.data;
// Tick once a second so the per-job countdowns move between polls.
// Tick once a second so the per-job countdowns move between polls — but only while the tab is
// visible; a hidden tab doesn't need to re-render the whole page every second (SB3).
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
let id: ReturnType<typeof setInterval> | undefined;
const start = () => {
if (id == null) id = setInterval(() => setTick((n) => n + 1), 1000);
};
const stop = () => {
if (id != null) {
clearInterval(id);
id = undefined;
}
};
const onVisibility = () => {
if (document.hidden) stop();
else {
setTick((n) => n + 1); // snap the countdown to the correct value on return
start();
}
};
onVisibility(); // honor the current visibility on mount
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const pauseResume = useMutation({

View file

@ -185,5 +185,17 @@
"added": "Hinzugefügt",
"user": "Nutzer"
},
"clipBadge": "Clip"
"clipBadge": "Clip",
"errors": {
"quota": {
"max_bytes": "Dein Download-Speicherkontingent ist aufgebraucht ({{size}} GB). Gib zuerst Speicher frei.",
"max_jobs": "Du hast dein Download-Limit erreicht ({{limit}} Objekte). Entferne zuerst einige.",
"generic": "Download-Kontingent überschritten."
},
"edit": {
"empty_edit": "Wähle zuerst einen Zuschneidebereich oder Ausschnitt.",
"source_not_ready": "Der Quell-Download ist noch nicht bearbeitbar.",
"generic": "Dieser Schnitt konnte nicht erstellt werden."
}
}
}

View file

@ -124,7 +124,10 @@
"resetTitle": "Demo-Zustand zurücksetzen?",
"resetConfirm": "Dies löscht alles, was das Demo-Konto angesammelt hat — Playlists, ausgeblendete/angesehene/gespeicherte Videos und Einstellungen — und legt ein paar Beispiel-Playlists neu an. Es kann nicht rückgängig gemacht werden.",
"resetDone": "Demo zurückgesetzt — {{count}} Beispiel-Playlist(s) angelegt",
"resetFailed": "Das Demo-Konto konnte nicht zurückgesetzt werden"
"resetFailed": "Das Demo-Konto konnte nicht zurückgesetzt werden",
"removed": "Von der Demo-Whitelist entfernt",
"removeTitle": "Demo-Zugang entfernen?",
"removeConfirm": "{{email}} von der Demo-Whitelist entfernen? Der Zugang zum Demo-Konto ist dann nicht mehr möglich."
},
"invites": {
"title": "Zugriffsanfragen",
@ -141,6 +144,9 @@
"approveHint": "Genehmigen — setzt diese E-Mail auf die Whitelist und benachrichtigt sie per E-Mail, dass sie dabei sind.",
"approve": "Genehmigen",
"denyHint": "Diese Anfrage ablehnen.",
"deny": "Ablehnen"
"deny": "Ablehnen",
"denied": "Anfrage abgelehnt",
"denyTitle": "Zugriffsanfrage ablehnen?",
"denyConfirm": "Die Zugriffsanfrage von {{email}} ablehnen?"
}
}

View file

@ -185,5 +185,17 @@
"added": "Added",
"user": "User"
},
"clipBadge": "Clip"
"clipBadge": "Clip",
"errors": {
"quota": {
"max_bytes": "You've used your download storage quota ({{size}} GB). Free up space first.",
"max_jobs": "You've reached your download limit ({{limit}} items). Remove some first.",
"generic": "Download quota exceeded."
},
"edit": {
"empty_edit": "Choose a trim range or crop area first.",
"source_not_ready": "The source download isn't ready to edit.",
"generic": "That edit couldn't be created."
}
}
}

View file

@ -124,7 +124,10 @@
"resetTitle": "Reset demo state?",
"resetConfirm": "This clears everything the demo account has accumulated — playlists, hidden/watched/saved videos and settings — and re-seeds a few sample playlists. It can't be undone.",
"resetDone": "Demo reset — {{count}} sample playlist(s) seeded",
"resetFailed": "Couldn't reset the demo account"
"resetFailed": "Couldn't reset the demo account",
"removed": "Removed from the demo whitelist",
"removeTitle": "Remove demo access?",
"removeConfirm": "Remove {{email}} from the demo whitelist? They'll no longer be able to enter the demo account."
},
"invites": {
"title": "Access requests",
@ -141,6 +144,9 @@
"approveHint": "Approve — whitelist this email and email them they're in.",
"approve": "Approve",
"denyHint": "Deny this request.",
"deny": "Deny"
"deny": "Deny",
"denied": "Request denied",
"denyTitle": "Deny access request?",
"denyConfirm": "Deny the access request from {{email}}?"
}
}

View file

@ -185,5 +185,17 @@
"added": "Hozzáadva",
"user": "Felhasználó"
},
"clipBadge": "Klip"
"clipBadge": "Klip",
"errors": {
"quota": {
"max_bytes": "Elfogyott a letöltési tárhelykereted ({{size}} GB). Előbb szabadíts fel helyet.",
"max_jobs": "Elérted a letöltési korlátodat ({{limit}} elem). Előbb távolíts el néhányat.",
"generic": "Túllépted a letöltési kvótát."
},
"edit": {
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet.",
"source_not_ready": "A forrásletöltés még nem szerkeszthető.",
"generic": "Ezt a vágást nem sikerült létrehozni."
}
}
}

View file

@ -124,7 +124,10 @@
"resetTitle": "Visszaállítod a demo állapotot?",
"resetConfirm": "Ez törli mindazt, amit a demo fiók összegyűjtött — lejátszási listák, elrejtett/megnézett/mentett videók és beállítások —, és újra létrehoz pár minta lejátszási listát. Nem vonható vissza.",
"resetDone": "Demo visszaállítva — {{count}} minta lejátszási lista létrehozva",
"resetFailed": "Nem sikerült visszaállítani a demo fiókot"
"resetFailed": "Nem sikerült visszaállítani a demo fiókot",
"removed": "Eltávolítva a demo fehérlistáról",
"removeTitle": "Visszavonod a demo hozzáférést?",
"removeConfirm": "Eltávolítod {{email}} címet a demo fehérlistáról? Többé nem tud belépni a demo fiókba."
},
"invites": {
"title": "Hozzáférési kérelmek",
@ -141,6 +144,9 @@
"approveHint": "Jóváhagyás — fehérlistára teszi ezt az e-mailt és e-mailben értesíti őket, hogy bekerültek.",
"approve": "Jóváhagyás",
"denyHint": "Kérelem elutasítása.",
"deny": "Elutasítás"
"deny": "Elutasítás",
"denied": "Kérelem elutasítva",
"denyTitle": "Elutasítod a hozzáférési kérelmet?",
"denyConfirm": "Elutasítod {{email}} hozzáférési kérelmét?"
}
}

View file

@ -209,13 +209,35 @@ export interface VersionInfo {
class HttpError extends Error {
status: number;
detail?: string; // server-provided reason (FastAPI's `detail`), when present
constructor(status: number, detail?: string) {
detailData?: any; // structured detail ({ code, reason, ... }) when the server sent an object
constructor(status: number, detail?: string, detailData?: any) {
super(detail || `HTTP ${status}`);
this.status = status;
this.detail = detail;
this.detailData = detailData;
}
}
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
function localizeDetail(d: any): string {
const t = i18n.t.bind(i18n);
if (d?.code === "quota") {
if (d.reason === "max_bytes") {
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
(d.limit || 0) / 1_073_741_824,
);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (d.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: d.limit });
return t("downloads.errors.quota.generic");
}
if (d?.code === "edit") return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic"));
return t("errors.generic");
}
// 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
@ -337,9 +359,14 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
if (!r.ok) {
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
let detail: string | undefined;
let detailData: any;
try {
const body = await r.json();
if (body && typeof body.detail === "string") detail = body.detail;
else if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail;
detail = localizeDetail(body.detail); // structured error → localized message
}
} catch {
/* no JSON body */
}
@ -361,7 +388,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
reportError(detail);
}
throw new HttpError(r.status, detail);
throw new HttpError(r.status, detail, detailData);
}
return r.status === 204 ? null : r.json();
}
@ -601,6 +628,7 @@ export interface ConfigItem {
min: number | null;
max: number | null;
is_set: boolean; // a DB override row exists (else using the env/config default)
allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting
value?: string | number | boolean; // non-secret: effective value
default?: string | number | boolean; // non-secret: env/config default
default_is_set?: boolean; // secret: whether an env default exists
@ -1074,6 +1102,30 @@ export const api = {
},
{ idempotent: true }
),
// Fire-and-forget YT progress save that survives page unload (F5/close/navigate) — mirrors
// plexProgressBeacon. A normal fetch is cancelled on unload and React effect cleanup doesn't run
// on a full reload, so the last position (esp. right after a seek) would be lost; keepalive lets
// it complete.
saveProgressBeacon: (id: string, positionSeconds: number, durationSeconds: number): void => {
const active = getActiveAccount();
try {
void fetch(`/api/videos/${encodeURIComponent(id)}/progress`, {
method: "POST",
keepalive: true,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
body: JSON.stringify({
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
}).catch(() => {});
} catch {
/* ignore */
}
},
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
resumeSync: () => req("/api/sync/resume", { method: "POST" }),

View file

@ -112,6 +112,15 @@ export async function loadFromDevice(userId: number): Promise<boolean> {
return false;
}
// Install a freshly-imported private key as the current identity: drop any derived conversation
// keys, persist it to this device, and notify unlock subscribers. Shared by setup() and unlock().
async function installKey(userId: number, key: CryptoKey): Promise<void> {
privKey = key;
convKeys.clear();
await idbPut(userId, key);
emitUnlock();
}
// First-run setup: generate a keypair, wrap the private key with the passphrase, persist a
// non-extractable copy locally, and return the bundle to upload to the server.
export async function setup(userId: number, passphrase: string): Promise<KeySetup> {
@ -128,10 +137,7 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(checkIv) }, wrapKey, bsrc(enc.encode("ok")));
const spki = await subtle.exportKey("spki", kp.publicKey);
privKey = await importPrivate(pkcs8);
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
await installKey(userId, await importPrivate(pkcs8));
return {
public_key: b64(spki),
wrapped_private_key: b64(wrapped),
@ -154,10 +160,7 @@ export async function unlock(userId: number, passphrase: string, bundle: MyKeyBu
wrapKey,
bsrc(ub64(bundle.wrapped_private_key))
);
privKey = await importPrivate(pkcs8);
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
await installKey(userId, await importPrivate(pkcs8));
}
// --- per-conversation encryption ---

View file

@ -11,8 +11,10 @@ interface IncomingMessage {
to?: MessageUser;
}
type Handler = (e: IncomingMessage) => void;
type UnreadHandler = () => void;
const handlers = new Set<Handler>();
const unreadHandlers = new Set<UnreadHandler>();
let ws: WebSocket | null = null;
let started = false;
let backoff = 1000;
@ -21,7 +23,9 @@ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
// A WebSocket can't carry the X-Siftlode-Account header, so the per-tab account rides in the
// query string (validated against the browser wallet server-side).
// query string (validated against the browser wallet server-side). The account is read once
// here: switching accounts always goes through location.reload() (NavSidebar), which rebuilds
// this module and reopens the socket, so it's intentionally fixed for the socket's lifetime.
const account = getActiveAccount();
const qs = account != null ? `?account=${account}` : "";
return `${proto}://${location.host}/api/messages/ws${qs}`;
@ -43,6 +47,9 @@ function open() {
if (data?.type === "message" && data.message) {
const e: IncomingMessage = { message: data.message as Message, from: data.from, to: data.to };
for (const h of handlers) h(e);
} else if (data?.type === "unread") {
// Sent to a user's OTHER tabs when they read a thread elsewhere, so the badge updates live.
for (const h of unreadHandlers) h();
}
} catch {
/* ignore malformed frames */
@ -87,3 +94,13 @@ export function onMessage(h: Handler): () => void {
}
};
}
// Subscribe to "your unread changed elsewhere" pings (a read on another tab/device). The socket is
// already opened/closed by onMessage subscribers (the app-wide ChatDock keeps one), so this only
// registers a callback — it does not open the socket on its own.
export function onUnread(h: UnreadHandler): () => void {
unreadHandlers.add(h);
return () => {
unreadHandlers.delete(h);
};
}

View file

@ -6,6 +6,9 @@ import { isUnlocked, subscribeUnlock } from "./e2ee";
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
// Safety-net poll interval for message queries; live updates arrive over the WebSocket.
export const POLL_MS = 20000;
// Set-once cache of partner public keys (the server is the directory; keys never change).
const pubCache = new Map<number, string>();
export async function partnerPub(partnerId: number): Promise<string> {