fix(web): code review round 1 remaining UI fixes
- EmojiPanel: add missing 手势/物品 categories, 12-column grid per README §3.3
- DetailColumn: add 投诉举报 row, real ID/region/签名 via new getUserProfile
API, chevron_right icons now use --color-icon-arrow token (were tertiary
text color), regroup rows so dividers only sit between sections not every
row (matches design-code reference exactly)
- MessageBubble/ConversationView: right-clicked message bubble now gets the
spec's outline while its context menu is open
- ChatListColumn/AppShell: pinned 系统消息/文件助手 rows using confirmed
bootstrap entries/entry_badges topic names ("sys"/"slf"); route to the
existing SectionPlaceholder pattern since their own dedicated layouts are
Phase 8, not built yet
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a7da4dc079
commit
93d4077d1e
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatMessagePreview } from '@/lib/messages/preview';
|
||||
import { formatListTime } from '@/lib/messages/time';
|
||||
@@ -10,20 +11,45 @@ import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Chat-list row sort: by `touched` (last activity) descending. Pinned-conversations-first isn't
|
||||
* wired yet -- that's driven by the P2P conversation-settings API (GET/PATCH /conversations/{topic}/
|
||||
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands. The
|
||||
* fixed 系统消息/文件助手 rows (README §2.1) are also still missing -- pinning them correctly needs
|
||||
* to know which topic name(s) those actually are on this backend, which hasn't been confirmed yet.
|
||||
* README §2.1's two always-pinned utility rows. Real, confirmed backend facts (docs/IM-前后端对接
|
||||
* 文档.md:909): topic names are fixed ("slf"/"sys"), but 系统消息's unread comes from a separate
|
||||
* notifications table (not the topic itself) and 文件助手's own dedicated 3-column layout (README
|
||||
* §9.2) is Phase 8, not built yet -- so both route to the same SectionPlaceholder-backed "later
|
||||
* phase" destinations the rest of the nav already uses (contacts/favorites/calls/settings), rather
|
||||
* than a half-working ConversationView that assumes a regular P2P/group contact.
|
||||
*/
|
||||
const PINNED_ENTRIES = [
|
||||
{
|
||||
key: 'system_messages' as const,
|
||||
badgeKey: 'system_unread_count' as const,
|
||||
label: '系统消息',
|
||||
icon: 'notifications',
|
||||
route: '/chat/system',
|
||||
colorClass: 'bg-warn',
|
||||
},
|
||||
{
|
||||
key: 'file_assistant' as const,
|
||||
badgeKey: 'file_assistant_unread_count' as const,
|
||||
label: '文件助手',
|
||||
icon: 'folder',
|
||||
route: '/files',
|
||||
colorClass: 'bg-success',
|
||||
},
|
||||
];
|
||||
|
||||
/** Chat-list row sort: by `touched` (last activity) descending. Pinned-conversations-first isn't
|
||||
* wired yet -- that's driven by the P2P conversation-settings API (GET/PATCH /conversations/{topic}/
|
||||
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands. */
|
||||
export function ChatListColumn() {
|
||||
const { topicId } = useParams();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const contacts = useChatList();
|
||||
const bootstrap = useBootstrap();
|
||||
const [query, setQuery] = useState('');
|
||||
const sorted = [...contacts].sort((a, b) => (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0));
|
||||
const trimmedQuery = query.trim();
|
||||
const filtered = trimmedQuery ? sorted.filter((c) => (c.public?.fn || c.topic || '').toLowerCase().includes(trimmedQuery.toLowerCase())) : sorted;
|
||||
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
|
||||
@@ -41,6 +67,30 @@ export function ChatListColumn() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{pinned.map((entry) => {
|
||||
const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0;
|
||||
return (
|
||||
<Link
|
||||
key={entry.key}
|
||||
to={entry.route}
|
||||
className="grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item bg-white px-4 py-2.5 no-underline hover:bg-[#f6f8fb]"
|
||||
>
|
||||
<span className={`grid h-11 w-11 place-items-center rounded-lg text-white ${entry.colorClass}`}>
|
||||
<Icon name={entry.icon} className="text-[22px]" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<strong className="truncate text-[15px] font-medium text-[#272b31]">{entry.label}</strong>
|
||||
</span>
|
||||
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
|
||||
{unread ? (
|
||||
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
||||
{unread}
|
||||
</b>
|
||||
) : null}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-text-tertiary-4">
|
||||
<Icon name={trimmedQuery ? 'search_off' : 'forum'} className="!text-4xl" />
|
||||
|
||||
@@ -307,6 +307,7 @@ export function ConversationView() {
|
||||
myUserId={myUserId}
|
||||
selectMode={selectMode}
|
||||
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
|
||||
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
|
||||
onToggleSelect={toggleSelect}
|
||||
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getBlocks,
|
||||
getConversationSettings,
|
||||
getFriends,
|
||||
getUserProfile,
|
||||
unblockUser,
|
||||
updateConversationSettings,
|
||||
updateFriendRemark,
|
||||
@@ -53,6 +54,11 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
||||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => getFriends() });
|
||||
const friend = friendsQuery.data?.items.find((f) => f.user_id === topicId);
|
||||
|
||||
const profileQuery = useQuery({ queryKey: ['user-profile', topicId], queryFn: () => getUserProfile(topicId) });
|
||||
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ['conversation-settings', topicId],
|
||||
queryFn: () => getConversationSettings(topicId),
|
||||
@@ -98,6 +104,10 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
||||
deleteFriendMutation.mutate();
|
||||
};
|
||||
|
||||
const reportAbuse = () => {
|
||||
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[300px]">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-list-item px-5">
|
||||
@@ -109,11 +119,15 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col items-center gap-1 px-5 pb-4 pt-6">
|
||||
<div className="flex flex-col items-center gap-1 border-b border-list-item px-5 pb-4 pt-6">
|
||||
<span className={`grid h-18 w-18 place-items-center rounded-2xl text-2xl font-medium text-white ${avatarBgClass(topicId)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="mt-2 text-[17px] font-semibold text-text-heading">{peerName}</strong>
|
||||
{idLine ? <span className="text-xs text-text-tertiary-2">{idLine}</span> : null}
|
||||
{profileQuery.data?.signature ? (
|
||||
<p className="m-0 text-center text-[13px] text-text-secondary">{profileQuery.data.signature}</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -134,10 +148,10 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-3 text-sm">
|
||||
<div className="border-b border-list-item px-5 pb-3 pt-2 text-sm">
|
||||
<button type="button" onClick={() => setHistoryTab('text')} className="flex w-full items-center justify-between py-2">
|
||||
<span className="text-text-body">查找聊天记录</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-text-tertiary-2" />
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
<div className="mt-1 grid grid-cols-4 gap-2">
|
||||
{(
|
||||
@@ -156,7 +170,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border-t border-list-item px-5 pb-2 text-sm">
|
||||
<div className="flex flex-col border-b border-list-item px-5 text-sm">
|
||||
{remarkEditing ? (
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<span className="flex-none text-text-body">备注</span>
|
||||
@@ -181,13 +195,13 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
||||
className="flex items-center justify-between py-3 text-left"
|
||||
>
|
||||
<span className="text-text-body">备注</span>
|
||||
<span className="flex items-center gap-1 text-text-tertiary-2">
|
||||
<span className="flex items-center gap-0.5 text-[13px] text-text-tertiary-2">
|
||||
{friend?.remark || '未设置'}
|
||||
<Icon name="chevron_right" className="text-[18px]" />
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-t border-list-item py-3">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-text-body">置顶聊天</span>
|
||||
<Toggle
|
||||
checked={!!settingsQuery.data?.pinned}
|
||||
@@ -195,7 +209,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
||||
onChange={() => updateSettings.mutate({ pinned: !settingsQuery.data?.pinned })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-list-item py-3">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-text-body">消息免打扰</span>
|
||||
<Toggle
|
||||
checked={!!settingsQuery.data?.mute_notifications}
|
||||
@@ -203,13 +217,20 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
||||
onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-list-item py-3">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-text-body">加入黑名单</span>
|
||||
<Toggle checked={isBlocked} disabled={blockMutation.isPending} onChange={() => blockMutation.mutate(!isBlocked)} />
|
||||
</div>
|
||||
<button type="button" onClick={clearHistory} className="flex items-center justify-between border-t border-list-item py-3 text-left">
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col px-5 pb-2 text-sm">
|
||||
<button type="button" onClick={clearHistory} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">清空聊天记录</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-text-tertiary-2" />
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
<button type="button" onClick={reportAbuse} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">投诉举报</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
// README §3.3: "表情图位(使用系统 Emoji 或品牌表情包)" -- the design mockup itself has no
|
||||
// README §3.3: "分类 Tab(最近/表情/手势/物品),12 列网格" -- the design mockup itself has no
|
||||
// specific emoji asset list, just gray placeholder tiles captioned "use system emoji or a branded
|
||||
// pack." This is a plain Unicode emoji grid (no custom art), same call the old app made (per its own
|
||||
// comment: no WeChat/QQ-style sticker assets available, system emoji is the fallback).
|
||||
const EMOJI = [
|
||||
const EMOJI_FACES = [
|
||||
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😜', '🤔', '😐', '😴', '😭', '😡', '🥳', '😱', '🤯', '🥰', '😇', '🙃', '😉',
|
||||
'👍', '👎', '👏', '🙏', '💪', '🤝', '✌️', '👌', '🤞', '👋', '❤️', '💔', '💯', '🔥', '✨', '🎉', '🎂', '☕', '🍺', '🌹',
|
||||
'😆', '😅', '☺️', '😋', '😝', '🤗', '🤩', '🥺', '😢', '😤', '🤬', '🥵', '🥶', '😵', '🤢', '😷', '🤒', '🥴', '😪', '🙄',
|
||||
];
|
||||
const EMOJI_GESTURES = [
|
||||
'👍', '👎', '👏', '🙏', '💪', '🤝', '✌️', '👌', '🤞', '👋', '👊', '✊', '🤛', '🤜', '🤟', '🤘', '👆', '👇', '👈', '👉',
|
||||
'☝️', '✋', '🤚', '🖐️', '🖖', '🤙', '💅', '🤳', '👐', '🙌', '🤲', '🫶', '🤌', '🫰', '🫵', '👋', '💃', '🕺', '🙋', '🙆',
|
||||
];
|
||||
const EMOJI_OBJECTS = [
|
||||
'❤️', '💔', '💯', '🔥', '✨', '🎉', '🎂', '☕', '🍺', '🌹', '🎁', '🎈', '⭐', '🌈', '☀️', '🌙', '⚡', '💧', '🎵', '🎮',
|
||||
'📷', '📱', '💻', '📌', '📎', '🔔', '💰', '⏰', '🔑', '💡', '📖', '✏️', '🍎', '🍕', '🍔', '🍜', '🎄', '🎃', '⚽', '🏆',
|
||||
];
|
||||
|
||||
type EmojiTab = 'recent' | 'faces' | 'gestures' | 'objects';
|
||||
|
||||
const TABS: { key: EmojiTab; label: string; list: string[] }[] = [
|
||||
{ key: 'recent', label: '最近', list: [] },
|
||||
{ key: 'faces', label: '表情', list: EMOJI_FACES },
|
||||
{ key: 'gestures', label: '手势', list: EMOJI_GESTURES },
|
||||
{ key: 'objects', label: '物品', list: EMOJI_OBJECTS },
|
||||
];
|
||||
|
||||
interface EmojiPanelProps {
|
||||
@@ -14,7 +31,7 @@ interface EmojiPanelProps {
|
||||
}
|
||||
|
||||
export function EmojiPanel({ onPick }: EmojiPanelProps) {
|
||||
const [tab, setTab] = useState<'recent' | 'all'>('all');
|
||||
const [tab, setTab] = useState<EmojiTab>('faces');
|
||||
const recent = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('im-recent-emoji') || '[]') as string[];
|
||||
@@ -24,7 +41,7 @@ export function EmojiPanel({ onPick }: EmojiPanelProps) {
|
||||
})();
|
||||
|
||||
const pick = (emoji: string) => {
|
||||
const next = [emoji, ...recent.filter((e) => e !== emoji)].slice(0, 30);
|
||||
const next = [emoji, ...recent.filter((e) => e !== emoji)].slice(0, 36);
|
||||
try {
|
||||
localStorage.setItem('im-recent-emoji', JSON.stringify(next));
|
||||
} catch {
|
||||
@@ -33,36 +50,32 @@ export function EmojiPanel({ onPick }: EmojiPanelProps) {
|
||||
onPick(emoji);
|
||||
};
|
||||
|
||||
const list = tab === 'recent' ? recent : EMOJI;
|
||||
const list = tab === 'recent' ? recent : (TABS.find((t) => t.key === tab)?.list ?? []);
|
||||
|
||||
return (
|
||||
<div className="w-[320px] rounded-xl border border-border-panel bg-white p-2 shadow-menu">
|
||||
<div className="w-[380px] rounded-xl border border-border-panel bg-white p-2 shadow-menu">
|
||||
<div className="mb-1 flex gap-1 px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('recent')}
|
||||
className={`rounded-lg px-2.5 py-1 text-xs ${tab === 'recent' ? 'bg-accent-soft-bg text-accent' : 'text-text-tertiary-2'}`}
|
||||
>
|
||||
最近
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('all')}
|
||||
className={`rounded-lg px-2.5 py-1 text-xs ${tab === 'all' ? 'bg-accent-soft-bg text-accent' : 'text-text-tertiary-2'}`}
|
||||
>
|
||||
表情
|
||||
</button>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`rounded-lg px-2.5 py-1 text-xs ${tab === t.key ? 'bg-accent-soft-bg text-accent' : 'text-text-tertiary-2'}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{list.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-text-tertiary-4">暂无最近使用</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-8 gap-1">
|
||||
<div className="grid grid-cols-12 gap-1">
|
||||
{list.map((emoji, i) => (
|
||||
<button
|
||||
key={`${emoji}-${i}`}
|
||||
type="button"
|
||||
onClick={() => pick(emoji)}
|
||||
className="grid h-9 w-9 place-items-center rounded-lg text-xl hover:bg-surface-rail"
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-lg hover:bg-surface-rail"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
|
||||
@@ -17,11 +17,26 @@ interface MessageRowProps {
|
||||
myUserId: string | null;
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
|
||||
* context menu's target. */
|
||||
menuActive?: boolean;
|
||||
onToggleSelect?: (seq: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
|
||||
}
|
||||
|
||||
export function MessageRow({ topic, msg, mine, isGroup, peerName, myUserId, selectMode, selected, onToggleSelect, onContextMenu }: MessageRowProps) {
|
||||
export function MessageRow({
|
||||
topic,
|
||||
msg,
|
||||
mine,
|
||||
isGroup,
|
||||
peerName,
|
||||
myUserId,
|
||||
selectMode,
|
||||
selected,
|
||||
menuActive,
|
||||
onToggleSelect,
|
||||
onContextMenu,
|
||||
}: MessageRowProps) {
|
||||
if (isServerDeleted(msg)) {
|
||||
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
|
||||
}
|
||||
@@ -77,7 +92,7 @@ export function MessageRow({ topic, msg, mine, isGroup, peerName, myUserId, sele
|
||||
<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' : ''}`}
|
||||
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
|
||||
>
|
||||
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user