mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-22 05:56:58 +08:00
fix: 修复语音首播并完善消息解析与会话兼容性
(cherry picked from commit 214090192f5dfc91cdec0269bad434d3e22394d8)
This commit is contained in:
@@ -601,8 +601,11 @@ function App(): React.ReactElement {
|
||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||
setIsDatabaseConnected(true)
|
||||
setDbKeyStatus('已连接数据库')
|
||||
// Cached contacts/self info are enough for startup. Native refresh is
|
||||
// intentionally user-triggered so it cannot freeze the first session.
|
||||
// The cached list paints first. Refresh lightweight session flags and
|
||||
// missing avatars after the database is connected.
|
||||
void loadContacts({ waitForAvatars: false }).catch((error) => {
|
||||
console.warn('[Startup] background contact refresh failed:', error)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[Startup] background database init failed:', error)
|
||||
|
||||
@@ -24,13 +24,11 @@ export function RichMessageBubble({
|
||||
return <CardBubble data={contentData} />
|
||||
case 'share':
|
||||
return <ShareBubble data={contentData} />
|
||||
case 'forwardBundle':
|
||||
return <ForwardBundleBubble data={contentData} />
|
||||
case 'miniProgram':
|
||||
return (
|
||||
<MiniProgramBubble
|
||||
data={contentData}
|
||||
sessionId={sessionId}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
<MiniProgramBubble data={contentData} sessionId={sessionId} onImageClick={onImageClick} />
|
||||
)
|
||||
case 'redPacket':
|
||||
return <RedPacketBubble data={contentData} />
|
||||
@@ -44,8 +42,11 @@ export function RichMessageBubble({
|
||||
return <SystemBubble data={contentData} />
|
||||
case 'unknown':
|
||||
return (
|
||||
<div className="message-text">
|
||||
{renderWechatEmojiText((contentData as { raw?: string }).raw || '[未知消息]')}
|
||||
<div className="unsupported-message">
|
||||
<strong>暂不支持此消息</strong>
|
||||
<span>
|
||||
消息类型 {(contentData as { messageType?: string | number }).messageType || '未知'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
@@ -53,6 +54,56 @@ export function RichMessageBubble({
|
||||
}
|
||||
}
|
||||
|
||||
function ForwardBundleBubble({
|
||||
data
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'forwardBundle' }>
|
||||
}): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const visibleItems = expanded ? data.items : data.items.slice(0, 3)
|
||||
const hiddenCount = Math.max(0, data.items.length - visibleItems.length)
|
||||
|
||||
return (
|
||||
<div className="forward-bundle-message">
|
||||
<button
|
||||
type="button"
|
||||
className="forward-bundle-header"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span>{data.title || '聊天记录'}</span>
|
||||
<small>
|
||||
{data.items.length ? `${data.items.length} 条消息` : data.description || '聊天记录'}
|
||||
</small>
|
||||
</button>
|
||||
<div className="forward-bundle-list">
|
||||
{visibleItems.length ? (
|
||||
visibleItems.map((item, index) => (
|
||||
<div
|
||||
className="forward-bundle-item"
|
||||
key={`${item.sender || ''}-${item.sentAt || ''}-${index}`}
|
||||
>
|
||||
{item.sender && <b>{item.sender}</b>}
|
||||
<span>{renderWechatEmojiText(item.text, 24)}</span>
|
||||
{item.nested?.length ? <small>包含 {item.nested.length} 条聊天记录</small> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="forward-bundle-empty">暂未解析到可展示的记录</div>
|
||||
)}
|
||||
</div>
|
||||
{(hiddenCount > 0 || expanded) && data.items.length > 3 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="forward-bundle-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? '收起' : `展开其余 ${hiddenCount} 条`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocationBubble({
|
||||
data
|
||||
}: {
|
||||
@@ -161,12 +212,7 @@ function MiniProgramBubble({
|
||||
/>
|
||||
</div>
|
||||
) : data.iconUrl ? (
|
||||
<img
|
||||
className="mini-program-icon"
|
||||
src={data.iconUrl}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<img className="mini-program-icon" src={data.iconUrl} alt="" referrerPolicy="no-referrer" />
|
||||
) : null}
|
||||
<div className="mini-program-footer">
|
||||
<span aria-hidden>⌁</span>
|
||||
@@ -242,6 +288,7 @@ function StickerBubble({
|
||||
)
|
||||
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
|
||||
const [error, setError] = useState(false)
|
||||
const [errorText, setErrorText] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheKey || displayUrl || error) return
|
||||
@@ -255,12 +302,17 @@ function StickerBubble({
|
||||
stickerDataUrlCache.set(cacheKey, result.data)
|
||||
setDisplayUrl(result.data)
|
||||
setError(false)
|
||||
setErrorText('')
|
||||
} else {
|
||||
setError(true)
|
||||
setErrorText(result.error || '表情包未缓存')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true)
|
||||
if (!cancelled) {
|
||||
setError(true)
|
||||
setErrorText('表情包加载失败')
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
@@ -289,7 +341,7 @@ function StickerBubble({
|
||||
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<div className="sticker-placeholder">{error ? '表情包未缓存' : '表情包'}</div>
|
||||
<div className="sticker-placeholder">{error ? errorText || '表情包未缓存' : '表情包'}</div>
|
||||
{md5 && <div className="sticker-md5">MD5: {md5}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -16,15 +16,16 @@ export function VoicePlayer({
|
||||
sessionId,
|
||||
localId,
|
||||
createTime,
|
||||
svrId
|
||||
svrId,
|
||||
duration
|
||||
}: VoicePlayerProps): JSX.Element {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [audioDuration, setAudioDuration] = useState<number | undefined>(undefined)
|
||||
const [shouldAutoPlay, setShouldAutoPlay] = useState(false)
|
||||
const [audioDuration, setAudioDuration] = useState<number | undefined>(duration)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const objectUrlRef = useRef<string | null>(null)
|
||||
|
||||
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
|
||||
if (globalCurrentAudio && globalCurrentAudio !== audio) {
|
||||
@@ -35,126 +36,113 @@ export function VoicePlayer({
|
||||
globalCurrentAudio = audio
|
||||
}, [])
|
||||
|
||||
const handlePlayPause = useCallback(async () => {
|
||||
// 如果还没有音频数据,先获取
|
||||
if (!audioUrl && !loading) {
|
||||
setLoading(true)
|
||||
setShouldAutoPlay(true)
|
||||
console.log('[VoicePlayer] fetching voice data:', { sessionId, localId, createTime })
|
||||
try {
|
||||
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
|
||||
console.log('[VoicePlayer] got result:', result)
|
||||
if (result.success && result.data) {
|
||||
console.log('[VoicePlayer] setting audioUrl, data length:', result.data.length)
|
||||
// 使用 Blob URL 替代 data URL,绕过 CSP 限制
|
||||
const byteCharacters = atob(result.data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: 'audio/wav' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
console.log('[VoicePlayer] created blob URL:', blobUrl)
|
||||
setAudioUrl(blobUrl)
|
||||
} else {
|
||||
console.log('[VoicePlayer] getVoiceData failed:', result.error)
|
||||
setError(result.error || '获取语音数据失败')
|
||||
setShouldAutoPlay(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[VoicePlayer] exception:', e)
|
||||
setError('加载语音失败')
|
||||
setShouldAutoPlay(false)
|
||||
}
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!audioRef.current) {
|
||||
console.log('[VoicePlayer] no audioRef')
|
||||
return
|
||||
}
|
||||
|
||||
const audio = audioRef.current
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
globalStopCallback = null
|
||||
} else {
|
||||
const playAudio = useCallback(
|
||||
async (audio: HTMLAudioElement): Promise<void> => {
|
||||
stopCurrentAndPlay(audio)
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
console.log('[VoicePlayer] play() succeeded')
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log('[VoicePlayer] play() failed:', e)
|
||||
})
|
||||
setIsPlaying(true)
|
||||
globalStopCallback = () => {
|
||||
setIsPlaying(false)
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}
|
||||
}, [audioUrl, loading, isPlaying, sessionId, localId, createTime, svrId, stopCurrentAndPlay])
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl) return
|
||||
|
||||
let audio = audioRef.current
|
||||
if (!audio) {
|
||||
audio = new Audio(audioUrl)
|
||||
audioRef.current = audio
|
||||
}
|
||||
|
||||
const audioEl = audio!
|
||||
|
||||
audioEl.addEventListener('loadedmetadata', () => {
|
||||
setAudioDuration(audioEl.duration)
|
||||
console.log('[VoicePlayer] loadedmetadata, duration:', audioEl.duration)
|
||||
})
|
||||
|
||||
audioEl.addEventListener('ended', () => {
|
||||
setIsPlaying(false)
|
||||
globalStopCallback = null
|
||||
})
|
||||
|
||||
audioEl.addEventListener('timeupdate', () => {
|
||||
if (audioEl.duration && isFinite(audioEl.duration)) {
|
||||
setAudioDuration(audioEl.duration)
|
||||
}
|
||||
})
|
||||
|
||||
audioEl.addEventListener('canplay', () => {
|
||||
console.log('[VoicePlayer] canplay event, shouldAutoPlay:', shouldAutoPlay)
|
||||
if (shouldAutoPlay && audioRef.current) {
|
||||
setShouldAutoPlay(false)
|
||||
stopCurrentAndPlay(audioRef.current)
|
||||
audioRef.current.play()
|
||||
try {
|
||||
await audio.play()
|
||||
setError(null)
|
||||
setIsPlaying(true)
|
||||
globalStopCallback = () => {
|
||||
setIsPlaying(false)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = 0
|
||||
}
|
||||
audio.currentTime = 0
|
||||
}
|
||||
} catch (playError) {
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
setIsPlaying(false)
|
||||
setError('语音播放失败,请重试')
|
||||
console.warn('[VoicePlayer] play failed:', playError)
|
||||
}
|
||||
})
|
||||
},
|
||||
[stopCurrentAndPlay]
|
||||
)
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ''
|
||||
audioRef.current = null
|
||||
}
|
||||
if (globalCurrentAudio === audioRef.current) {
|
||||
const createAudio = useCallback((blobUrl: string): HTMLAudioElement => {
|
||||
const audio = new Audio()
|
||||
audio.preload = 'auto'
|
||||
audio.src = blobUrl
|
||||
audio.onloadedmetadata = () => {
|
||||
if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration)
|
||||
}
|
||||
audio.ontimeupdate = () => {
|
||||
if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration)
|
||||
}
|
||||
audio.onended = () => {
|
||||
setIsPlaying(false)
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
}
|
||||
}, [audioUrl, shouldAutoPlay, stopCurrentAndPlay])
|
||||
audioRef.current = audio
|
||||
objectUrlRef.current = blobUrl
|
||||
return audio
|
||||
}, [])
|
||||
|
||||
const handlePlayPause = useCallback(async () => {
|
||||
if (loading) return
|
||||
|
||||
let audio = audioRef.current
|
||||
if (!audio) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
|
||||
if (result.success && result.data) {
|
||||
const byteCharacters = atob(result.data)
|
||||
const byteArray = new Uint8Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteArray[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: 'audio/wav' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
setAudioUrl(blobUrl)
|
||||
audio = createAudio(blobUrl)
|
||||
await playAudio(audio)
|
||||
} else {
|
||||
setError(result.error || '获取语音数据失败')
|
||||
}
|
||||
} catch (loadError) {
|
||||
console.warn('[VoicePlayer] load failed:', loadError)
|
||||
setError('加载语音失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
} else {
|
||||
await playAudio(audio)
|
||||
}
|
||||
}, [createAudio, createTime, isPlaying, loading, localId, playAudio, sessionId, svrId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const audio = audioRef.current
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
}
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current)
|
||||
objectUrlRef.current = null
|
||||
audioRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const formatDuration = (seconds: number | undefined): string => {
|
||||
if (!seconds || !isFinite(seconds)) return '0:00'
|
||||
|
||||
@@ -31,7 +31,9 @@ const RICH_MESSAGE_TYPES = [
|
||||
'引用消息',
|
||||
'通话',
|
||||
'表情包',
|
||||
'系统消息'
|
||||
'系统消息',
|
||||
'合并转发',
|
||||
'不支持的消息'
|
||||
]
|
||||
|
||||
export function MessageBubble({
|
||||
@@ -46,7 +48,8 @@ export function MessageBubble({
|
||||
const isVoice = message.type === '语音'
|
||||
const isImage = message.type === '图片'
|
||||
const isVideo = message.type === '视频'
|
||||
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
||||
const isRichMedia =
|
||||
RICH_MESSAGE_TYPES.includes(message.type) || message.contentData?.type === 'unknown'
|
||||
const hoverTime = formatMessageTime(message)
|
||||
|
||||
return (
|
||||
@@ -63,6 +66,8 @@ export function MessageBubble({
|
||||
sessionId={message.sessionId}
|
||||
localId={message.localId || 0}
|
||||
createTime={message.createTime || 0}
|
||||
svrId={message.serverId}
|
||||
duration={message.voiceDuration}
|
||||
/>
|
||||
) : isImage && message.contentData && message.contentData.type === 'image' ? (
|
||||
<ImageBubble
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
|
||||
interface ConversationItemProps {
|
||||
@@ -16,6 +16,26 @@ export function ConversationItem({
|
||||
const wxid = contact.m_nsUsrName
|
||||
const displayName = nickname || wxid || '未命名会话'
|
||||
const initial = (displayName || wxid || '?').charAt(0)
|
||||
const [repairedAvatar, setRepairedAvatar] = useState<{ username: string; source: string }>()
|
||||
const [failedAvatar, setFailedAvatar] = useState<{ username: string; source: string }>()
|
||||
const repairedSource = repairedAvatar?.username === wxid ? repairedAvatar.source : undefined
|
||||
const avatar = repairedSource || contact.avatar
|
||||
const avatarFailed = failedAvatar?.username === wxid && failedAvatar.source === avatar
|
||||
|
||||
const handleAvatarError = (): void => {
|
||||
if (!avatar || avatarFailed) return
|
||||
setFailedAvatar({ username: wxid, source: avatar })
|
||||
if (contact.type !== 'group' || avatar.startsWith('data:')) return
|
||||
void window.api
|
||||
.getContactAvatars([wxid])
|
||||
.then((avatars) => {
|
||||
const fallback = avatars[wxid]
|
||||
if (!fallback || fallback === avatar) return
|
||||
setRepairedAvatar({ username: wxid, source: fallback })
|
||||
setFailedAvatar(undefined)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -26,13 +46,14 @@ export function ConversationItem({
|
||||
>
|
||||
<span className="conversation-item-active-mark" aria-hidden />
|
||||
<span className="conversation-item-avatar">
|
||||
{contact.avatar ? (
|
||||
{avatar && !avatarFailed ? (
|
||||
<img
|
||||
src={contact.avatar}
|
||||
src={avatar}
|
||||
alt={displayName}
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={handleAvatarError}
|
||||
/>
|
||||
) : (
|
||||
initial
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface ConversationSidebarProps {
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
type SectionName = 'groups' | 'contacts'
|
||||
type SectionName = 'groups' | 'folded' | 'contacts'
|
||||
type ConversationRow =
|
||||
| { kind: 'header'; id: string; title: string; count: number; section: SectionName }
|
||||
| { kind: 'contact'; id: string; contact: Contact }
|
||||
@@ -44,24 +44,67 @@ export function ConversationSidebar({
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
||||
groups: true,
|
||||
folded: false,
|
||||
contacts: false
|
||||
})
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const groups = contacts.filter((contact) => contact.type === 'group')
|
||||
const groups = contacts.filter((contact) => contact.type === 'group' && !contact.isFolded)
|
||||
const foldedGroups = contacts.filter((contact) => contact.type === 'group' && contact.isFolded)
|
||||
const users = contacts.filter((contact) => contact.type === 'user')
|
||||
const rows = useMemo<ConversationRow[]>(
|
||||
() => [
|
||||
{ kind: 'header', id: 'groups-header', title: '群聊', count: groups.length, section: 'groups' },
|
||||
{
|
||||
kind: 'header',
|
||||
id: 'groups-header',
|
||||
title: '群聊',
|
||||
count: groups.length,
|
||||
section: 'groups'
|
||||
},
|
||||
...(expandedSections.groups
|
||||
? groups.map((contact) => ({ kind: 'contact' as const, id: `group-${contact.md5}`, contact }))
|
||||
? groups.map((contact) => ({
|
||||
kind: 'contact' as const,
|
||||
id: `group-${contact.md5}`,
|
||||
contact
|
||||
}))
|
||||
: []),
|
||||
{ kind: 'header', id: 'contacts-header', title: '联系人', count: users.length, section: 'contacts' },
|
||||
...(foldedGroups.length
|
||||
? [
|
||||
{
|
||||
kind: 'header' as const,
|
||||
id: 'folded-header',
|
||||
title: '折叠群聊',
|
||||
count: foldedGroups.length,
|
||||
section: 'folded' as const
|
||||
},
|
||||
...(expandedSections.folded
|
||||
? foldedGroups.map((contact) => ({
|
||||
kind: 'contact' as const,
|
||||
id: `folded-${contact.md5}`,
|
||||
contact
|
||||
}))
|
||||
: [])
|
||||
]
|
||||
: []),
|
||||
{
|
||||
kind: 'header',
|
||||
id: 'contacts-header',
|
||||
title: '联系人',
|
||||
count: users.length,
|
||||
section: 'contacts'
|
||||
},
|
||||
...(expandedSections.contacts
|
||||
? users.map((contact) => ({ kind: 'contact' as const, id: `user-${contact.md5}`, contact }))
|
||||
: [])
|
||||
],
|
||||
[expandedSections.contacts, expandedSections.groups, groups, users]
|
||||
[
|
||||
expandedSections.contacts,
|
||||
expandedSections.folded,
|
||||
expandedSections.groups,
|
||||
foldedGroups,
|
||||
groups,
|
||||
users
|
||||
]
|
||||
)
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
@@ -84,7 +127,10 @@ export function ConversationSidebar({
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
||||
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
<div
|
||||
className="conversation-virtual-content"
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const row = rows[virtualItem.index]
|
||||
if (!row) return null
|
||||
@@ -95,9 +141,15 @@ export function ConversationSidebar({
|
||||
key={virtualItem.key}
|
||||
type="button"
|
||||
className="conversation-section-header conversation-virtual-row"
|
||||
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||
style={{
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
height: `${virtualItem.size}px`
|
||||
}}
|
||||
onClick={() =>
|
||||
setExpandedSections((current) => ({ ...current, [row.section]: !current[row.section] }))
|
||||
setExpandedSections((current) => ({
|
||||
...current,
|
||||
[row.section]: !current[row.section]
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="conversation-section-chevron" aria-hidden="true">
|
||||
@@ -105,7 +157,9 @@ export function ConversationSidebar({
|
||||
<path d={expanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'} />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="conversation-section-title">{row.title} ({row.count})</span>
|
||||
<span className="conversation-section-title">
|
||||
{row.title} ({row.count})
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -113,7 +167,10 @@ export function ConversationSidebar({
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
className="conversation-virtual-row"
|
||||
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||
style={{
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
height: `${virtualItem.size}px`
|
||||
}}
|
||||
>
|
||||
<ConversationItem
|
||||
contact={row.contact}
|
||||
|
||||
@@ -329,3 +329,94 @@
|
||||
.voip-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.forward-bundle-message {
|
||||
width: min(320px, 56vw);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.forward-bundle-header,
|
||||
.forward-bundle-toggle {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.forward-bundle-header {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 0 0 9px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.forward-bundle-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 9px 0;
|
||||
}
|
||||
|
||||
.forward-bundle-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 3px 6px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
|
||||
b {
|
||||
color: var(--wxex-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
small {
|
||||
grid-column: 2;
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.forward-bundle-empty,
|
||||
.unsupported-message span {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.forward-bundle-toggle {
|
||||
padding: 8px 0 0;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
color: var(--wxex-brand);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unsupported-message {
|
||||
display: grid;
|
||||
min-width: 150px;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user