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>
184 lines
7.2 KiB
TypeScript
184 lines
7.2 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
|
|
import { Icon } from '@/app/Icon';
|
|
import { useAuthedBlobUrl } from '@/lib/hooks';
|
|
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
|
|
|
interface ImageData {
|
|
val?: string;
|
|
ref?: string;
|
|
mime?: string;
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
export function ImageAttachment({ data }: { data: ImageData }) {
|
|
const inline = typeof data.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
|
|
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
|
const src = inline || remote.url;
|
|
|
|
if (!src) {
|
|
return (
|
|
<div className="grid h-[170px] w-[260px] place-items-center rounded-lg bg-surface-rail">
|
|
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[28px] text-text-tertiary-3" />
|
|
</div>
|
|
);
|
|
}
|
|
// README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box.
|
|
return <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
|
|
}
|
|
|
|
interface FileData {
|
|
val?: string;
|
|
ref?: string;
|
|
mime?: string;
|
|
name?: string;
|
|
size?: number;
|
|
}
|
|
|
|
export function FileAttachment({ data }: { data: FileData }) {
|
|
const style = fileTypeStyle(data.name, data.mime);
|
|
const remote = useAuthedBlobUrl(typeof data.val === 'string' ? undefined : data.ref);
|
|
const inline = typeof data.val === 'string' ? `data:${data.mime || 'application/octet-stream'};base64,${data.val}` : null;
|
|
const href = inline || remote.url || undefined;
|
|
|
|
return (
|
|
<a
|
|
href={href}
|
|
download={data.name}
|
|
onClick={(e) => {
|
|
if (remote.error) {
|
|
e.preventDefault();
|
|
remote.retry();
|
|
} else if (!href) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
className="flex w-[280px] items-center gap-3 rounded-lg bg-white px-3.5 py-3 no-underline shadow-bubble"
|
|
>
|
|
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
|
|
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
|
|
</span>
|
|
<span className="flex min-w-0 flex-col">
|
|
<span className="truncate text-sm font-medium text-text-body">{data.name || '文件'}</span>
|
|
<span className={`text-xs ${remote.error ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
|
|
{typeof data.size === 'number' ? formatFileSize(data.size) : ''} ·{' '}
|
|
{remote.error ? '下载失败,点击重试' : remote.loading ? '加载中…' : '点击下载'}
|
|
</span>
|
|
</span>
|
|
<Icon
|
|
name={remote.error ? 'error_outline' : 'download'}
|
|
className={`ml-auto flex-none text-[20px] ${remote.error ? 'text-danger-text' : 'text-text-placeholder-2'}`}
|
|
/>
|
|
</a>
|
|
);
|
|
}
|
|
|
|
interface VideoData {
|
|
val?: string;
|
|
ref?: string;
|
|
mime?: string;
|
|
preview?: string;
|
|
duration?: number;
|
|
}
|
|
|
|
function formatDuration(ms: number | undefined): string {
|
|
const totalSeconds = Math.round((ms || 0) / 1000);
|
|
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
|
}
|
|
|
|
/** README §3.2: 260x170 like ImageAttachment, `play_circle` overlay + duration badge, no controls
|
|
* until tapped. The video's own `val`/`mime` describe the full video body (confirmed live via
|
|
* favorites' identical `VD` entity shape, docs/design_handoff plan file Part D) -- not paintable as
|
|
* an `<img>`, so the static poster comes from the separate inline `preview` field (a real JPEG,
|
|
* confirmed by its base64 signature) instead. Tapping swaps the poster for a real `<video>` playing
|
|
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality. */
|
|
export function VideoAttachment({ data }: { data: VideoData }) {
|
|
const [playing, setPlaying] = useState(false);
|
|
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
|
|
const videoInline = typeof data.val === 'string' ? `data:${data.mime || 'video/webm'};base64,${data.val}` : null;
|
|
const remote = useAuthedBlobUrl(videoInline ? undefined : data.ref);
|
|
const videoSrc = videoInline || remote.url;
|
|
|
|
if (playing && videoSrc) {
|
|
// eslint-disable-next-line jsx-a11y/media-has-caption -- user-generated chat media, no captions exist to attach.
|
|
return <video src={videoSrc} controls autoPlay className="h-[170px] w-[260px] rounded-lg bg-black object-contain" />;
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPlaying(true)}
|
|
disabled={!videoSrc}
|
|
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
|
|
>
|
|
{posterInline ? (
|
|
<img src={posterInline} alt="" className="h-full w-full object-cover" />
|
|
) : (
|
|
<div className="grid h-full w-full place-items-center">
|
|
<Icon name={remote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
|
|
</div>
|
|
)}
|
|
{videoSrc ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
|
|
{typeof data.duration === 'number' ? (
|
|
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface AudioData {
|
|
val?: string;
|
|
ref?: string;
|
|
mime?: string;
|
|
duration?: number;
|
|
}
|
|
|
|
export function AudioAttachment({ data }: { data: AudioData }) {
|
|
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
|
|
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
|
const src = inline || remote.url;
|
|
const [playing, setPlaying] = useState(false);
|
|
const [playError, setPlayError] = useState(false);
|
|
const [audioEl] = useState(() => (typeof Audio !== 'undefined' ? new Audio() : null));
|
|
|
|
useEffect(() => {
|
|
if (!audioEl) return undefined;
|
|
const onEnded = () => setPlaying(false);
|
|
audioEl.addEventListener('ended', onEnded);
|
|
// Without this, navigating away (or scrolling this bubble out) while a voice message is still
|
|
// playing leaves it playing in the background with no control left on screen to stop it.
|
|
return () => {
|
|
audioEl.removeEventListener('ended', onEnded);
|
|
audioEl.pause();
|
|
};
|
|
}, [audioEl]);
|
|
|
|
const toggle = () => {
|
|
if (!audioEl || !src) return;
|
|
if (playing) {
|
|
audioEl.pause();
|
|
setPlaying(false);
|
|
return;
|
|
}
|
|
if (audioEl.src !== src) audioEl.src = src;
|
|
setPlayError(false);
|
|
audioEl
|
|
.play()
|
|
.then(() => setPlaying(true))
|
|
.catch(() => setPlayError(true));
|
|
};
|
|
|
|
return (
|
|
<button type="button" onClick={toggle} disabled={!src} className="flex w-[200px] items-center gap-2">
|
|
<Icon
|
|
name={playError ? 'error_outline' : playing ? 'pause_circle' : 'play_circle'}
|
|
className={`text-[24px] ${playError ? 'text-danger-text' : 'text-accent'}`}
|
|
/>
|
|
<span className="h-4 flex-1 rounded bg-[repeating-linear-gradient(90deg,#b7c3d4_0px,#b7c3d4_2px,transparent_2px,transparent_5px)]" />
|
|
<span className={`flex-none text-xs ${playError ? 'text-danger-text' : 'text-text-secondary'}`}>
|
|
{playError ? '播放失败' : remote.loading ? '…' : formatDuration(data.duration)}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|