补全群邀请功能的接收方一侧:系统消息里的群邀请通知现在能真正接受
追查"邀请功能不完善"时发现的更大缺口:群邀请在需要审批的情况下(群自身的
进群验证,或被邀请人自己的隐私设置要求验证)不会立即拉人入群,而是创建一条
真正的邀请记录,通过系统消息投递给对方(event_type: group.invitation.created,
action.type: group_invitation_accept,带invitation_id)——但这条系统消息
此前完全没有专门处理,走的是所有group.*事件通用的"查看群聊"跳转逻辑,直接
导航到一个对方根本还不是成员的会话,大概率打不开。
协议文档把POST /v1/im/group-invitations/{id}/accept标成"待实现",和今天
早些时候发现的草稿持久化接口是同一种文档滞后于后端实际进度的情况——读了
im_be的serveIMGroupInvitationResource源码确认这个接口真实可用。
actionRoute()现在对这个特定事件类型不再返回通用的导航链接,卡片里改成渲染
一个真正的"接受邀请"按钮(阻止事件冒泡,避免和卡片自身的整体点击-标记已读
逻辑冲突),点击后调用accept接口,成功直接跳转到这个群(这时候已经是成员
了),失败在卡片上原地显示失败原因。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
66dad284c7
commit
c53e6552eb
@@ -5,6 +5,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { Toast } from '@/app/Toast';
|
import { Toast } from '@/app/Toast';
|
||||||
|
import { acceptGroupInvitation } from '@/lib/api/groups';
|
||||||
import { useBootstrap } from '@/lib/api/hooks';
|
import { useBootstrap } from '@/lib/api/hooks';
|
||||||
import { getSystemNotifications, markSystemNotificationRead, type SystemNotification } from '@/lib/api/notifications';
|
import { getSystemNotifications, markSystemNotificationRead, type SystemNotification } from '@/lib/api/notifications';
|
||||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||||
@@ -61,6 +62,12 @@ function iconForEvent(eventType: string): { icon: string; className: string } {
|
|||||||
*/
|
*/
|
||||||
function actionRoute(item: SystemNotification): { label: string; to: string } | null {
|
function actionRoute(item: SystemNotification): { label: string; to: string } | null {
|
||||||
if (item.event_type === 'friend.request.created') return { label: '查看新朋友', to: '/contacts/requests' };
|
if (item.event_type === 'friend.request.created') return { label: '查看新朋友', to: '/contacts/requests' };
|
||||||
|
// Handled separately (see the card's own `action.type === 'group_invitation_accept'` branch below,
|
||||||
|
// rendering a real "接受邀请" button) -- NOT a plain navigation like every other group.* event.
|
||||||
|
// You are not yet a member of this group at the point this notification arrives, so navigating
|
||||||
|
// straight to `/messages/{topic}` (what the branch below would otherwise do, since this event also
|
||||||
|
// carries a real `grp...` action.topic) would open a conversation you have no access to.
|
||||||
|
if (item.action?.type === 'group_invitation_accept') return null;
|
||||||
if (item.event_type.startsWith('group.') && typeof item.action?.topic === 'string' && item.action.topic.startsWith('grp')) {
|
if (item.event_type.startsWith('group.') && typeof item.action?.topic === 'string' && item.action.topic.startsWith('grp')) {
|
||||||
return { label: '查看群聊', to: `/messages/${item.action.topic}` };
|
return { label: '查看群聊', to: `/messages/${item.action.topic}` };
|
||||||
}
|
}
|
||||||
@@ -102,6 +109,29 @@ export function SystemMessagesView() {
|
|||||||
},
|
},
|
||||||
onError: (err) => setError(err instanceof Error ? err.message : '标记已读失败'),
|
onError: (err) => setError(err instanceof Error ? err.message : '标记已读失败'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// README/docs :1464/1739's `group.invitation.created` flow -- the doc marks the accept endpoint
|
||||||
|
// "待实现" but it's real (see acceptGroupInvitation's own comment). Success takes you straight to
|
||||||
|
// the group (you're a member now); failure is surfaced inline on the card itself (an expired/
|
||||||
|
// 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>>({});
|
||||||
|
const acceptInvitationMutation = useMutation({
|
||||||
|
mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId),
|
||||||
|
onSuccess: (data, invitationId) => {
|
||||||
|
setInvitationError((prev) => {
|
||||||
|
if (!prev[invitationId]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[invitationId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
navigate(`/messages/${data.topic}`);
|
||||||
|
},
|
||||||
|
onError: (err, invitationId) => {
|
||||||
|
setInvitationError((prev) => ({ ...prev, [invitationId]: err instanceof Error ? err.message : '邀请已失效,请联系邀请人重新邀请' }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const [markingAll, setMarkingAll] = useState(false);
|
const [markingAll, setMarkingAll] = useState(false);
|
||||||
// `unread` only reflects PAGES ALREADY LOADED into this useInfiniteQuery -- looping just that set
|
// `unread` only reflects PAGES ALREADY LOADED into this useInfiniteQuery -- looping just that set
|
||||||
// used to leave any unread notification on a page the user never scrolled to untouched, while the
|
// used to leave any unread notification on a page the user never scrolled to untouched, while the
|
||||||
@@ -234,6 +264,32 @@ export function SystemMessagesView() {
|
|||||||
same illegal-nesting/double-fire shape already fixed once in this codebase for
|
same illegal-nesting/double-fire shape already fixed once in this codebase for
|
||||||
favorites' voice player. */}
|
favorites' voice player. */}
|
||||||
{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' ? (
|
||||||
|
<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>
|
||||||
|
{invitationError[item.action.invitation_id] ? (
|
||||||
|
<span className="text-xs text-danger-text">{invitationError[item.action.invitation_id]}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 08-system-files.dc.html lines 40/108: only the 安全 category gets this footer --
|
{/* 08-system-files.dc.html lines 40/108: only the 安全 category gets this footer --
|
||||||
|
|||||||
@@ -300,13 +300,43 @@ export function updateGroupGovernance(
|
|||||||
return imPatch<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`, patch);
|
return imPatch<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`, patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-invitee outcome, confirmed by reading im_be's serveIMGroupInvitations directly (not just the
|
||||||
|
* doc's own single-outcome worked example): `"joined"` when the group's own governance AND the
|
||||||
|
* target's own privacy setting both don't require approval (added immediately, no further action
|
||||||
|
* needed); `"already_member"`; `"pending"`/`"pending_approval"` when either side DOES require it (a
|
||||||
|
* real GroupInvitation record is created instead, delivered to the target as a system notification --
|
||||||
|
* see SystemMessagesView's `group.invitation.created` handling -- and needs their own accept, this
|
||||||
|
* app has no UI surface for the inviter to ever learn the outcome beyond "sent"); `"failed"` with a
|
||||||
|
* `reason`.
|
||||||
|
*/
|
||||||
|
export interface GroupInvitationResult {
|
||||||
|
user_id: string;
|
||||||
|
status: 'joined' | 'already_member' | 'pending' | 'pending_approval' | 'failed';
|
||||||
|
invitation_id?: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function inviteGroupMembers(topic: string, userIds: string[], message?: string) {
|
export function inviteGroupMembers(topic: string, userIds: string[], message?: string) {
|
||||||
return imPost<{ items: Array<{ user_id: string; status: string; invitation_id?: string }> }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
|
return imPost<{ items: GroupInvitationResult[] }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
|
||||||
user_ids: userIds,
|
user_ids: userIds,
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The target's own action on a `group.invitation.created` system notification (README/docs
|
||||||
|
* :1464/1739 -- doc marks this "待实现" but confirmed by reading im_be's serveIMGroupInvitationResource
|
||||||
|
* directly, it's real and live, same doc-drift as the topic-draft endpoint). Joins the group
|
||||||
|
* immediately on success (no further approval step at this point -- any approval requirement was
|
||||||
|
* already resolved by the invite-creation side choosing "pending"/"pending_approval" as the status
|
||||||
|
* instead of creating a real invitation, or is inherent to a `pending_approval` invitation itself,
|
||||||
|
* which this same endpoint resolves).
|
||||||
|
*/
|
||||||
|
export function acceptGroupInvitation(invitationId: string) {
|
||||||
|
return imPost<{ invitation_id: string; status: string; topic: string }>(`/group-invitations/${encodeURIComponent(invitationId)}/accept`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Confirmed LIVE (2026-09-09) this does NOT match either the protocol doc's own worked example
|
/** Confirmed LIVE (2026-09-09) this does NOT match either the protocol doc's own worked example
|
||||||
* (`docs/IM-前后端对接文档.md:1741`, which shows a nested `applicant:{user_id,display_name}`
|
* (`docs/IM-前后端对接文档.md:1741`, which shows a nested `applicant:{user_id,display_name}`
|
||||||
* object) or this file's own previous type -- the real response is a FLAT `user_id` string with no
|
* object) or this file's own previous type -- the real response is a FLAT `user_id` string with no
|
||||||
|
|||||||
Reference in New Issue
Block a user