修复邀请入群交互:点击邀请要直接进入邀请,而不是先看成员列表再点第二次
用户实测反馈的真实bug:GroupDetailColumn里那个专门的"+邀请"图标(虚线边框
方块,只有canInvite为真才显示——这个权限门控本身一直是对的)点击后走的是
setOpenModal('members'),和"查看全部成员"是完全同一个入口——弹出的是成员
列表,标题栏里那个真正的"邀请"按钮还要再点一次才能看到可邀请的联系人列表。
GroupMembersModal新增initialView属性,"invite"直接以邀请视图打开(跳过
成员列表这一步),"查看全部"/"查看全部成员"两处不受影响仍然打开成员列表。
同时补全了邀请结果的反馈——原来邀请成功后只做了查询失效,无论是"立即加入"
还是需要对方/群设置审批的"待确认",界面上都没有任何区别于点击前的视觉反馈,
容易让人怀疑是不是点了没反应从而重复点击。读了im_be的serveIMGroupInvitations
源码(不是协议文档的单一示例)确认真实的单人邀请结果有joined/already_member/
pending/pending_approval/failed五种可能,现在每种都有对应的行内文案,
"failed"不计入"已处理"状态,允许原地重试。
实机验证:全新建群后点"+邀请"直接进入邀请视图(标题"邀请成员",不经过成员
列表);邀请一个全新好友后确认成功加入、群成员数正确从2变3、界面正确显示
结果反馈。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3fab8b08b9
commit
66dad284c7
@@ -81,7 +81,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
const [nicknameEditing, setNicknameEditing] = useState(false);
|
||||
const [nicknameDraft, setNicknameDraft] = useState('');
|
||||
const [errorToast, setErrorToast] = useState('');
|
||||
const [openModal, setOpenModal] = useState<'members' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
|
||||
const [openModal, setOpenModal] = useState<'members' | 'invite' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
|
||||
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
|
||||
|
||||
// Same long-lived-instance leak this exact pattern already caused twice in DetailColumn.tsx --
|
||||
@@ -284,7 +284,13 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
</div>
|
||||
))}
|
||||
{canInvite ? (
|
||||
<button type="button" onClick={() => setOpenModal('members')} className="flex flex-col items-center gap-1">
|
||||
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
|
||||
// "invite"`), not the member list -- this dedicated "+邀请" tile landing on the member
|
||||
// list and requiring a SECOND click on an in-modal "邀请" button was a real, reported
|
||||
// UX complaint ("点击邀请那个按钮弹出的并不是直接到邀请,而是显示的成员列表,还要再次
|
||||
// 点击邀请"). "查看全部"/"查看全部成员" right below/above this still correctly open the
|
||||
// plain member list -- only THIS tile's whole purpose is inviting.
|
||||
<button type="button" onClick={() => setOpenModal('invite')} className="flex flex-col items-center gap-1">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3 md:h-10 md:w-10">
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
</span>
|
||||
@@ -420,8 +426,13 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
|
||||
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
|
||||
|
||||
{openModal === 'members' ? (
|
||||
<GroupMembersModal topicId={topicId} canInvite={canInvite} onClose={() => setOpenModal(null)} />
|
||||
{openModal === 'members' || openModal === 'invite' ? (
|
||||
<GroupMembersModal
|
||||
topicId={topicId}
|
||||
canInvite={canInvite}
|
||||
initialView={openModal === 'invite' ? 'invite' : 'members'}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
) : null}
|
||||
{openModal === 'announcement' ? (
|
||||
<GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(null)} />
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 GroupRole } from '@/lib/api/groups';
|
||||
import { getAllGroupMembers, inviteGroupMembers, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
@@ -28,6 +28,13 @@ 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;
|
||||
/** "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 "邀请"
|
||||
* button to actually reach this view (a real, reported UX complaint: "点击邀请那个按钮弹出的并不是
|
||||
* 直接到邀请,而是显示的成员列表,还要再次点击邀请"). Defaults to "members" for every OTHER caller
|
||||
* (查看全部成员 etc.), which should still land on the member list as before. */
|
||||
initialView?: 'members' | 'invite';
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -36,11 +43,27 @@ interface GroupMembersModalProps {
|
||||
* 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. */
|
||||
export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersModalProps) {
|
||||
/** 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
|
||||
* (confirmed: grepped the protocol doc), so this can only ever reflect invites sent from THIS modal
|
||||
* session, not ones sent earlier and never followed up on -- reopening the modal loses this. */
|
||||
type InviteOutcome = GroupInvitationResult['status'];
|
||||
|
||||
const OUTCOME_LABEL: Record<InviteOutcome, string> = {
|
||||
joined: '已加入',
|
||||
already_member: '已在群内',
|
||||
pending: '邀请已发送',
|
||||
pending_approval: '待对方确认',
|
||||
failed: '邀请失败',
|
||||
};
|
||||
|
||||
export function GroupMembersModal({ topicId, canInvite, initialView = 'members', onClose }: GroupMembersModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [query, setQuery] = useState('');
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [inviting, setInviting] = useState(initialView === 'invite');
|
||||
const [error, setError] = useState('');
|
||||
const [outcomeByUser, setOutcomeByUser] = useState<Record<string, InviteOutcome>>({});
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
@@ -59,7 +82,17 @@ export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersM
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (userId: string) => inviteGroupMembers(topicId, [userId]),
|
||||
onSuccess: () => {
|
||||
onSuccess: (data, userId) => {
|
||||
// A real, previously-missing gap this fixes: the old version only ever refetched the member
|
||||
// list and left it at that -- for the common "joined immediately" case that's a silent success
|
||||
// with no feedback beyond the candidate eventually re-rendering as "已在群内" on its own, and
|
||||
// for "pending"/"pending_approval" (requires the target's own action, or the group's/their own
|
||||
// approval setting) there was NO feedback at all -- the row just sat there looking un-invited,
|
||||
// inviting a confused repeat click. `items[0]` because this call always invites exactly one
|
||||
// user_id at a time.
|
||||
const outcome = data.items[0]?.status;
|
||||
if (outcome) setOutcomeByUser((prev) => ({ ...prev, [userId]: outcome }));
|
||||
if (outcome === 'failed' && data.items[0]?.reason) setError(data.items[0].reason);
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
@@ -101,22 +134,34 @@ export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersM
|
||||
.filter((f) => !query.trim() || (f.remark || f.display_name || f.user_id).toLowerCase().includes(query.trim().toLowerCase()))
|
||||
.map((f) => {
|
||||
const label = f.remark || f.display_name || f.user_id;
|
||||
// `alreadyMember` (a real member, known up front) and a THIS-SESSION invite outcome
|
||||
// are two different reasons a candidate can be non-actionable -- alreadyMember wins if
|
||||
// both are somehow true (the member-list refetch above should already have cleared the
|
||||
// "joined" outcome to alreadyMember=true by then anyway).
|
||||
const outcome = f.alreadyMember ? 'already_member' : outcomeByUser[f.user_id];
|
||||
// "failed" deliberately does NOT count as settled -- could be a transient network blip,
|
||||
// and there's no harm in letting the same click retry (a genuinely permanent failure,
|
||||
// e.g. the target blocked invites, just fails again harmlessly). Every other outcome is
|
||||
// a real, done state with nothing left to retry.
|
||||
const settled = !!outcome && outcome !== 'failed';
|
||||
return (
|
||||
<button
|
||||
key={f.user_id}
|
||||
type="button"
|
||||
disabled={inviteMutation.isPending || f.alreadyMember}
|
||||
disabled={inviteMutation.isPending || settled}
|
||||
onClick={() => inviteMutation.mutate(f.user_id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:hover:bg-transparent ${
|
||||
f.alreadyMember ? 'opacity-50' : ''
|
||||
settled ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}>
|
||||
{avatarLetter(label)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{label}</span>
|
||||
{f.alreadyMember ? (
|
||||
<span className="flex-none text-xs text-text-tertiary-3">已在群内</span>
|
||||
{outcome ? (
|
||||
<span className={`flex-none text-xs ${outcome === 'failed' ? 'text-danger-text' : 'text-text-tertiary-3'}`}>
|
||||
{OUTCOME_LABEL[outcome]}
|
||||
</span>
|
||||
) : (
|
||||
<Icon name="person_add" className="text-[18px] text-accent" />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user