diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 270f303..38b5dff 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -185,7 +185,11 @@ def discover_channels( with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt: apply_channel_details(db, yt.get_channels(need)) db.commit() - rows = sorted(rows, key=lambda r: (-int(r[1]), (r[0].title or "").lower())) + # Match _discovery_rows' ORDER BY exactly: video-count DESC, then title (NULLs LAST, + # as Postgres orders them — coercing NULL→"" would wrongly float untitled channels up). + rows = sorted( + rows, key=lambda r: (-int(r[1]), r[0].title is None, (r[0].title or "").lower()) + ) except YouTubeError as exc: log.warning("discovery: channel enrich failed (user %s): %s", user.id, exc) db.rollback() diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 3d85884..de41d13 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -319,15 +319,10 @@ def get_thread( changed = True if changed: db.commit() - # MB3: tell this user's OTHER tabs/devices their unread dropped so the badge updates - # live instead of waiting for the next 20s poll. Carries the fresh total so the client - # needs no extra fetch. - n = db.scalar( - select(func.count()) - .select_from(Message) - .where(Message.recipient_id == user.id, Message.read_at.is_(None)) - ) - manager.push(user.id, {"type": "unread", "count": int(n or 0)}) + # MB3: ping this user's OTHER tabs/devices that their unread dropped so the badge + # refreshes live instead of waiting for the next 20s poll. The client re-fetches the + # count on this signal, so no number is carried here. + manager.push(user.id, {"type": "unread"}) return {"partner": partner, "items": [_serialize_msg(m) for m in msgs]} diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index d112173..9c56ba2 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -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(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>(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 }) { >