fix: Reply UI

This commit is contained in:
Jason
2026-05-09 23:48:10 +08:00
parent e41a1197cb
commit 762a2ec832
2 changed files with 215 additions and 92 deletions

View File

@@ -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,

View File

@@ -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<string | undefined>(undefined)
const [senderName, setSenderName] = useState<string | undefined>(undefined)
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
const [quoteLayout, setQuoteLayout] = useState<QuoteLayout>('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 (
<div className={`bubble-content ${quoteFirst ? 'quote-layout-top' : 'quote-layout-bottom'}`}>
{quoteFirst ? (
<>
{quotedNode}
{messageNode}
</>
) : (
<>
{messageNode}
{quotedNode}
</>
)}
</div>
)
}, [quoteLayout])
// Ambient Reply: single fixed layout (anchor above, message below)
const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => (
<div className="bubble-content">
{quotedNode}
{messageNode}
</div>
), [])
// Ambient Reply: render reply-anchor + ghost preview
const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => (
<div className="quoted-message">
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{contentNode}</span>
<div className="ambient-reply-wrapper">
{/* Reply anchor - always visible, subtle */}
<div className="reply-anchor">
<svg className="reply-anchor-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 14 4 9 9 4" />
<path d="M20 20v-7a4 4 0 0 0-4-4H4" />
</svg>
{displayQuotedSenderName && <span className="reply-anchor-name">{displayQuotedSenderName}</span>}
<span className="reply-anchor-sep">&middot;</span>
<span className="reply-anchor-excerpt">{contentNode}</span>
</div>
{/* Ghost preview - appears on hover */}
<div className="reply-ghost">
{displayQuotedSenderName && <div className="reply-ghost-sender">{displayQuotedSenderName}</div>}
<div className="reply-ghost-text">{contentNode}</div>
</div>
</div>
), [displayQuotedSenderName])