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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -0,0 +1,137 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user