mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
feat: 完善微信小程序与红包消息解析
- 新增小程序和红包结构化消息及专用卡片 - 修复嵌套类型、拍一拍和表情包误判 - 补充常见 AppMsg 类型分类
This commit is contained in:
@@ -16,6 +16,22 @@ type ShareContent = {
|
|||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
}
|
}
|
||||||
|
type MiniProgramContent = {
|
||||||
|
type: 'miniProgram'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
appName?: string
|
||||||
|
iconUrl?: string
|
||||||
|
thumbMd5?: string
|
||||||
|
thumbDatName?: string
|
||||||
|
thumbDataUrl?: string
|
||||||
|
}
|
||||||
|
type RedPacketContent = {
|
||||||
|
type: 'redPacket'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
type ImageContent = {
|
type ImageContent = {
|
||||||
type: 'image'
|
type: 'image'
|
||||||
@@ -74,6 +90,8 @@ export type ParsedContent =
|
|||||||
| LocationContent
|
| LocationContent
|
||||||
| CardContent
|
| CardContent
|
||||||
| ShareContent
|
| ShareContent
|
||||||
|
| MiniProgramContent
|
||||||
|
| RedPacketContent
|
||||||
| VoipContent
|
| VoipContent
|
||||||
| ImageContent
|
| ImageContent
|
||||||
| VideoContent
|
| VideoContent
|
||||||
@@ -413,6 +431,31 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appMsgType === '33' || appMsgType === '36') {
|
||||||
|
return {
|
||||||
|
type: 'miniProgram',
|
||||||
|
title: extractXmlValue(content, 'title') || '小程序',
|
||||||
|
description: extractXmlValue(content, 'des') || undefined,
|
||||||
|
appName:
|
||||||
|
extractXmlValue(content, 'sourcedisplayname') ||
|
||||||
|
extractXmlValue(content, 'appname') ||
|
||||||
|
'小程序',
|
||||||
|
iconUrl: decodeXmlUrl(extractXmlValue(content, 'weappiconurl')) || undefined,
|
||||||
|
thumbMd5: normalizeMd5(
|
||||||
|
extractXmlValue(content, 'cdnthumbmd5') || extractXmlValue(content, 'md5')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appMsgType === '2001') {
|
||||||
|
return {
|
||||||
|
type: 'redPacket',
|
||||||
|
title: extractXmlValue(content, 'title') || '微信红包',
|
||||||
|
description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利',
|
||||||
|
url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const title = extractXmlValue(content, 'title') || ''
|
const title = extractXmlValue(content, 'title') || ''
|
||||||
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
|
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
|
||||||
const url = extractXmlValue(content, 'url') || ''
|
const url = extractXmlValue(content, 'url') || ''
|
||||||
@@ -485,6 +528,10 @@ function extractAppMsgType(content: string): string {
|
|||||||
const inner = appmsgMatch[1]
|
const inner = appmsgMatch[1]
|
||||||
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||||
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||||
|
.replace(/<weappinfo[\s\S]*?<\/weappinfo>/gi, '')
|
||||||
|
.replace(/<appattach[\s\S]*?<\/appattach>/gi, '')
|
||||||
|
.replace(/<wcpayinfo[\s\S]*?<\/wcpayinfo>/gi, '')
|
||||||
|
.replace(/<findernamecard[\s\S]*?<\/findernamecard>/gi, '')
|
||||||
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
||||||
return typeMatch?.[1]?.trim() || ''
|
return typeMatch?.[1]?.trim() || ''
|
||||||
}
|
}
|
||||||
@@ -666,6 +713,71 @@ export function parseImageDatNameFromRow(row: Record<string, unknown>): string |
|
|||||||
return hexMatch?.[1]?.toLowerCase()
|
return hexMatch?.[1]?.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseImageBufferDataUrlFromRow(
|
||||||
|
row: Record<string, unknown>
|
||||||
|
): string | undefined {
|
||||||
|
const raw = pickRowString(row, [
|
||||||
|
'ImgBuf',
|
||||||
|
'imgBuf',
|
||||||
|
'img_buf',
|
||||||
|
'imageBuffer',
|
||||||
|
'image_buffer',
|
||||||
|
'thumbBuffer',
|
||||||
|
'thumb_buffer',
|
||||||
|
'WCDB_CT_img_buf',
|
||||||
|
'WCDB_CT_ImgBuf'
|
||||||
|
])
|
||||||
|
const buffer = decodeInlineImageBuffer(raw)
|
||||||
|
if (!buffer || buffer.length === 0) return undefined
|
||||||
|
const mime = detectImageMime(buffer)
|
||||||
|
return mime ? `data:${mime};base64,${buffer.toString('base64')}` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeInlineImageBuffer(raw: unknown): Buffer | null {
|
||||||
|
if (!raw) return null
|
||||||
|
if (Buffer.isBuffer(raw)) return raw
|
||||||
|
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||||
|
if (Array.isArray(raw)) return Buffer.from(raw)
|
||||||
|
if (typeof raw === 'object') {
|
||||||
|
const record = raw as { buffer?: unknown; data?: unknown }
|
||||||
|
return decodeInlineImageBuffer(record.buffer ?? record.data)
|
||||||
|
}
|
||||||
|
if (typeof raw !== 'string') return null
|
||||||
|
const value = raw.trim()
|
||||||
|
const dataUrl = /^data:image\/[a-z0-9.+-]+;base64,(.+)$/i.exec(value)
|
||||||
|
const encoded = dataUrl?.[1] || value
|
||||||
|
if (!/^[a-z0-9+/]+={0,2}$/i.test(encoded)) return null
|
||||||
|
try {
|
||||||
|
return Buffer.from(encoded, 'base64')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectImageMime(buffer: Buffer): string | undefined {
|
||||||
|
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||||
|
return 'image/jpeg'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buffer.length >= 8 &&
|
||||||
|
buffer[0] === 0x89 &&
|
||||||
|
buffer.subarray(1, 4).toString('ascii') === 'PNG'
|
||||||
|
) {
|
||||||
|
return 'image/png'
|
||||||
|
}
|
||||||
|
if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString('ascii'))) {
|
||||||
|
return 'image/gif'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buffer.length >= 12 &&
|
||||||
|
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return 'image/webp'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
||||||
|
|||||||
@@ -155,6 +155,27 @@ function cachedMessageIdentity(message: Message): string {
|
|||||||
return `id:${message.id}`
|
return `id:${message.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
|
||||||
|
return items.some((message) => {
|
||||||
|
const content = message.contentData
|
||||||
|
if (content?.type === 'system' && content.raw) {
|
||||||
|
return (
|
||||||
|
/<weappinfo\b/i.test(content.raw) &&
|
||||||
|
/<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
content?.type === 'share' &&
|
||||||
|
((content.typeVal === '3' && !content.url) || content.typeVal === '2001')
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (content?.type !== 'sticker') return false
|
||||||
|
const url = String(content.url || content.thumbUrl || '')
|
||||||
|
return /wxapp\.tenpay\.com\/mmpayhb|mp\.weixin\.qq\.com\/mp\/waerrpage/i.test(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
||||||
const entries = Object.entries(messages)
|
const entries = Object.entries(messages)
|
||||||
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
||||||
@@ -315,7 +336,7 @@ export function getCachedMessagePage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
hit: Boolean(bucket),
|
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []),
|
||||||
messages: bucket?.items || [],
|
messages: bucket?.items || [],
|
||||||
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
|
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { WechatDb, WechatMessage } from '../wechat-db'
|
import { WechatDb, WechatMessage } from '../wechat-db'
|
||||||
import {
|
import {
|
||||||
|
parseImageBufferDataUrlFromRow,
|
||||||
parseImageDatNameFromRow,
|
parseImageDatNameFromRow,
|
||||||
parseMessageContent,
|
parseMessageContent,
|
||||||
parseStickerMessageFromRow
|
parseStickerMessageFromRow
|
||||||
@@ -235,7 +236,10 @@ function listSourceMessages(
|
|||||||
|
|
||||||
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
||||||
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
||||||
const isPatMessage = /<patMsg\b|拍了拍|拍一拍/i.test(String(content || ''))
|
const rawContent = String(content || '')
|
||||||
|
const isPatMessage =
|
||||||
|
/<patinfo\b|<type>\s*62\s*<\/type>/i.test(rawContent) ||
|
||||||
|
([10000, 10002].includes(msgType) && /拍了拍/i.test(rawContent))
|
||||||
if (isPatMessage) {
|
if (isPatMessage) {
|
||||||
const system = parseMessageContent(content, 10000)
|
const system = parseMessageContent(content, 10000)
|
||||||
const patContent =
|
const patContent =
|
||||||
@@ -254,27 +258,40 @@ function listSourceMessages(
|
|||||||
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||||
try {
|
try {
|
||||||
const isQuotePayload = /<refermsg\b/i.test(content)
|
const isQuotePayload = /<refermsg\b/i.test(content)
|
||||||
const hasStickerHints =
|
const hasStickerPayload =
|
||||||
/<(?:emoji|sticker|emoticon)\b/i.test(content) ||
|
/<(?:emoji|sticker|emoticon)\b/i.test(content) ||
|
||||||
[
|
/<type>\s*47\s*<\/type>/i.test(content)
|
||||||
'emoji_md5',
|
|
||||||
'emojiMd5',
|
|
||||||
'emoji_cdn_url',
|
|
||||||
'emojiCdnUrl',
|
|
||||||
'packed_info_data',
|
|
||||||
'packed_info',
|
|
||||||
'reserved0',
|
|
||||||
'Reserved0',
|
|
||||||
'WCDB_CT_reserved0'
|
|
||||||
].some((key) => Boolean(msg[key]))
|
|
||||||
const rowSticker =
|
const rowSticker =
|
||||||
inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerHints)
|
inferredMsgType === 47 ||
|
||||||
|
(inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
||||||
? parseStickerMessageFromRow(msg, content)
|
? parseStickerMessageFromRow(msg, content)
|
||||||
: undefined
|
: undefined
|
||||||
const parsed =
|
const parsedContent = parseMessageContent(content, inferredMsgType)
|
||||||
rowSticker?.type === 'sticker'
|
const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : ''
|
||||||
|
const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : ''
|
||||||
|
const redPacketUrl = rowStickerUrl || parsedShareUrl
|
||||||
|
const isRedPacketFallback =
|
||||||
|
(parsedContent.type === 'share' && parsedContent.typeVal === '2001') ||
|
||||||
|
/wxapp\.tenpay\.com\/mmpayhb/i.test(redPacketUrl)
|
||||||
|
const parsed: ReturnType<typeof parseMessageContent> =
|
||||||
|
parsedContent.type === 'miniProgram' || parsedContent.type === 'redPacket'
|
||||||
|
? parsedContent
|
||||||
|
: isRedPacketFallback
|
||||||
|
? {
|
||||||
|
type: 'redPacket',
|
||||||
|
title:
|
||||||
|
parsedContent.type === 'share' && parsedContent.title
|
||||||
|
? parsedContent.title
|
||||||
|
: '微信红包',
|
||||||
|
description:
|
||||||
|
parsedContent.type === 'share' && parsedContent.des
|
||||||
|
? parsedContent.des
|
||||||
|
: '恭喜发财,大吉大利',
|
||||||
|
url: redPacketUrl || undefined
|
||||||
|
}
|
||||||
|
: rowSticker?.type === 'sticker'
|
||||||
? rowSticker
|
? rowSticker
|
||||||
: parseMessageContent(content, inferredMsgType)
|
: parsedContent
|
||||||
if (parsed.type === 'system') {
|
if (parsed.type === 'system') {
|
||||||
content = parsed.content
|
content = parsed.content
|
||||||
contentData = parsed
|
contentData = parsed
|
||||||
@@ -284,6 +301,13 @@ function listSourceMessages(
|
|||||||
if (parsed.type === 'image') {
|
if (parsed.type === 'image') {
|
||||||
const imageDatName = parseImageDatNameFromRow(msg)
|
const imageDatName = parseImageDatNameFromRow(msg)
|
||||||
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
||||||
|
} else if (parsed.type === 'miniProgram') {
|
||||||
|
contentData = {
|
||||||
|
...parsed,
|
||||||
|
thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg),
|
||||||
|
thumbDataUrl:
|
||||||
|
parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
|
||||||
|
}
|
||||||
} else if (parsed.type !== 'system') {
|
} else if (parsed.type !== 'system') {
|
||||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
||||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||||
@@ -295,6 +319,15 @@ function listSourceMessages(
|
|||||||
}
|
}
|
||||||
if (parsed.type === 'quote') displayType = '引用消息'
|
if (parsed.type === 'quote') displayType = '引用消息'
|
||||||
if (parsed.type === 'sticker') displayType = '表情包'
|
if (parsed.type === 'sticker') displayType = '表情包'
|
||||||
|
if (parsed.type === 'miniProgram') displayType = '小程序'
|
||||||
|
if (parsed.type === 'redPacket') displayType = '微信红包'
|
||||||
|
if (parsed.type === 'share') {
|
||||||
|
if (parsed.typeVal === '5') displayType = '公众号链接'
|
||||||
|
if (parsed.typeVal === '6') displayType = '文件'
|
||||||
|
if (parsed.typeVal === '74') displayType = '文件发送中'
|
||||||
|
if (parsed.typeVal === '51') displayType = '视频号'
|
||||||
|
if (parsed.typeVal === '2000') displayType = '转账'
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+19
-1
@@ -46,7 +46,25 @@ export type ParsedContent =
|
|||||||
| { type: 'voice'; duration?: number }
|
| { type: 'voice'; duration?: number }
|
||||||
| { type: 'location'; poiname?: string; label?: string; lat: number; lng: number }
|
| { type: 'location'; poiname?: string; label?: string; lat: number; lng: number }
|
||||||
| { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
| { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||||
| { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string }
|
| {
|
||||||
|
type: 'share'
|
||||||
|
title: string
|
||||||
|
des?: string
|
||||||
|
url: string
|
||||||
|
appname?: string
|
||||||
|
typeVal?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'miniProgram'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
appName?: string
|
||||||
|
iconUrl?: string
|
||||||
|
thumbMd5?: string
|
||||||
|
thumbDatName?: string
|
||||||
|
thumbDataUrl?: string
|
||||||
|
}
|
||||||
|
| { type: 'redPacket'; title: string; description?: string; url?: string }
|
||||||
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ function App(): React.ReactElement {
|
|||||||
const messageHistoryRef = React.useRef<Message[]>([])
|
const messageHistoryRef = React.useRef<Message[]>([])
|
||||||
const messagesRef = React.useRef<Message[]>([])
|
const messagesRef = React.useRef<Message[]>([])
|
||||||
const messagePrefetchRef = React.useRef<Promise<void> | null>(null)
|
const messagePrefetchRef = React.useRef<Promise<void> | null>(null)
|
||||||
|
const pendingLiveRefreshMd5Ref = React.useRef('')
|
||||||
messagesRef.current = messages
|
messagesRef.current = messages
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!reportNotice) return
|
if (!reportNotice) return
|
||||||
@@ -922,6 +923,7 @@ function App(): React.ReactElement {
|
|||||||
storeGroupMemberMeta(contact, cachedPage.groupSnapshot)
|
storeGroupMemberMeta(contact, cachedPage.groupSnapshot)
|
||||||
}
|
}
|
||||||
messageHistoryRef.current = cachedMsgs
|
messageHistoryRef.current = cachedMsgs
|
||||||
|
pendingLiveRefreshMd5Ref.current = cachedPage.hit ? '' : contact.md5
|
||||||
setMessages(
|
setMessages(
|
||||||
applyGroupMemberMeta(
|
applyGroupMemberMeta(
|
||||||
contact,
|
contact,
|
||||||
@@ -929,7 +931,9 @@ function App(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
const needsLivePage =
|
const needsLivePage =
|
||||||
forceLive || (isDatabaseConnected && cachedMsgs.length < MESSAGE_PREFETCH_COUNT)
|
forceLive ||
|
||||||
|
(isDatabaseConnected &&
|
||||||
|
(!cachedPage.hit || cachedMsgs.length < MESSAGE_PREFETCH_COUNT))
|
||||||
if (!needsLivePage) {
|
if (!needsLivePage) {
|
||||||
setIsMessagesLoading(false)
|
setIsMessagesLoading(false)
|
||||||
if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) {
|
if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) {
|
||||||
@@ -955,6 +959,7 @@ function App(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
messageHistoryRef.current = msgs
|
messageHistoryRef.current = msgs
|
||||||
|
pendingLiveRefreshMd5Ref.current = ''
|
||||||
const visibleMessages = applyGroupMemberMeta(
|
const visibleMessages = applyGroupMemberMeta(
|
||||||
contact,
|
contact,
|
||||||
mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT))
|
mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT))
|
||||||
@@ -986,7 +991,9 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isDatabaseConnected || !selectedContact || messages.length > 0) return
|
if (!isDatabaseConnected || !selectedContact) return
|
||||||
|
const needsPendingRefresh = pendingLiveRefreshMd5Ref.current === selectedContact.md5
|
||||||
|
if (messages.length > 0 && !needsPendingRefresh) return
|
||||||
// The first live page uses async native cursors, so a cache miss can be filled
|
// The first live page uses async native cursors, so a cache miss can be filled
|
||||||
// without blocking the Electron main thread.
|
// without blocking the Electron main thread.
|
||||||
void handleSelectContact(selectedContact)
|
void handleSelectContact(selectedContact)
|
||||||
|
|||||||
@@ -2837,6 +2837,143 @@ body {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mini-program-message {
|
||||||
|
width: min(280px, 48vw);
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-description {
|
||||||
|
margin: -4px 0 8px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-preview .image-bubble,
|
||||||
|
.mini-program-preview .image-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-preview .image-content {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-inline-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-preview .image-content.image-fallback {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 28px auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-preview .image-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-icon {
|
||||||
|
display: block;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 7px;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-message {
|
||||||
|
width: min(280px, 48vw);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fa9d3b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-message.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 36px;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid rgba(255, 239, 170, 0.92);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff1a8;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-copy strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 21px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-copy small {
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-footer {
|
||||||
|
padding: 5px 14px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.voip-message {
|
.voip-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ interface ImageBubbleProps {
|
|||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
isThumb?: boolean
|
isThumb?: boolean
|
||||||
|
fallbackUrl?: string
|
||||||
onImageClick?: (imageUrl: string) => void
|
onImageClick?: (imageUrl: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export function ImageBubble({
|
|||||||
imageDatName,
|
imageDatName,
|
||||||
sessionId,
|
sessionId,
|
||||||
isThumb = false,
|
isThumb = false,
|
||||||
|
fallbackUrl,
|
||||||
onImageClick
|
onImageClick
|
||||||
}: ImageBubbleProps): JSX.Element {
|
}: ImageBubbleProps): JSX.Element {
|
||||||
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
|
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
|
||||||
@@ -61,11 +63,18 @@ export function ImageBubble({
|
|||||||
const [upgrading, setUpgrading] = useState(false)
|
const [upgrading, setUpgrading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||||
|
const [usingFallback, setUsingFallback] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const loadImage = useCallback(async () => {
|
const loadImage = useCallback(async () => {
|
||||||
if (imageUrl || loading) return
|
if (imageUrl || loading) return
|
||||||
if (!imageMd5 && !imageDatName) {
|
if (!imageMd5 && !imageDatName) {
|
||||||
|
if (fallbackUrl) {
|
||||||
|
setImageUrl(fallbackUrl)
|
||||||
|
setUsingFallback(true)
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
setError('缺少图片标识')
|
setError('缺少图片标识')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -79,17 +88,30 @@ export function ImageBubble({
|
|||||||
isThumbnail: Boolean(result.isThumb)
|
isThumbnail: Boolean(result.isThumb)
|
||||||
})
|
})
|
||||||
setImageUrl(result.data)
|
setImageUrl(result.data)
|
||||||
|
setUsingFallback(false)
|
||||||
setIsThumbnail(Boolean(result.isThumb))
|
setIsThumbnail(Boolean(result.isThumb))
|
||||||
setError(null)
|
setError(null)
|
||||||
|
} else {
|
||||||
|
if (fallbackUrl) {
|
||||||
|
setImageUrl(fallbackUrl)
|
||||||
|
setUsingFallback(true)
|
||||||
|
setError(null)
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || '加载图片失败')
|
setError(result.error || '加载图片失败')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if (fallbackUrl) {
|
||||||
|
setImageUrl(fallbackUrl)
|
||||||
|
setUsingFallback(true)
|
||||||
|
setError(null)
|
||||||
|
} else {
|
||||||
setError('加载图片失败')
|
setError('加载图片失败')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
}, [fallbackUrl, imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imageUrl || loading || error) return
|
if (imageUrl || loading || error) return
|
||||||
@@ -141,6 +163,7 @@ export function ImageBubble({
|
|||||||
isThumbnail: Boolean(result.isThumb)
|
isThumbnail: Boolean(result.isThumb)
|
||||||
})
|
})
|
||||||
setImageUrl(result.data)
|
setImageUrl(result.data)
|
||||||
|
setUsingFallback(false)
|
||||||
setIsThumbnail(Boolean(result.isThumb))
|
setIsThumbnail(Boolean(result.isThumb))
|
||||||
setError(null)
|
setError(null)
|
||||||
onImageClick?.(result.data)
|
onImageClick?.(result.data)
|
||||||
@@ -184,7 +207,11 @@ export function ImageBubble({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="image-bubble image-loaded" onClick={handleClick}>
|
<div className="image-bubble image-loaded" onClick={handleClick}>
|
||||||
<img src={imageUrl} alt="图片" className="image-content" />
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="图片"
|
||||||
|
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
|
||||||
|
/>
|
||||||
{(upgrading || isThumbnail) && (
|
{(upgrading || isThumbnail) && (
|
||||||
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ export function RichMessageBubble({
|
|||||||
return <CardBubble data={contentData} />
|
return <CardBubble data={contentData} />
|
||||||
case 'share':
|
case 'share':
|
||||||
return <ShareBubble data={contentData} />
|
return <ShareBubble data={contentData} />
|
||||||
|
case 'miniProgram':
|
||||||
|
return (
|
||||||
|
<MiniProgramBubble
|
||||||
|
data={contentData}
|
||||||
|
sessionId={sessionId}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'redPacket':
|
||||||
|
return <RedPacketBubble data={contentData} />
|
||||||
case 'voip':
|
case 'voip':
|
||||||
return <VoipBubble data={contentData} />
|
return <VoipBubble data={contentData} />
|
||||||
case 'sticker':
|
case 'sticker':
|
||||||
@@ -117,6 +127,83 @@ function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MiniProgramBubble({
|
||||||
|
data,
|
||||||
|
sessionId,
|
||||||
|
onImageClick
|
||||||
|
}: {
|
||||||
|
data: Extract<ParsedContent, { type: 'miniProgram' }>
|
||||||
|
sessionId?: string
|
||||||
|
onImageClick?: (imageUrl: string) => void
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="mini-program-message">
|
||||||
|
<div className="mini-program-title">{data.title}</div>
|
||||||
|
{data.description && <div className="mini-program-description">{data.description}</div>}
|
||||||
|
{data.thumbDataUrl ? (
|
||||||
|
<div className="mini-program-preview">
|
||||||
|
<img
|
||||||
|
className="mini-program-inline-image"
|
||||||
|
src={data.thumbDataUrl}
|
||||||
|
alt={data.title}
|
||||||
|
onClick={() => onImageClick?.(data.thumbDataUrl || '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : data.thumbMd5 ? (
|
||||||
|
<div className="mini-program-preview">
|
||||||
|
<ImageBubble
|
||||||
|
imageMd5={data.thumbMd5}
|
||||||
|
imageDatName={data.thumbDatName}
|
||||||
|
sessionId={sessionId}
|
||||||
|
isThumb
|
||||||
|
fallbackUrl={data.iconUrl}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : data.iconUrl ? (
|
||||||
|
<img
|
||||||
|
className="mini-program-icon"
|
||||||
|
src={data.iconUrl}
|
||||||
|
alt=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="mini-program-footer">
|
||||||
|
<span aria-hidden>⌁</span>
|
||||||
|
<span>{data.appName || '小程序'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedPacketBubble({
|
||||||
|
data
|
||||||
|
}: {
|
||||||
|
data: Extract<ParsedContent, { type: 'redPacket' }>
|
||||||
|
}): JSX.Element {
|
||||||
|
const handleClick = (): void => {
|
||||||
|
if (data.url) window.open(data.url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`red-packet-message ${data.url ? 'is-clickable' : ''}`}
|
||||||
|
onClick={data.url ? handleClick : undefined}
|
||||||
|
>
|
||||||
|
<div className="red-packet-main">
|
||||||
|
<span className="red-packet-icon" aria-hidden>
|
||||||
|
¥
|
||||||
|
</span>
|
||||||
|
<span className="red-packet-copy">
|
||||||
|
<strong>{data.title}</strong>
|
||||||
|
<small>{data.description || '恭喜发财,大吉大利'}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="red-packet-footer">微信红包</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
|
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
|
||||||
const { status, roomType, duration } = data
|
const { status, roomType, duration } = data
|
||||||
const isVideo = roomType === 1
|
const isVideo = roomType === 1
|
||||||
|
|||||||
@@ -16,7 +16,22 @@ interface MessageBubbleProps {
|
|||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '引用消息', '通话', '表情包', '系统消息']
|
const RICH_MESSAGE_TYPES = [
|
||||||
|
'名片',
|
||||||
|
'位置',
|
||||||
|
'分享消息',
|
||||||
|
'小程序',
|
||||||
|
'微信红包',
|
||||||
|
'公众号链接',
|
||||||
|
'文件',
|
||||||
|
'文件发送中',
|
||||||
|
'视频号',
|
||||||
|
'转账',
|
||||||
|
'引用消息',
|
||||||
|
'通话',
|
||||||
|
'表情包',
|
||||||
|
'系统消息'
|
||||||
|
]
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -53,6 +53,22 @@ type ShareContent = {
|
|||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
}
|
}
|
||||||
|
type MiniProgramContent = {
|
||||||
|
type: 'miniProgram'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
appName?: string
|
||||||
|
iconUrl?: string
|
||||||
|
thumbMd5?: string
|
||||||
|
thumbDatName?: string
|
||||||
|
thumbDataUrl?: string
|
||||||
|
}
|
||||||
|
type RedPacketContent = {
|
||||||
|
type: 'redPacket'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
type ImageContent = {
|
type ImageContent = {
|
||||||
type: 'image'
|
type: 'image'
|
||||||
@@ -111,6 +127,8 @@ export type ParsedContent =
|
|||||||
| LocationContent
|
| LocationContent
|
||||||
| CardContent
|
| CardContent
|
||||||
| ShareContent
|
| ShareContent
|
||||||
|
| MiniProgramContent
|
||||||
|
| RedPacketContent
|
||||||
| VoipContent
|
| VoipContent
|
||||||
| ImageContent
|
| ImageContent
|
||||||
| VideoContent
|
| VideoContent
|
||||||
|
|||||||
Reference in New Issue
Block a user