diff --git a/web/src/features/system/SystemMessagesView.tsx b/web/src/features/system/SystemMessagesView.tsx index 24036280..ac139055 100644 --- a/web/src/features/system/SystemMessagesView.tsx +++ b/web/src/features/system/SystemMessagesView.tsx @@ -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>({}); + 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 ? {action.label} › : null} + {item.action?.type === 'group_invitation_accept' && typeof item.action.invitation_id === 'string' ? ( +
+ {/* 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. */} + + {invitationError[item.action.invitation_id] ? ( + {invitationError[item.action.invitation_id]} + ) : null} +
+ ) : null} {/* 08-system-files.dc.html lines 40/108: only the 安全 category gets this footer -- diff --git a/web/src/lib/api/groups.ts b/web/src/lib/api/groups.ts index fe5f539f..50e2f99c 100644 --- a/web/src/lib/api/groups.ts +++ b/web/src/lib/api/groups.ts @@ -300,13 +300,43 @@ export function updateGroupGovernance( return imPatch(`/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) { - 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, 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 * (`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