Files
buzz/desktop/src/features/channels/ui/useMembersSidebarModeration.ts
Matt Toohey 48fccaca32 fix(desktop): fence pre-join rosters to read-only
PR #6852 review found the newly reachable pre-join open-channel roster
was not actually read-only: member rows still surfaced community
moderation (Ban/Timeout, from the relay role) and managed-agent
lifecycle, access, and activity controls (from the viewer's agent
directory), since those authorities don't derive from channel role.

Derive one explicit fence, isReadOnlyRoster = channel.isMember !== true,
in MembersSidebar and apply it to every non-profile affordance and its
supporting action-data fetch: add/search (previously the only gate),
community moderation and the restrictions query, role changes, removal,
managed-agent bulk lists, and the agent runtimes query. Member cards
take a readOnly prop that suppresses the entire row actions menu while
identity, presence, status badges, and profile opening stay.

Adds a Playwright regression that seeds the strongest pre-join viewer —
a community admin who owns a running managed bot inside the unjoined
channel — and proves no row menus or bulk controls render before Join,
then that moderation and agent lifecycle/access controls appear after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
2026-09-24 11:40:01 +10:00

118 lines
3.6 KiB
TypeScript

import * as React from "react";
import { toast } from "sonner";
import {
useBanMemberMutation,
useModerationRestrictionsQuery,
useTimeoutMemberMutation,
useUnbanMemberMutation,
useUntimeoutMemberMutation,
} from "@/features/moderation/hooks";
import { useMyRelayMembershipQuery } from "@/features/community-members/hooks";
import { isTimedOut } from "@/features/moderation/lib/restrictionState";
import type { ChannelMember } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { MemberModerationState } from "./MembersSidebarMemberCard";
/**
* Owns community ban/timeout wiring for the members sidebar. Gated by relay
* role (owner/admin), independent of the per-channel role — the relay rejects
* the command events otherwise. Restrictions are only fetched while `enabled`
* (sidebar open on a channel the viewer has joined — pre-join rosters are
* read-only) and the caller can moderate.
*/
export function useMembersSidebarModeration(enabled: boolean) {
const relayMembershipQuery = useMyRelayMembershipQuery();
const relayRole = relayMembershipQuery.data?.role;
const canModerate = relayRole === "owner" || relayRole === "admin";
const restrictionsQuery = useModerationRestrictionsQuery(
enabled && canModerate,
);
const banMutation = useBanMemberMutation();
const unbanMutation = useUnbanMemberMutation();
const timeoutMutation = useTimeoutMemberMutation();
const untimeoutMutation = useUntimeoutMemberMutation();
const isModerationPending =
banMutation.isPending ||
unbanMutation.isPending ||
timeoutMutation.isPending ||
untimeoutMutation.isPending;
const moderationStateByPubkey = React.useMemo(() => {
const nowMs = Date.now();
const map = new Map<string, MemberModerationState>();
for (const restriction of restrictionsQuery.data ?? []) {
map.set(normalizePubkey(restriction.pubkey), {
banned: restriction.banned,
timedOut: isTimedOut(restriction.mutedUntil, nowMs),
});
}
return map;
}, [restrictionsQuery.data]);
const runModerationAction = React.useCallback(
async (action: () => Promise<unknown>, success: string) => {
try {
await action();
toast.success(success);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Moderation action failed",
);
}
},
[],
);
const onBan = React.useCallback(
(member: ChannelMember) =>
void runModerationAction(
() => banMutation.mutateAsync({ pubkey: member.pubkey }),
"Member banned",
),
[banMutation, runModerationAction],
);
const onUnban = React.useCallback(
(member: ChannelMember) =>
void runModerationAction(
() => unbanMutation.mutateAsync(member.pubkey),
"Ban lifted",
),
[unbanMutation, runModerationAction],
);
const onTimeout = React.useCallback(
(member: ChannelMember, expiresAtSecs: number) =>
void runModerationAction(
() =>
timeoutMutation.mutateAsync({
pubkey: member.pubkey,
expiresAt: expiresAtSecs,
}),
"Member timed out",
),
[timeoutMutation, runModerationAction],
);
const onUntimeout = React.useCallback(
(member: ChannelMember) =>
void runModerationAction(
() => untimeoutMutation.mutateAsync(member.pubkey),
"Timeout lifted",
),
[untimeoutMutation, runModerationAction],
);
return {
canModerate,
isModerationPending,
moderationStateByPubkey,
onBan,
onUnban,
onTimeout,
onUntimeout,
};
}