@@ -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 */}
-
+
- ), [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 '群聊'