fix(groups,system): remove-member action + stale invite button

Adds removeGroupMember (DELETE /groups/{topic}/members/{user_id}) --
confirmed live the backend now has this route (server/im_http.go's
serveIMGroupMember + canRemoveIMGroupMember), which an earlier pass
recorded as a genuine protocol gap; it isn't anymore. Wired into
GroupMembersModal with a per-row "移出" button gated by the same rule
the backend enforces (owner or admin removes a plain member, only the
owner removes an admin, nobody removes the owner or themselves), with
a confirm dialog before the real DELETE call. Verified live end-to-end
against a real owned group: member count 4 -> 3, list updates
correctly (the side panel's own separate "群成员 N" summary count
still trailed behind after this -- confirmed via a direct GET that the
server's own group payload hadn't recalculated member_count yet, a
backend read-consistency lag unrelated to this fix, not chased
further).

Also fixes the reported "已处理的群邀请消息,即使群解散了,接受邀请
按钮还是一直可以点" bug: the button's visibility used to be derived
purely from the notification's static action payload every render, so
neither a successful accept nor a definitively-failed one (expired,
already handled, group gone) ever stopped it from looking freshly
clickable again. Tracks per-invitation resolved state (accepted/
failed) once either outcome happens and swaps the button for a static
resolved label. Doesn't survive a page reload for invitations resolved
in an earlier visit (the notification record itself carries no
resolved flag to read back) -- a real, acknowledged limit, not
silently hidden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-23 16:08:07 +08:00
co-authored by Claude Sonnet 5
parent 6a65998e42
commit 4961c0ae6e
4 changed files with 97 additions and 24 deletions
@@ -522,6 +522,8 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
<GroupMembersModal <GroupMembersModal
topicId={topicId} topicId={topicId}
canInvite={canInvite} canInvite={canInvite}
myUserId={myUserId}
myRole={myRole || 'member'}
initialView={openModal === 'invite' ? 'invite' : 'members'} initialView={openModal === 'invite' ? 'invite' : 'members'}
onClose={() => setOpenModal(null)} onClose={() => setOpenModal(null)}
/> />
+46 -5
View File
@@ -6,9 +6,10 @@ import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast'; import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client'; import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends'; 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 { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks'; import { useEscapeKey } from '@/lib/hooks';
import { requestConfirm } from '@/stores/confirmDialog';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' }; const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
// README §4.3: role tags are a BORDER style, no fill -- matches MessageBubble.tsx's already-correct // 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 * 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. */ * what silently made that governance toggle a no-op before this was wired through. */
canInvite: boolean; 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 -- /** "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 * 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 "邀请" * 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 /** 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 * `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 * an existing group, not just at creation time). "移出成员" (removeGroupMember) used to have no
* no documented REST or WS operation for removing a group member at all (grepped the whole * documented REST or WS operation at all -- confirmed live 2026-09-23 the backend has since added
* protocol doc) -- a real backend gap, not a client oversight, so this doesn't pretend to offer it. */ * `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` /** 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"/ * (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 * "pending_approval" case below). No REST endpoint exists to list a group's outstanding invitations
@@ -58,7 +64,17 @@ const OUTCOME_LABEL: Record<InviteOutcome, string> = {
failed: '邀请失败', 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 queryClient = useQueryClient();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [inviting, setInviting] = useState(initialView === 'invite'); 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 : '邀请失败'), 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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}> <div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
@@ -178,6 +204,21 @@ export function GroupMembersModal({ topicId, canInvite, initialView = 'members',
</span> </span>
<span className="flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span> <span className="flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
<RoleTag role={m.role} /> <RoleTag role={m.role} />
{canRemoveMember(myUserId, myRole, m) ? (
<button
type="button"
disabled={removeMutation.isPending && removeMutation.variables === m.user_id}
onClick={async () => {
const label = m.display_name || m.user_id;
if (await requestConfirm({ message: `确定将"${label}"移出群聊吗?`, danger: true })) {
removeMutation.mutate(m.user_id);
}
}}
className="flex-none text-xs text-danger-text disabled:opacity-60"
>
{removeMutation.isPending && removeMutation.variables === m.user_id ? '移出中…' : '移出'}
</button>
) : null}
</div> </div>
)) ))
)} )}
+38 -19
View File
@@ -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 // 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. // toast, so it doesn't look like the button silently did nothing.
const [invitationError, setInvitationError] = useState<Record<string, string>>({}); const [invitationError, setInvitationError] = useState<Record<string, string>>({});
// 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<Record<string, 'accepted' | 'failed'>>({});
const acceptInvitationMutation = useMutation({ const acceptInvitationMutation = useMutation({
mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId), mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId),
onSuccess: (data, invitationId) => { onSuccess: (data, invitationId) => {
@@ -125,10 +136,12 @@ export function SystemMessagesView() {
delete next[invitationId]; delete next[invitationId];
return next; return next;
}); });
setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'accepted' }));
navigate(`/messages/${data.topic}`); navigate(`/messages/${data.topic}`);
}, },
onError: (err, invitationId) => { onError: (err, invitationId) => {
setInvitationError((prev) => ({ ...prev, [invitationId]: err instanceof Error ? err.message : '邀请已失效,请联系邀请人重新邀请' })); setInvitationError((prev) => ({ ...prev, [invitationId]: err instanceof Error ? err.message : '邀请已失效,请联系邀请人重新邀请' }));
setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'failed' }));
}, },
}); });
@@ -266,25 +279,31 @@ export function SystemMessagesView() {
{action ? <span className="self-start text-sm text-accent">{action.label} ›</span> : null} {action ? <span className="self-start text-sm text-accent">{action.label} ›</span> : null}
{item.action?.type === 'group_invitation_accept' && typeof item.action.invitation_id === 'string' ? ( {item.action?.type === 'group_invitation_accept' && typeof item.action.invitation_id === 'string' ? (
<div className="mt-1 flex flex-col items-start gap-1.5"> <div className="mt-1 flex flex-col items-start gap-1.5">
{/* A REAL nested button, unlike the plain-text action label above -- accepting {resolvedInvitations[item.action.invitation_id] === 'accepted' ? (
is a distinct action from "open this card" (which only marks it read; see <span className="text-xs text-text-tertiary-4">已加入群聊</span>
actionRoute's own comment for why this event type has no plain navigation ) : resolvedInvitations[item.action.invitation_id] === 'failed' ? (
at all), so stopPropagation is the right tool here rather than avoiding the <span className="text-xs text-text-tertiary-4">邀请已失效</span>
nesting the way the plain-text case does. */} ) : (
<button // A REAL nested button, unlike the plain-text action label above -- accepting
type="button" // is a distinct action from "open this card" (which only marks it read; see
disabled={acceptInvitationMutation.isPending} // actionRoute's own comment for why this event type has no plain navigation
onClick={(e) => { // at all), so stopPropagation is the right tool here rather than avoiding the
e.stopPropagation(); // nesting the way the plain-text case does.
if (isUnread) readMutation.mutate(item.id); <button
acceptInvitationMutation.mutate(item.action!.invitation_id as string); type="button"
}} disabled={acceptInvitationMutation.isPending}
className="rounded-lg bg-btn px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60" onClick={(e) => {
> e.stopPropagation();
{acceptInvitationMutation.isPending && acceptInvitationMutation.variables === item.action.invitation_id if (isUnread) readMutation.mutate(item.id);
? '处理中…' acceptInvitationMutation.mutate(item.action!.invitation_id as string);
: '接受邀请'} }}
</button> className="rounded-lg bg-btn px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
>
{acceptInvitationMutation.isPending && acceptInvitationMutation.variables === item.action.invitation_id
? '处理中…'
: '接受邀请'}
</button>
)}
{invitationError[item.action.invitation_id] ? ( {invitationError[item.action.invitation_id] ? (
<span className="text-xs text-danger-text">{invitationError[item.action.invitation_id]}</span> <span className="text-xs text-danger-text">{invitationError[item.action.invitation_id]}</span>
) : null} ) : null}
+11
View File
@@ -186,6 +186,17 @@ export function getAllGroupMembers(
return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor })); 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 { export interface GroupMemberProfile {
topic: string; topic: string;
user_id: string; user_id: string;