群成员格子改用头像外角标 ⊖ 移出,替换上一版的行内文字按钮
上一版把 ➖ 加在了"查看全部"弹层的成员行文字按钮里,用户纠正:应该是紧凑头像格子 (群聊设置面板里"查看全部"之前那一屏)里,像"+邀请"那样在头像外侧显示一个独立的 角标,而不是列表行内的文字。canRemoveMember 挪到 lib/api/groups.ts 共享,避免 两处判断逻辑分叉。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
2d589cbe6b
commit
05ee23aefa
@@ -12,6 +12,7 @@ import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal
|
|||||||
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
|
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
|
||||||
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
|
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
|
||||||
import {
|
import {
|
||||||
|
canRemoveMember,
|
||||||
disbandGroup,
|
disbandGroup,
|
||||||
getGroup,
|
getGroup,
|
||||||
getMyGroupProfile,
|
getMyGroupProfile,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
getGroupSettings,
|
getGroupSettings,
|
||||||
getGroupSummary,
|
getGroupSummary,
|
||||||
leaveGroup,
|
leaveGroup,
|
||||||
|
removeGroupMember,
|
||||||
updateGroup,
|
updateGroup,
|
||||||
updateGroupMemberSettings,
|
updateGroupMemberSettings,
|
||||||
updateMyGroupNickname,
|
updateMyGroupNickname,
|
||||||
@@ -71,6 +73,28 @@ function RoleCorner({ role }: { role: GroupMember['role'] }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Corner "-" badge for a removable member's avatar in the compact grid -- the "outside the avatar,
|
||||||
|
* like the +邀请 tile" treatment (explicit user correction: a text "移出" link inside a member ROW is
|
||||||
|
* the full-list modal's job, not this grid's). Opposite corner from RoleCorner (top-right, not
|
||||||
|
* top-left) because both can legitimately apply to the same avatar at once -- an owner removing an
|
||||||
|
* admin still needs the "管" tag AND this badge on one avatar. */
|
||||||
|
function RemoveBadge({ pending, onRemove }: { pending: boolean; onRemove: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
aria-label="移出群聊"
|
||||||
|
className="absolute -right-1 -top-1 z-10 grid h-4 w-4 place-items-center rounded-full bg-danger-text text-white shadow-sm disabled:opacity-60 md:h-[18px] md:w-[18px]"
|
||||||
|
>
|
||||||
|
<Icon name="remove" className="text-[10px]" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* README §4.4's 群聊设置 detail rail (group-owner/admin/member view). Sibling of DetailColumn.tsx
|
* README §4.4's 群聊设置 detail rail (group-owner/admin/member view). Sibling of DetailColumn.tsx
|
||||||
* (the P2P equivalent) -- same layout conventions, but group-specific data source (lib/api/groups.ts,
|
* (the P2P equivalent) -- same layout conventions, but group-specific data source (lib/api/groups.ts,
|
||||||
@@ -183,6 +207,19 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
onError: (err) => showError(err, '群名保存失败'),
|
onError: (err) => showError(err, '群名保存失败'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drives the corner "-" badge on the compact member grid below -- same removeGroupMember call and
|
||||||
|
// same invalidation keys as GroupMembersModal's own remove button (canRemoveMember is now shared
|
||||||
|
// from lib/api/groups.ts so the two surfaces can't drift on who's eligible to remove whom).
|
||||||
|
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) => showError(err, '移出失败,请重试'),
|
||||||
|
});
|
||||||
|
|
||||||
// Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting
|
// Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting
|
||||||
// on the async native/`group.dissolved` gone-signal that normally follows a group actually going
|
// on the async native/`group.dissolved` gone-signal that normally follows a group actually going
|
||||||
// away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale
|
// away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale
|
||||||
@@ -372,6 +409,17 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
className="h-11 w-11 rounded-lg text-sm md:h-10 md:w-10"
|
className="h-11 w-11 rounded-lg text-sm md:h-10 md:w-10"
|
||||||
/>
|
/>
|
||||||
<RoleCorner role={m.role} />
|
<RoleCorner role={m.role} />
|
||||||
|
{canRemoveMember(myUserId, myRole || 'member', m) ? (
|
||||||
|
<RemoveBadge
|
||||||
|
pending={removeMutation.isPending && removeMutation.variables === m.user_id}
|
||||||
|
onRemove={async () => {
|
||||||
|
const label = m.display_name || m.user_id;
|
||||||
|
if (await requestConfirm({ message: `确定将"${label}"移出群聊吗?`, danger: true })) {
|
||||||
|
removeMutation.mutate(m.user_id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
|
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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, removeGroupMember, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
|
import { canRemoveMember, 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';
|
import { requestConfirm } from '@/stores/confirmDialog';
|
||||||
@@ -64,15 +64,6 @@ const OUTCOME_LABEL: Record<InviteOutcome, string> = {
|
|||||||
failed: '邀请失败',
|
failed: '邀请失败',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 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) {
|
export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initialView = 'members', onClose }: GroupMembersModalProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
@@ -213,6 +213,18 @@ export function removeGroupMember(topic: string, userId: string) {
|
|||||||
return imDelete<{ topic: string; user_id: string; status: string }>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}`);
|
return imDelete<{ topic: string; user_id: string; status: string }>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
* Shared by every surface that renders a per-member remove affordance (GroupMembersModal's full
|
||||||
|
* list, GroupDetailColumn's compact avatar grid) so the rule can't drift between the two. */
|
||||||
|
export 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 interface GroupMemberProfile {
|
export interface GroupMemberProfile {
|
||||||
topic: string;
|
topic: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user