From 796515d3e8c7d081b74549c41828ae121d8cc0a7 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 10 May 2026 21:50:46 +0800 Subject: [PATCH] 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 '群聊'