Files
im_webapp/web/src/features/groups/JoinRequestsModal.tsx
T
Your NameandClaude Sonnet 5 5c3f3b7653 feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:16:33 +08:00

138 lines
7.4 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { fetchAllPages } from '@/lib/api/client';
import { acceptGroupJoinRequest, getAllGroupMembers, getGroupJoinRequests, rejectGroupJoinRequest, type GroupJoinRequest } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { formatEventTimestamp } from '@/lib/messages/time';
interface JoinRequestsModalProps {
topicId: string;
onClose: () => void;
}
/**
* README §4.6. Reject sends `{version}` ONLY -- no `reason` field, even though the protocol doc's
* own example shows one (see lib/api/groups.ts's rejectGroupJoinRequest comment: the old app
* confirmed live that the real server 400s on an unrecognized `reason` key).
*/
export function JoinRequestsModal({ topicId, onClose }: JoinRequestsModalProps) {
const queryClient = useQueryClient();
const [handled, setHandled] = useState<Record<string, 'accepted' | 'rejected'>>({});
useEscapeKey(onClose);
const query = useQuery({
queryKey: ['group-join-requests', topicId],
// `'pending'`-only used to mean a request resolved before this session (or by another admin)
// just never appeared here at all -- the render below already has a real "已同意/已拒绝" branch
// keyed off the server's own `req.status`, it just never got fed anything but pending requests to
// apply that branch to. README §4.6's own mock shows an already-resolved row persisting in the
// list, matching `'all'`, not `'pending'`.
queryFn: () => fetchAllPages<GroupJoinRequest>((cursor) => getGroupJoinRequests(topicId, 'all', cursor)),
});
// The applicant themself has no display name anywhere on the real response (see groups.ts's own
// GroupJoinRequest comment) and isn't a member yet, so there's no list to resolve THEIR name from
// either -- shown as a bare id, honestly, rather than guessed. The INVITER (mock: "王工 邀请") IS
// already a member by definition, so that name can be resolved the same way GroupFilesModal's
// uploader name and GroupAnnouncementModal's author name already do.
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const memberName = (userId: string) => membersQuery.data?.find((m) => m.user_id === userId)?.display_name || userId;
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['group-summary', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
};
const acceptMutation = useMutation({
mutationFn: (req: GroupJoinRequest) => acceptGroupJoinRequest(topicId, req.request_id, req.version),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'accepted' }));
invalidate();
},
});
const rejectMutation = useMutation({
mutationFn: (req: GroupJoinRequest) => rejectGroupJoinRequest(topicId, req.request_id, req.version),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'rejected' }));
// Unlike accept, nothing here changes group membership -- but `group-summary`'s
// `pending_join_request_count` badge (read by GroupAdminModal's "进群申请" row) does need to
// drop by one, and this previously invalidated nothing at all, leaving that badge stuck high.
invalidate();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[480px] w-[560px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">进群申请</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-2 py-2">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : !query.data?.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无进群申请</div>
) : (
query.data.map((req) => {
const status = handled[req.request_id];
return (
<div key={req.request_id} className="flex items-center gap-3 px-3 py-2.5">
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(req.user_id)}`}>
{avatarLetter(req.user_id)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<strong className="truncate text-sm font-medium text-text-body">{req.user_id}</strong>
<span className="truncate text-xs text-text-tertiary-2">
{/* mock (#1c): "{邀请人} 邀请" when invited, else a source label -- NOT
`source==='qr' ? ... : 邀请`, which used to show "邀请" for every
non-qr source including a plain self-initiated search request that
nobody actually invited (confirmed live: `source` really does take a
`"search"` value distinct from an invite). `inviter_id` is the real
signal for "was this an invite", not `source`. */}
{req.inviter_id ? `${memberName(req.inviter_id)} 邀请` : req.source === 'qr' ? '扫码申请' : '申请加入'}
{req.message ? ` · "${req.message}"` : ''} · {formatEventTimestamp(new Date(req.created_at))}
</span>
</span>
{status || req.status !== 'pending' ? (
<span className="flex-none text-xs text-text-tertiary-3">
{status === 'accepted' || req.status === 'accepted' ? '已同意' : '已拒绝'}
</span>
) : (
<span className="flex flex-none items-center gap-2">
<button
type="button"
onClick={() => rejectMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
>
拒绝
</button>
<button
type="button"
onClick={() => acceptMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
>
同意
</button>
</span>
)}
</div>
);
})
)}
</div>
</div>
</div>
);
}