import React, { useState, useMemo, useEffect } from 'react' import { createPortal } from 'react-dom' import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react' import { SnsPost, SnsLinkCardData, SnsLocation } 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'] const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle'] const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload'] const isSnsVideoUrl = (url?: string): boolean => { if (!url) return false const lower = url.toLowerCase() return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb') } const decodeHtmlEntities = (text: string): string => { if (!text) return '' return text .replace(//g, '$1') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/'/gi, "'") .trim() } const normalizeUrlCandidate = (raw: string): string | null => { const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim() if (!value) return null if (!/^https?:\/\//i.test(value)) return null return value } const simplifyUrlForCompare = (value: string): string => { const normalized = value.trim().toLowerCase().replace(/^https?:\/\//, '') const [withoutQuery] = normalized.split('?') return withoutQuery.replace(/\/+$/, '') } const getXmlTagValues = (xml: string, tags: string[]): string[] => { if (!xml) return [] const results: string[] = [] for (const tag of tags) { const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig') let match: RegExpExecArray | null while ((match = reg.exec(xml)) !== null) { if (match[1]) results.push(match[1]) } } return results } const getUrlLikeStrings = (text: string): string[] => { if (!text) return [] return text.match(/https?:\/\/[^\s<>"']+/gi) || [] } const isLikelyMediaAssetUrl = (url: string): boolean => { const lower = url.toLowerCase() return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint)) } 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 const mediaValues = post.media .flatMap((item) => [item.url, item.thumb]) .filter((value): value is string => Boolean(value)) const mediaSet = new Set(mediaValues.map((value) => simplifyUrlForCompare(value))) const urlCandidates: string[] = [ post.linkUrl || '', ...getXmlTagValues(post.rawXml || '', LINK_XML_URL_TAGS), ...getUrlLikeStrings(post.rawXml || ''), ...getUrlLikeStrings(post.contentDesc || '') ] const normalizedCandidates = urlCandidates .map(normalizeUrlCandidate) .filter((value): value is string => Boolean(value)) const dedupedCandidates: string[] = [] const seen = new Set() for (const candidate of normalizedCandidates) { if (seen.has(candidate)) continue seen.add(candidate) dedupedCandidates.push(candidate) } const linkUrl = dedupedCandidates.find((candidate) => { const simplified = simplifyUrlForCompare(candidate) if (mediaSet.has(simplified)) return false if (isLikelyMediaAssetUrl(candidate)) return false return true }) if (!linkUrl) return null const titleCandidates = [ post.linkTitle || '', ...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS), post.contentDesc || '' ] const title = titleCandidates .map((value) => decodeHtmlEntities(value)) .find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) return { url: linkUrl, title: title || '网页链接', thumb: post.media[0]?.thumb || post.media[0]?.url } } const buildLocationText = (location?: SnsLocation): string => { if (!location) return '' const normalize = (value?: string): string => ( decodeHtmlEntities(String(value || '')).replace(/\s+/g, ' ').trim() ) const primary = [ normalize(location.poiName), normalize(location.poiAddressName), normalize(location.label), normalize(location.poiAddress) ].find(Boolean) || '' const region = [normalize(location.country), normalize(location.city)] .filter(Boolean) .join(' ') if (primary && region && !primary.includes(region)) { return `${primary} · ${region}` } return primary || region } const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { const [thumbFailed, setThumbFailed] = useState(false) const hostname = useMemo(() => { try { return new URL(card.url).hostname.replace(/^www\./i, '') } catch { return card.url } }, [card.url]) const handleClick = async (e: React.MouseEvent) => { e.stopPropagation() try { await window.electronAPI.shell.openExternal(card.url) } catch (error) { console.error('[SnsLinkCard] openExternal failed:', error) } } return ( ) } // 表情包内存缓存 const emojiLocalCache = new Map() // 评论表情包组件 const CommentEmoji: React.FC<{ emoji: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string } onPreview?: (src: string) => void }> = ({ emoji, onPreview }) => { const cacheKey = emoji.encryptUrl || emoji.url const [localSrc, setLocalSrc] = useState(() => emojiLocalCache.get(cacheKey) || '') useEffect(() => { if (!cacheKey) return if (emojiLocalCache.has(cacheKey)) { setLocalSrc(emojiLocalCache.get(cacheKey)!) return } let cancelled = false const load = async () => { try { const res = await window.electronAPI.sns.downloadEmoji({ url: emoji.url, encryptUrl: emoji.encryptUrl, aesKey: emoji.aesKey }) if (cancelled) return if (res.success && res.localPath) { const fileUrl = res.localPath.startsWith('file:') ? res.localPath : `file://${res.localPath.replace(/\\/g, '/')}` emojiLocalCache.set(cacheKey, fileUrl) setLocalSrc(fileUrl) } } catch { /* 静默失败 */ } } load() return () => { cancelled = true } }, [cacheKey]) if (!localSrc) return null return ( emoji { e.stopPropagation(); onPreview?.(localSrc) }} style={{ width: Math.min(emoji.width || 24, 30), height: Math.min(emoji.height || 24, 30), verticalAlign: 'middle', marginLeft: 2, borderRadius: 4, cursor: onPreview ? 'pointer' : 'default' }} /> ) } interface SnsPostItemProps { post: SnsPost onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onDebug: (post: SnsPost) => void onDelete?: (postId: string, username: string) => void onOpenAuthorPosts?: (post: SnsPost) => void hideAuthorMeta?: boolean } export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => { const [mediaDeleted, setMediaDeleted] = useState(false) const [dbDeleted, setDbDeleted] = useState(false) const [deleting, setDeleting] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const linkCard = buildLinkCardData(post) const locationText = useMemo(() => buildLocationText(post.location), [post.location]) const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url)) const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia const showMediaGrid = post.media.length > 0 && !showLinkCard const formatTime = (ts: number) => { const date = new Date(ts * 1000) const isCurrentYear = date.getFullYear() === new Date().getFullYear() return date.toLocaleString('zh-CN', { year: isCurrentYear ? undefined : 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) } // 解析微信表情 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 {`[${part}]`} } return `[${part}]` } return part }) } const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation() if (deleting || dbDeleted) return setShowDeleteConfirm(true) } const handleDeleteConfirm = async () => { setShowDeleteConfirm(false) setDeleting(true) try { const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id) if (r.success) { setDbDeleted(true) onDelete?.(post.id, post.username) } } finally { setDeleting(false) } } const handleOpenAuthorPosts = (e: React.MouseEvent) => { e.stopPropagation() onOpenAuthorPosts?.(post) } return ( <>
{!hideAuthorMeta && (
)}
{hideAuthorMeta ? ( {formatTime(post.createTime)} ) : (
{formatTime(post.createTime)}
)}
{(mediaDeleted || dbDeleted) && ( 已删除 )}
{post.contentDesc && (
{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}
)} {locationText && (
{locationText}
)} {showLinkCard && linkCard && ( )} {showMediaGrid && (
setMediaDeleted(true) : undefined} />
)} {(post.likes.length > 0 || post.comments.length > 0) && (
{post.likes.length > 0 && (
{post.likes.join('、')}
)} {post.comments.length > 0 && (
{post.comments.map((c, idx) => (
{c.nickname} {c.refNickname && ( <> 回复 {c.refNickname} )} {c.content && ( {renderTextWithEmoji(c.content)} )} {c.emojis && c.emojis.map((emoji, ei) => ( onPreview(src)} /> ))}
))}
)}
)}
{/* 删除确认弹窗 - 用 Portal 挂到 body,避免父级 transform 影响 fixed 定位 */} {showDeleteConfirm && createPortal(
setShowDeleteConfirm(false)}>
e.stopPropagation()}>
删除这条记录?
将从本地数据库中永久删除,无法恢复。
, document.body )} ) }