diff --git a/electron/main.ts b/electron/main.ts index 58df32c..cf80daf 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1001,6 +1001,8 @@ function createAgreementWindow() { */ function createSplashWindow(): BrowserWindow { const isDev = !!process.env.VITE_DEV_SERVER_URL + const splashThemeId = configService?.get('themeId') || 'cloud-dancer' + const splashThemeMode = configService?.get('theme') || 'system' const iconPath = isDev ? join(__dirname, '../public/icon.ico') : (process.platform === 'darwin' @@ -1008,8 +1010,8 @@ function createSplashWindow(): BrowserWindow { : join(process.resourcesPath, 'icon.ico')) splashWindow = new BrowserWindow({ - width: 856, - height: 540, + width: 680, + height: 460, resizable: false, frame: false, transparent: true, @@ -1027,9 +1029,17 @@ function createSplashWindow(): BrowserWindow { }) if (isDev) { - splashWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}splash.html`) + const splashUrl = new URL('splash.html', process.env.VITE_DEV_SERVER_URL) + splashUrl.searchParams.set('themeId', splashThemeId) + splashUrl.searchParams.set('themeMode', splashThemeMode) + splashWindow.loadURL(splashUrl.toString()) } else { - splashWindow.loadFile(join(__dirname, '../dist/splash.html')) + splashWindow.loadFile(join(__dirname, '../dist/splash.html'), { + query: { + themeId: splashThemeId, + themeMode: splashThemeMode + } + }) } splashWindow.once('ready-to-show', () => { diff --git a/public/splash.html b/public/splash.html index 56141e5..0c50382 100644 --- a/public/splash.html +++ b/public/splash.html @@ -4,23 +4,55 @@ WeFlow +
-
-
- -

WeFlow

-

微信聊天记录管理工具

+
+ + +

WeFlow

+

微信聊天记录管理工具

-
正在启动...
+
+ +
正在预加载会话逻辑...
+
@@ -288,82 +403,45 @@
diff --git a/src/pages/Chat/ChatMessageBubble.tsx b/src/pages/Chat/ChatMessageBubble.tsx index d88c6fd..e413979 100644 --- a/src/pages/Chat/ChatMessageBubble.tsx +++ b/src/pages/Chat/ChatMessageBubble.tsx @@ -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 && }
onContextMenu?.(event, message)} >
@@ -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 && diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 321447c..3d2fa58 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -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, diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b53b789..7b6281b 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -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 + // 全局头像加载队列管理器已移至 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(null) const pendingInSessionSearchRef = useRef(null) const pendingFootprintJumpRef = useRef(null) + const pendingQuotedMessageJumpRef = useRef(null) + const loadMessagesRef = useRef(null) const pendingGlobalMsgSearchReplayRef = useRef(null) const globalMsgPrefixCacheRef = useRef(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() + 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(undefined) const [senderName, setSenderName] = useState(undefined) const [quotedSenderName, setQuotedSenderName] = useState(undefined) - const [quoteLayout, setQuoteLayout] = useState('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 ( -
- {quoteFirst ? ( - <> - {quotedNode} - {messageNode} - - ) : ( - <> - {messageNode} - {quotedNode} - - )} -
- ) - }, [quoteLayout]) + const quotedJumpTarget = useMemo(() => { + if (!hasQuote) return null - const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => ( -
- {displayQuotedSenderName && {displayQuotedSenderName}} - {contentNode} + 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) => { + if (isSelectionMode) return + if (!quotedJumpTarget || !onJumpToQuotedMessage) return + event.stopPropagation() + onJumpToQuotedMessage(quotedJumpTarget) + }, [isSelectionMode, onJumpToQuotedMessage, quotedJumpTarget]) + const handleQuotedJumpKeyDown = useCallback((event: React.KeyboardEvent) => { + 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) => ( +
+ {quotedNode} + {messageNode}
- ), [displayQuotedSenderName]) + ), []) + + // Ambient Reply: render reply-anchor + ghost preview + const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => ( +
+ {/* Reply anchor - always visible, subtle */} +
+ + + + + {displayQuotedSenderName && {displayQuotedSenderName}} + · + {contentNode} +
+ {/* Ghost preview - appears on hover */} +
+ {displayQuotedSenderName &&
{displayQuotedSenderName}
} +
{contentNode}
+
+
+ ), [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 ( diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 0af23b4..909b4f7 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -793,7 +793,7 @@ function ContactsPage() { } } - const getContactTypeName = (type: string) => { + function getContactTypeName(type: string) { switch (type) { case 'friend': return '好友' case 'group': return '群聊'