diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index ba8c70b..b8bdf73 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2549,6 +2549,25 @@ } // Ghost preview — appears on hover, frosted glass + &.preview-below { + margin-top: 4px; + margin-bottom: 0; + + .reply-ghost { + top: calc(100% + 6px); + bottom: auto; + transform: translateY(-4px) scale(0.98); + } + + &:hover .reply-ghost { + transform: translateY(0) scale(1); + } + } + + &.preview-above { + margin-bottom: 4px; + } + .reply-ghost { position: absolute; bottom: calc(100% + 6px); diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 37acf41..a840199 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1510,6 +1510,7 @@ function ChatPage(props: ChatPageProps) { const [groupMemberSearchKeyword, setGroupMemberSearchKeyword] = useState('') const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) + const [quoteLayout, setQuoteLayout] = useState('quote-top') const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图 const [bizView, setBizView] = useState(false) // 是否在"公众号"视图 @@ -3066,6 +3067,33 @@ function ChatPage(props: ChatPageProps) { } }, []) + useEffect(() => { + let canceled = false + const loadQuoteLayout = () => { + void configService.getQuoteLayout() + .then((layout) => { + if (!canceled) setQuoteLayout(layout) + }) + .catch(() => { + if (!canceled) setQuoteLayout('quote-top') + }) + } + + loadQuoteLayout() + const handleFocus = () => loadQuoteLayout() + const handleQuoteLayoutChanged = (event: Event) => { + const layout = (event as CustomEvent).detail + setQuoteLayout(layout === 'quote-bottom' ? 'quote-bottom' : 'quote-top') + } + window.addEventListener('focus', handleFocus) + window.addEventListener('quote-layout-changed', handleQuoteLayoutChanged) + return () => { + canceled = true + window.removeEventListener('focus', handleFocus) + window.removeEventListener('quote-layout-changed', handleQuoteLayoutChanged) + } + }, []) + useEffect(() => { let cancelled = false void (async () => { @@ -6820,6 +6848,7 @@ function ChatPage(props: ChatPageProps) { myAvatarUrl={myAvatarUrl} myWxid={myWxid} isGroupChat={isCurrentSessionGroup} + quoteLayout={quoteLayout} autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled} onRequireModelDownload={handleRequireModelDownload} onContextMenu={handleContextMenu} @@ -6840,6 +6869,7 @@ function ChatPage(props: ChatPageProps) { myAvatarUrl, myWxid, isCurrentSessionGroup, + quoteLayout, autoTranscribeVoiceEnabled, handleRequireModelDownload, handleContextMenu, @@ -8395,6 +8425,7 @@ function MessageBubble({ myAvatarUrl, myWxid, isGroupChat, + quoteLayout, autoTranscribeVoiceEnabled, onRequireModelDownload, onContextMenu, @@ -8410,6 +8441,7 @@ function MessageBubble({ myAvatarUrl?: string; myWxid?: string; isGroupChat?: boolean; + quoteLayout: configService.QuoteLayout; autoTranscribeVoiceEnabled?: boolean; onRequireModelDownload?: (sessionId: string, messageId: string) => void; onContextMenu?: (e: React.MouseEvent, message: Message) => void; @@ -9665,17 +9697,26 @@ function MessageBubble({ event.stopPropagation() onJumpToQuotedMessage(quotedJumpTarget) }, [isSelectionMode, onJumpToQuotedMessage, quotedJumpTarget]) - // Ambient Reply: single fixed layout (anchor above, message below) + const isQuoteBelow = quoteLayout === 'quote-bottom' const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => ( -
- {quotedNode} - {messageNode} +
+ {isQuoteBelow ? ( + <> + {messageNode} + {quotedNode} + + ) : ( + <> + {quotedNode} + {messageNode} + + )}
- ), []) + ), [isQuoteBelow]) // Ambient Reply: render reply-anchor + ghost preview const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => ( -
+
{/* Reply anchor - always visible, subtle */}
{contentNode}
- ), [displayQuotedSenderName, handleQuotedJumpClick, handleQuotedJumpKeyDown, isSelectionMode, quotedJumpTarget]) + ), [displayQuotedSenderName, handleQuotedJumpClick, handleQuotedJumpKeyDown, isQuoteBelow, isSelectionMode, quotedJumpTarget]) const handlePlayVideo = useCallback(async () => { if (!videoInfo?.videoUrl) return @@ -10893,6 +10934,7 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => { if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false if (prevProps.myWxid !== nextProps.myWxid) return false if (prevProps.isGroupChat !== nextProps.isGroupChat) return false + if (prevProps.quoteLayout !== nextProps.quoteLayout) return false if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false if (prevProps.isSelected !== nextProps.isSelected) return false diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index ddd274b..dc53ef3 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1698,6 +1698,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { if (selected) return setQuoteLayout(option.value) await configService.setQuoteLayout(option.value) + window.dispatchEvent(new CustomEvent('quote-layout-changed', { detail: option.value })) showMessage(option.successMessage, true) }} role="radio"