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,154 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { createGroupQRCode, createMyQRCode, createUserQRCode, type GroupQRCode, type MyQRCode } from '@/lib/api/qrcodes';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
interface QRCodeModalProps {
|
||||
/** Omit both to show the CURRENT user's own code; pass a friend's user_id/account to show theirs
|
||||
* (README's "二维码名片" row on a friend's profile). */
|
||||
identifier?: string;
|
||||
/** 03-group.dc.html #1a's `qr_code_2` icon on the group-info card (admin/owner-visible group-join
|
||||
* code) -- mutually exclusive with `identifier`, a group is never also a user. */
|
||||
groupTopic?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §5.4/§8.2's "我的二维码"/"二维码名片", plus the group-join code (03-group.dc.html #1a). The
|
||||
* QR image encodes `qr_payload` (`im://qr?token=...&purpose=add_friend|join_group`), never a raw
|
||||
* user/group id -- confirmed live for the personal-code path that this is a real, working backend
|
||||
* feature (`POST /me/qrcodes` / `POST /users/{id}/qrcodes`), not a documented-but-unbuilt one; the
|
||||
* group-code path (`POST /groups/{topic}/qrcodes`) is the same documented shape (docs/IM-前后端对接
|
||||
* 文档.md:1590,1737) but not yet live-verified against a real deployment. Re-mints a fresh token on
|
||||
* every mount (POST semantics, not cached) since each one is short-lived (~24h) and single-purpose.
|
||||
*/
|
||||
export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProps) {
|
||||
useEscapeKey(onClose);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [drawError, setDrawError] = useState('');
|
||||
const query = useQuery<MyQRCode | GroupQRCode>({
|
||||
queryKey: ['qrcode', groupTopic ? `group:${groupTopic}` : identifier || 'me'],
|
||||
queryFn: (): Promise<MyQRCode | GroupQRCode> => (groupTopic ? createGroupQRCode(groupTopic) : identifier ? createUserQRCode(identifier) : createMyQRCode()),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data || !canvasRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-generating library
|
||||
// isn't in every visitor's main bundle when most sessions never open this modal.
|
||||
import('qrcode')
|
||||
.then(({ default: QRCode }) => QRCode.toCanvas(canvas, query.data.qr_payload, { width: 200, margin: 1, color: { dark: '#22262d', light: '#ffffff' } }))
|
||||
.catch(() => setDrawError('二维码生成失败'));
|
||||
}, [query.data]);
|
||||
|
||||
const profile = query.data && 'display_profile' in query.data ? query.data.display_profile : undefined;
|
||||
const group = query.data && 'group_snapshot' in query.data ? query.data.group_snapshot : undefined;
|
||||
const title = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
|
||||
const fileBaseName = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
|
||||
const caption = groupTopic ? '扫一扫上面的二维码,申请加入群聊' : '扫一扫上面的二维码,加我为好友';
|
||||
|
||||
const canvasToBlob = (): Promise<Blob | null> => new Promise((resolve) => canvasRef.current?.toBlob((b) => resolve(b), 'image/png'));
|
||||
|
||||
const saveToAlbum = async () => {
|
||||
const blob = await canvasToBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${fileBaseName}.png`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const shareToFriend = async () => {
|
||||
const blob = await canvasToBlob();
|
||||
if (!blob) return;
|
||||
const file = new File([blob], 'qrcode.png', { type: 'image/png' });
|
||||
// Real capability check, not just feature-detecting `navigator.share` itself -- some browsers
|
||||
// implement `share()` for URLs/text only and reject a `files` payload at call time, so
|
||||
// `canShare({ files })` is the one call that actually answers "can this share an image".
|
||||
if (navigator.canShare?.({ files: [file] })) {
|
||||
try {
|
||||
await navigator.share({ files: [file], title: fileBaseName });
|
||||
} catch {
|
||||
// AbortError (user cancelled the native share sheet) or any other failure -- either way
|
||||
// there's nothing useful to surface, the share sheet's own UI already communicated the outcome.
|
||||
}
|
||||
} else {
|
||||
await saveToAlbum();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex w-[360px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-13 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">{title}</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 flex-col items-center gap-3 px-6 py-7">
|
||||
{profile ? (
|
||||
<>
|
||||
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(profile.user_id)}`}>
|
||||
{avatarLetter(profile.display_name)}
|
||||
</span>
|
||||
<strong className="text-base font-semibold text-text-heading">{profile.display_name}</strong>
|
||||
{profile.public_account ? <span className="text-xs text-text-tertiary-2">ID:{profile.public_account}</span> : null}
|
||||
</>
|
||||
) : group ? (
|
||||
<>
|
||||
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(group.topic)}`}>
|
||||
{avatarLetter(group.name)}
|
||||
</span>
|
||||
<strong className="text-base font-semibold text-text-heading">{group.name}</strong>
|
||||
<span className="text-xs text-text-tertiary-2">{group.member_count} 人</span>
|
||||
</>
|
||||
) : null}
|
||||
<div className="mt-2 grid h-[220px] w-[220px] place-items-center rounded-lg border border-border-panel">
|
||||
{query.isLoading ? (
|
||||
<span className="text-sm text-text-tertiary-4">生成中…</span>
|
||||
) : query.isError || drawError ? (
|
||||
<div className="flex flex-col items-center gap-2 px-4 text-center">
|
||||
<Icon name="error_outline" className="text-2xl text-danger-text" />
|
||||
<span className="text-xs text-text-tertiary-4">{drawError || '二维码生成失败,请重试'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<canvas ref={canvasRef} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-text-tertiary-3">{caption}</span>
|
||||
{/* No mock anywhere in this design handoff draws these two buttons (grepped every
|
||||
screens/*.dc.html) -- added on the strength of a near-universal QR-display convention
|
||||
(save/share), not a documented spec. Reuses the `share` icon the add-friend chip
|
||||
(04-contacts.dc.html:101) already uses for the same verb elsewhere in this app. */}
|
||||
{query.data && !drawError ? (
|
||||
<div className="mt-1 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveToAlbum}
|
||||
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
|
||||
>
|
||||
<Icon name="download" className="text-[16px]" />
|
||||
保存到相册
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={shareToFriend}
|
||||
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
|
||||
>
|
||||
<Icon name="share" className="text-[16px]" />
|
||||
分享好友
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user