Merge pull request #945 from Jasonzhu1207/refactor/ui-rebuild

Refactor/UI rebuild
This commit is contained in:
cc
2026-05-11 22:26:19 +08:00
committed by GitHub
6 changed files with 784 additions and 296 deletions

View File

@@ -13,6 +13,7 @@ export interface ChatMessageBubbleProps {
isSystem: boolean
isEmoji?: boolean
isImage?: boolean
isVideo?: boolean
isVoice?: boolean
emojiHasAsset?: boolean
emojiError?: boolean
@@ -45,6 +46,7 @@ function ChatMessageBubble({
isSystem,
isEmoji,
isImage,
isVideo,
isVoice,
emojiHasAsset,
emojiError,
@@ -82,7 +84,7 @@ function ChatMessageBubble({
{isSelectionMode && !isSent && <SelectionCheckbox checked={isSelected} side="left" />}
<div
className={`message-bubble ${bubbleClass} ${isEmoji && emojiHasAsset && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVoice ? 'voice' : ''}`}
className={`message-bubble ${bubbleClass} ${isEmoji && emojiHasAsset && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVideo ? 'video' : ''} ${isVoice ? 'voice' : ''}`}
onContextMenu={(event) => onContextMenu?.(event, message)}
>
<div className="bubble-avatar">
@@ -118,6 +120,7 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps) {
prev.isSystem === next.isSystem &&
prev.isEmoji === next.isEmoji &&
prev.isImage === next.isImage &&
prev.isVideo === next.isVideo &&
prev.isVoice === next.isVoice &&
prev.emojiHasAsset === next.emojiHasAsset &&
prev.emojiError === next.emojiError &&

View File

@@ -1,4 +1,4 @@
.chat-page {
.chat-page {
position: relative;
display: flex;
height: 100%;
@@ -666,7 +666,9 @@
}
}
&.emoji {
&.emoji,
&.image,
&.video {
.bubble-content {
background: transparent !important;
padding: 0;
@@ -2012,6 +2014,15 @@
color: var(--text-primary);
}
}
&.voice {
.bubble-content {
background: transparent !important;
padding: 0 !important;
border: 0 !important;
box-shadow: none !important;
}
}
}
.bubble-avatar {
@@ -2067,7 +2078,12 @@
.message-bubble .bubble-content:has(> .solitaire-message),
.message-bubble .bubble-content:has(> .official-message),
.message-bubble .bubble-content:has(> .channel-video-card),
.message-bubble .bubble-content:has(> .location-message) {
.message-bubble .bubble-content:has(> .location-message),
.message-bubble .bubble-content:has(> .voice-stack),
.message-bubble .bubble-content:has(> .video-thumb-wrapper),
.message-bubble .bubble-content:has(> .video-placeholder),
.message-bubble .bubble-content:has(> .video-loading),
.message-bubble .bubble-content:has(> .video-unavailable) {
background: transparent !important;
padding: 0 !important;
border: none !important;
@@ -2465,13 +2481,202 @@
white-space: nowrap;
}
// 引用消息样式
// ═══════════════════════════════════════════
// Ambient Reply System — "Spectral Thread"
// ═══════════════════════════════════════════
// Wrapper for the entire ambient reply UI
.ambient-reply-wrapper {
position: relative;
cursor: pointer;
margin-bottom: 4px;
// Reply anchor — always visible, minimal
.reply-anchor {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px 2px 4px;
border-radius: 8px;
background: transparent;
opacity: 0.42;
transition: opacity 0.35s ease, background-color 0.35s ease;
user-select: none;
max-width: 100%;
overflow: hidden;
&.jumpable {
cursor: pointer;
}
&:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary) 45%, transparent);
outline-offset: 2px;
}
}
&:hover .reply-anchor {
opacity: 1;
background: color-mix(in srgb, var(--primary) 8%, transparent);
}
.reply-anchor-icon {
width: 13px;
height: 13px;
color: var(--primary);
opacity: 0.7;
flex-shrink: 0;
transition: opacity 0.3s ease;
}
&:hover .reply-anchor-icon {
opacity: 1;
}
.reply-anchor-name {
font-size: 12px;
font-weight: 500;
color: var(--primary);
letter-spacing: 0.01em;
flex-shrink: 0;
}
.reply-anchor-sep {
font-size: 10px;
color: var(--text-tertiary);
margin: 0 1px;
flex-shrink: 0;
}
.reply-anchor-excerpt {
font-size: 12px;
color: var(--text-secondary);
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.7;
// Hide inline emoji images in anchor excerpt
.inline-emoji {
width: 14px !important;
height: 14px !important;
vertical-align: -2px;
}
}
// Ghost preview — appears on hover, frosted glass
.reply-ghost {
position: absolute;
bottom: calc(100% + 6px);
max-width: 320px;
min-width: 160px;
padding: 8px 12px 12px 12px;
border-radius: 12px;
background: color-mix(in srgb, var(--bg-primary) 75%, transparent);
backdrop-filter: blur(20px) saturate(1.3);
-webkit-backdrop-filter: blur(20px) saturate(1.3);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
opacity: 0;
transform: translateY(4px) scale(0.98);
pointer-events: none;
transition: opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 50;
// Gradient accent bar on left edge
&::before {
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 2.5px;
border-radius: 2px;
background: linear-gradient(to bottom, color-mix(in srgb, var(--primary) 40%, transparent), transparent);
}
}
&:hover .reply-ghost {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.reply-ghost-sender {
font-size: 11px;
font-weight: 500;
color: var(--primary);
opacity: 0.75;
margin-bottom: 3px;
padding-left: 6px;
}
.reply-ghost-text {
font-size: 12.5px;
line-height: 1.5;
color: var(--text-secondary);
opacity: 0.82;
padding-left: 6px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: pre-wrap;
word-break: break-word;
.inline-emoji {
width: 16px !important;
height: 16px !important;
vertical-align: -2px;
}
}
}
// Sent message — adjust ghost position to right
.message-bubble.sent .ambient-reply-wrapper {
.reply-ghost {
right: 0;
}
.reply-anchor-name {
color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
}
.reply-anchor-excerpt {
color: color-mix(in srgb, var(--on-primary) 72%, var(--primary));
}
.reply-anchor-sep {
color: color-mix(in srgb, var(--on-primary) 50%, var(--primary));
}
.reply-anchor-icon {
color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
}
&:hover .reply-anchor {
background: color-mix(in srgb, var(--on-primary) 10%, transparent);
}
.reply-ghost {
background: color-mix(in srgb, var(--bg-primary) 80%, transparent);
}
}
// Received message — ghost to left
.message-bubble.received .ambient-reply-wrapper {
.reply-ghost {
left: 0;
}
}
// Legacy .quoted-message — used by SettingsPage quote-layout preview widget
.quoted-message {
background: rgba(0, 0, 0, 0.04);
border-left: 2px solid var(--primary);
padding: 6px 10px;
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
font-size: 12px;
.quoted-sender {
color: var(--primary);
@@ -2485,38 +2690,9 @@
.quoted-text {
color: var(--text-secondary);
white-space: pre-wrap;
.quoted-type-label {
font-style: italic;
opacity: 0.8;
}
.quoted-emoji-image {
width: 40px;
height: 40px;
vertical-align: middle;
object-fit: contain;
}
}
}
// 自己发送的消息中的引用样式
.message-bubble.sent .quoted-message {
background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
border-left-color: color-mix(in srgb, var(--on-primary) 36%, var(--primary));
.quoted-sender {
color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
}
.quoted-text {
color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
}
}
// 气泡内容区域(包含名字和内容)
.bubble-body {
display: flex;
@@ -2531,14 +2707,6 @@
.bubble-content {
-webkit-app-region: no-drag;
&.quote-layout-top .quoted-message {
margin-bottom: 8px;
}
&.quote-layout-bottom .quoted-message {
margin-top: 8px;
}
}
// 时间分隔
@@ -5589,7 +5757,8 @@
}
&.emoji,
&.image {
&.image,
&.video {
max-width: min(82%, 760px);
.bubble-content {
@@ -5602,6 +5771,13 @@
background: var(--bg-tertiary);
color: var(--text-primary);
}
&.voice .bubble-content {
background: transparent !important;
padding: 0 !important;
border: 0 !important;
box-shadow: none !important;
}
}
.message-bubble .bubble-content:has(> .link-message),
@@ -5615,7 +5791,12 @@
.message-bubble .bubble-content:has(> .transfer-message),
.message-bubble .bubble-content:has(> .gift-message),
.message-bubble .bubble-content:has(> .miniapp-message),
.message-bubble .bubble-content:has(> .file-message) {
.message-bubble .bubble-content:has(> .file-message),
.message-bubble .bubble-content:has(> .voice-stack),
.message-bubble .bubble-content:has(> .video-thumb-wrapper),
.message-bubble .bubble-content:has(> .video-placeholder),
.message-bubble .bubble-content:has(> .video-loading),
.message-bubble .bubble-content:has(> .video-unavailable) {
background: transparent !important;
padding: 0 !important;
border: 0 !important;
@@ -5628,23 +5809,7 @@
margin-bottom: 5px;
}
.quoted-message {
background: transparent;
border-left: 2px solid color-mix(in srgb, var(--primary) 62%, var(--text-tertiary));
border-radius: 0;
padding: 2px 0 2px 10px;
color: var(--text-secondary);
}
.message-bubble.sent .quoted-message {
background: color-mix(in srgb, var(--on-primary) 12%, transparent);
border-left-color: color-mix(in srgb, var(--on-primary) 62%, var(--primary));
.quoted-sender,
.quoted-text {
color: color-mix(in srgb, var(--on-primary) 82%, var(--primary));
}
}
// Ambient Reply dark mode / alternate adjustments handled via CSS variables
.link-message,
.card-message,

View File

@@ -55,6 +55,17 @@ interface PendingFootprintJumpPayload {
createTime: number
}
interface QuotedMessageJumpTarget {
sourceMessageKey: string
sourceCreateTime: number
sessionId: string
localId?: number
serverId?: string
createTime?: number
senderUsername?: string
content?: string
}
type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done'
type GlobalMsgSearchResult = Message & { sessionId: string }
@@ -64,7 +75,7 @@ interface GlobalMsgPrefixCacheEntry {
completed: boolean
}
type QuoteLayout = configService.QuoteLayout
const GLOBAL_MSG_PER_SESSION_LIMIT = 10
const GLOBAL_MSG_SEED_LIMIT = 120
@@ -676,6 +687,26 @@ function cleanMessageContent(content: string): string {
return content.trim()
}
function normalizeMessageIdToken(value: unknown): string {
const raw = String(value ?? '').trim()
if (!raw) return ''
if (!/^\d+$/.test(raw)) return raw
return raw.replace(/^0+(?=\d)/, '')
}
function parsePositiveInteger(value: unknown): number | undefined {
const raw = String(value ?? '').trim()
if (!raw) return undefined
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
return Math.floor(parsed)
}
function normalizeQuotedComparableText(value: unknown): string {
const text = cleanMessageContent(String(value ?? '')).replace(/\s+/g, ' ').trim()
return text.length > 160 ? text.slice(0, 160) : text
}
const CHAT_SESSION_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30
@@ -1114,6 +1145,15 @@ interface LoadMessagesOptions {
inSessionJumpRequestSeq?: number
}
type LoadMessagesFn = (
sessionId: string,
offset?: number,
startTime?: number,
endTime?: number,
ascending?: boolean,
options?: LoadMessagesOptions
) => Promise<void>
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
import { Avatar } from '../components/Avatar'
@@ -1572,6 +1612,8 @@ function ChatPage(props: ChatPageProps) {
const [globalMsgSearchError, setGlobalMsgSearchError] = useState<string | null>(null)
const pendingInSessionSearchRef = useRef<PendingInSessionSearchPayload | null>(null)
const pendingFootprintJumpRef = useRef<PendingFootprintJumpPayload | null>(null)
const pendingQuotedMessageJumpRef = useRef<QuotedMessageJumpTarget | null>(null)
const loadMessagesRef = useRef<LoadMessagesFn | null>(null)
const pendingGlobalMsgSearchReplayRef = useRef<string | null>(null)
const globalMsgPrefixCacheRef = useRef<GlobalMsgPrefixCacheEntry | null>(null)
@@ -3772,6 +3814,8 @@ function ChatPage(props: ChatPageProps) {
}
}
loadMessagesRef.current = loadMessages
const handleJumpDateSelect = useCallback((date: Date, options: { sessionId?: string; switchRequestSeq?: number } = {}) => {
const targetSessionId = String(options.sessionId || currentSessionRef.current || currentSessionId || '').trim()
if (!targetSessionId) return
@@ -4881,6 +4925,136 @@ function ChatPage(props: ChatPageProps) {
}, 2500)
}, [])
const findQuotedTargetInMessages = useCallback((target: QuotedMessageJumpTarget): { index: number; message: Message } | null => {
if (messages.length === 0) return null
const targetServerId = normalizeMessageIdToken(target.serverId)
const targetLocalId = typeof target.localId === 'number' && target.localId > 0 ? target.localId : undefined
const targetCreateTime = typeof target.createTime === 'number' && target.createTime > 0 ? target.createTime : undefined
const targetSender = String(target.senderUsername || '').trim()
const targetContent = normalizeQuotedComparableText(target.content)
const sourceIndex = target.sourceMessageKey
? messages.findIndex((item) => getMessageKey(item) === target.sourceMessageKey)
: -1
const orderedIndices: number[] = []
const usedIndices = new Set<number>()
const pushIndex = (index: number) => {
if (index < 0 || index >= messages.length || usedIndices.has(index)) return
usedIndices.add(index)
orderedIndices.push(index)
}
if (sourceIndex > 0) {
for (let index = sourceIndex - 1; index >= 0; index--) {
pushIndex(index)
}
}
for (let index = 0; index < messages.length; index++) {
pushIndex(index)
}
let best: { index: number; message: Message; score: number } | null = null
for (const index of orderedIndices) {
const item = messages[index]
const itemKey = getMessageKey(item)
if (itemKey === target.sourceMessageKey) continue
const itemServerId = normalizeMessageIdToken(item.serverIdRaw ?? item.serverId)
const serverMatch = Boolean(targetServerId && itemServerId && itemServerId === targetServerId)
const localIdMatch = Boolean(targetLocalId && Number(item.localId || 0) === targetLocalId)
const itemCreateTime = Number(item.createTime || 0)
const timeDelta = targetCreateTime ? Math.abs(itemCreateTime - targetCreateTime) : Number.POSITIVE_INFINITY
const exactTimeMatch = Boolean(targetCreateTime && timeDelta <= 1)
const nearTimeMatch = Boolean(targetCreateTime && timeDelta <= 300)
const senderMatch = Boolean(targetSender && String(item.senderUsername || '').trim() === targetSender)
const itemText = targetContent
? normalizeQuotedComparableText(item.parsedContent || item.rawContent || item.content || '')
: ''
const contentMatch = Boolean(
targetContent &&
itemText &&
(itemText.includes(targetContent) || targetContent.includes(itemText))
)
const strongMatch = Boolean(
serverMatch ||
localIdMatch ||
(exactTimeMatch && (senderMatch || contentMatch)) ||
(exactTimeMatch && !targetSender && !targetContent)
)
if (!strongMatch) continue
const score =
(localIdMatch ? 100 : 0) +
(serverMatch ? 90 : 0) +
(exactTimeMatch ? 35 : (nearTimeMatch ? 8 : 0)) +
(senderMatch ? 12 : 0) +
(contentMatch ? 12 : 0)
if (!best || score > best.score) {
best = { index, message: item, score }
if (score >= 125) break
}
}
return best ? { index: best.index, message: best.message } : null
}, [messages, getMessageKey])
const scrollToResolvedMessage = useCallback((resolved: { index: number; message: Message }, behavior: 'auto' | 'smooth' = 'smooth') => {
const key = getMessageKey(resolved.message)
flashNewMessages([key])
requestAnimationFrame(() => {
if (messageVirtuosoRef.current) {
messageVirtuosoRef.current.scrollToIndex({
index: resolved.index,
align: 'center',
behavior
})
}
})
}, [flashNewMessages, getMessageKey])
const handleJumpToQuotedMessage = useCallback((target: QuotedMessageJumpTarget) => {
const targetSessionId = String(currentSessionRef.current || currentSessionId || target.sessionId || '').trim()
if (!targetSessionId) return
const normalizedTarget: QuotedMessageJumpTarget = {
...target,
sessionId: targetSessionId
}
const resolved = findQuotedTargetInMessages(normalizedTarget)
if (resolved) {
pendingQuotedMessageJumpRef.current = null
scrollToResolvedMessage(resolved)
return
}
pendingQuotedMessageJumpRef.current = normalizedTarget
const targetTime = Number(normalizedTarget.createTime || 0)
if (!targetTime) return
const requestSeq = inSessionResultJumpRequestSeqRef.current + 1
inSessionResultJumpRequestSeqRef.current = requestSeq
setCurrentOffset(0)
setJumpStartTime(0)
setJumpEndTime(targetTime + 1)
suppressAutoLoadLaterRef.current = true
void loadMessagesRef.current?.(targetSessionId, 0, 0, targetTime + 1, false, {
forceInitialLimit: 120,
inSessionJumpRequestSeq: requestSeq
})
}, [currentSessionId, findQuotedTargetInMessages, scrollToResolvedMessage])
useEffect(() => {
const pending = pendingQuotedMessageJumpRef.current
if (!pending) return
const resolved = findQuotedTargetInMessages(pending)
if (!resolved) return
pendingQuotedMessageJumpRef.current = null
scrollToResolvedMessage(resolved, 'auto')
}, [messages, findQuotedTargetInMessages, scrollToResolvedMessage])
const handleInSessionResultJump = useCallback((msg: Message) => {
const targetTime = Number(msg.createTime || 0)
const targetSessionId = String(currentSessionRef.current || currentSessionId || '').trim()
@@ -6679,6 +6853,7 @@ function ChatPage(props: ChatPageProps) {
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu}
onJumpToQuotedMessage={handleJumpToQuotedMessage}
isSelectionMode={isSelectionMode}
messageKey={messageKey}
isSelected={selectedMessages.has(messageKey)}
@@ -6698,6 +6873,7 @@ function ChatPage(props: ChatPageProps) {
autoTranscribeVoiceEnabled,
handleRequireModelDownload,
handleContextMenu,
handleJumpToQuotedMessage,
isSelectionMode,
selectedMessages,
handleToggleSelection
@@ -8261,6 +8437,7 @@ function MessageBubble({
autoTranscribeVoiceEnabled,
onRequireModelDownload,
onContextMenu,
onJumpToQuotedMessage,
isSelectionMode,
isSelected,
onToggleSelection
@@ -8275,6 +8452,7 @@ function MessageBubble({
autoTranscribeVoiceEnabled?: boolean;
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
onContextMenu?: (e: React.MouseEvent, message: Message) => void;
onJumpToQuotedMessage?: (target: QuotedMessageJumpTarget) => void;
isSelectionMode?: boolean;
isSelected?: boolean;
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void;
@@ -8291,7 +8469,6 @@ function MessageBubble({
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
const [senderName, setSenderName] = useState<string | undefined>(undefined)
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
const [quoteLayout, setQuoteLayout] = useState<QuoteLayout>('quote-top')
const [solitaireExpanded, setSolitaireExpanded] = useState(false)
const senderProfileRequestSeqRef = useRef(0)
const [emojiError, setEmojiError] = useState(false)
@@ -9464,17 +9641,7 @@ function MessageBubble({
myWxid
])
useEffect(() => {
let cancelled = false
void configService.getQuoteLayout().then((layout) => {
if (!cancelled) setQuoteLayout(layout)
}).catch(() => {
if (!cancelled) setQuoteLayout('quote-top')
})
return () => {
cancelled = true
}
}, [])
// quoteLayout config removed - Ambient Reply uses a single fixed layout
const locationMessageMeta = useMemo(() => {
if (message.localType !== 48) return null
@@ -9511,31 +9678,90 @@ function MessageBubble({
// 是否有引用消息
const hasQuote = quotedContent.length > 0
const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName
const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => {
const quoteFirst = quoteLayout !== 'quote-bottom'
return (
<div className={`bubble-content ${quoteFirst ? 'quote-layout-top' : 'quote-layout-bottom'}`}>
{quoteFirst ? (
<>
{quotedNode}
{messageNode}
</>
) : (
<>
{messageNode}
{quotedNode}
</>
)}
</div>
)
}, [quoteLayout])
const quotedJumpTarget = useMemo<QuotedMessageJumpTarget | null>(() => {
if (!hasQuote) return null
const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => (
<div className="quoted-message">
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{contentNode}</span>
const quotedServerId = normalizeMessageIdToken(
queryAppMsgText('refermsg > svrid') ||
queryAppMsgText('refermsg > msgsvrid') ||
queryAppMsgText('refermsg > newmsgid') ||
queryAppMsgText('refermsg > msgid')
)
const quotedCreateTime = parsePositiveInteger(
queryAppMsgText('refermsg > createtime') ||
queryAppMsgText('refermsg > create_time') ||
queryAppMsgText('refermsg > createTime')
)
const quotedLocalId = parsePositiveInteger(
queryAppMsgText('refermsg > localid') ||
queryAppMsgText('refermsg > local_id') ||
queryAppMsgText('refermsg > localId')
)
const normalizedQuotedContent = normalizeQuotedComparableText(quotedContent)
if (!quotedServerId && !quotedCreateTime && !quotedLocalId && !normalizedQuotedContent) {
return null
}
return {
sourceMessageKey: messageKey,
sourceCreateTime: Number(message.createTime || 0),
sessionId: session.username,
localId: quotedLocalId,
serverId: quotedServerId || undefined,
createTime: quotedCreateTime,
senderUsername: quotedSenderUsername || undefined,
content: normalizedQuotedContent || undefined
}
}, [hasQuote, message.createTime, messageKey, queryAppMsgText, quotedContent, quotedSenderUsername, session.username])
const handleQuotedJumpClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (isSelectionMode) return
if (!quotedJumpTarget || !onJumpToQuotedMessage) return
event.stopPropagation()
onJumpToQuotedMessage(quotedJumpTarget)
}, [isSelectionMode, onJumpToQuotedMessage, quotedJumpTarget])
const handleQuotedJumpKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
if (isSelectionMode) return
if (!quotedJumpTarget || !onJumpToQuotedMessage) return
event.preventDefault()
event.stopPropagation()
onJumpToQuotedMessage(quotedJumpTarget)
}, [isSelectionMode, onJumpToQuotedMessage, quotedJumpTarget])
// Ambient Reply: single fixed layout (anchor above, message below)
const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => (
<div className="bubble-content">
{quotedNode}
{messageNode}
</div>
), [displayQuotedSenderName])
), [])
// Ambient Reply: render reply-anchor + ghost preview
const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => (
<div className="ambient-reply-wrapper">
{/* Reply anchor - always visible, subtle */}
<div
className={`reply-anchor ${quotedJumpTarget ? 'jumpable' : ''}`}
role={quotedJumpTarget && !isSelectionMode ? 'button' : undefined}
tabIndex={quotedJumpTarget && !isSelectionMode ? 0 : undefined}
onClick={handleQuotedJumpClick}
onKeyDown={handleQuotedJumpKeyDown}
>
<svg className="reply-anchor-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 14 4 9 9 4" />
<path d="M20 20v-7a4 4 0 0 0-4-4H4" />
</svg>
{displayQuotedSenderName && <span className="reply-anchor-name">{displayQuotedSenderName}</span>}
<span className="reply-anchor-sep">&middot;</span>
<span className="reply-anchor-excerpt">{contentNode}</span>
</div>
{/* Ghost preview - appears on hover */}
<div className="reply-ghost">
{displayQuotedSenderName && <div className="reply-ghost-sender">{displayQuotedSenderName}</div>}
<div className="reply-ghost-text">{contentNode}</div>
</div>
</div>
), [displayQuotedSenderName, handleQuotedJumpClick, handleQuotedJumpKeyDown, isSelectionMode, quotedJumpTarget])
const handlePlayVideo = useCallback(async () => {
if (!videoInfo?.videoUrl) return
@@ -10773,6 +10999,7 @@ function MessageBubble({
isSystem={isSystem}
isEmoji={isEmoji}
isImage={isImage}
isVideo={isVideo}
isVoice={isVoice}
emojiHasAsset={Boolean(message.emojiCdnUrl || message.emojiLocalPath)}
emojiError={emojiError}
@@ -10802,6 +11029,7 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
if (prevProps.isSelected !== nextProps.isSelected) return false
if (prevProps.onRequireModelDownload !== nextProps.onRequireModelDownload) return false
if (prevProps.onContextMenu !== nextProps.onContextMenu) return false
if (prevProps.onJumpToQuotedMessage !== nextProps.onJumpToQuotedMessage) return false
if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false
return (

View File

@@ -793,7 +793,7 @@ function ContactsPage() {
}
}
const getContactTypeName = (type: string) => {
function getContactTypeName(type: string) {
switch (type) {
case 'friend': return '好友'
case 'group': return '群聊'