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:
npeter83 2026-07-12 06:21:29 +02:00
parent f0198f2e0a
commit 3394dabbeb
5 changed files with 72 additions and 64 deletions

View file

@ -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"
>