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"
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue