实现 #580 引用消息支持部分引用显示

This commit is contained in:
ethan
2026-04-05 17:28:08 -04:00
parent 209b91bfef
commit 867f85e8f2
3 changed files with 174 additions and 11 deletions

View File

@@ -4486,15 +4486,16 @@ class ChatService {
*/ */
private parseQuoteMessage(content: string): { content?: string; sender?: string } { private parseQuoteMessage(content: string): { content?: string; sender?: string } {
try { try {
const normalizedContent = this.decodeHtmlEntities(content || '')
// 提取 refermsg 部分 // 提取 refermsg 部分
const referMsgStart = content.indexOf('<refermsg>') const referMsgStart = normalizedContent.indexOf('<refermsg>')
const referMsgEnd = content.indexOf('</refermsg>') const referMsgEnd = normalizedContent.indexOf('</refermsg>')
if (referMsgStart === -1 || referMsgEnd === -1) { if (referMsgStart === -1 || referMsgEnd === -1) {
return {} return {}
} }
const referMsgXml = content.substring(referMsgStart, referMsgEnd + 11) const referMsgXml = normalizedContent.substring(referMsgStart, referMsgEnd + 11)
// 提取发送者名称 // 提取发送者名称
let displayName = this.extractXmlValue(referMsgXml, 'displayname') let displayName = this.extractXmlValue(referMsgXml, 'displayname')
@@ -4511,8 +4512,8 @@ class ChatService {
let displayContent = referContent let displayContent = referContent
switch (referType) { switch (referType) {
case '1': case '1':
// 文本消息,清理可能的 wxid // 文本消息优先取“部分引用”字段,缺失时再回退到完整 content
displayContent = this.sanitizeQuotedContent(referContent) displayContent = this.extractPreferredQuotedText(referMsgXml)
break break
case '3': case '3':
displayContent = '[图片]' displayContent = '[图片]'
@@ -4552,6 +4553,76 @@ class ChatService {
} }
} }
private extractPreferredQuotedText(referMsgXml: string): string {
if (!referMsgXml) return ''
const sources = [this.decodeHtmlEntities(referMsgXml)]
const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource')
if (rawMsgSource) {
const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource)
if (decodedMsgSource) {
sources.push(decodedMsgSource)
}
}
const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content'))
const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent)
if (partialText) return partialText
const candidateTags = [
'selectedcontent',
'selectedtext',
'selectcontent',
'selecttext',
'quotecontent',
'quotetext',
'partcontent',
'parttext',
'excerpt',
'summary',
'preview'
]
for (const source of sources) {
for (const tag of candidateTags) {
const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag))
if (value) return value
}
}
return fullContent
}
private extractPartialQuotedText(xml: string, fullContent: string): string {
if (!xml || !fullContent) return ''
const startChar = this.extractXmlValue(xml, 'start')
const endChar = this.extractXmlValue(xml, 'end')
const startIndexRaw = this.extractXmlValue(xml, 'startindex')
const endIndexRaw = this.extractXmlValue(xml, 'endindex')
const startIndex = Number.parseInt(startIndexRaw, 10)
const endIndex = Number.parseInt(endIndexRaw, 10)
if (startChar && endChar) {
const startPos = fullContent.indexOf(startChar)
if (startPos !== -1) {
const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1)
if (endPos !== -1 && endPos >= startPos) {
const sliced = fullContent.slice(startPos, endPos + endChar.length).trim()
if (sliced) return sliced
}
}
}
if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) {
const chars = Array.from(fullContent)
const sliced = chars.slice(startIndex, endIndex + 1).join('').trim()
if (sliced) return sliced
}
return ''
}
/** /**
* 解析名片消息 * 解析名片消息
* 格式: <msg username="wxid_xxx" nickname="昵称" ... /> * 格式: <msg username="wxid_xxx" nickname="昵称" ... />

View File

@@ -2254,7 +2254,7 @@ class ExportService {
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
const quoteInfo = this.parseQuoteMessage(normalized) const quoteInfo = this.parseQuoteMessage(normalized)
const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '') const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '')
const quotedPreview = this.formatQuotedReferencePreview( const quotedPreview = quoteInfo.content || this.formatQuotedReferencePreview(
this.extractXmlValue(referMsgXml, 'content'), this.extractXmlValue(referMsgXml, 'content'),
this.extractXmlValue(referMsgXml, 'type') this.extractXmlValue(referMsgXml, 'type')
) )
@@ -2960,7 +2960,7 @@ class ExportService {
switch (referType) { switch (referType) {
case '1': case '1':
displayContent = this.sanitizeQuotedContent(referContent) displayContent = this.extractPreferredQuotedText(referMsgXml)
break break
case '3': case '3':
displayContent = '[图片]' displayContent = '[图片]'
@@ -3001,6 +3001,76 @@ class ExportService {
} }
} }
private extractPreferredQuotedText(referMsgXml: string): string {
if (!referMsgXml) return ''
const sources = [this.decodeHtmlEntities(referMsgXml)]
const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource')
if (rawMsgSource) {
const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource)
if (decodedMsgSource) {
sources.push(decodedMsgSource)
}
}
const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content'))
const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent)
if (partialText) return partialText
const candidateTags = [
'selectedcontent',
'selectedtext',
'selectcontent',
'selecttext',
'quotecontent',
'quotetext',
'partcontent',
'parttext',
'excerpt',
'summary',
'preview'
]
for (const source of sources) {
for (const tag of candidateTags) {
const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag))
if (value) return value
}
}
return fullContent
}
private extractPartialQuotedText(xml: string, fullContent: string): string {
if (!xml || !fullContent) return ''
const startChar = this.extractXmlValue(xml, 'start')
const endChar = this.extractXmlValue(xml, 'end')
const startIndexRaw = this.extractXmlValue(xml, 'startindex')
const endIndexRaw = this.extractXmlValue(xml, 'endindex')
const startIndex = Number.parseInt(startIndexRaw, 10)
const endIndex = Number.parseInt(endIndexRaw, 10)
if (startChar && endChar) {
const startPos = fullContent.indexOf(startChar)
if (startPos !== -1) {
const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1)
if (endPos !== -1 && endPos >= startPos) {
const sliced = fullContent.slice(startPos, endPos + endChar.length).trim()
if (sliced) return sliced
}
}
}
if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) {
const chars = Array.from(fullContent)
const sliced = chars.slice(startIndex, endIndex + 1).join('').trim()
if (sliced) return sliced
}
return ''
}
private extractChatLabReplyToMessageId(content: string): string | undefined { private extractChatLabReplyToMessageId(content: string): string | undefined {
try { try {
const normalized = this.normalizeAppMessageContent(content || '') const normalized = this.normalizeAppMessageContent(content || '')

View File

@@ -8695,6 +8695,28 @@ function MessageBubble({
appMsgTextCache.set(selector, value) appMsgTextCache.set(selector, value)
return value return value
}, [appMsgDoc, appMsgTextCache]) }, [appMsgDoc, appMsgTextCache])
const queryPreferredQuotedContent = useCallback((): string => {
if (message.quotedContent) return message.quotedContent
const candidates = [
'refermsg > selectedcontent',
'refermsg > selectedtext',
'refermsg > selectcontent',
'refermsg > selecttext',
'refermsg > quotecontent',
'refermsg > quotetext',
'refermsg > partcontent',
'refermsg > parttext',
'refermsg > excerpt',
'refermsg > summary',
'refermsg > preview',
'refermsg > content'
]
for (const selector of candidates) {
const value = queryAppMsgText(selector)
if (value) return value
}
return ''
}, [message.quotedContent, queryAppMsgText])
const appMsgThumbRawCandidate = useMemo(() => ( const appMsgThumbRawCandidate = useMemo(() => (
message.linkThumb || message.linkThumb ||
message.appMsgThumbUrl || message.appMsgThumbUrl ||
@@ -8712,7 +8734,7 @@ function MessageBubble({
queryAppMsgText('refermsg > fromusr'), queryAppMsgText('refermsg > fromusr'),
queryAppMsgText('refermsg > chatusr') queryAppMsgText('refermsg > chatusr')
) )
const quotedContent = message.quotedContent || queryAppMsgText('refermsg > content') || '' const quotedContent = queryPreferredQuotedContent()
const quotedSenderFallbackName = useMemo( const quotedSenderFallbackName = useMemo(
() => resolveQuotedSenderFallbackDisplayName( () => resolveQuotedSenderFallbackDisplayName(
session.username, session.username,
@@ -9262,7 +9284,7 @@ function MessageBubble({
// type 57: 引用回复消息,解析 refermsg 渲染为引用样式 // type 57: 引用回复消息,解析 refermsg 渲染为引用样式
if (xmlType === '57') { if (xmlType === '57') {
const replyText = q('title') || cleanedParsedContent || '' const replyText = q('title') || cleanedParsedContent || ''
const referContent = q('refermsg > content') || '' const referContent = queryPreferredQuotedContent()
const referType = q('refermsg > type') || '' const referType = q('refermsg > type') || ''
// 根据被引用消息类型渲染对应内容 // 根据被引用消息类型渲染对应内容
@@ -9385,7 +9407,7 @@ function MessageBubble({
if (kind === 'quote') { if (kind === 'quote') {
// 引用回复消息appMsgKind='quote'xmlType=57 // 引用回复消息appMsgKind='quote'xmlType=57
const replyText = message.linkTitle || q('title') || cleanedParsedContent || '' const replyText = message.linkTitle || q('title') || cleanedParsedContent || ''
const referContent = message.quotedContent || q('refermsg > content') || '' const referContent = queryPreferredQuotedContent()
return ( return (
renderBubbleWithQuote( renderBubbleWithQuote(
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))), renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))),
@@ -9576,7 +9598,7 @@ function MessageBubble({
// 引用回复消息 (type=57),防止被误判为链接 // 引用回复消息 (type=57),防止被误判为链接
if (appMsgType === '57') { if (appMsgType === '57') {
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || '' const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || ''
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || '' const referContent = queryPreferredQuotedContent()
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || '' const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
const renderReferContent2 = () => { const renderReferContent2 = () => {