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
@@ -1,26 +1,68 @@
|
||||
import type { StoredMessage, Topic } from 'tinode-sdk';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { isRecalled, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
|
||||
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
|
||||
import { retryMessage } from '@/lib/tinode/send';
|
||||
|
||||
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
|
||||
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
|
||||
* 一个组件:三处的视觉规格互不相同,抽象成一个带 variant 参数的组件不会比各写各的更清楚。 */
|
||||
/** Distinct from a regular Drafty-rendered bubble -- see `contactCardContentOf`'s own doc comment
|
||||
* for why this is dispatched by `head['x-im-type']` rather than a Drafty style tag. Tapping opens
|
||||
* the real profile page, the one thing a contact-card message is fundamentally for. */
|
||||
function ContactCardBubble({ content }: { content: ContactCardContent }) {
|
||||
const name = content.display_name || content.public_account || content.user_id;
|
||||
return (
|
||||
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
|
||||
<Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{name}</span>
|
||||
{content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRoleTag({ role }: { role: 'owner' | 'admin' | undefined }) {
|
||||
if (role === 'owner') return <span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">群主</span>;
|
||||
if (role === 'admin') return <span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">管理员</span>;
|
||||
return null;
|
||||
}
|
||||
|
||||
interface MessageRowProps {
|
||||
topic: Topic;
|
||||
msg: StoredMessage;
|
||||
mine: boolean;
|
||||
isGroup: boolean;
|
||||
peerName: string;
|
||||
/** Only meaningful when `isGroup` -- the sender's real group-scoped name/role, resolved by
|
||||
* ConversationView from its own member-list fetch. `peerName` alone is the GROUP's name, wrong
|
||||
* for per-message attribution once there's more than one possible sender. */
|
||||
senderName?: string;
|
||||
senderRole?: 'owner' | 'admin' | 'member';
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
|
||||
* context menu's target. */
|
||||
menuActive?: boolean;
|
||||
/** True for a few seconds right after jumping here from a 查找聊天记录 search result (ConversationView's
|
||||
* scroll-to-message flow) -- a brief background flash, distinct from `menuActive`'s permanent-while-
|
||||
* open outline, since the two states mean different things (context menu open vs. "you asked to find
|
||||
* this one") and can't reuse the same visual without conflating them. */
|
||||
highlighted?: boolean;
|
||||
onToggleSelect?: (seq: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
|
||||
/** Fired when the inline "重试" retry (below) itself fails -- e.g. still offline. Without this the
|
||||
* failed-send icon just silently stays put with no feedback at all that the retry attempt was
|
||||
* even made. */
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function MessageRow({
|
||||
@@ -29,20 +71,28 @@ export function MessageRow({
|
||||
mine,
|
||||
isGroup,
|
||||
peerName,
|
||||
senderName,
|
||||
senderRole,
|
||||
selectMode,
|
||||
selected,
|
||||
menuActive,
|
||||
highlighted,
|
||||
onToggleSelect,
|
||||
onContextMenu,
|
||||
onError,
|
||||
}: MessageRowProps) {
|
||||
if (isServerDeleted(msg)) {
|
||||
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
|
||||
return (
|
||||
<div data-seq={msg.seq} className="py-1 text-center text-xs text-text-tertiary-4">
|
||||
此消息已被删除
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const noticeText = systemNoticeText(msg);
|
||||
if (noticeText) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-1">
|
||||
<div data-seq={msg.seq} className="flex flex-col items-center gap-1 py-1">
|
||||
<div className="text-xs text-text-tertiary-4">{noticeText}</div>
|
||||
{msg.ts ? <div className="text-xs text-text-tertiary-4">{formatEventTimestamp(msg.ts)}</div> : null}
|
||||
</div>
|
||||
@@ -52,23 +102,33 @@ export function MessageRow({
|
||||
const failed = isFailedStatus(topic, msg);
|
||||
const sending = isSendingStatus(topic, msg);
|
||||
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
|
||||
const callResult = callResultDisplay(msg, mine);
|
||||
const contactCard = contactCardContentOf(msg);
|
||||
|
||||
const retry = () => {
|
||||
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq);
|
||||
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq)?.catch(() => onError?.('重试发送失败'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div data-seq={msg.seq} className={`flex items-center gap-2 ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}>
|
||||
{selectMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)}
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full text-[13px] text-white ${
|
||||
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2]'
|
||||
}`}
|
||||
>
|
||||
{selected ? <Icon name="check" className="text-[14px]" /> : null}
|
||||
</button>
|
||||
// A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below)
|
||||
// has nothing real to forward/favorite -- an empty flex-none spacer of the same size keeps
|
||||
// every row's content left-edge aligned instead of the checkbox's absence shifting this one
|
||||
// row over, while still refusing to let it join a batch action.
|
||||
isRecalled(msg) ? (
|
||||
<div className="h-5 w-5 flex-none" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)}
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full text-[13px] text-white ${
|
||||
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2]'
|
||||
}`}
|
||||
>
|
||||
{selected ? <Icon name="check" className="text-[14px]" /> : null}
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
<div
|
||||
onContextMenu={(e) => {
|
||||
@@ -76,25 +136,46 @@ export function MessageRow({
|
||||
e.preventDefault();
|
||||
onContextMenu?.(e, msg);
|
||||
}}
|
||||
className={`flex flex-1 items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
|
||||
className={`flex flex-1 items-start gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
|
||||
mine ? 'bg-accent' : avatarBgClass(msg.from || peerName)
|
||||
mine ? 'bg-accent' : avatarBgClass(msg.from || (isGroup ? '' : peerName))
|
||||
}`}
|
||||
>
|
||||
{avatarLetter(mine ? '我' : peerName)}
|
||||
{avatarLetter(mine ? '我' : isGroup ? senderName || msg.from : peerName)}
|
||||
</span>
|
||||
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
|
||||
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text. */}
|
||||
<div className={`flex items-end gap-1.5 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
{isGroup ? (
|
||||
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
<span>{mine ? '我' : senderName || msg.from}</span>
|
||||
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text.
|
||||
items-center (not -end) so the icon stays vertically centered on the bubble regardless of
|
||||
how many lines the bubble wraps to; no flex-row-reverse here (unlike the outer avatar row)
|
||||
-- this icon only ever appears on `mine` bubbles and belongs on the OUTER edge, away from
|
||||
the avatar, which is what the DOM order [icon, bubble] already gives without reversing it. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{failed ? <Icon name="error" className="flex-none text-[20px] text-danger-text" /> : null}
|
||||
<div
|
||||
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
|
||||
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
|
||||
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
|
||||
>
|
||||
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
|
||||
{isRecalled(msg) ? (
|
||||
'⚠ 消息已删除'
|
||||
) : callResult ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name={callResult.icon} className={`text-[18px] ${callResult.danger ? 'text-danger-text' : 'text-current'}`} />
|
||||
{callResult.text}
|
||||
</span>
|
||||
) : contactCard ? (
|
||||
<ContactCardBubble content={contactCard} />
|
||||
) : (
|
||||
renderDrafty(msg.content)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{failed ? (
|
||||
|
||||
Reference in New Issue
Block a user