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>
138 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|