fix(deferred): address /code-review findings on the closeout diff
- channels CB3: sort discovery rows NULLs-last (title is None) to match the DB
ORDER BY exactly — coercing NULL title to "" floated untitled channels to the top.
- AdminUsers SC1: track in-flight rows in a Set, not a single id — per-row disabling
now lets the admin start concurrent row actions, and a shared id was cleared by
whichever settled first (re-enabling a still-pending row → possible double-submit).
- ChatThread CT4: reset the incoming-count ref when partnerId changes — the Messages
page reuses one ChatThread across conversation switches, so a same-count switch
could skip the open-marks-read badge refresh.
- messages MB3: push a bare {type:"unread"} (drop the now-dead count query — the
client re-fetches on the signal and ignored the number anyway).
- api.ts: extract the shared keepalive `beacon()` helper (saveProgressBeacon +
plexProgressBeacon were near-verbatim copies).
This commit is contained in:
parent
f0198f2e0a
commit
3394dabbeb
5 changed files with 72 additions and 64 deletions
|
|
@ -52,25 +52,32 @@ 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);
|
||||
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
|
||||
// with per-row disabling the admin can now start actions on several rows concurrently.
|
||||
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
|
||||
const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id));
|
||||
const endPending = (id: number) =>
|
||||
setPendingIds((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(id);
|
||||
return n;
|
||||
});
|
||||
const setRole = useMutation({
|
||||
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
|
||||
api.setUserRole(id, role),
|
||||
onMutate: (vars) => setPendingId(vars.id),
|
||||
onMutate: (vars) => startPending(vars.id),
|
||||
onSuccess: (_d, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
||||
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
|
||||
},
|
||||
onSettled: () => setPendingId(null),
|
||||
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||
// 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),
|
||||
onMutate: (vars) => startPending(vars.id),
|
||||
onSuccess: (_d, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
||||
notify({
|
||||
|
|
@ -80,17 +87,17 @@ function UsersRoles({ me }: { me: Me }) {
|
|||
}),
|
||||
});
|
||||
},
|
||||
onSettled: () => setPendingId(null),
|
||||
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||
// 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),
|
||||
onMutate: (vars) => startPending(vars.id),
|
||||
onSuccess: (_d, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["admin-users"] });
|
||||
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
|
||||
},
|
||||
onSettled: () => setPendingId(null),
|
||||
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||
});
|
||||
|
||||
const onToggle = async (u: AdminUserRow) => {
|
||||
|
|
@ -171,7 +178,7 @@ function UsersRoles({ me }: { me: Me }) {
|
|||
>
|
||||
<button
|
||||
onClick={() => onToggle(u)}
|
||||
disabled={u.is_demo || self || pendingId === u.id}
|
||||
disabled={u.is_demo || self || pendingIds.has(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" />
|
||||
|
|
@ -191,7 +198,7 @@ function UsersRoles({ me }: { me: Me }) {
|
|||
>
|
||||
<button
|
||||
onClick={() => onSuspend(u)}
|
||||
disabled={u.is_demo || self || pendingId === u.id}
|
||||
disabled={u.is_demo || self || pendingIds.has(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
|
||||
|
|
@ -205,7 +212,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 || pendingId === u.id}
|
||||
disabled={u.is_demo || self || pendingIds.has(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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -68,8 +68,16 @@ export default function ChatThread({
|
|||
// 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);
|
||||
const lastPartner = useRef(partnerId);
|
||||
useEffect(() => {
|
||||
if (!q.dataUpdatedAt) return;
|
||||
// The Messages page reuses one ChatThread instance across conversation switches (not keyed by
|
||||
// partnerId), so reset the counter when the partner changes — otherwise switching to a thread
|
||||
// with the same incoming-count would skip the open-marks-read refresh.
|
||||
if (lastPartner.current !== partnerId) {
|
||||
lastPartner.current = partnerId;
|
||||
lastIncoming.current = -1;
|
||||
}
|
||||
const incoming = (q.data?.items ?? []).filter((m) => m.recipient_id === meId).length;
|
||||
if (incoming !== lastIncoming.current) {
|
||||
lastIncoming.current = incoming;
|
||||
|
|
@ -77,7 +85,7 @@ export default function ChatThread({
|
|||
qc.invalidateQueries({ queryKey: ["message-unread"] });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q.dataUpdatedAt, meId, qc]);
|
||||
}, [q.dataUpdatedAt, partnerId, 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
|
||||
|
|
|
|||
|
|
@ -299,6 +299,29 @@ const setupHeaders = (token: string) => ({
|
|||
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
|
||||
|
||||
export const getActiveAccount = activeAccountId;
|
||||
|
||||
// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
|
||||
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
|
||||
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
|
||||
// would be lost.
|
||||
function beacon(url: string, body: Record<string, unknown>): void {
|
||||
const active = getActiveAccount();
|
||||
try {
|
||||
void fetch(url, {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveAccount(id: number): void {
|
||||
try {
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
|
||||
|
|
@ -1102,30 +1125,12 @@ 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 */
|
||||
}
|
||||
},
|
||||
// Fire-and-forget YT progress save that survives page unload (F5/close/navigate).
|
||||
saveProgressBeacon: (id: string, positionSeconds: number, durationSeconds: number): void =>
|
||||
beacon(`/api/videos/${encodeURIComponent(id)}/progress`, {
|
||||
position_seconds: Math.floor(positionSeconds),
|
||||
duration_seconds: Math.floor(durationSeconds),
|
||||
}),
|
||||
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
|
||||
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
||||
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
||||
|
|
@ -1358,23 +1363,12 @@ export const api = {
|
|||
position_seconds: number,
|
||||
duration_seconds: number,
|
||||
final = false,
|
||||
): void => {
|
||||
const active = getActiveAccount();
|
||||
try {
|
||||
void fetch(`/api/plex/item/${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, duration_seconds, final }),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
): void =>
|
||||
beacon(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
||||
position_seconds,
|
||||
duration_seconds,
|
||||
final,
|
||||
}),
|
||||
plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise<unknown> =>
|
||||
req(`/api/plex/item/${encodeURIComponent(id)}/state`, {
|
||||
method: "POST",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue