From 7e64ea398b5e9b977df9eba8333289ddc039f11d Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 20:28:42 -0400 Subject: [PATCH] fix(moderation): knock Deny gated on kick power; approve/deny errors surfaced Fixes #49 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/room/MembersDrawer.tsx | 72 +++++++++++++++++++++---- 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 54fb4ff1b..eb62e2b23 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -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(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)} > Approve - + {canDenyKnock && ( + + )} );