fix(moderation): knock Deny gated on kick power; approve/deny errors surfaced

Fixes #49

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 20:28:42 -04:00
co-authored by Claude Opus 5
parent 086e4b3b03
commit 7e64ea398b
+61 -11
View File
@@ -30,6 +30,7 @@ import {
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames';
import { useSetAtom } from 'jotai';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { Membership } from '../../../types/matrix/room';
@@ -71,6 +72,7 @@ import { MemberVerificationBadge } from '../../components/MemberVerificationBadg
import { useUserPresence } from '../../hooks/useUserPresence';
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
type MemberDrawerHeaderProps = {
room: Room;
@@ -239,11 +241,55 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const myUserId = mx.getUserId();
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
const kickPowerLevel = readPowerLevel.action(powerLevels, 'kick');
const canApproveKnock = myPowerLevel >= invitePowerLevel;
// Deny uses mx.kick, which is gated by the room's kick power level, not invite -
// these can differ (e.g. moderators can invite but only admins can kick).
const canDenyKnock = myPowerLevel >= kickPowerLevel;
const knockMembers = useMemo(
() => (canApproveKnock ? room.getMembersWithMembership(Membership.Knock) : []),
[room, canApproveKnock],
);
const setToast = useSetAtom(toastQueueAtom);
const [pendingKnockAction, setPendingKnockAction] = useState<string | undefined>(undefined);
const handleApproveKnock = useCallback(
(userId: string) => {
setPendingKnockAction(userId);
mx.invite(room.roomId, userId)
.catch((err: unknown) => {
console.error('Failed to approve knock request:', err);
setToast(
createErrorToast(
'Could not approve this request. Please try again.',
Icons.Warning,
'Failed',
),
);
})
.finally(() => setPendingKnockAction(undefined));
},
[mx, room.roomId, setToast],
);
const handleDenyKnock = useCallback(
(userId: string) => {
setPendingKnockAction(userId);
mx.kick(room.roomId, userId)
.catch((err: unknown) => {
console.error('Failed to deny knock request:', err);
setToast(
createErrorToast(
'Could not deny this request. Please try again.',
Icons.Warning,
'Failed',
),
);
})
.finally(() => setPendingKnockAction(undefined));
},
[mx, room.roomId, setToast],
);
const filteredMembers = useMemo(
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
@@ -462,20 +508,24 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
radii="300"
fill="Soft"
className={MobileTouchTarget}
onClick={() => mx.invite(room.roomId, knockMember.userId)}
disabled={pendingKnockAction === knockMember.userId}
onClick={() => handleApproveKnock(knockMember.userId)}
>
<Text size="B300">Approve</Text>
</Button>
<Button
size="300"
variant="Critical"
radii="300"
fill="Soft"
className={MobileTouchTarget}
onClick={() => mx.kick(room.roomId, knockMember.userId)}
>
<Text size="B300">Deny</Text>
</Button>
{canDenyKnock && (
<Button
size="300"
variant="Critical"
radii="300"
fill="Soft"
className={MobileTouchTarget}
disabled={pendingKnockAction === knockMember.userId}
onClick={() => handleDenyKnock(knockMember.userId)}
>
<Text size="B300">Deny</Text>
</Button>
)}
</Box>
</Box>
);