diff --git a/web/src/features/groups/GroupDetailColumn.tsx b/web/src/features/groups/GroupDetailColumn.tsx index 368ea1dc..ce10cf84 100644 --- a/web/src/features/groups/GroupDetailColumn.tsx +++ b/web/src/features/groups/GroupDetailColumn.tsx @@ -522,6 +522,8 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: setOpenModal(null)} /> diff --git a/web/src/features/groups/GroupMembersModal.tsx b/web/src/features/groups/GroupMembersModal.tsx index 82f746d4..7916b61d 100644 --- a/web/src/features/groups/GroupMembersModal.tsx +++ b/web/src/features/groups/GroupMembersModal.tsx @@ -6,9 +6,10 @@ import { Icon } from '@/app/Icon'; import { Toast } from '@/app/Toast'; import { fetchAllPages } from '@/lib/api/client'; import { getFriends } from '@/lib/api/friends'; -import { getAllGroupMembers, inviteGroupMembers, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups'; +import { getAllGroupMembers, inviteGroupMembers, removeGroupMember, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useEscapeKey } from '@/lib/hooks'; +import { requestConfirm } from '@/stores/confirmDialog'; const ROLE_LABEL: Record = { owner: '群主', admin: '管理员', member: '' }; // README §4.3: role tags are a BORDER style, no fill -- matches MessageBubble.tsx's already-correct @@ -28,6 +29,10 @@ interface GroupMembersModalProps { * from the same settings query) rather than re-deriving from a bare role here, which is exactly * what silently made that governance toggle a no-op before this was wired through. */ canInvite: boolean; + /** Own user id + role -- drives per-row remove-member eligibility (canRemoveMember below), mirroring + * the backend's own canRemoveIMGroupMember rule exactly rather than re-guessing it client-side. */ + myUserId: string | null; + myRole: GroupRole; /** "invite" opens straight into the invite-candidates view, skipping the member list entirely -- * the dedicated "邀请" entry point (GroupDetailColumn's dashed-border tile) used to route through * here too but land on the member list regardless, requiring a SECOND click on an in-modal "邀请" @@ -40,9 +45,10 @@ interface GroupMembersModalProps { /** README §4.4's "查看全部成员" -- full member list, plus an invite flow backed by * `inviteGroupMembers` (README §4.7's create/invite modal covers the same endpoint for adding to - * an existing group, not just at creation time). No "移出成员" affordance here: this protocol has - * no documented REST or WS operation for removing a group member at all (grepped the whole - * protocol doc) -- a real backend gap, not a client oversight, so this doesn't pretend to offer it. */ + * an existing group, not just at creation time). "移出成员" (removeGroupMember) used to have no + * documented REST or WS operation at all -- confirmed live 2026-09-23 the backend has since added + * `DELETE /groups/{topic}/members/{user_id}` (see removeGroupMember's own comment), so this is no + * longer a backend gap. */ /** Per-candidate outcome once invited THIS session -- keyed by user_id, separate from `alreadyMember` * (a person can be successfully invited without becoming a member yet -- see the "pending"/ * "pending_approval" case below). No REST endpoint exists to list a group's outstanding invitations @@ -58,7 +64,17 @@ const OUTCOME_LABEL: Record = { failed: '邀请失败', }; -export function GroupMembersModal({ topicId, canInvite, initialView = 'members', onClose }: GroupMembersModalProps) { +/** Mirrors server/im_http.go's canRemoveIMGroupMember exactly: can't remove yourself or the owner; + * removing a plain member needs admin-or-better, removing an admin needs the owner specifically. */ +function canRemoveMember(myUserId: string | null, myRole: GroupRole, member: { user_id: string; role: GroupRole }): boolean { + if (!myUserId || member.user_id === myUserId) return false; + if (myRole === 'member') return false; + if (member.role === 'owner') return false; + if (member.role === 'admin' && myRole !== 'owner') return false; + return true; +} + +export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initialView = 'members', onClose }: GroupMembersModalProps) { const queryClient = useQueryClient(); const [query, setQuery] = useState(''); const [inviting, setInviting] = useState(initialView === 'invite'); @@ -100,6 +116,16 @@ export function GroupMembersModal({ topicId, canInvite, initialView = 'members', onError: (err) => setError(err instanceof Error ? err.message : '邀请失败'), }); + const removeMutation = useMutation({ + mutationFn: (userId: string) => removeGroupMember(topicId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['group-members', topicId] }); + queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] }); + queryClient.invalidateQueries({ queryKey: ['group', topicId] }); + }, + onError: (err) => setError(err instanceof Error ? err.message : '移出失败'), + }); + return (
e.stopPropagation()}> @@ -178,6 +204,21 @@ export function GroupMembersModal({ topicId, canInvite, initialView = 'members', {m.display_name || m.user_id} + {canRemoveMember(myUserId, myRole, m) ? ( + + ) : null}
)) )} diff --git a/web/src/features/system/SystemMessagesView.tsx b/web/src/features/system/SystemMessagesView.tsx index ac139055..e6584d62 100644 --- a/web/src/features/system/SystemMessagesView.tsx +++ b/web/src/features/system/SystemMessagesView.tsx @@ -116,6 +116,17 @@ export function SystemMessagesView() { // already-handled invitation, or a race where someone else's action beat you to it), not just a // toast, so it doesn't look like the button silently did nothing. const [invitationError, setInvitationError] = useState>({}); + // Real, reported bug: this card's "接受邀请" button stayed live and clickable forever, even for an + // invitation already accepted (in an earlier visit to this same list) or whose group had since been + // dissolved -- there was no concept of "this one's done" anywhere, on success OR failure, so every + // render just re-derived "show the button" from the notification's own static `action` payload, + // which the server never updates after the fact. Once EITHER outcome happens for a given + // invitation_id, it should never look freshly-clickable again -- a genuinely transient failure + // (a network blip) is rare enough, and "already handled"/"group gone" common enough, that treating + // every failure as terminal is the safer default. Doesn't survive a page reload (the server's own + // notification record has no resolved flag to read back), only this list staying mounted -- a real, + // acknowledged gap, not silently pretended away. + const [resolvedInvitations, setResolvedInvitations] = useState>({}); const acceptInvitationMutation = useMutation({ mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId), onSuccess: (data, invitationId) => { @@ -125,10 +136,12 @@ export function SystemMessagesView() { delete next[invitationId]; return next; }); + setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'accepted' })); navigate(`/messages/${data.topic}`); }, onError: (err, invitationId) => { setInvitationError((prev) => ({ ...prev, [invitationId]: err instanceof Error ? err.message : '邀请已失效,请联系邀请人重新邀请' })); + setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'failed' })); }, }); @@ -266,25 +279,31 @@ export function SystemMessagesView() { {action ? {action.label} › : null} {item.action?.type === 'group_invitation_accept' && typeof item.action.invitation_id === 'string' ? (
- {/* A REAL nested button, unlike the plain-text action label above -- accepting - is a distinct action from "open this card" (which only marks it read; see - actionRoute's own comment for why this event type has no plain navigation - at all), so stopPropagation is the right tool here rather than avoiding the - nesting the way the plain-text case does. */} - + {resolvedInvitations[item.action.invitation_id] === 'accepted' ? ( + 已加入群聊 + ) : resolvedInvitations[item.action.invitation_id] === 'failed' ? ( + 邀请已失效 + ) : ( + // A REAL nested button, unlike the plain-text action label above -- accepting + // is a distinct action from "open this card" (which only marks it read; see + // actionRoute's own comment for why this event type has no plain navigation + // at all), so stopPropagation is the right tool here rather than avoiding the + // nesting the way the plain-text case does. + + )} {invitationError[item.action.invitation_id] ? ( {invitationError[item.action.invitation_id]} ) : null} diff --git a/web/src/lib/api/groups.ts b/web/src/lib/api/groups.ts index df8dfd8b..d3dd340b 100644 --- a/web/src/lib/api/groups.ts +++ b/web/src/lib/api/groups.ts @@ -186,6 +186,17 @@ export function getAllGroupMembers( return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor })); } +/** + * `DELETE /groups/{topic}/members/{user_id}` -- confirmed live (2026-09-23) this route now exists + * (`server/im_http.go`'s `serveIMGroupMember`, `canRemoveIMGroupMember`): owner or admin can remove a + * plain member, only the owner can remove an admin, nobody (owner included) can remove the owner or + * themselves. An earlier pass through the protocol doc found no such operation at all -- that was + * true then, the backend has since added it, this isn't a doc-vs-reality mismatch this time. + */ +export function removeGroupMember(topic: string, userId: string) { + return imDelete<{ topic: string; user_id: string; status: string }>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}`); +} + export interface GroupMemberProfile { topic: string; user_id: string;