补全群邀请功能的接收方一侧:系统消息里的群邀请通知现在能真正接受

追查"邀请功能不完善"时发现的更大缺口:群邀请在需要审批的情况下(群自身的
进群验证,或被邀请人自己的隐私设置要求验证)不会立即拉人入群,而是创建一条
真正的邀请记录,通过系统消息投递给对方(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:
Your Name
2026-09-16 22:26:29 +08:00
co-authored by Claude Sonnet 5
parent 66dad284c7
commit c53e6552eb
2 changed files with 87 additions and 1 deletions
@@ -5,6 +5,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { acceptGroupInvitation } from '@/lib/api/groups';
import { useBootstrap } from '@/lib/api/hooks';
import { getSystemNotifications, markSystemNotificationRead, type SystemNotification } from '@/lib/api/notifications';
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 {
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')) {
return { label: '查看群聊', to: `/messages/${item.action.topic}` };
}
@@ -102,6 +109,29 @@ export function SystemMessagesView() {
},
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);
// `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
@@ -234,6 +264,32 @@ export function SystemMessagesView() {
same illegal-nesting/double-fire shape already fixed once in this codebase for
favorites' voice player. */}
{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>
{/* 08-system-files.dc.html lines 40/108: only the 安全 category gets this footer --