mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
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>
118 lines
3.6 KiB
TypeScript
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,
|
|
};
|
|
}
|