From 762a2ec8324239f67c77cabd72d72aa0a8e3db5f Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 9 May 2026 23:48:10 +0800 Subject: [PATCH 1/5] fix: Reply UI --- src/pages/ChatPage.scss | 247 ++++++++++++++++++++++++++++++---------- src/pages/ChatPage.tsx | 60 +++++----- 2 files changed, 215 insertions(+), 92 deletions(-) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 9b79cd2..b7c5039 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%; @@ -2448,13 +2448,197 @@ 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; + } + + &: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 10px 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; + + // Edge fade effect + -webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 55%, transparent 100%); + + .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); @@ -2468,38 +2652,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; @@ -2514,14 +2669,6 @@ .bubble-content { -webkit-app-region: no-drag; - - &.quote-layout-top .quoted-message { - margin-bottom: 8px; - } - - &.quote-layout-bottom .quoted-message { - margin-top: 8px; - } } // 时间分隔 @@ -5611,23 +5758,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 714c2a2..d8c63bc 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -64,7 +64,7 @@ interface GlobalMsgPrefixCacheEntry { completed: boolean } -type QuoteLayout = configService.QuoteLayout + const GLOBAL_MSG_PER_SESSION_LIMIT = 10 const GLOBAL_MSG_SEED_LIMIT = 120 @@ -8252,7 +8252,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) @@ -9401,17 +9400,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 @@ -9448,29 +9437,32 @@ 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]) + // Ambient Reply: single fixed layout (anchor above, message below) + const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => ( +
+ {quotedNode} + {messageNode} +
+ ), []) + // Ambient Reply: render reply-anchor + ghost preview const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => ( -
- {displayQuotedSenderName && {displayQuotedSenderName}} - {contentNode} +
+ {/* Reply anchor - always visible, subtle */} +
+ + + + + {displayQuotedSenderName && {displayQuotedSenderName}} + · + {contentNode} +
+ {/* Ghost preview - appears on hover */} +
+ {displayQuotedSenderName &&
{displayQuotedSenderName}
} +
{contentNode}
+
), [displayQuotedSenderName]) From 796515d3e8c7d081b74549c41828ae121d8cc0a7 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 10 May 2026 21:50:46 +0800 Subject: [PATCH 2/5] fix: Updated Chat Page UI, & Fixed Address Book White Screen Issue & Optimized Launch Page UI --- public/splash.html | 10 +- src/pages/Chat/ChatMessageBubble.tsx | 5 +- src/pages/ChatPage.scss | 52 +++++- src/pages/ChatPage.tsx | 240 ++++++++++++++++++++++++++- src/pages/ContactsPage.tsx | 2 +- 5 files changed, 291 insertions(+), 18 deletions(-) diff --git a/public/splash.html b/public/splash.html index 56141e5..8295a56 100644 --- a/public/splash.html +++ b/public/splash.html @@ -10,7 +10,7 @@ --primary-rgb: 139, 115, 85; --on-primary: #ffffff; --window-bg: transparent; - --card-bg: rgba(255, 255, 255, 0.96); + --card-bg: #ffffff; --card-bg-solid: #ffffff; --card-border: rgba(0, 0, 0, 0.06); --text-primary: #171717; @@ -54,11 +54,11 @@ min-height: 0; position: relative; overflow: hidden; - border-radius: 0; + border-radius: 18px; border: none; background: linear-gradient(180deg, var(--card-gloss-start) 0%, var(--card-gloss-end) 48%), - var(--card-bg); + var(--card-bg-solid); box-shadow: var(--shadow); isolation: isolate; animation: shellIn 420ms cubic-bezier(0.22, 1, 0.36, 1) both; @@ -340,7 +340,7 @@ setVar("--window-bg", "transparent"); if (isDark) { - setVar("--card-bg", "rgba(31, 31, 31, 0.96)"); + setVar("--card-bg", "#1f1f1f"); setVar("--card-bg-solid", "#1f1f1f"); setVar("--card-border", "rgba(255, 255, 255, 0.08)"); setVar("--text-primary", "#f1f1f1"); @@ -352,7 +352,7 @@ setVar("--card-gloss-end", "rgba(255, 255, 255, 0)"); setVar("--shadow", "none"); } else { - setVar("--card-bg", "rgba(255, 255, 255, 0.96)"); + setVar("--card-bg", "#ffffff"); setVar("--card-bg-solid", "#ffffff"); setVar("--card-border", "rgba(0, 0, 0, 0.06)"); setVar("--text-primary", "#171717"); 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 b7c5039..9730e6b 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -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; @@ -2471,6 +2487,15 @@ 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 { @@ -2529,7 +2554,7 @@ bottom: calc(100% + 6px); max-width: 320px; min-width: 160px; - padding: 8px 12px 10px 12px; + 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); @@ -2583,10 +2608,6 @@ white-space: pre-wrap; word-break: break-word; - // Edge fade effect - -webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 100%); - mask-image: linear-gradient(to bottom, #000 55%, transparent 100%); - .inline-emoji { width: 16px !important; height: 16px !important; @@ -5719,7 +5740,8 @@ } &.emoji, - &.image { + &.image, + &.video { max-width: min(82%, 760px); .bubble-content { @@ -5732,6 +5754,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), @@ -5745,7 +5774,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; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index d8c63bc..37acf41 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 } @@ -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' @@ -1571,6 +1611,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) @@ -3742,6 +3784,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 @@ -4851,6 +4895,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() @@ -6649,6 +6823,7 @@ function ChatPage(props: ChatPageProps) { autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled} onRequireModelDownload={handleRequireModelDownload} onContextMenu={handleContextMenu} + onJumpToQuotedMessage={handleJumpToQuotedMessage} isSelectionMode={isSelectionMode} messageKey={messageKey} isSelected={selectedMessages.has(messageKey)} @@ -6668,6 +6843,7 @@ function ChatPage(props: ChatPageProps) { autoTranscribeVoiceEnabled, handleRequireModelDownload, handleContextMenu, + handleJumpToQuotedMessage, isSelectionMode, selectedMessages, handleToggleSelection @@ -8222,6 +8398,7 @@ function MessageBubble({ autoTranscribeVoiceEnabled, onRequireModelDownload, onContextMenu, + onJumpToQuotedMessage, isSelectionMode, isSelected, onToggleSelection @@ -8236,6 +8413,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; @@ -9437,6 +9615,56 @@ function MessageBubble({ // 是否有引用消息 const hasQuote = quotedContent.length > 0 const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName + const quotedJumpTarget = useMemo(() => { + if (!hasQuote) return null + + 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) => (
@@ -9449,7 +9677,13 @@ function MessageBubble({ const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => (
{/* Reply anchor - always visible, subtle */} -
+
@@ -9464,7 +9698,7 @@ function MessageBubble({
{contentNode}
- ), [displayQuotedSenderName]) + ), [displayQuotedSenderName, handleQuotedJumpClick, handleQuotedJumpKeyDown, isSelectionMode, quotedJumpTarget]) const handlePlayVideo = useCallback(async () => { if (!videoInfo?.videoUrl) return @@ -10634,6 +10868,7 @@ function MessageBubble({ isSystem={isSystem} isEmoji={isEmoji} isImage={isImage} + isVideo={isVideo} isVoice={isVoice} emojiHasAsset={Boolean(message.emojiCdnUrl || message.emojiLocalPath)} emojiError={emojiError} @@ -10663,6 +10898,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 '群聊' From b6b930ebb9e196e784f01a1d2ed42ca282021dda Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 10 May 2026 22:29:39 +0800 Subject: [PATCH 3/5] feat: enhance splash screen with dynamic theme support and improved styling --- electron/main.ts | 18 +- public/splash.html | 523 ++++++++++++++++++++++++++++----------------- 2 files changed, 343 insertions(+), 198 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 1a6809a..e9bc512 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 8295a56..aa32404 100644 --- a/public/splash.html +++ b/public/splash.html @@ -4,23 +4,65 @@ WeFlow +
-
-
- -

WeFlow

-

微信聊天记录管理工具

+
+ + +

WeFlow

+

微信聊天记录管理工具

-
正在启动...
+
+ +
正在预加载会话逻辑...
+
@@ -288,82 +456,45 @@
From fea00a6e36658a8e201ca322ef327f3cf8d45841 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 10 May 2026 22:50:19 +0800 Subject: [PATCH 4/5] fix: Splash Page --- public/splash.html | 116 ++++++++++++++++++++++++--------------------- 1 file changed, 62 insertions(+), 54 deletions(-) diff --git a/public/splash.html b/public/splash.html index aa32404..f16f995 100644 --- a/public/splash.html +++ b/public/splash.html @@ -26,7 +26,6 @@ --accent: #5b6abf; --accent-rgb: 91, 106, 191; --accent-hot: #364491; - --logo-highlight: #5b6abf; --ambient-glow: rgba(91, 106, 191, 0.08); --core-glow: rgba(91, 106, 191, 0.16); --text: #1a1b1e; @@ -51,7 +50,6 @@ --accent: #7c8deb; --accent-rgb: 124, 141, 235; --accent-hot: #ffffff; - --logo-highlight: #ffffff; --ambient-glow: rgba(124, 141, 235, 0.08); --core-glow: rgba(124, 141, 235, 0.28); --text: #f0f0f0; @@ -153,22 +151,13 @@ border-color: rgba(255, 255, 255, 0.10); } - .logo-mark { - width: 32px; - height: 32px; - color: var(--accent); + .logo-image { + width: 52px; + height: 52px; display: block; - filter: drop-shadow(0 0 8px rgba(var(--accent-rgb), 0.18)); - } - - [data-mode="light"] .logo-mark .mark-base { - color: var(--text-faint); - opacity: 0.42; - } - - [data-mode="dark"] .logo-mark .mark-base { - color: var(--accent); - opacity: 1; + object-fit: contain; + border-radius: 16px; + filter: drop-shadow(0 10px 22px rgba(var(--accent-rgb), 0.18)); } .app-name { @@ -276,18 +265,17 @@ .progress-fill { position: absolute; - left: 0; + left: -18%; bottom: 0; - width: 0%; + width: 8%; height: 100%; min-width: 0; - border-radius: 0 999px 999px 0; + border-radius: 999px; background: linear-gradient(90deg, transparent 0%, var(--accent) 54%, var(--accent-hot) 100%); box-shadow: 0 0 10px 1px rgba(var(--accent-rgb), 0.30); - transition: - width 680ms var(--ease-ambient), - opacity 260ms ease, - left 680ms var(--ease-ambient); + transform-origin: center; + will-change: left, width, transform, opacity; + animation: beamSweep 2680ms var(--ease-ambient) infinite; } .progress-fill::before { @@ -315,12 +303,6 @@ animation-delay: 180ms; } - .progress-fill.waiting { - border-radius: 999px; - transition: none; - animation: beamSweep 2500ms var(--ease-ambient) infinite; - } - @media (prefers-reduced-motion: reduce) { .splash-shell, .brand-stage, @@ -334,9 +316,10 @@ transition: none !important; } - .progress-fill.waiting { + .progress-fill { left: 0 !important; - width: 70% !important; + width: 46% !important; + opacity: 1 !important; } } @@ -414,16 +397,52 @@ @keyframes beamSweep { 0% { - left: -30%; - width: 0%; + left: -18%; + width: 8%; + opacity: 0; + transform: scaleX(0.72); } - 50% { - left: 30%; - width: 40%; + 11% { + left: -2%; + width: 18%; + opacity: 0.72; + transform: scaleX(0.92); + } + 34% { + left: 27%; + width: 38%; + opacity: 1; + transform: scaleX(1.08); + } + 58% { + left: 60%; + width: 35%; + opacity: 1; + transform: scaleX(1); + } + 73% { + left: 86%; + width: 18%; + opacity: 0.94; + transform: scaleX(0.68); + } + 82% { + left: 96%; + width: 8%; + opacity: 0.58; + transform: scaleX(0.30); + } + 91% { + left: 92%; + width: 11%; + opacity: 0.18; + transform: scaleX(0.22); } 100% { - left: 100%; - width: 10%; + left: 112%; + width: 0%; + opacity: 0; + transform: scaleX(0.18); } } @@ -432,10 +451,7 @@

WeFlow

@@ -497,21 +513,13 @@ syncSystemModeListener(safeMode); } - function updateProgress(percent, text, waiting) { + function updateProgress(_percent, text, _waiting) { var fill = document.getElementById("progressFill"); var label = document.getElementById("progressText"); - var safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); if (fill) { - if (waiting) { - fill.classList.add("waiting"); - fill.style.left = ""; - fill.style.width = ""; - } else { - fill.classList.remove("waiting"); - fill.style.left = "0"; - fill.style.width = safePercent + "%"; - } + fill.style.left = ""; + fill.style.width = ""; } if (label && text) label.textContent = text; From 0dc5efb63557dd3581719a2808783c5a06d9d388 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 10 May 2026 23:07:26 +0800 Subject: [PATCH 5/5] fix: Splash Page UI --- public/splash.html | 139 +++++++++++++-------------------------------- 1 file changed, 39 insertions(+), 100 deletions(-) diff --git a/public/splash.html b/public/splash.html index f16f995..0c50382 100644 --- a/public/splash.html +++ b/public/splash.html @@ -21,18 +21,14 @@ :root { --surface-start: #ffffff; --surface-end: #f8f9fc; - --surface-glass: rgba(255, 255, 255, 0.78); - --surface-glass-end: rgba(255, 255, 255, 0.52); --accent: #5b6abf; --accent-rgb: 91, 106, 191; - --accent-hot: #364491; --ambient-glow: rgba(91, 106, 191, 0.08); - --core-glow: rgba(91, 106, 191, 0.16); --text: #1a1b1e; --text-muted: #5f6368; --text-faint: #9aa0a6; --border-subtle: rgba(0, 0, 0, 0.05); - --loader-track: rgba(0, 0, 0, 0.03); + --loader-track: rgba(0, 0, 0, 0.06); --shadow-window: 0 24px 60px rgba(23, 27, 38, 0.10), 0 4px 12px rgba(23, 27, 38, 0.04), @@ -45,18 +41,14 @@ [data-mode="dark"] { --surface-start: #14171d; --surface-end: #0b0d10; - --surface-glass: rgba(255, 255, 255, 0.10); - --surface-glass-end: rgba(255, 255, 255, 0.02); --accent: #7c8deb; --accent-rgb: 124, 141, 235; - --accent-hot: #ffffff; --ambient-glow: rgba(124, 141, 235, 0.08); - --core-glow: rgba(124, 141, 235, 0.28); --text: #f0f0f0; --text-muted: #8b92a5; --text-faint: #4e5569; --border-subtle: rgba(255, 255, 255, 0.06); - --loader-track: rgba(255, 255, 255, 0.03); + --loader-track: rgba(255, 255, 255, 0.09); --shadow-window: 0 24px 80px rgba(0, 0, 0, 0.60), inset 0 1px 0 rgba(255, 255, 255, 0.05); @@ -132,32 +124,20 @@ .logo-core { width: 64px; height: 64px; - border-radius: 20px; display: grid; place-items: center; margin-bottom: 24px; - background: linear-gradient(135deg, var(--surface-glass), var(--surface-glass-end)); - border: 1px solid rgba(255, 255, 255, 0.18); - backdrop-filter: blur(12px) saturate(1.16); - -webkit-backdrop-filter: blur(12px) saturate(1.16); - animation: coreBreathe 3200ms ease-in-out infinite alternate; - } - - [data-mode="light"] .logo-core { - border-color: rgba(255, 255, 255, 0.82); - } - - [data-mode="dark"] .logo-core { - border-color: rgba(255, 255, 255, 0.10); + background: transparent; + border: 0; } .logo-image { - width: 52px; - height: 52px; + width: 64px; + height: 64px; display: block; object-fit: contain; - border-radius: 16px; - filter: drop-shadow(0 10px 22px rgba(var(--accent-rgb), 0.18)); + border-radius: 20px; + animation: logoBreathe 3200ms ease-in-out infinite alternate; } .app-name { @@ -254,28 +234,27 @@ right: 0; bottom: 0; z-index: 3; - height: 1.5px; + height: 3px; background: var(--loader-track); overflow: hidden; } [data-mode="dark"] .progress-track { - height: 1px; + height: 3px; } .progress-fill { position: absolute; - left: -18%; + left: 0; bottom: 0; - width: 8%; + width: 0%; height: 100%; min-width: 0; - border-radius: 999px; - background: linear-gradient(90deg, transparent 0%, var(--accent) 54%, var(--accent-hot) 100%); - box-shadow: 0 0 10px 1px rgba(var(--accent-rgb), 0.30); - transform-origin: center; - will-change: left, width, transform, opacity; - animation: beamSweep 2680ms var(--ease-ambient) infinite; + border-radius: 0 999px 999px 0; + background: var(--accent); + box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.34); + overflow: hidden; + transition: width 440ms var(--ease-ambient); } .progress-fill::before { @@ -288,8 +267,7 @@ border-radius: 999px; background: rgba(var(--accent-rgb), 0.34); filter: blur(8px); - opacity: 0.65; - animation: leadingGlow 1800ms ease-in-out infinite; + opacity: 0; } .progress-fill::after { @@ -299,15 +277,19 @@ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.54), transparent); opacity: 0; transform: translateX(-100%); - animation: spectralGlide 1500ms ease-out infinite; - animation-delay: 180ms; + animation: spectralGlide 1200ms ease-out; + } + + .progress-fill.waiting::before { + opacity: 0.65; + animation: leadingGlow 1300ms ease-in-out infinite; } @media (prefers-reduced-motion: reduce) { .splash-shell, .brand-stage, .status-row, - .logo-core, + .logo-image, .status-dot, .progress-fill, .progress-fill::before, @@ -318,7 +300,6 @@ .progress-fill { left: 0 !important; - width: 46% !important; opacity: 1 !important; } } @@ -345,13 +326,13 @@ } } - @keyframes coreBreathe { + @keyframes logoBreathe { 0% { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.03), 0 0 0 1px rgba(var(--accent-rgb), 0.08); + opacity: 0.94; transform: translateY(0); } 100% { - box-shadow: 0 8px 30px var(--core-glow), 0 0 0 1px rgba(var(--accent-rgb), 0.20); + opacity: 1; transform: translateY(-3px); } } @@ -395,56 +376,6 @@ } } - @keyframes beamSweep { - 0% { - left: -18%; - width: 8%; - opacity: 0; - transform: scaleX(0.72); - } - 11% { - left: -2%; - width: 18%; - opacity: 0.72; - transform: scaleX(0.92); - } - 34% { - left: 27%; - width: 38%; - opacity: 1; - transform: scaleX(1.08); - } - 58% { - left: 60%; - width: 35%; - opacity: 1; - transform: scaleX(1); - } - 73% { - left: 86%; - width: 18%; - opacity: 0.94; - transform: scaleX(0.68); - } - 82% { - left: 96%; - width: 8%; - opacity: 0.58; - transform: scaleX(0.30); - } - 91% { - left: 92%; - width: 11%; - opacity: 0.18; - transform: scaleX(0.22); - } - 100% { - left: 112%; - width: 0%; - opacity: 0; - transform: scaleX(0.18); - } - } @@ -513,13 +444,21 @@ syncSystemModeListener(safeMode); } - function updateProgress(_percent, text, _waiting) { + function updateProgress(percent, text, waiting) { var fill = document.getElementById("progressFill"); var label = document.getElementById("progressText"); + var safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); if (fill) { - fill.style.left = ""; - fill.style.width = ""; + fill.style.width = safePercent + "%"; + if (waiting) { + fill.classList.add("waiting"); + } else { + fill.classList.remove("waiting"); + fill.style.animation = "none"; + fill.offsetHeight; + fill.style.animation = ""; + } } if (label && text) label.textContent = text;