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:
co-authored by
Claude Sonnet 5
parent
6a65998e42
commit
4961c0ae6e
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,11 +279,16 @@ 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. */}
|
) : (
|
||||||
|
// 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={acceptInvitationMutation.isPending}
|
disabled={acceptInvitationMutation.isPending}
|
||||||
@@ -285,6 +303,7 @@ export function SystemMessagesView() {
|
|||||||
? '处理中…'
|
? '处理中…'
|
||||||
: '接受邀请'}
|
: '接受邀请'}
|
||||||
</button>
|
</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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user