联系人页面优化算法,同时支持获取曾经的好友;支持通过联系人页面打开聊天会话;朋友圈页面优化;支持检测并标记部分已删除的朋友圈

This commit is contained in:
cc
2026-02-21 23:06:41 +08:00
parent d49c44f3be
commit 5ab0466a87
8 changed files with 203 additions and 82 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { Play, Lock, Download } from 'lucide-react'
import React, { useState, useRef } from 'react'
import { Play, Lock, Download, ImageOff } from 'lucide-react'
import { LivePhotoIcon } from '../../components/LivePhotoIcon'
import { RefreshCw } from 'lucide-react'
@@ -22,6 +22,7 @@ interface SnsMedia {
interface SnsMediaGridProps {
mediaList: SnsMedia[]
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onMediaDeleted?: () => void
}
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 [deleted, setDeleted] = useState(false)
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 [videoPath, setVideoPath] = 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 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
React.useEffect(() => {
@@ -112,7 +127,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
if (result.dataUrl) setThumbSrc(result.dataUrl)
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
} else {
setThumbSrc(targetUrl)
markDeleted()
}
// Pre-load live photo video if needed
@@ -149,11 +164,11 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
if (!cancelled) setThumbSrc(coverDataUrl)
} catch (err) {
console.error('Frame extraction failed', err)
// Fallback to video path if extraction fails, though it might be black
// Only set thumbSrc if extraction fails, so we don't override the generated one
// 封面提取失败,用视频路径作为 fallback,让 <video> 标签显示
if (!cancelled) setThumbSrc(localPath)
}
} else {
console.error('Video decryption for cover failed')
videoRetryOrDelete()
}
setIsGeneratingCover(false)
@@ -162,7 +177,11 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
} catch (e) {
console.error(e)
if (!cancelled) {
setThumbSrc(targetUrl)
if (isVideo) {
videoRetryOrDelete()
} else {
markDeleted()
}
setLoading(false)
setIsGeneratingCover(false)
}
@@ -171,7 +190,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
load()
return () => { cancelled = true }
}, [media, isVideo, isLive, targetUrl])
}, [media, isVideo, isLive, targetUrl, retryKey])
const handlePreview = async (e: React.MouseEvent) => {
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 (
<div
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
@@ -267,15 +297,15 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
e.currentTarget.currentTime = 0.1
}}
/>
) : (
) : thumbSrc ? (
<img
src={thumbSrc || targetUrl}
src={thumbSrc}
className="media-image"
loading="lazy"
onError={() => setError(true)}
onError={() => { if (!loading && !isVideo) markDeleted() }}
alt=""
/>
)}
) : null}
{isGeneratingCover && (
<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
const count = mediaList.length
@@ -320,7 +350,7 @@ export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview
return (
<div className={`sns-media-grid ${gridClass}`}>
{mediaList.map((media, idx) => (
<MediaItem key={idx} media={media} onPreview={onPreview} />
<MediaItem key={idx} media={media} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
))}
</div>
)

View File

@@ -1,8 +1,9 @@
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 { Avatar } from '../Avatar'
import { SnsMediaGrid } from './SnsMediaGrid'
import { getEmojiPath } from 'wechat-emojis'
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
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 => {
// 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))
if (hasVideoMedia) return null
@@ -169,6 +185,7 @@ interface SnsPostItemProps {
}
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
const [mediaDeleted, setMediaDeleted] = useState(false)
const linkCard = buildLinkCardData(post)
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
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 (
<div className="sns-post-item">
<div className={`sns-post-item ${mediaDeleted ? 'post-deleted' : ''}`}>
<div className="post-avatar-col">
<Avatar
src={post.avatarUrl}
@@ -207,16 +238,24 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
<span className="post-time">{formatTime(post.createTime)}</span>
</div>
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
e.stopPropagation();
onDebug(post);
}} title="查看原始数据">
<Code size={14} />
</button>
<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) => {
e.stopPropagation();
onDebug(post);
}} title="查看原始数据">
<Code size={14} />
</button>
</div>
</div>
{post.contentDesc && (
<div className="post-text">{decodeHtmlEntities(post.contentDesc)}</div>
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
)}
{showLinkCard && linkCard && (
@@ -225,7 +264,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
{showMediaGrid && (
<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>
)}
@@ -250,7 +289,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
</>
)}
<span className="comment-colon"></span>
<span className="comment-content">{c.content}</span>
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
</div>
))}
</div>