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
topicId={topicId}
canInvite={canInvite}
myUserId={myUserId}
myRole={myRole || 'member'}
initialView={openModal === 'invite' ? 'invite' : 'members'}
onClose={() => setOpenModal(null)}
/>
+46 -5
View File
@@ -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<GroupRole, string> = { 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<InviteOutcome, string> = {
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 (
<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()}>
@@ -178,6 +204,21 @@ export function GroupMembersModal({ topicId, canInvite, initialView = 'members',
</span>
<span className="flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
<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>
))
)}
+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
// toast, so it doesn't look like the button silently did nothing.
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({
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 ? <span className="self-start text-sm text-accent">{action.label} ›</span> : null}
{item.action?.type === 'group_invitation_accept' && typeof item.action.invitation_id === 'string' ? (
<div className="mt-1 flex flex-col items-start gap-1.5">
{/* 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. */}
<button
type="button"
disabled={acceptInvitationMutation.isPending}
onClick={(e) => {
e.stopPropagation();
if (isUnread) readMutation.mutate(item.id);
acceptInvitationMutation.mutate(item.action!.invitation_id as string);
}}
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>
{resolvedInvitations[item.action.invitation_id] === 'accepted' ? (
<span className="text-xs text-text-tertiary-4">已加入群聊</span>
) : resolvedInvitations[item.action.invitation_id] === 'failed' ? (
<span className="text-xs text-text-tertiary-4">邀请已失效</span>
) : (
// 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.
<button
type="button"
disabled={acceptInvitationMutation.isPending}
onClick={(e) => {
e.stopPropagation();
if (isUnread) readMutation.mutate(item.id);
acceptInvitationMutation.mutate(item.action!.invitation_id as string);
}}
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] ? (
<span className="text-xs text-danger-text">{invitationError[item.action.invitation_id]}</span>
) : null}
+11
View File
@@ -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;