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:
parent
4e80e2b39b
commit
f0198f2e0a
19 changed files with 302 additions and 45 deletions
|
|
@ -275,8 +275,10 @@ export default function App() {
|
||||||
// "Focus this channel in the manager": jump to the Channels page and seed its name filter
|
// "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.
|
// 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 [focusChannelName, setFocusChannelName] = useState<string | null>(null);
|
||||||
|
const [focusChannelToken, setFocusChannelToken] = useState(0);
|
||||||
const focusChannel = (name: string) => {
|
const focusChannel = (name: string) => {
|
||||||
setFocusChannelName(name);
|
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
|
setChannelsView("subscribed"); // the name filter applies to the subscriptions table
|
||||||
setPage("channels");
|
setPage("channels");
|
||||||
};
|
};
|
||||||
|
|
@ -803,6 +805,7 @@ export default function App() {
|
||||||
canWrite={meQuery.data!.can_write}
|
canWrite={meQuery.data!.can_write}
|
||||||
isAdmin={meQuery.data!.role === "admin"}
|
isAdmin={meQuery.data!.role === "admin"}
|
||||||
focusChannelName={focusChannelName}
|
focusChannelName={focusChannelName}
|
||||||
|
focusChannelToken={focusChannelToken}
|
||||||
onFocusChannel={focusChannel}
|
onFocusChannel={focusChannel}
|
||||||
statusFilter={channelFilter}
|
statusFilter={channelFilter}
|
||||||
setStatusFilter={setChannelFilter}
|
setStatusFilter={setChannelFilter}
|
||||||
|
|
|
||||||
|
|
@ -52,19 +52,25 @@ function UsersRoles({ me }: { me: Me }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
|
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({
|
const setRole = useMutation({
|
||||||
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
|
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
|
||||||
api.setUserRole(id, role),
|
api.setUserRole(id, role),
|
||||||
|
onMutate: (vars) => setPendingId(vars.id),
|
||||||
onSuccess: (_d, vars) => {
|
onSuccess: (_d, vars) => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
||||||
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
|
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.
|
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
|
||||||
});
|
});
|
||||||
|
|
||||||
const suspend = useMutation({
|
const suspend = useMutation({
|
||||||
mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) =>
|
mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) =>
|
||||||
api.setUserSuspended(id, suspended),
|
api.setUserSuspended(id, suspended),
|
||||||
|
onMutate: (vars) => setPendingId(vars.id),
|
||||||
onSuccess: (_d, vars) => {
|
onSuccess: (_d, vars) => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
||||||
notify({
|
notify({
|
||||||
|
|
@ -74,14 +80,17 @@ function UsersRoles({ me }: { me: Me }) {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onSettled: () => setPendingId(null),
|
||||||
// Guards (demo / self / last admin) surface via the global error dialog.
|
// Guards (demo / self / last admin) surface via the global error dialog.
|
||||||
});
|
});
|
||||||
const del = useMutation({
|
const del = useMutation({
|
||||||
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
|
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
|
||||||
|
onMutate: (vars) => setPendingId(vars.id),
|
||||||
onSuccess: (_d, vars) => {
|
onSuccess: (_d, vars) => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
||||||
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
|
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
|
||||||
},
|
},
|
||||||
|
onSettled: () => setPendingId(null),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onToggle = async (u: AdminUserRow) => {
|
const onToggle = async (u: AdminUserRow) => {
|
||||||
|
|
@ -162,7 +171,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggle(u)}
|
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"
|
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" />
|
<Shield className="w-3.5 h-3.5" />
|
||||||
|
|
@ -182,7 +191,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSuspend(u)}
|
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")}
|
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 ${
|
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
|
||||||
u.is_suspended
|
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")}>
|
<Tooltip hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(u)}
|
onClick={() => onDelete(u)}
|
||||||
disabled={u.is_demo || self || del.isPending}
|
disabled={u.is_demo || self || pendingId === u.id}
|
||||||
aria-label={t("users.delete.action")}
|
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"
|
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() {
|
function AdminInvites() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
|
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
|
||||||
const [newEmail, setNewEmail] = useState("");
|
const [newEmail, setNewEmail] = useState("");
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
|
|
@ -233,9 +243,21 @@ function AdminInvites() {
|
||||||
});
|
});
|
||||||
const deny = useMutation({
|
const deny = useMutation({
|
||||||
mutationFn: (id: number) => api.denyInvite(id),
|
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") }),
|
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({
|
const add = useMutation({
|
||||||
mutationFn: (email: string) => api.addInvite(email),
|
mutationFn: (email: string) => api.addInvite(email),
|
||||||
onSuccess: (_d, email) => {
|
onSuccess: (_d, email) => {
|
||||||
|
|
@ -261,7 +283,7 @@ function AdminInvites() {
|
||||||
key={i.id}
|
key={i.id}
|
||||||
inv={i}
|
inv={i}
|
||||||
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
|
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
|
||||||
onDeny={() => deny.mutate(i.id)}
|
onDeny={() => onDeny(i)}
|
||||||
busy={approve.isPending || deny.isPending}
|
busy={approve.isPending || deny.isPending}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -329,9 +351,21 @@ function AdminDemo() {
|
||||||
});
|
});
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: number) => api.removeDemoWhitelist(id),
|
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") }),
|
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({
|
const reset = useMutation({
|
||||||
mutationFn: () => api.resetDemo(),
|
mutationFn: () => api.resetDemo(),
|
||||||
onSuccess: (r: { playlists_seeded: number }) =>
|
onSuccess: (r: { playlists_seeded: number }) =>
|
||||||
|
|
@ -362,7 +396,7 @@ function AdminDemo() {
|
||||||
</div>
|
</div>
|
||||||
<Tooltip hint={t("settings.demo.removeHint")}>
|
<Tooltip hint={t("settings.demo.removeHint")}>
|
||||||
<button
|
<button
|
||||||
onClick={() => remove.mutate(r.id)}
|
onClick={() => onRemove(r)}
|
||||||
disabled={remove.isPending}
|
disabled={remove.isPending}
|
||||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
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")}
|
aria-label={t("settings.demo.remove")}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export default function Channels({
|
||||||
onFilterByTag,
|
onFilterByTag,
|
||||||
onFocusChannel,
|
onFocusChannel,
|
||||||
focusChannelName,
|
focusChannelName,
|
||||||
|
focusChannelToken,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
view,
|
view,
|
||||||
|
|
@ -61,6 +62,9 @@ export default function Channels({
|
||||||
onFilterByTag: (tagId: number, name: string) => void;
|
onFilterByTag: (tagId: number, name: string) => void;
|
||||||
onFocusChannel: (name: string) => void;
|
onFocusChannel: (name: string) => void;
|
||||||
focusChannelName: string | null;
|
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;
|
statusFilter: ChannelStatusFilter;
|
||||||
setStatusFilter: (f: ChannelStatusFilter) => void;
|
setStatusFilter: (f: ChannelStatusFilter) => void;
|
||||||
// The active tab lives in App so navigation intents that target the subscriptions table
|
// 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: ["channels"] });
|
||||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
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);
|
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.
|
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusChannelName) setSearch(focusChannelName);
|
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).
|
// The header's reset intent clears the search + tag chips (skip the initial mount).
|
||||||
const resetRef = useRef(filtersResetToken);
|
const resetRef = useRef(filtersResetToken);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||||
import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock";
|
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 * as e2ee from "../lib/e2ee";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import ChatThread from "./ChatThread";
|
import ChatThread from "./ChatThread";
|
||||||
|
|
@ -39,6 +39,16 @@ export default function ChatDock({ meId }: { meId: number }) {
|
||||||
[qc, meId]
|
[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;
|
if (chats.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
|
<div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,10 @@ import { api, HttpError, type Message, type MessageUser } from "../lib/api";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { relativeTime } from "../lib/format";
|
import { relativeTime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
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 * as e2ee from "../lib/e2ee";
|
||||||
import KeyGate from "./KeyGate";
|
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
|
// 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
|
// 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
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
|
}, [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(() => {
|
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: ["conversations"] });
|
||||||
qc.invalidateQueries({ queryKey: ["message-unread"] });
|
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`,
|
// 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
|
// which gets a fresh object on every poll's decrypt pass and would yank a scrolled-up reader
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,23 @@ export default function ConfigPanel() {
|
||||||
// clearing the field, since an empty secret field means "leave unchanged").
|
// clearing the field, since an empty secret field means "leave unchanged").
|
||||||
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
|
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
|
||||||
const [saveState, setSaveState] = useState<SaveState>("idle");
|
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
|
// 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.
|
// stable; the active id is clamped to a real group at render time once data has loaded.
|
||||||
const [tab, setTab] = usePersistedTab(LS.configTab);
|
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(() => {
|
useEffect(() => {
|
||||||
setDraft(baseline);
|
setDraft(baseline);
|
||||||
setSecretReset({});
|
setSecretReset({});
|
||||||
}, [baseline]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dataVersion, reseedToken]);
|
||||||
|
|
||||||
const isDirty = (it: ConfigItem): boolean => {
|
const isDirty = (it: ConfigItem): boolean => {
|
||||||
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
|
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
|
||||||
|
|
@ -67,12 +75,16 @@ export default function ConfigPanel() {
|
||||||
} else if (it.type === "bool") {
|
} else if (it.type === "bool") {
|
||||||
await api.setConfig(key, raw === "true");
|
await api.setConfig(key, raw === "true");
|
||||||
} else if (raw === "") {
|
} 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 {
|
} else {
|
||||||
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
|
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await qc.invalidateQueries({ queryKey: ["admin-config"] });
|
await qc.invalidateQueries({ queryKey: ["admin-config"] });
|
||||||
|
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
|
||||||
setSaveState("saved");
|
setSaveState("saved");
|
||||||
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
|
||||||
import { api, type Conversation, type MessageUser } from "../lib/api";
|
import { api, type Conversation, type MessageUser } from "../lib/api";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { relativeTime } from "../lib/format";
|
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 { useHistorySubview } from "../lib/history";
|
||||||
import { openChat } from "../lib/chatDock";
|
import { openChat } from "../lib/chatDock";
|
||||||
import * as e2ee from "../lib/e2ee";
|
import * as e2ee from "../lib/e2ee";
|
||||||
|
|
@ -13,8 +13,6 @@ import Avatar from "./Avatar";
|
||||||
import KeyGate from "./KeyGate";
|
import KeyGate from "./KeyGate";
|
||||||
import ChatThread from "./ChatThread";
|
import ChatThread from "./ChatThread";
|
||||||
|
|
||||||
const POLL_MS = 20000;
|
|
||||||
|
|
||||||
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
|
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
|
||||||
|
|
||||||
export default function Messages({ meId }: { meId: number }) {
|
export default function Messages({ meId }: { meId: number }) {
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,10 @@ export default function PlayerModal({
|
||||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||||
const playerRef = useRef<any>(null);
|
const playerRef = useRef<any>(null);
|
||||||
const autoMarkedRef = useRef(false);
|
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
|
// 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
|
// 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
|
// 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) => {
|
onStateChange: (e: any) => {
|
||||||
// 1 === playing → sync the navigated-to video's title/author for display.
|
// 1 === playing → sync the navigated-to video's title/author for display.
|
||||||
if (e?.data === 1) {
|
if (e?.data === 1) {
|
||||||
|
errorStreakRef.current = 0; // a successful play breaks any unplayable run
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||||||
if (d) setLiveData({ title: d.title, author: d.author });
|
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…).
|
// The embed couldn't play this video (embedding disabled, removed, private…).
|
||||||
// Surface our own message + an "Open on YouTube" escape hatch.
|
// 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.
|
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
|
||||||
const timer = window.setInterval(persist, 5000);
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
|
window.removeEventListener("pagehide", onHide);
|
||||||
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
|
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
|
||||||
Promise.resolve(persist()).finally(() => {
|
Promise.resolve(persist()).finally(() => {
|
||||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
|
|
||||||
|
|
@ -318,11 +318,33 @@ export default function Scheduler() {
|
||||||
});
|
});
|
||||||
const data = q.data;
|
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);
|
const [, setTick] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setTick((n) => n + 1), 1000);
|
let id: ReturnType<typeof setInterval> | undefined;
|
||||||
return () => clearInterval(id);
|
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({
|
const pauseResume = useMutation({
|
||||||
|
|
|
||||||
|
|
@ -185,5 +185,17 @@
|
||||||
"added": "Hinzugefügt",
|
"added": "Hinzugefügt",
|
||||||
"user": "Nutzer"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,10 @@
|
||||||
"resetTitle": "Demo-Zustand zurücksetzen?",
|
"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.",
|
"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",
|
"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": {
|
"invites": {
|
||||||
"title": "Zugriffsanfragen",
|
"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.",
|
"approveHint": "Genehmigen — setzt diese E-Mail auf die Whitelist und benachrichtigt sie per E-Mail, dass sie dabei sind.",
|
||||||
"approve": "Genehmigen",
|
"approve": "Genehmigen",
|
||||||
"denyHint": "Diese Anfrage ablehnen.",
|
"denyHint": "Diese Anfrage ablehnen.",
|
||||||
"deny": "Ablehnen"
|
"deny": "Ablehnen",
|
||||||
|
"denied": "Anfrage abgelehnt",
|
||||||
|
"denyTitle": "Zugriffsanfrage ablehnen?",
|
||||||
|
"denyConfirm": "Die Zugriffsanfrage von {{email}} ablehnen?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,5 +185,17 @@
|
||||||
"added": "Added",
|
"added": "Added",
|
||||||
"user": "User"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,10 @@
|
||||||
"resetTitle": "Reset demo state?",
|
"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.",
|
"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",
|
"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": {
|
"invites": {
|
||||||
"title": "Access requests",
|
"title": "Access requests",
|
||||||
|
|
@ -141,6 +144,9 @@
|
||||||
"approveHint": "Approve — whitelist this email and email them they're in.",
|
"approveHint": "Approve — whitelist this email and email them they're in.",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"denyHint": "Deny this request.",
|
"denyHint": "Deny this request.",
|
||||||
"deny": "Deny"
|
"deny": "Deny",
|
||||||
|
"denied": "Request denied",
|
||||||
|
"denyTitle": "Deny access request?",
|
||||||
|
"denyConfirm": "Deny the access request from {{email}}?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,5 +185,17 @@
|
||||||
"added": "Hozzáadva",
|
"added": "Hozzáadva",
|
||||||
"user": "Felhasználó"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,10 @@
|
||||||
"resetTitle": "Visszaállítod a demo állapotot?",
|
"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.",
|
"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",
|
"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": {
|
"invites": {
|
||||||
"title": "Hozzáférési kérelmek",
|
"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.",
|
"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",
|
"approve": "Jóváhagyás",
|
||||||
"denyHint": "Kérelem elutasítása.",
|
"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?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,13 +209,35 @@ export interface VersionInfo {
|
||||||
class HttpError extends Error {
|
class HttpError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
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}`);
|
super(detail || `HTTP ${status}`);
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.detail = detail;
|
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
|
// 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
|
// 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
|
// 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) {
|
if (!r.ok) {
|
||||||
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
|
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
|
||||||
let detail: string | undefined;
|
let detail: string | undefined;
|
||||||
|
let detailData: any;
|
||||||
try {
|
try {
|
||||||
const body = await r.json();
|
const body = await r.json();
|
||||||
if (body && typeof body.detail === "string") detail = body.detail;
|
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 {
|
} catch {
|
||||||
/* no JSON body */
|
/* 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) {
|
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
||||||
reportError(detail);
|
reportError(detail);
|
||||||
}
|
}
|
||||||
throw new HttpError(r.status, detail);
|
throw new HttpError(r.status, detail, detailData);
|
||||||
}
|
}
|
||||||
return r.status === 204 ? null : r.json();
|
return r.status === 204 ? null : r.json();
|
||||||
}
|
}
|
||||||
|
|
@ -601,6 +628,7 @@ export interface ConfigItem {
|
||||||
min: number | null;
|
min: number | null;
|
||||||
max: number | null;
|
max: number | null;
|
||||||
is_set: boolean; // a DB override row exists (else using the env/config default)
|
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
|
value?: string | number | boolean; // non-secret: effective value
|
||||||
default?: string | number | boolean; // non-secret: env/config default
|
default?: string | number | boolean; // non-secret: env/config default
|
||||||
default_is_set?: boolean; // secret: whether an env default exists
|
default_is_set?: boolean; // secret: whether an env default exists
|
||||||
|
|
@ -1074,6 +1102,30 @@ export const api = {
|
||||||
},
|
},
|
||||||
{ idempotent: true }
|
{ 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}`),
|
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
|
||||||
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
||||||
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,15 @@ export async function loadFromDevice(userId: number): Promise<boolean> {
|
||||||
return false;
|
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
|
// 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.
|
// non-extractable copy locally, and return the bundle to upload to the server.
|
||||||
export async function setup(userId: number, passphrase: string): Promise<KeySetup> {
|
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 keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(checkIv) }, wrapKey, bsrc(enc.encode("ok")));
|
||||||
const spki = await subtle.exportKey("spki", kp.publicKey);
|
const spki = await subtle.exportKey("spki", kp.publicKey);
|
||||||
|
|
||||||
privKey = await importPrivate(pkcs8);
|
await installKey(userId, await importPrivate(pkcs8));
|
||||||
convKeys.clear();
|
|
||||||
await idbPut(userId, privKey);
|
|
||||||
emitUnlock();
|
|
||||||
return {
|
return {
|
||||||
public_key: b64(spki),
|
public_key: b64(spki),
|
||||||
wrapped_private_key: b64(wrapped),
|
wrapped_private_key: b64(wrapped),
|
||||||
|
|
@ -154,10 +160,7 @@ export async function unlock(userId: number, passphrase: string, bundle: MyKeyBu
|
||||||
wrapKey,
|
wrapKey,
|
||||||
bsrc(ub64(bundle.wrapped_private_key))
|
bsrc(ub64(bundle.wrapped_private_key))
|
||||||
);
|
);
|
||||||
privKey = await importPrivate(pkcs8);
|
await installKey(userId, await importPrivate(pkcs8));
|
||||||
convKeys.clear();
|
|
||||||
await idbPut(userId, privKey);
|
|
||||||
emitUnlock();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- per-conversation encryption ---
|
// --- per-conversation encryption ---
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@ interface IncomingMessage {
|
||||||
to?: MessageUser;
|
to?: MessageUser;
|
||||||
}
|
}
|
||||||
type Handler = (e: IncomingMessage) => void;
|
type Handler = (e: IncomingMessage) => void;
|
||||||
|
type UnreadHandler = () => void;
|
||||||
|
|
||||||
const handlers = new Set<Handler>();
|
const handlers = new Set<Handler>();
|
||||||
|
const unreadHandlers = new Set<UnreadHandler>();
|
||||||
let ws: WebSocket | null = null;
|
let ws: WebSocket | null = null;
|
||||||
let started = false;
|
let started = false;
|
||||||
let backoff = 1000;
|
let backoff = 1000;
|
||||||
|
|
@ -21,7 +23,9 @@ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
function url(): string {
|
function url(): string {
|
||||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
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
|
// 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 account = getActiveAccount();
|
||||||
const qs = account != null ? `?account=${account}` : "";
|
const qs = account != null ? `?account=${account}` : "";
|
||||||
return `${proto}://${location.host}/api/messages/ws${qs}`;
|
return `${proto}://${location.host}/api/messages/ws${qs}`;
|
||||||
|
|
@ -43,6 +47,9 @@ function open() {
|
||||||
if (data?.type === "message" && data.message) {
|
if (data?.type === "message" && data.message) {
|
||||||
const e: IncomingMessage = { message: data.message as Message, from: data.from, to: data.to };
|
const e: IncomingMessage = { message: data.message as Message, from: data.from, to: data.to };
|
||||||
for (const h of handlers) h(e);
|
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 {
|
} catch {
|
||||||
/* ignore malformed frames */
|
/* 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import { isUnlocked, subscribeUnlock } from "./e2ee";
|
||||||
|
|
||||||
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
|
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).
|
// Set-once cache of partner public keys (the server is the directory; keys never change).
|
||||||
const pubCache = new Map<number, string>();
|
const pubCache = new Map<number, string>();
|
||||||
export async function partnerPub(partnerId: number): Promise<string> {
|
export async function partnerPub(partnerId: number): Promise<string> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue