mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
联系人页面优化算法,同时支持获取曾经的好友;支持通过联系人页面打开聊天会话;朋友圈页面优化;支持检测并标记部分已删除的朋友圈
This commit is contained in:
@@ -173,6 +173,20 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确)
|
||||||
|
win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
|
||||||
|
const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
|
||||||
|
try {
|
||||||
|
const host = new URL(url).hostname
|
||||||
|
if (trusted.some(d => host.endsWith(d))) {
|
||||||
|
event.preventDefault()
|
||||||
|
callback(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
callback(false)
|
||||||
|
})
|
||||||
|
|
||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export interface ContactInfo {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other'
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表情包缓存
|
// 表情包缓存
|
||||||
@@ -603,7 +603,7 @@ class ChatService {
|
|||||||
// 使用execQuery直接查询加密的contact.db
|
// 使用execQuery直接查询加密的contact.db
|
||||||
// kind='contact', path=null表示使用已打开的contact.db
|
// kind='contact', path=null表示使用已打开的contact.db
|
||||||
const contactQuery = `
|
const contactQuery = `
|
||||||
SELECT username, remark, nick_name, alias, local_type, flag
|
SELECT username, remark, nick_name, alias, local_type, flag, quan_pin
|
||||||
FROM contact
|
FROM contact
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -651,50 +651,25 @@ class ChatService {
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const username = row.username || ''
|
const username = row.username || ''
|
||||||
|
|
||||||
// 过滤系统账号和特殊账号 - 完全复制cipher的逻辑
|
|
||||||
if (!username) continue
|
if (!username) continue
|
||||||
if (username === 'filehelper' || username === 'fmessage' || username === 'floatbottle' ||
|
|
||||||
username === 'medianote' || username === 'newsapp' || username.startsWith('fake_') ||
|
|
||||||
username === 'weixin' || username === 'qmessage' || username === 'qqmail' ||
|
|
||||||
username === 'tmessage' || username.startsWith('wxid_') === false &&
|
|
||||||
username.includes('@') === false && username.startsWith('gh_') === false &&
|
|
||||||
/^[a-zA-Z0-9_-]+$/.test(username) === false) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断类型 - 正确规则:wxid开头且有alias的是好友
|
|
||||||
let type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other' = 'other'
|
|
||||||
const localType = row.local_type || 0
|
|
||||||
|
|
||||||
|
const excludeNames = ['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']
|
||||||
|
let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other'
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
|
||||||
const flag = Number(row.flag ?? 0)
|
const flag = Number(row.flag ?? 0)
|
||||||
|
const quanPin = this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || ''
|
||||||
|
|
||||||
if (username.includes('@chatroom')) {
|
if (username.includes('@chatroom')) {
|
||||||
type = 'group'
|
type = 'group'
|
||||||
} else if (username.startsWith('gh_')) {
|
} else if (username.startsWith('gh_')) {
|
||||||
if (flag === 0) continue
|
|
||||||
type = 'official'
|
type = 'official'
|
||||||
} else if (localType === 3 || localType === 4) {
|
} else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 1 && !excludeNames.includes(username)) {
|
||||||
if (flag === 0) continue
|
type = 'friend'
|
||||||
if (flag === 4) continue
|
} else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 0 && quanPin) {
|
||||||
type = 'official'
|
type = 'former_friend'
|
||||||
} else if (username.startsWith('wxid_') && row.alias) {
|
|
||||||
type = flag === 0 ? 'deleted_friend' : 'friend'
|
|
||||||
} else if (localType === 1) {
|
|
||||||
type = flag === 0 ? 'deleted_friend' : 'friend'
|
|
||||||
} else if (localType === 2) {
|
|
||||||
// local_type=2 是群成员但非好友,跳过
|
|
||||||
continue
|
|
||||||
} else if (localType === 0) {
|
|
||||||
// local_type=0 可能是好友或其他,检查是否有备注或昵称
|
|
||||||
if (row.remark || row.nick_name) {
|
|
||||||
type = flag === 0 ? 'deleted_friend' : 'friend'
|
|
||||||
} else {
|
} else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// 其他未知类型,跳过
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayName = row.remark || row.nick_name || row.alias || username
|
const displayName = row.remark || row.nick_name || row.alias || username
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useRef } from 'react'
|
||||||
import { Play, Lock, Download } from 'lucide-react'
|
import { Play, Lock, Download, ImageOff } from 'lucide-react'
|
||||||
import { LivePhotoIcon } from '../../components/LivePhotoIcon'
|
import { LivePhotoIcon } from '../../components/LivePhotoIcon'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ interface SnsMedia {
|
|||||||
interface SnsMediaGridProps {
|
interface SnsMediaGridProps {
|
||||||
mediaList: SnsMedia[]
|
mediaList: SnsMedia[]
|
||||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||||
|
onMediaDeleted?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSnsVideoUrl = (url?: string): boolean => {
|
const isSnsVideoUrl = (url?: string): boolean => {
|
||||||
@@ -79,9 +80,13 @@ const extractVideoFrame = async (videoPath: string): Promise<string> => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => {
|
const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
|
const [deleted, setDeleted] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const markDeleted = () => { setDeleted(true); onMediaDeleted?.() }
|
||||||
|
const retryCount = useRef(0)
|
||||||
|
const [retryKey, setRetryKey] = useState(0)
|
||||||
const [thumbSrc, setThumbSrc] = useState<string>('')
|
const [thumbSrc, setThumbSrc] = useState<string>('')
|
||||||
const [videoPath, setVideoPath] = useState<string>('')
|
const [videoPath, setVideoPath] = useState<string>('')
|
||||||
const [liveVideoPath, setLiveVideoPath] = useState<string>('')
|
const [liveVideoPath, setLiveVideoPath] = useState<string>('')
|
||||||
@@ -92,6 +97,16 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
|||||||
const isLive = !!media.livePhoto
|
const isLive = !!media.livePhoto
|
||||||
const targetUrl = media.thumb || media.url
|
const targetUrl = media.thumb || media.url
|
||||||
|
|
||||||
|
// 视频重试:失败时重试最多2次,耗尽才标记删除
|
||||||
|
const videoRetryOrDelete = () => {
|
||||||
|
if (retryCount.current < 2) {
|
||||||
|
retryCount.current++
|
||||||
|
setRetryKey(k => k + 1)
|
||||||
|
} else {
|
||||||
|
markDeleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Simple effect to load image/decrypt
|
// Simple effect to load image/decrypt
|
||||||
// Simple effect to load image/decrypt
|
// Simple effect to load image/decrypt
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -112,7 +127,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
|||||||
if (result.dataUrl) setThumbSrc(result.dataUrl)
|
if (result.dataUrl) setThumbSrc(result.dataUrl)
|
||||||
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
|
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
|
||||||
} else {
|
} else {
|
||||||
setThumbSrc(targetUrl)
|
markDeleted()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-load live photo video if needed
|
// Pre-load live photo video if needed
|
||||||
@@ -149,11 +164,11 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
|||||||
if (!cancelled) setThumbSrc(coverDataUrl)
|
if (!cancelled) setThumbSrc(coverDataUrl)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Frame extraction failed', err)
|
console.error('Frame extraction failed', err)
|
||||||
// Fallback to video path if extraction fails, though it might be black
|
// 封面提取失败,用视频路径作为 fallback,让 <video> 标签显示
|
||||||
// Only set thumbSrc if extraction fails, so we don't override the generated one
|
if (!cancelled) setThumbSrc(localPath)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Video decryption for cover failed')
|
videoRetryOrDelete()
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsGeneratingCover(false)
|
setIsGeneratingCover(false)
|
||||||
@@ -162,7 +177,11 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setThumbSrc(targetUrl)
|
if (isVideo) {
|
||||||
|
videoRetryOrDelete()
|
||||||
|
} else {
|
||||||
|
markDeleted()
|
||||||
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setIsGeneratingCover(false)
|
setIsGeneratingCover(false)
|
||||||
}
|
}
|
||||||
@@ -171,7 +190,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
|||||||
|
|
||||||
load()
|
load()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [media, isVideo, isLive, targetUrl])
|
}, [media, isVideo, isLive, targetUrl, retryKey])
|
||||||
|
|
||||||
const handlePreview = async (e: React.MouseEvent) => {
|
const handlePreview = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -248,6 +267,17 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
return (
|
||||||
|
<div className="sns-media-item deleted-media">
|
||||||
|
<div className="deleted-placeholder">
|
||||||
|
<ImageOff size={24} />
|
||||||
|
<span>已删除</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
|
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
|
||||||
@@ -267,15 +297,15 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
|||||||
e.currentTarget.currentTime = 0.1
|
e.currentTarget.currentTime = 0.1
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : thumbSrc ? (
|
||||||
<img
|
<img
|
||||||
src={thumbSrc || targetUrl}
|
src={thumbSrc}
|
||||||
className="media-image"
|
className="media-image"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={() => setError(true)}
|
onError={() => { if (!loading && !isVideo) markDeleted() }}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{isGeneratingCover && (
|
{isGeneratingCover && (
|
||||||
<div className="media-decrypting-mask">
|
<div className="media-decrypting-mask">
|
||||||
@@ -304,7 +334,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview }) => {
|
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview, onMediaDeleted }) => {
|
||||||
if (!mediaList || mediaList.length === 0) return null
|
if (!mediaList || mediaList.length === 0) return null
|
||||||
|
|
||||||
const count = mediaList.length
|
const count = mediaList.length
|
||||||
@@ -320,7 +350,7 @@ export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview
|
|||||||
return (
|
return (
|
||||||
<div className={`sns-media-grid ${gridClass}`}>
|
<div className={`sns-media-grid ${gridClass}`}>
|
||||||
{mediaList.map((media, idx) => (
|
{mediaList.map((media, idx) => (
|
||||||
<MediaItem key={idx} media={media} onPreview={onPreview} />
|
<MediaItem key={idx} media={media} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal } from 'lucide-react'
|
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal, Trash2 } from 'lucide-react'
|
||||||
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
||||||
import { Avatar } from '../Avatar'
|
import { Avatar } from '../Avatar'
|
||||||
import { SnsMediaGrid } from './SnsMediaGrid'
|
import { SnsMediaGrid } from './SnsMediaGrid'
|
||||||
|
import { getEmojiPath } from 'wechat-emojis'
|
||||||
|
|
||||||
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
||||||
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
||||||
@@ -64,6 +65,21 @@ const isLikelyMediaAssetUrl = (url: string): boolean => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
||||||
|
// type 3 是链接类型,直接用 media[0] 的 url 和 thumb
|
||||||
|
if (post.type === 3) {
|
||||||
|
const url = post.media[0]?.url || post.linkUrl
|
||||||
|
if (!url) return null
|
||||||
|
const titleCandidates = [
|
||||||
|
post.linkTitle || '',
|
||||||
|
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||||
|
post.contentDesc || ''
|
||||||
|
]
|
||||||
|
const title = titleCandidates
|
||||||
|
.map((v) => decodeHtmlEntities(v))
|
||||||
|
.find((v) => Boolean(v) && !/^https?:\/\//i.test(v))
|
||||||
|
return { url, title: title || '网页链接', thumb: post.media[0]?.thumb }
|
||||||
|
}
|
||||||
|
|
||||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
if (hasVideoMedia) return null
|
if (hasVideoMedia) return null
|
||||||
|
|
||||||
@@ -169,6 +185,7 @@ interface SnsPostItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
|
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
|
||||||
|
const [mediaDeleted, setMediaDeleted] = useState(false)
|
||||||
const linkCard = buildLinkCardData(post)
|
const linkCard = buildLinkCardData(post)
|
||||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
||||||
@@ -187,11 +204,25 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add extra class for media-only posts (no text) to adjust spacing?
|
// 解析微信表情
|
||||||
// Not strictly needed but good to know
|
const renderTextWithEmoji = (text: string) => {
|
||||||
|
if (!text) return text
|
||||||
|
const parts = text.split(/\[(.*?)\]/g)
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
if (index % 2 === 1) {
|
||||||
|
// @ts-ignore
|
||||||
|
const path = getEmojiPath(part as any)
|
||||||
|
if (path) {
|
||||||
|
return <img key={index} src={`${import.meta.env.BASE_URL}${path}`} alt={`[${part}]`} className="inline-emoji" style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }} />
|
||||||
|
}
|
||||||
|
return `[${part}]`
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sns-post-item">
|
<div className={`sns-post-item ${mediaDeleted ? 'post-deleted' : ''}`}>
|
||||||
<div className="post-avatar-col">
|
<div className="post-avatar-col">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={post.avatarUrl}
|
src={post.avatarUrl}
|
||||||
@@ -207,6 +238,13 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
||||||
<span className="post-time">{formatTime(post.createTime)}</span>
|
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="post-header-actions">
|
||||||
|
{mediaDeleted && (
|
||||||
|
<span className="post-deleted-badge">
|
||||||
|
<Trash2 size={12} />
|
||||||
|
<span>已删除</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDebug(post);
|
onDebug(post);
|
||||||
@@ -214,9 +252,10 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
<Code size={14} />
|
<Code size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{post.contentDesc && (
|
{post.contentDesc && (
|
||||||
<div className="post-text">{decodeHtmlEntities(post.contentDesc)}</div>
|
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showLinkCard && linkCard && (
|
{showLinkCard && linkCard && (
|
||||||
@@ -225,7 +264,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
|
|
||||||
{showMediaGrid && (
|
{showMediaGrid && (
|
||||||
<div className="post-media-container">
|
<div className="post-media-container">
|
||||||
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} />
|
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -250,7 +289,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="comment-colon">:</span>
|
<span className="comment-colon">:</span>
|
||||||
<span className="comment-content">{c.content}</span>
|
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -281,6 +281,8 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||||
|
const [noMessageTable, setNoMessageTable] = useState(false)
|
||||||
|
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
|
||||||
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
||||||
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||||
|
|
||||||
@@ -857,6 +859,10 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
if (result.success && result.messages) {
|
if (result.success && result.messages) {
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
setMessages(result.messages)
|
setMessages(result.messages)
|
||||||
|
if (result.messages.length === 0) {
|
||||||
|
setNoMessageTable(true)
|
||||||
|
setHasMoreMessages(false)
|
||||||
|
}
|
||||||
|
|
||||||
// 预取发送者信息:在关闭加载遮罩前处理
|
// 预取发送者信息:在关闭加载遮罩前处理
|
||||||
const unreadCount = session?.unreadCount ?? 0
|
const unreadCount = session?.unreadCount ?? 0
|
||||||
@@ -929,7 +935,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
setCurrentOffset(offset + result.messages.length)
|
setCurrentOffset(offset + result.messages.length)
|
||||||
} else if (!result.success) {
|
} else if (!result.success) {
|
||||||
setConnectionError(result.error || '加载消息失败')
|
setNoMessageTable(true)
|
||||||
setHasMoreMessages(false)
|
setHasMoreMessages(false)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1247,6 +1253,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentSessionId !== prevSessionRef.current) {
|
if (currentSessionId !== prevSessionRef.current) {
|
||||||
prevSessionRef.current = currentSessionId
|
prevSessionRef.current = currentSessionId
|
||||||
|
setNoMessageTable(false)
|
||||||
if (initialRevealTimerRef.current !== null) {
|
if (initialRevealTimerRef.current !== null) {
|
||||||
window.clearTimeout(initialRevealTimerRef.current)
|
window.clearTimeout(initialRevealTimerRef.current)
|
||||||
initialRevealTimerRef.current = null
|
initialRevealTimerRef.current = null
|
||||||
@@ -1260,11 +1267,11 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
}, [currentSessionId, messages.length, isLoadingMessages])
|
}, [currentSessionId, messages.length, isLoadingMessages])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore) {
|
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
|
||||||
setHasInitialMessages(false)
|
setHasInitialMessages(false)
|
||||||
loadMessages(currentSessionId, 0)
|
loadMessages(currentSessionId, 0)
|
||||||
}
|
}
|
||||||
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore])
|
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1340,10 +1347,24 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
sortTimestamp: 0,
|
sortTimestamp: 0,
|
||||||
lastTimestamp: 0,
|
lastTimestamp: 0,
|
||||||
lastMsgType: 0,
|
lastMsgType: 0,
|
||||||
displayName: currentSessionId,
|
displayName: fallbackDisplayName || currentSessionId,
|
||||||
} as ChatSession
|
} as ChatSession
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentSessionId) return
|
||||||
|
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
||||||
|
if (found) {
|
||||||
|
setFallbackDisplayName(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadContactInfoBatch([currentSessionId]).then(() => {
|
||||||
|
const cached = senderAvatarCache.get(currentSessionId)
|
||||||
|
if (cached?.displayName) setFallbackDisplayName(cached.displayName)
|
||||||
|
})
|
||||||
|
}, [currentSessionId, sessions])
|
||||||
|
|
||||||
// 判断是否为群聊
|
// 判断是否为群聊
|
||||||
const isGroupChat = (username: string) => username.includes('@chatroom')
|
const isGroupChat = (username: string) => username.includes('@chatroom')
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
// 左侧联系人面板
|
// 左侧联系人面板
|
||||||
.contacts-panel {
|
.contacts-panel {
|
||||||
width: 400px;
|
width: 350px;
|
||||||
min-width: 400px;
|
min-width: 350px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
@@ -115,11 +115,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.type-filters {
|
.type-filters {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 20px 16px;
|
padding: 0 20px 16px;
|
||||||
flex-wrap: nowrap;
|
max-width: 300px;
|
||||||
overflow-x: auto;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -397,6 +397,7 @@
|
|||||||
.detail-value {
|
.detail-value {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface ContactInfo {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other'
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContactsPage() {
|
function ContactsPage() {
|
||||||
@@ -21,8 +21,8 @@ function ContactsPage() {
|
|||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
const [contactTypes, setContactTypes] = useState({
|
const [contactTypes, setContactTypes] = useState({
|
||||||
friends: true,
|
friends: true,
|
||||||
groups: true,
|
groups: false,
|
||||||
officials: true,
|
officials: false,
|
||||||
deletedFriends: false
|
deletedFriends: false
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ function ContactsPage() {
|
|||||||
if (c.type === 'friend' && !contactTypes.friends) return false
|
if (c.type === 'friend' && !contactTypes.friends) return false
|
||||||
if (c.type === 'group' && !contactTypes.groups) return false
|
if (c.type === 'group' && !contactTypes.groups) return false
|
||||||
if (c.type === 'official' && !contactTypes.officials) return false
|
if (c.type === 'official' && !contactTypes.officials) return false
|
||||||
if (c.type === 'deleted_friend' && !contactTypes.deletedFriends) return false
|
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ function ContactsPage() {
|
|||||||
case 'friend': return <User size={14} />
|
case 'friend': return <User size={14} />
|
||||||
case 'group': return <Users size={14} />
|
case 'group': return <Users size={14} />
|
||||||
case 'official': return <MessageSquare size={14} />
|
case 'official': return <MessageSquare size={14} />
|
||||||
case 'deleted_friend': return <UserX size={14} />
|
case 'former_friend': return <UserX size={14} />
|
||||||
default: return <User size={14} />
|
default: return <User size={14} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ function ContactsPage() {
|
|||||||
case 'friend': return '好友'
|
case 'friend': return '好友'
|
||||||
case 'group': return '群聊'
|
case 'group': return '群聊'
|
||||||
case 'official': return '公众号'
|
case 'official': return '公众号'
|
||||||
case 'deleted_friend': return '已删除'
|
case 'former_friend': return '曾经的好友'
|
||||||
default: return '其他'
|
default: return '其他'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,7 +292,7 @@ function ContactsPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
|
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
|
||||||
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
|
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
|
||||||
<UserX size={16} /><span>已删除</span>
|
<UserX size={16} /><span>曾经的好友</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -105,11 +105,28 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s;
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.post-deleted {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-deleted-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(255, 77, 79, 0.1);
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-avatar-col {
|
.post-avatar-col {
|
||||||
@@ -147,6 +164,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.debug-btn {
|
.debug-btn {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
@@ -313,12 +336,15 @@
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
/* Max width constraint */
|
|
||||||
max-height: 480px;
|
max-height: 480px;
|
||||||
/* Increased max height a bit */
|
|
||||||
aspect-ratio: auto;
|
aspect-ratio: auto;
|
||||||
border-radius: var(--sns-border-radius-md);
|
border-radius: var(--sns-border-radius-md);
|
||||||
|
|
||||||
|
&.deleted-media {
|
||||||
|
width: 200px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
img,
|
img,
|
||||||
video {
|
video {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -327,7 +353,6 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
/* Remove baseline space */
|
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,6 +469,22 @@
|
|||||||
&:hover .media-download-btn {
|
&:hover .media-download-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.deleted-media {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
|
||||||
|
.deleted-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user