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 { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSetAtom } from 'jotai';
import { MobileTouchTarget } from '../../styles/mobile.css'; import { MobileTouchTarget } from '../../styles/mobile.css';
import { Membership } from '../../../types/matrix/room'; import { Membership } from '../../../types/matrix/room';
@@ -71,6 +72,7 @@ import { MemberVerificationBadge } from '../../components/MemberVerificationBadg
import { useUserPresence } from '../../hooks/useUserPresence'; import { useUserPresence } from '../../hooks/useUserPresence';
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence'; import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration'; import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
type MemberDrawerHeaderProps = { type MemberDrawerHeaderProps = {
room: Room; room: Room;
@@ -239,11 +241,55 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const myUserId = mx.getUserId(); const myUserId = mx.getUserId();
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined); const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite'); const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
const kickPowerLevel = readPowerLevel.action(powerLevels, 'kick');
const canApproveKnock = myPowerLevel >= invitePowerLevel; 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( const knockMembers = useMemo(
() => (canApproveKnock ? room.getMembersWithMembership(Membership.Knock) : []), () => (canApproveKnock ? room.getMembersWithMembership(Membership.Knock) : []),
[room, canApproveKnock], [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( const filteredMembers = useMemo(
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort), () => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
@@ -462,20 +508,24 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
radii="300" radii="300"
fill="Soft" fill="Soft"
className={MobileTouchTarget} className={MobileTouchTarget}
onClick={() => mx.invite(room.roomId, knockMember.userId)} disabled={pendingKnockAction === knockMember.userId}
onClick={() => handleApproveKnock(knockMember.userId)}
> >
<Text size="B300">Approve</Text> <Text size="B300">Approve</Text>
</Button> </Button>
<Button {canDenyKnock && (
size="300" <Button
variant="Critical" size="300"
radii="300" variant="Critical"
fill="Soft" radii="300"
className={MobileTouchTarget} fill="Soft"
onClick={() => mx.kick(room.roomId, knockMember.userId)} className={MobileTouchTarget}
> disabled={pendingKnockAction === knockMember.userId}
<Text size="B300">Deny</Text> onClick={() => handleDenyKnock(knockMember.userId)}
</Button> >
<Text size="B300">Deny</Text>
</Button>
)}
</Box> </Box>
</Box> </Box>
); );