From a4be7f9005aa7c8043ea3126c6d5172e8c72e746 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:28:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=9C=8B=E5=8F=8B=E5=9C=88?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Sns/SnsFilterPanel.tsx | 185 +++ src/components/Sns/SnsMediaGrid.tsx | 327 ++++ src/components/Sns/SnsPostItem.tsx | 263 ++++ src/pages/SnsPage.scss | 1987 ++++++++++--------------- src/pages/SnsPage.tsx | 986 ++---------- src/types/sns.ts | 47 + 6 files changed, 1676 insertions(+), 2119 deletions(-) create mode 100644 src/components/Sns/SnsFilterPanel.tsx create mode 100644 src/components/Sns/SnsMediaGrid.tsx create mode 100644 src/components/Sns/SnsPostItem.tsx create mode 100644 src/types/sns.ts diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx new file mode 100644 index 0000000..6182d88 --- /dev/null +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -0,0 +1,185 @@ +import React, { useState } from 'react' +import { Search, Calendar, User, X, Filter, Check } from 'lucide-react' +import { Avatar } from '../Avatar' +// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved + +interface Contact { + username: string + displayName: string + avatarUrl?: string +} + +interface SnsFilterPanelProps { + searchKeyword: string + setSearchKeyword: (val: string) => void + jumpTargetDate?: Date + setJumpTargetDate: (date?: Date) => void + onOpenJumpDialog: () => void + selectedUsernames: string[] + setSelectedUsernames: (val: string[]) => void + contacts: Contact[] + contactSearch: string + setContactSearch: (val: string) => void + loading?: boolean +} + +export const SnsFilterPanel: React.FC = ({ + searchKeyword, + setSearchKeyword, + jumpTargetDate, + setJumpTargetDate, + onOpenJumpDialog, + selectedUsernames, + setSelectedUsernames, + contacts, + contactSearch, + setContactSearch, + loading +}) => { + + const filteredContacts = contacts.filter(c => + c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || + c.username.toLowerCase().includes(contactSearch.toLowerCase()) + ) + + const toggleUserSelection = (username: string) => { + if (selectedUsernames.includes(username)) { + setSelectedUsernames(selectedUsernames.filter(u => u !== username)) + } else { + setJumpTargetDate(undefined) // Reset date jump when selecting user + setSelectedUsernames([...selectedUsernames, username]) + } + } + + const clearFilters = () => { + setSearchKeyword('') + setSelectedUsernames([]) + setJumpTargetDate(undefined) + } + + return ( + + ) +} + +function RefreshCw({ size, className }: { size?: number, className?: string }) { + return ( + + + + + + ) +} diff --git a/src/components/Sns/SnsMediaGrid.tsx b/src/components/Sns/SnsMediaGrid.tsx new file mode 100644 index 0000000..a9ce6a9 --- /dev/null +++ b/src/components/Sns/SnsMediaGrid.tsx @@ -0,0 +1,327 @@ +import React, { useState } from 'react' +import { Play, Lock, Download } from 'lucide-react' +import { LivePhotoIcon } from '../../components/LivePhotoIcon' +import { RefreshCw } from 'lucide-react' + +interface SnsMedia { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + livePhoto?: { + url: string + thumb: string + token?: string + key?: string + encIdx?: string + } +} + +interface SnsMediaGridProps { + mediaList: SnsMedia[] + onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void +} + +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 extractVideoFrame = async (videoPath: string): Promise => { + return new Promise((resolve, reject) => { + const video = document.createElement('video') + video.preload = 'auto' + video.src = videoPath + video.muted = true + video.currentTime = 0 // Initial reset + // video.crossOrigin = 'anonymous' // Not needed for file:// usually + + const onSeeked = () => { + try { + const canvas = document.createElement('canvas') + canvas.width = video.videoWidth + canvas.height = video.videoHeight + const ctx = canvas.getContext('2d') + if (ctx) { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height) + const dataUrl = canvas.toDataURL('image/jpeg', 0.8) + resolve(dataUrl) + } else { + reject(new Error('Canvas context failed')) + } + } catch (e) { + reject(e) + } finally { + // Cleanup + video.removeEventListener('seeked', onSeeked) + video.src = '' + video.load() + } + } + + video.onloadedmetadata = () => { + if (video.duration === Infinity || isNaN(video.duration)) { + // Determine duration failed, try a fixed small offset + video.currentTime = 1 + } else { + video.currentTime = Math.max(0.1, video.duration / 2) + } + } + + video.onseeked = onSeeked + + video.onerror = (e) => { + reject(new Error('Video load failed')) + } + }) +} + +const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => { + const [error, setError] = useState(false) + const [loading, setLoading] = useState(true) + const [thumbSrc, setThumbSrc] = useState('') + const [videoPath, setVideoPath] = useState('') + const [liveVideoPath, setLiveVideoPath] = useState('') + const [isDecrypting, setIsDecrypting] = useState(false) + const [isGeneratingCover, setIsGeneratingCover] = useState(false) + + const isVideo = isSnsVideoUrl(media.url) + const isLive = !!media.livePhoto + const targetUrl = media.thumb || media.url + + // Simple effect to load image/decrypt + // Simple effect to load image/decrypt + React.useEffect(() => { + let cancelled = false + setLoading(true) + + const load = async () => { + try { + if (!isVideo) { + // For images, we proxy to get the local path/base64 + const result = await window.electronAPI.sns.proxyImage({ + url: targetUrl, + key: media.key + }) + if (cancelled) return + + if (result.success) { + if (result.dataUrl) setThumbSrc(result.dataUrl) + else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`) + } else { + setThumbSrc(targetUrl) + } + + // Pre-load live photo video if needed + if (isLive && media.livePhoto?.url) { + window.electronAPI.sns.proxyImage({ + url: media.livePhoto.url, + key: media.livePhoto.key || media.key + }).then((res: any) => { + if (!cancelled && res.success && res.videoPath) { + setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`) + } + }).catch(() => { }) + } + setLoading(false) + } else { + // Video logic: Decrypt -> Extract Frame + setIsGeneratingCover(true) + + // First check if we already have it decryptable? + // Usually we need to call proxyImage with the video URL to decrypt it to cache + const result = await window.electronAPI.sns.proxyImage({ + url: media.url, + key: media.key + }) + + if (cancelled) return + + if (result.success && result.videoPath) { + const localPath = `file://${result.videoPath.replace(/\\/g, '/')}` + setVideoPath(localPath) + + try { + const coverDataUrl = await extractVideoFrame(localPath) + 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 + } + } else { + console.error('Video decryption for cover failed') + } + + setIsGeneratingCover(false) + setLoading(false) + } + } catch (e) { + console.error(e) + if (!cancelled) { + setThumbSrc(targetUrl) + setLoading(false) + setIsGeneratingCover(false) + } + } + } + + load() + return () => { cancelled = true } + }, [media, isVideo, isLive, targetUrl]) + + const handlePreview = async (e: React.MouseEvent) => { + e.stopPropagation() + if (isVideo) { + // Decrypt video on demand if not already + if (!videoPath) { + setIsDecrypting(true) + try { + const res = await window.electronAPI.sns.proxyImage({ + url: media.url, + key: media.key + }) + if (res.success && res.videoPath) { + const local = `file://${res.videoPath.replace(/\\/g, '/')}` + setVideoPath(local) + onPreview(local, true, undefined) + } else { + alert('视频解密失败') + } + } catch (e) { + console.error(e) + } finally { + setIsDecrypting(false) + } + } else { + onPreview(videoPath, true, undefined) + } + } else { + onPreview(thumbSrc || targetUrl, false, liveVideoPath) + } + } + + const handleDownload = async (e: React.MouseEvent) => { + e.stopPropagation() + setLoading(true) + try { + const result = await window.electronAPI.sns.proxyImage({ + url: media.url, + key: media.key + }) + + if (result.success) { + const link = document.createElement('a') + link.download = `sns_media_${Date.now()}.${isVideo ? 'mp4' : 'jpg'}` + + if (result.dataUrl) { + link.href = result.dataUrl + } else if (result.videoPath) { + // For local video files, we need to fetch as blob to force download behavior + // or just use the file protocol url if the browser supports it + try { + const response = await fetch(`file://${result.videoPath}`) + const blob = await response.blob() + const url = URL.createObjectURL(blob) + link.href = url + setTimeout(() => URL.revokeObjectURL(url), 60000) + } catch (err) { + console.error('Video fetch failed, falling back to direct link', err) + link.href = `file://${result.videoPath}` + } + } + + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } else { + alert('下载失败: 无法获取资源') + } + } catch (e) { + console.error('Download error:', e) + alert('下载出错') + } finally { + setLoading(false) + } + } + + return ( +
+ {(thumbSrc && !thumbSrc.startsWith('data:') && (thumbSrc.toLowerCase().endsWith('.mp4') || thumbSrc.includes('video'))) ? ( +
+ ) +} + +export const SnsMediaGrid: React.FC = ({ mediaList, onPreview }) => { + if (!mediaList || mediaList.length === 0) return null + + const count = mediaList.length + let gridClass = '' + + if (count === 1) gridClass = 'grid-1' + else if (count === 2) gridClass = 'grid-2' + else if (count === 3) gridClass = 'grid-3' + else if (count === 4) gridClass = 'grid-4' // 2x2 + else if (count <= 6) gridClass = 'grid-6' // 3 cols + else gridClass = 'grid-9' // 3x3 + + return ( +
+ {mediaList.map((media, idx) => ( + + ))} +
+ ) +} diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx new file mode 100644 index 0000000..50ac5cd --- /dev/null +++ b/src/components/Sns/SnsPostItem.tsx @@ -0,0 +1,263 @@ +import React, { useState, useMemo } from 'react' +import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal } from 'lucide-react' +import { SnsPost, SnsLinkCardData } from '../../types/sns' +import { Avatar } from '../Avatar' +import { SnsMediaGrid } from './SnsMediaGrid' + +// 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 => { + 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 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 ( + + ) +} + +interface SnsPostItemProps { + post: SnsPost + onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void + onDebug: (post: SnsPost) => void +} + +export const SnsPostItem: React.FC = ({ post, onPreview, onDebug }) => { + 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 + 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' + }) + } + + // Add extra class for media-only posts (no text) to adjust spacing? + // Not strictly needed but good to know + + return ( +
+
+ +
+ +
+
+
+ {decodeHtmlEntities(post.nickname)} + {formatTime(post.createTime)} +
+ +
+ + {post.contentDesc && ( +
{decodeHtmlEntities(post.contentDesc)}
+ )} + + {showLinkCard && linkCard && ( + + )} + + {showMediaGrid && ( +
+ +
+ )} + + {(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} +
+ ))} +
+ )} +
+ )} +
+
+ ) +} diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 31cd990..a18fba0 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1,1202 +1,820 @@ -.sns-page { - height: 100%; - background: var(--bg-primary); - color: var(--text-primary); - overflow: hidden; +/* Global Variables */ +:root { + --sns-max-width: 800px; + --sns-panel-width: 320px; + --sns-bg-color: var(--bg-primary); + --sns-card-bg: var(--bg-secondary); + --sns-border-radius-lg: 16px; + --sns-border-radius-md: 12px; + --sns-border-radius-sm: 8px; +} - .sns-container { - display: flex; - height: 100%; +.sns-page-layout { + display: flex; + height: 100%; + overflow: hidden; + background: var(--sns-bg-color); + position: relative; + color: var(--text-primary); +} + +/* ========================================= + Main Viewport & Feed + ========================================= */ +.sns-main-viewport { + flex: 1; + overflow-y: scroll; + /* Always show scrollbar track for stability */ + scroll-behavior: smooth; + position: relative; + display: flex; + justify-content: center; +} + +.sns-feed-container { + width: 100%; + max-width: var(--sns-max-width); + padding: 20px 24px 60px 24px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.feed-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + padding: 0 4px; + + h2 { + font-size: 20px; + font-weight: 700; + margin: 0; + color: var(--text-primary); } - .sns-sidebar { - width: 320px; - background: var(--bg-secondary); - border-left: 1px solid var(--border-color); + .icon-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--sns-border-radius-sm); + padding: 8px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--hover-bg); + color: var(--primary); + transform: scale(1.05); + } + + &.spinning { + animation: spin 1s linear infinite; + } + } +} + +.posts-list { + display: flex; + flex-direction: column; + gap: 24px; +} + +/* ========================================= + Post Item Component + ========================================= */ +.sns-post-item { + background: var(--sns-card-bg); + border-radius: var(--sns-border-radius-lg); + border: 1px solid var(--border-color); + padding: 20px; + display: flex; + gap: 16px; + 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); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06); + } +} + +.post-avatar-col { + flex-shrink: 0; +} + +.post-content-col { + flex: 1; + min-width: 0; + /* Enable ellipsis */ +} + +.post-header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; + + .post-author-info { display: flex; flex-direction: column; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - flex-shrink: 0; - z-index: 10; - box-shadow: -4px 0 16px rgba(0, 0, 0, 0.05); - &.closed { - width: 0; - opacity: 0; - transform: translateX(100%); - pointer-events: none; - border-left: none; + .author-name { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + /* Changed to primary from accent for cleaner look, or keep accent */ + color: var(--primary); + margin-bottom: 2px; } - .sidebar-header { - padding: 0 24px; - height: 64px; - box-sizing: border-box; + .post-time { + font-size: 12px; + color: var(--text-tertiary); + } + } + + .debug-btn { + opacity: 0; + transition: opacity 0.2s; + background: none; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px; + color: var(--text-tertiary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--text-primary); + background: var(--bg-tertiary); + border-color: var(--text-secondary); + } + } +} + +.sns-post-item:hover .debug-btn { + opacity: 1; +} + +.post-text { + font-size: 15px; + line-height: 1.6; + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; + margin-bottom: 12px; +} + +.post-media-container { + margin-bottom: 12px; +} + +.post-link-card { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--sns-border-radius-md); + padding: 10px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + text-align: left; + transition: all 0.2s; + margin-bottom: 12px; + + &:hover { + background: rgba(var(--primary-rgb), 0.08); + border-color: rgba(var(--primary-rgb), 0.3); + } + + .link-thumb { + width: 60px; + height: 60px; + border-radius: var(--sns-border-radius-sm); + overflow: hidden; + flex-shrink: 0; + background: var(--bg-secondary); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .link-thumb-fallback { display: flex; align-items: center; - /* justify-content: space-between; -- No longer needed as it's just h3 */ - border-bottom: 1px solid var(--border-color); - background: var(--bg-secondary); + justify-content: center; + height: 100%; + color: var(--text-tertiary); + } + } - h3 { - margin: 0; - font-size: 18px; - font-weight: 700; - color: var(--text-primary); - letter-spacing: 0; - } + .link-meta { + flex: 1; + min-width: 0; + + .link-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } - .filter-content { - flex: 1; - overflow-y: hidden; - /* Changed from auto to hidden to allow inner scrolling of contact list */ - padding: 16px; + .link-url { + font-size: 12px; + color: var(--text-tertiary); + margin-top: 4px; + } + } + + .link-arrow { + color: var(--text-tertiary); + } +} + +.post-interactions { + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed var(--border-color); + font-size: 13px; + + .likes-block { + display: flex; + gap: 8px; + margin-bottom: 8px; + color: var(--primary); + font-weight: 500; + + .like-icon { + margin-top: 2px; + } + } + + .comments-block { + background: var(--bg-tertiary); + border-radius: var(--sns-border-radius-sm); + padding: 8px 12px; + + .comment-row { + margin-bottom: 4px; + line-height: 1.4; + color: var(--text-secondary); + + &:last-child { + margin-bottom: 0; + } + + .comment-user { + color: var(--primary); + font-weight: 500; + } + + .reply-text { + margin: 0 4px; + color: var(--text-tertiary); + } + + .comment-colon { + margin-right: 4px; + } + } + } +} + + +/* ========================================= + Media Grid Component + ========================================= */ +.sns-media-grid { + display: grid; + gap: 6px; + + &.grid-1 .sns-media-item { + width: fit-content; + height: auto; + max-width: 300px; + /* Max width constraint */ + max-height: 480px; + /* Increased max height a bit */ + aspect-ratio: auto; + border-radius: var(--sns-border-radius-md); + + img, + video { + max-width: 100%; + max-height: 480px; + width: auto; + height: auto; + object-fit: contain; + display: block; + /* Remove baseline space */ + background: rgba(0, 0, 0, 0.05); + } + } + + &.grid-2, + &.grid-4 { + grid-template-columns: repeat(2, 1fr); + max-width: 320px; + } + + &.grid-3, + &.grid-6, + &.grid-9 { + grid-template-columns: repeat(3, 1fr); + max-width: 320px; + } + + .sns-media-item { + position: relative; + aspect-ratio: 1; + border-radius: var(--sns-border-radius-md); + /* Consistent radius for grid items */ + overflow: hidden; + cursor: zoom-in; + background: var(--bg-tertiary); + transition: opacity 0.2s; + + &:hover { + opacity: 0.9; + } + + img, + video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + animation: fade-in 0.3s ease; + } + + .media-badge { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 32px; + height: 32px; + background: rgba(0, 0, 0, 0.4); + border-radius: 50%; + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + color: white; + pointer-events: none; + /* Let clicks pass through badge to item */ + + &.live { + top: 8px; + left: 8px; + transform: none; + width: 24px; + height: 24px; + } + + } + + .media-decrypting-mask { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); display: flex; flex-direction: column; - gap: 16px; + align-items: center; + justify-content: center; + color: white; + gap: 8px; + z-index: 10; + font-size: 12px; + backdrop-filter: blur(2px); + } - .filter-card { - background: var(--bg-primary); - border-radius: 12px; - border: 1px solid var(--border-color); - padding: 14px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); - transition: transform 0.2s, box-shadow 0.2s; - flex-shrink: 0; + .media-download-btn { + position: absolute; + bottom: 6px; + right: 6px; + width: 28px; + height: 28px; + background: rgba(0, 0, 0, 0.5); + border-radius: 50%; + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + color: white; + cursor: pointer; + opacity: 0; + transition: all 0.2s; + z-index: 5; - &:hover { - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04); - } - - &.jump-date-card { - .jump-date-btn { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 10px 14px; - color: var(--text-secondary); - font-size: 13px; - cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - overflow: hidden; - - &.active { - border-color: var(--accent-color); - color: var(--text-primary); - font-weight: 500; - background: rgba(var(--accent-color-rgb), 0.05); - - .icon { - color: var(--accent-color); - opacity: 1; - } - } - - &:hover { - border-color: var(--accent-color); - background: var(--bg-primary); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.08); - } - - &:active { - transform: translateY(0); - } - - .text { - flex: 1; - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .icon { - opacity: 0.5; - transition: all 0.2s; - margin-left: 8px; - } - } - - .clear-jump-date-inline { - width: 100%; - margin-top: 10px; - background: rgba(var(--accent-color-rgb), 0.06); - border: 1px dashed rgba(var(--accent-color-rgb), 0.3); - color: var(--accent-color); - font-size: 12px; - cursor: pointer; - text-align: center; - padding: 6px; - border-radius: 8px; - transition: all 0.2s; - font-weight: 500; - - &:hover { - background: var(--accent-color); - color: white; - border-style: solid; - } - } - } - - &.contact-card { - flex: 1; - display: flex; - flex-direction: column; - min-height: 200px; - padding: 0; - overflow: hidden; - } + &:hover { + background: var(--primary); + transform: scale(1.1); } - - - .filter-section { - margin-bottom: 0px; - - label { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - color: var(--text-secondary); - margin-bottom: 10px; - font-weight: 600; - - svg { - color: var(--accent-color); - opacity: 0.8; - } - } - - .search-input-wrapper { - position: relative; - display: flex; - align-items: center; - - .input-icon { - position: absolute; - left: 12px; - color: var(--text-tertiary); - pointer-events: none; - } - - input { - width: 100%; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 10px 10px 10px 36px; - color: var(--text-primary); - font-size: 13px; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - &::placeholder { - color: var(--text-tertiary); - opacity: 0.6; - } - - &:focus { - outline: none; - border-color: var(--accent-color); - background: var(--bg-primary); - box-shadow: 0 0 0 4px rgba(var(--accent-color-rgb), 0.1); - } - } - - .clear-input { - position: absolute; - right: 8px; - background: none; - border: none; - color: var(--text-tertiary); - cursor: pointer; - padding: 4px; - display: flex; - border-radius: 50%; - transition: all 0.2s; - - &:hover { - color: var(--text-secondary); - background: var(--hover-bg); - transform: rotate(90deg); - } - } - } - } - - .contact-filter-section { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - - .section-header { - padding: 16px 16px 1px 16px; - margin-bottom: 12px; - /* Increased spacing */ - display: flex; - justify-content: space-between; - align-items: center; - flex-shrink: 0; - - .header-actions { - display: flex; - align-items: center; - gap: 8px; - - .clear-selection-btn { - background: none; - border: none; - color: var(--text-tertiary); - font-size: 11px; - cursor: pointer; - padding: 2px 6px; - border-radius: 4px; - transition: all 0.2s; - - &:hover { - color: var(--accent-color); - background: rgba(var(--accent-color-rgb), 0.1); - } - } - - .selected-count { - font-size: 10px; - background: var(--accent-color); - color: white; - min-width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - font-weight: bold; - } - } - } - - .contact-search { - padding: 0 16px 12px 16px; - position: relative; - display: flex; - align-items: center; - flex-shrink: 0; - - .search-icon { - position: absolute; - left: 26px; - color: var(--text-tertiary); - pointer-events: none; - z-index: 1; - opacity: 0.6; - } - - input { - width: 100%; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 8px 30px 8px 30px; - font-size: 12px; - color: var(--text-primary); - transition: all 0.2s; - - &:focus { - outline: none; - border-color: var(--accent-color); - background: var(--bg-primary); - } - } - - .clear-search-icon { - position: absolute; - right: 24px; - color: var(--text-tertiary); - cursor: pointer; - padding: 4px; - border-radius: 50%; - transition: all 0.2s; - - &:hover { - color: var(--text-secondary); - background: var(--hover-bg); - } - } - } - - .contact-list { - flex: 1; - overflow-y: auto; - padding: 4px 8px; - margin: 0 4px 8px 4px; - min-height: 0; - - .contact-item { - display: flex; - align-items: center; - padding: 8px 12px; - border-radius: 10px; - cursor: pointer; - gap: 12px; - margin-bottom: 2px; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - - &:hover { - background: var(--hover-bg); - transform: translateX(2px); - } - - &.active { - background: rgba(var(--accent-color-rgb), 0.08); - - .contact-name { - color: var(--accent-color); - font-weight: 600; - } - - .check-box { - border-color: var(--accent-color); - background: var(--accent-color); - - .inner-check { - transform: scale(1); - } - } - } - - .avatar-wrapper { - position: relative; - display: flex; - - .active-badge { - position: absolute; - bottom: -1px; - right: -1px; - width: 10px; - height: 10px; - background: var(--accent-color); - border: 2px solid var(--bg-secondary); - border-radius: 50%; - animation: badge-pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); - } - } - - .contact-name { - flex: 1; - font-size: 13px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--text-secondary); - transition: color 0.2s; - } - - .check-box { - width: 16px; - height: 16px; - border: 2px solid var(--border-color); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - - .inner-check { - width: 8px; - height: 8px; - border-radius: 1px; - background: white; - transform: scale(0); - transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); - } - } - } - - .empty-contacts { - padding: 32px 16px; - text-align: center; - font-size: 13px; - color: var(--text-tertiary); - font-style: italic; - } - } + /* Increase click area */ + &::after { + content: ''; + position: absolute; + inset: -4px; } } - .sidebar-footer { - padding: 16px; - border-top: 1px solid var(--border-color); + &:hover .media-download-btn { + opacity: 1; + } + } +} - .clear-btn { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 10px; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - color: var(--text-secondary); - border-radius: 8px; - cursor: pointer; - font-size: 13px; - font-weight: 500; - transition: all 0.2s; +@keyframes fade-in { + from { + opacity: 0; + } - &:hover { - background: var(--accent-color); - color: white; - border-color: var(--accent-color); - box-shadow: 0 4px 10px rgba(var(--accent-color-rgb), 0.2); - } + to { + opacity: 1; + } +} - &:active { - transform: scale(0.98); - } + +/* ========================================= + Filter Panel Component (Right Side) + ========================================= */ +.sns-filter-panel { + width: var(--sns-panel-width); + flex-shrink: 0; + border-left: 1px solid var(--border-color); + background: var(--bg-secondary); + display: flex; + flex-direction: column; + padding: 24px; + gap: 24px; + z-index: 10; + + .filter-header { + display: flex; + justify-content: space-between; + align-items: center; + + h3 { + font-size: 16px; + font-weight: 700; + margin: 0; + } + + .reset-all-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text-tertiary); + + &:hover { + color: var(--primary); + animation: spin 0.5s ease; } } } - .sns-main { - flex: 1; + .filter-widgets { display: flex; flex-direction: column; - min-width: 0; - background: var(--bg-primary); + gap: 20px; + flex: 1; + min-height: 0; + } - .sns-header { + .filter-widget { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--sns-border-radius-md); + padding: 16px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); + + .widget-header { display: flex; align-items: center; - justify-content: space-between; - padding: 0 24px; - height: 64px; + gap: 8px; + margin-bottom: 12px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + + svg { + color: var(--primary); + opacity: 0.8; + } + + .badge { + margin-left: auto; + background: var(--primary); + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + } + } + } + + /* Search Widget */ + .input-group { + position: relative; + display: flex; + align-items: center; + + input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid transparent; + border-radius: var(--sns-border-radius-sm); + padding: 10px 30px 10px 12px; + font-size: 13px; + color: var(--text-primary); + transition: all 0.2s; + + &:focus { + background: var(--bg-primary); + border-color: transparent; + /* Explicitly transparent */ + outline: none; + /* Ensure no outline */ + box-shadow: none; + } + } + + .clear-input-btn { + position: absolute; + right: 8px; + background: none; + border: none; + padding: 2px; + cursor: pointer; + color: var(--text-tertiary); + display: flex; + } + } + + /* Date Widget */ + .date-picker-trigger { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-tertiary); + border: 1px solid transparent; + border-radius: var(--sns-border-radius-sm); + padding: 12px; + cursor: pointer; + transition: all 0.2s; + font-size: 13px; + color: var(--text-secondary); + + &:hover { + background: var(--bg-primary); + border-color: var(--primary); + } + + &.active { + background: rgba(var(--primary-rgb), 0.08); + border-color: var(--primary); + color: var(--primary); + font-weight: 500; + } + + .clear-date-btn { + padding: 4px; + display: flex; + color: var(--primary); + + &:hover { + transform: scale(1.1); + } + } + } + + /* Contact Widget - Refactored */ + .contact-widget { + display: flex; + flex-direction: column; + flex: 1; + min-height: 300px; + overflow: hidden; + padding: 0; + + .widget-header { + padding: 16px 16px 12px 16px; + margin-bottom: 0; + } + + .contact-search-bar { + padding: 0 16px 12px 16px; + position: relative; border-bottom: 1px solid var(--border-color); - background: var(--bg-secondary); - backdrop-filter: blur(10px); - z-index: 5; - .header-left { - display: flex; - align-items: center; - gap: 16px; + input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid transparent; + border-radius: 8px; + padding: 8px 32px 8px 12px; + font-size: 12px; + color: var(--text-primary); - h2 { - margin: 0; - font-size: 18px; - font-weight: 700; - color: var(--text-primary); - } - - .sidebar-trigger { - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 8px; - - &:hover { - background: var(--hover-bg); - color: var(--accent-color); - } + &:focus { + outline: none; + background: var(--bg-primary); + border-color: var(--primary); } } - .header-right { + .search-icon { + position: absolute; + right: 28px; + top: 8px; + color: var(--text-tertiary); + pointer-events: none; + } + } + + .contact-list-scroll { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 0; + /* Remove gap to allow borders to merge */ + + .contact-row { display: flex; align-items: center; gap: 12px; - } - - .icon-btn { - background: none; - border: none; - color: var(--text-secondary); + padding: 10px; + border-radius: var(--sns-border-radius-md); cursor: pointer; - padding: 8px; - border-radius: 8px; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; + transition: background 0.2s ease, transform 0.2s ease; + border: 2px solid transparent; + margin-bottom: 4px; + /* Separation for unselected items */ &:hover { - color: var(--text-primary); background: var(--hover-bg); + transform: translateX(2px); + z-index: 10; } - &.refresh-btn { - &:hover { - color: var(--accent-color); - } - } - } + &.selected { + background: rgba(var(--primary-rgb), 0.1); + border-color: var(--primary); + box-shadow: none; + z-index: 5; + margin-bottom: 0; + /* Remove margin to merge */ - .spinning { - animation: spin 1s linear infinite; - } - } - - - .sns-content-wrapper { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - position: relative; - } - - .sns-notice-banner { - margin: 16px 24px 0 24px; - padding: 10px 16px; - background: rgba(var(--accent-color-rgb), 0.08); - border-radius: 10px; - border: 1px solid rgba(var(--accent-color-rgb), 0.2); - display: flex; - align-items: center; - gap: 10px; - color: var(--accent-color); - font-size: 13px; - font-weight: 500; - animation: banner-slide-down 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); - - svg { - flex-shrink: 0; - } - } - - @keyframes banner-slide-down { - from { - opacity: 0; - transform: translateY(-10px); - } - - to { - opacity: 1; - transform: translateY(0); - } - } - - .sns-content { - flex: 1; - overflow-y: auto; - padding: 24px 0; - scroll-behavior: smooth; - - .active-filters-bar { - max-width: 680px; - margin: 0 auto 24px auto; - display: flex; - align-items: center; - justify-content: space-between; - background: rgba(var(--accent-color-rgb), 0.08); - border: 1px solid rgba(var(--accent-color-rgb), 0.2); - padding: 10px 16px; - border-radius: 10px; - font-size: 13px; - color: var(--accent-color); - - .filter-info { - display: flex; - align-items: center; - gap: 8px; - font-weight: 500; - } - - .clear-chip-btn { - background: var(--accent-color); - border: none; - color: white; - cursor: pointer; - font-size: 11px; - padding: 4px 10px; - border-radius: 4px; - font-weight: 600; - - &:hover { - background: var(--accent-color-hover); - } - } - } - - .posts-list { - display: flex; - flex-direction: column; - gap: 32px; - } - - .sns-post-row { - display: flex; - width: 100%; - max-width: 800px; - position: relative; - } - - } - - .sns-post-wrapper { - width: 100%; - padding: 0 20px; - } - - .sns-post { - background: var(--bg-secondary); - border-radius: 16px; - padding: 24px; - border: 1px solid var(--border-color); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03); - transition: transform 0.2s; - - &:hover { - transform: translateY(-2px); - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06); - } - - .post-header { - display: flex; - align-items: center; - margin-bottom: 18px; - - .post-info { - margin-left: 14px; - - .nickname { - font-size: 15px; - font-weight: 700; - margin-bottom: 4px; - color: var(--accent-color); + .contact-name { + color: var(--primary); + font-weight: 600; } - .time { - font-size: 12px; - color: var(--text-tertiary); - display: flex; - align-items: center; - gap: 4px; - - &::before { - content: ''; - width: 4px; - height: 4px; - border-radius: 50%; - background: currentColor; - opacity: 0.5; - } - } - } - } - - .post-body { - margin-bottom: 20px; - - .post-text { - margin-bottom: 14px; - white-space: pre-wrap; - line-height: 1.7; - font-size: 15px; - color: var(--text-primary); - word-break: break-word; - } - - .post-link-card { - width: min(460px, 100%); - display: flex; - align-items: center; - gap: 12px; - padding: 10px; - margin-bottom: 14px; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 12px; - cursor: pointer; - text-align: left; - transition: all 0.2s ease; - - &:hover { - border-color: rgba(var(--accent-color-rgb), 0.35); - background: rgba(var(--accent-color-rgb), 0.08); - transform: translateY(-1px); - } - - .link-thumb { - width: 88px; - min-width: 88px; - height: 66px; - border-radius: 8px; - overflow: hidden; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - - img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - } - - .link-thumb-fallback { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-tertiary); - } - } - - .link-meta { - flex: 1; - min-width: 0; - - .link-title { - font-size: 14px; - line-height: 1.4; - font-weight: 600; - color: var(--text-primary); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-word; - } - - .link-url { - margin-top: 6px; - font-size: 12px; - color: var(--text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .link-arrow { - color: var(--text-tertiary); - flex-shrink: 0; - } - } - - .post-media-grid { - display: grid; - gap: 6px; - width: fit-content; - max-width: 100%; - - &.media-count-1 { - grid-template-columns: 1fr; - - .media-item { - width: 320px; - height: 240px; - max-width: 100%; - border-radius: 12px; - aspect-ratio: auto; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - } - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - } - - &.media-count-2, - &.media-count-4 { - grid-template-columns: repeat(2, 1fr); - } - - &.media-count-3, - &.media-count-5, - &.media-count-6, - &.media-count-7, - &.media-count-8, - &.media-count-9 { - grid-template-columns: repeat(3, 1fr); - } - - .media-item { - width: 160px; // 多图模式下项固定大小(或由 grid 控制,但确保有高度) - height: 160px; - aspect-ratio: 1; - background: var(--bg-tertiary); - border-radius: 6px; - overflow: hidden; - border: 1px solid var(--border-color); - position: relative; - - img { - width: 100%; - height: 100%; - object-fit: cover; - cursor: zoom-in; - } - - .live-badge { - position: absolute; - top: 8px; - left: 8px; - right: 8px; - left: auto; - background: rgba(255, 255, 255, 0.9); - background: rgba(0, 0, 0, 0.3); - backdrop-filter: blur(4px); - color: white; - padding: 4px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; - z-index: 2; - transition: opacity 0.2s; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - } - - .download-btn-overlay { - position: absolute; - bottom: 6px; - right: 6px; - width: 28px; - height: 28px; - border-radius: 50%; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - border: 1px solid rgba(255, 255, 255, 0.3); - color: white; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - opacity: 0; - transform: translateY(10px); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - z-index: 2; - - &:hover { - background: rgba(0, 0, 0, 0.7); - transform: scale(1.1); - border-color: rgba(255, 255, 255, 0.8); - } - } - - - - .video-badge-container { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 2; - pointer-events: none; - display: flex; - align-items: center; - justify-content: center; - - .video-badge { - width: 44px; - height: 44px; - background: rgba(0, 0, 0, 0.3); - backdrop-filter: blur(4px); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - color: white; - border: 1px solid rgba(255, 255, 255, 0.3); - transition: all 0.2s; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); - - svg { - fill: white; - opacity: 0.9; - } - } - - .decrypting-badge { - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(8px); - padding: 8px 16px; - border-radius: 20px; - display: flex; - align-items: center; - gap: 8px; - color: white; - font-size: 13px; - font-weight: 500; - border: 1px solid rgba(255, 255, 255, 0.2); - white-space: nowrap; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); - - .spin-icon { - animation: spin 1s linear infinite; - } - } - } - - &:hover { - .download-btn-overlay { - opacity: 1; - transform: translateY(0); - } - } - - .media-error-placeholder { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-deep); - color: var(--text-tertiary); - cursor: default; - } - } - } - - .post-video-placeholder { - display: inline-flex; - align-items: center; - gap: 10px; - background: rgba(var(--accent-color-rgb), 0.08); - color: var(--accent-color); - padding: 10px 18px; - border-radius: 12px; - font-size: 14px; - font-weight: 600; - border: 1px solid rgba(var(--accent-color-rgb), 0.1); - cursor: pointer; - - &:hover { - background: rgba(var(--accent-color-rgb), 0.12); - } - } - } - - .post-footer { - background: var(--bg-tertiary); - border-radius: 10px; - padding: 14px; - position: relative; - - &::after { - content: ''; - position: absolute; - top: -8px; - left: 20px; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-bottom: 8px solid var(--bg-tertiary); - } - - .likes-section { - display: flex; - align-items: flex-start; - color: var(--accent-color); - padding-bottom: 10px; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - margin-bottom: 10px; - font-size: 13px; - - &:last-child { - padding-bottom: 0; + /* If the NEXT item is also selected */ + &:has(+ .contact-row.selected) { border-bottom: none; - margin-bottom: 0; - } - - .icon { - margin-top: 3px; - margin-right: 10px; - flex-shrink: 0; - opacity: 0.8; - } - - .likes-list { - line-height: 1.6; - font-weight: 500; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + padding-bottom: 12px; + /* Compensate for missing border (+2px) */ } } - .comments-section { - .comment-item { - margin-bottom: 8px; - line-height: 1.6; - font-size: 13px; + /* If the PREVIOUS item is selected */ + &.selected+.contact-row.selected { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + margin-top: 0; + padding-top: 12px; + /* Compensate for missing border */ + } - &:last-child { - margin-bottom: 0; - } - - .comment-user { - color: var(--accent-color); - font-weight: 700; - cursor: pointer; - - &:hover { - text-decoration: underline; - } - } - - .reply-text { - color: var(--text-tertiary); - margin: 0 6px; - font-size: 12px; - } - - .comment-content { - color: var(--text-secondary); - } - } + .contact-name { + flex: 1; + font-size: 14px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } } } - .status-indicator { + .empty-state { text-align: center; - padding: 40px; color: var(--text-tertiary); - font-size: 14px; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - - &.loading-more, - &.loading-newer { - color: var(--accent-color); - } - - &.newer-hint { - background: rgba(var(--accent-color-rgb), 0.08); - padding: 12px; - border-radius: 12px; - cursor: pointer; - border: 1px dashed rgba(var(--accent-color-rgb), 0.2); - transition: all 0.2s; - margin-bottom: 16px; - - &:hover { - background: rgba(var(--accent-color-rgb), 0.15); - border-style: solid; - transform: translateY(-2px); - } - } + padding: 20px; + font-size: 12px; } + } +} - .no-results { - text-align: center; - padding: 80px 20px; - color: var(--text-tertiary); +/* ========================================= + Status Indicators, etc. + ========================================= */ +.status-indicator { + text-align: center; + padding: 16px; + color: var(--text-tertiary); + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; - .no-results-icon { - margin-bottom: 20px; - opacity: 0.2; - } + &.newer-hint { + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + cursor: pointer; + border-radius: var(--sns-border-radius-sm); + margin: 0 24px; + } +} - p { - font-size: 16px; - margin-bottom: 24px; - } +.no-results { + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 0; + color: var(--text-tertiary); - .reset-inline { - background: var(--accent-color); - color: white; - border: none; - padding: 10px 24px; - border-radius: 10px; - cursor: pointer; - font-size: 14px; - font-weight: 600; - box-shadow: 0 4px 15px rgba(var(--accent-color-rgb), 0.3); - transition: all 0.2s; + .no-results-icon { + margin-bottom: 16px; + opacity: 0.5; + } - &:hover { - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(var(--accent-color-rgb), 0.4); - } - } + .reset-inline { + margin-top: 16px; + background: none; + border: 1px solid var(--border-color); + padding: 6px 16px; + border-radius: 20px; + cursor: pointer; + color: var(--text-secondary); + + &:hover { + border-color: var(--primary); + color: var(--primary); } } } @keyframes spin { - from { - transform: rotate(0deg); - } - - to { + 100% { transform: rotate(360deg); } } -@keyframes badge-pop { - from { - transform: scale(0); - opacity: 0; - } - - to { - transform: scale(1); - opacity: 1; - } -} - -// Debug Dialog Styles -.debug-btn { - margin-left: auto; - background: transparent; - border: 1px solid var(--border-color); - color: var(--text-secondary); - padding: 6px; - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - - &:hover { - background: var(--hover-bg); - color: var(--accent-color); - border-color: var(--accent-color); - } -} - +/* ========================================= + Modal & Debug Dialog + ========================================= */ .modal-overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; - z-index: 10000; - backdrop-filter: blur(4px); + animation: fade-in 0.2s ease; } .debug-dialog { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 12px; - width: 90%; - max-width: 800px; - max-height: 85vh; + background: var(--sns-card-bg); + /* Use card bg */ + border-radius: var(--sns-border-radius-lg); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); + width: 600px; + max-width: 90vw; + height: 80vh; display: flex; flex-direction: column; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + border: 1px solid var(--border-color); + overflow: hidden; + /* Ensure header doesn't overflow corners */ + animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1); .debug-dialog-header { padding: 16px 20px; border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + /* Distinct header bg */ display: flex; align-items: center; justify-content: space-between; @@ -1204,24 +822,22 @@ h3 { margin: 0; font-size: 16px; - font-weight: 600; + font-weight: 700; color: var(--text-primary); } .close-btn { - background: transparent; + background: none; border: none; - color: var(--text-secondary); + color: var(--text-tertiary); cursor: pointer; - padding: 4px; + padding: 6px; + border-radius: 6px; display: flex; - align-items: center; - border-radius: 4px; - transition: all 0.2s; &:hover { - background: var(--hover-bg); - color: var(--accent-color); + background: rgba(0, 0, 0, 0.05); + color: var(--text-primary); } } } @@ -1230,123 +846,30 @@ flex: 1; overflow-y: auto; padding: 20px; + background: var(--bg-primary); - .debug-section { - margin-bottom: 24px; - padding-bottom: 20px; - border-bottom: 1px solid var(--border-color); - - &:last-child { - border-bottom: none; - } - - h4 { - margin: 0 0 12px 0; - font-size: 14px; - font-weight: 600; - color: var(--accent-color); - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .debug-item { - display: flex; - gap: 12px; - padding: 8px 0; - align-items: flex-start; - - .debug-key { - font-weight: 500; - color: var(--text-secondary); - min-width: 140px; - font-size: 13px; - font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace; - } - - .debug-value { - flex: 1; - color: var(--text-primary); - font-size: 13px; - word-break: break-all; - font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace; - user-select: text; - cursor: text; - padding: 2px 0; - } - } - - .media-debug-item { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 12px; - margin-bottom: 12px; - - .media-debug-header { - font-weight: 600; - color: var(--text-primary); - margin-bottom: 8px; - padding-bottom: 8px; - border-bottom: 1px solid var(--border-color); - } - - .live-photo-debug { - margin-top: 12px; - padding-top: 12px; - border-top: 1px dashed var(--border-color); - - .live-photo-label { - font-weight: 500; - color: var(--accent-color); - margin-bottom: 8px; - font-size: 13px; - } - } - } - - .json-code { - background: var(--bg-tertiary); - color: var(--text-primary); - padding: 16px; - border-radius: 8px; - border: 1px solid var(--border-color); - overflow-x: auto; - font-family: 'Consolas', 'Monaco', monospace; - font-size: 12px; - line-height: 1.5; - user-select: all; - max-height: 400px; - overflow-y: auto; - } - - .copy-json-btn { - margin-top: 12px; - padding: 8px 16px; - background: var(--accent-color); - color: white; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 13px; - font-weight: 500; - transition: all 0.2s; - - &:hover { - background: var(--accent-hover); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.3); - } - } - } - } - - @keyframes spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); + .json-code { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 12px; + line-height: 1.5; + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-all; + margin: 0; + user-select: text; + /* Allow selection */ } } } + +@keyframes slide-up-fade { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index d10031a..9853ede 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,426 +1,11 @@ -import { useEffect, useState, useRef, useCallback, useMemo } from 'react' -import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight, AlertTriangle } from 'lucide-react' -import { Avatar } from '../components/Avatar' +import { useEffect, useState, useRef, useCallback } from 'react' +import { RefreshCw, Search, X, Download, FolderOpen } from 'lucide-react' import { ImagePreview } from '../components/ImagePreview' import JumpToDateDialog from '../components/JumpToDateDialog' -import { LivePhotoIcon } from '../components/LivePhotoIcon' import './SnsPage.scss' - -interface SnsPost { - id: string - username: string - nickname: string - avatarUrl?: string - createTime: number - contentDesc: string - type?: number - media: { - url: string - thumb: string - md5?: string - token?: string - key?: string - encIdx?: string - livePhoto?: { - url: string - thumb: string - token?: string - key?: string - encIdx?: string - } - }[] - likes: string[] - comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] - rawXml?: string // 原始 XML 数据 - linkTitle?: string - linkUrl?: string -} - -interface SnsLinkCardData { - title: string - url: string - thumb?: string -} - -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 => { - 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 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('[SnsPage] openExternal failed:', error) - } - } - - return ( - - ) -} - -const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => { - const [error, setError] = useState(false) - const [thumbSrc, setThumbSrc] = useState('') // 缩略图 - const [videoPath, setVideoPath] = useState('') // 视频本地路径 - const [liveVideoPath, setLiveVideoPath] = useState('') // Live Photo 视频路径 - const [isDecrypting, setIsDecrypting] = useState(false) // 解密状态 - const { url, thumb, livePhoto } = media - const isLive = !!livePhoto - const targetUrl = thumb || url // 默认显示缩略图 - - // 判断是否为视频 - const isVideo = isSnsVideoUrl(url) - - useEffect(() => { - let cancelled = false - setError(false) - setThumbSrc('') - setVideoPath('') - setLiveVideoPath('') - setIsDecrypting(false) - - const extractFirstFrame = (videoUrl: string) => { - const video = document.createElement('video') - video.crossOrigin = 'anonymous' - video.style.display = 'none' - video.muted = true - video.src = videoUrl - video.currentTime = 0.1 - - const onLoadedData = () => { - if (cancelled) return cleanup() - try { - const canvas = document.createElement('canvas') - canvas.width = video.videoWidth - canvas.height = video.videoHeight - const ctx = canvas.getContext('2d') - if (ctx) { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height) - const dataUrl = canvas.toDataURL('image/jpeg', 0.8) - if (!cancelled) { - setThumbSrc(dataUrl) - setIsDecrypting(false) - } - } else { - if (!cancelled) setIsDecrypting(false) - } - } catch (e) { - console.warn('Frame extraction error', e) - if (!cancelled) setIsDecrypting(false) - } finally { - cleanup() - } - } - - const onError = () => { - if (!cancelled) { - setIsDecrypting(false) - setThumbSrc(targetUrl) // Fallback - } - cleanup() - } - - const cleanup = () => { - video.removeEventListener('seeked', onLoadedData) - video.removeEventListener('error', onError) - video.remove() - } - - video.addEventListener('seeked', onLoadedData) - video.addEventListener('error', onError) - video.load() - } - - const run = async () => { - try { - if (isVideo) { - setIsDecrypting(true) - - const videoResult = await window.electronAPI.sns.proxyImage({ - url: url, - key: media.key - }) - - if (cancelled) return - - if (videoResult.success && videoResult.videoPath) { - const localUrl = videoResult.videoPath.startsWith('file:') - ? videoResult.videoPath - : `file://${videoResult.videoPath.replace(/\\/g, '/')}` - setVideoPath(localUrl) - extractFirstFrame(localUrl) - } else { - console.warn('[MediaItem] Video decryption failed:', url, videoResult.error) - setIsDecrypting(false) - setError(true) - } - } else { - const result = await window.electronAPI.sns.proxyImage({ - url: targetUrl, - key: media.key - }) - - if (cancelled) return - if (result.success) { - if (result.dataUrl) { - setThumbSrc(result.dataUrl) - } else if (result.videoPath) { - const localUrl = result.videoPath.startsWith('file:') - ? result.videoPath - : `file://${result.videoPath.replace(/\\/g, '/')}` - setThumbSrc(localUrl) - } - } else { - console.warn('[MediaItem] Image proxy failed:', targetUrl, result.error) - setThumbSrc(targetUrl) - } - - if (isLive && livePhoto && livePhoto.url) { - window.electronAPI.sns.proxyImage({ - url: livePhoto.url, - key: livePhoto.key || media.key - }).then((res: any) => { - if (cancelled) return - if (res.success && res.videoPath) { - const localUrl = res.videoPath.startsWith('file:') - ? res.videoPath - : `file://${res.videoPath.replace(/\\/g, '/')}` - setLiveVideoPath(localUrl) - console.log('[MediaItem] Live video ready:', localUrl) - } else { - console.warn('[MediaItem] Live video failed:', res.error) - } - }).catch((e: any) => console.error('[MediaItem] Live video err:', e)) - } - } - } catch (err) { - if (!cancelled) { - console.error('[MediaItem] run error:', err) - setError(true) - setIsDecrypting(false) - } - } - } - - run() - return () => { cancelled = true } - }, [targetUrl, url, media.key, isVideo, isLive, livePhoto]) - - const handleDownload = async (e: React.MouseEvent) => { - e.stopPropagation() - try { - const result = await window.electronAPI.sns.downloadImage({ - url: url || targetUrl, // Use original url if available - key: media.key - }) - if (!result.success && result.error !== '用户已取消') { - alert(`下载失败: ${result.error}`) - } - } catch (error) { - console.error('Download failed:', error) - alert('下载过程中发生错误') - } - } - - // 点击时:如果是视频,应该传视频地址给 Preview? - // ImagePreview 目前可能只支持图片。需要检查 ImagePreview 是否支持视频。 - // 假设 ImagePreview 暂不支持视频播放,我们可以在这里直接点开播放? - // 或者,传视频 URL 给 onPreview,让父组件决定/ImagePreview 决定。 - // 通常做法:传给 ImagePreview,ImagePreview 识别 mp4 后播放。 - - // 显示用的图片:始终显示缩略图 - const displaySrc = thumbSrc || targetUrl - - // 预览用的地址:如果是视频,优先使用本地路径 - const previewSrc = isVideo ? (videoPath || url) : (thumbSrc || url || targetUrl) - - // 点击处理:解密中禁止点击 - const handleClick = () => { - if (isVideo && isDecrypting) return - onPreview(previewSrc, isVideo, liveVideoPath) - } - - return ( -
- {isVideo && isDecrypting ? ( -
- - 解密中... -
- ) : ( - setError(true)} - /> - )} - - {isVideo && !isDecrypting && ( -
-
- -
-
- )} - - {isLive && !isVideo && ( -
- -
- )} - -
- ) -} +import { SnsPost } from '../types/sns' +import { SnsPostItem } from '../components/Sns/SnsPostItem' +import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel' interface Contact { username: string @@ -431,37 +16,36 @@ interface Contact { export default function SnsPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(false) - const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(true) const loadingRef = useRef(false) - // 筛选与搜索状态 + // Filter states const [searchKeyword, setSearchKeyword] = useState('') const [selectedUsernames, setSelectedUsernames] = useState([]) - const [isSidebarOpen, setIsSidebarOpen] = useState(true) + const [jumpTargetDate, setJumpTargetDate] = useState(undefined) - // 联系人列表状态 + // Contacts state const [contacts, setContacts] = useState([]) const [contactSearch, setContactSearch] = useState('') const [contactsLoading, setContactsLoading] = useState(false) + + // UI states const [showJumpDialog, setShowJumpDialog] = useState(false) - const [jumpTargetDate, setJumpTargetDate] = useState(undefined) const [previewImage, setPreviewImage] = useState<{ src: string, isVideo?: boolean, liveVideoPath?: string } | null>(null) const [debugPost, setDebugPost] = useState(null) const postsContainerRef = useRef(null) - const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) const postsRef = useRef([]) const scrollAdjustmentRef = useRef(0) - // 同步 posts 到 ref 供 loadPosts 使用 + // Sync posts ref useEffect(() => { postsRef.current = posts }, [posts]) - // 处理向上加载动态时的滚动位置保持 + // Maintain scroll position when loading newer posts useEffect(() => { if (scrollAdjustmentRef.current !== 0 && postsContainerRef.current) { const container = postsContainerRef.current; @@ -488,6 +72,7 @@ export default function SnsPage() { let endTs: number | undefined = undefined if (reset) { + // If jumping to date, set endTs to end of that day if (jumpTargetDate) { endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399 } @@ -496,7 +81,6 @@ export default function SnsPage() { if (currentPosts.length > 0) { const topTs = currentPosts[0].createTime - const result = await window.electronAPI.sns.getTimeline( limit, 0, @@ -526,6 +110,7 @@ export default function SnsPage() { loadingRef.current = false; return; } else { + // Loading older const currentPosts = postsRef.current if (currentPosts.length > 0) { endTs = currentPosts[currentPosts.length - 1].createTime - 1 @@ -537,7 +122,7 @@ export default function SnsPage() { 0, selectedUsernames, searchKeyword, - startTs, + startTs, // default undefined endTs ) @@ -546,7 +131,7 @@ export default function SnsPage() { setPosts(result.timeline) setHasMore(result.timeline.length >= limit) - // 探测上方是否还有新动态(利用 DLL 过滤,而非底层 SQL) + // Check for newer items above topTs const topTs = result.timeline[0]?.createTime || 0; if (topTs > 0) { const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, searchKeyword, topTs + 1, undefined); @@ -576,7 +161,7 @@ export default function SnsPage() { } }, [selectedUsernames, searchKeyword, jumpTargetDate]) - // 获取联系人列表 + // Load Contacts const loadContacts = useCallback(async () => { setContactsLoading(true) try { @@ -622,372 +207,130 @@ export default function SnsPage() { } }, []) - // 初始加载 + // Initial Load & Listeners useEffect(() => { - const checkSchema = async () => { - try { - const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)"); - - if (schema.success && schema.rows) { - const columns = schema.rows.map((r: any) => r.name); - - } - } catch (e) { - console.error('[SnsPage] Failed to check schema:', e); - } - }; - checkSchema(); loadContacts() }, [loadContacts]) useEffect(() => { const handleChange = () => { - setPosts([]) - setHasMore(true) - setHasNewer(false) - setSelectedUsernames([]) - setSearchKeyword('') - setJumpTargetDate(undefined) - loadContacts() - loadPosts({ reset: true }) + // wxid changed, reset everything + setPosts([]); setHasMore(true); setHasNewer(false); + setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined); + loadContacts(); + loadPosts({ reset: true }); } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) }, [loadContacts, loadPosts]) useEffect(() => { - loadPosts({ reset: true }) - }, [selectedUsernames, searchKeyword, jumpTargetDate]) + const timer = setTimeout(() => { + loadPosts({ reset: true }) + }, 500) + return () => clearTimeout(timer) + }, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts]) const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget - - // 加载更旧的动态(触底) if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { loadPosts({ direction: 'older' }) } - - // 加载更新的动态(触顶触发) - // 这里的阈值可以保留,但主要依赖下面的 handleWheel 捕获到顶后的上划 if (scrollTop < 10 && hasNewer && !loading && !loadingNewer) { loadPosts({ direction: 'newer' }) } } - // 处理到顶后的手动上滚意图 const handleWheel = (e: React.WheelEvent) => { const container = postsContainerRef.current if (!container) return - - // deltaY < 0 表示向上滚,scrollTop === 0 表示已经在最顶端 if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) { - loadPosts({ direction: 'newer' }) } } - 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 toggleUserSelection = (username: string) => { - // 选择联系人时,如果当前有时间跳转,建议清除时间跳转以避免“跳到旧动态”的困惑 - // 或者保持原样。根据用户反馈“乱跳”,我们在这里选择: - // 如果用户选择了新的一个人,而之前有时间跳转,我们重置时间跳转到最新。 - setJumpTargetDate(undefined); - - setSelectedUsernames(prev => { - if (prev.includes(username)) { - return prev.filter(u => u !== username) - } else { - return [...prev, username] - } - }) - } - - const clearFilters = () => { - setSearchKeyword('') - setSelectedUsernames([]) - setJumpTargetDate(undefined) - } - - const filteredContacts = contacts.filter(c => - c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || - c.username.toLowerCase().includes(contactSearch.toLowerCase()) - ) - - - return ( -
-
-
-
-
-

社交动态

-
-
+
+
+
+
+

朋友圈

+
-
-
-
-
- {loadingNewer && ( -
- - 正在检查更新的动态... -
- )} - {!loadingNewer && hasNewer && ( -
loadPosts({ direction: 'newer' })}> - 查看更新的动态 -
- )} - {posts.map((post) => { - 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 - const showMediaGrid = post.media.length > 0 && !showLinkCard - return ( -
-
-
-
- -
-
{post.nickname}
-
{formatTime(post.createTime)}
-
- -
+ {loadingNewer && ( +
+ + 正在检查更新的动态... +
+ )} -
- {post.contentDesc &&
{post.contentDesc}
} + {!loadingNewer && hasNewer && ( +
loadPosts({ direction: 'newer' })}> + 有新动态,点击查看 +
+ )} - {showLinkCard && linkCard && ( - - )} +
+ {posts.map(post => ( + setPreviewImage({ src, isVideo, liveVideoPath })} + onDebug={(p) => setDebugPost(p)} + /> + ))} +
- {showMediaGrid && ( -
- {post.media.map((m, idx) => ( - setPreviewImage({ src, isVideo, liveVideoPath })} /> - ))} -
- )} -
+ {loading &&
+ + 正在加载更多... +
} - {(post.likes.length > 0 || post.comments.length > 0) && ( -
- {post.likes.length > 0 && ( -
- - - {post.likes.join('、')} - -
- )} + {!hasMore && posts.length > 0 && ( +
已经到底啦
+ )} - {post.comments.length > 0 && ( -
- {post.comments.map((c, idx) => ( -
- {c.nickname} - {c.refNickname && ( - <> - 回复 - {c.refNickname} - - )} - : - {c.content} -
- ))} -
- )} -
- )} -
-
-
- ) - })} -
- - {loading &&
- - 正在加载更多... -
} - {!hasMore && posts.length > 0 &&
已经到底啦
} - {!loading && posts.length === 0 && ( -
-
-

未找到相关动态

- {(selectedUsernames.length > 0 || searchKeyword) && ( - - )} -
+ {!loading && posts.length === 0 && ( +
+
+

未找到相关动态

+ {(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && ( + )}
-
-
- - {/* 侧边栏:过滤与搜索 (moved to right) */} - + )} +
+ + setShowJumpDialog(true)} + selectedUsernames={selectedUsernames} + setSelectedUsernames={setSelectedUsernames} + contacts={contacts} + contactSearch={contactSearch} + setContactSearch={setContactSearch} + loading={contactsLoading} + /> + + {/* Dialogs and Overlays */} {previewImage && ( setPreviewImage(null)} /> )} + { - setShowJumpDialog(false) - }} + onClose={() => setShowJumpDialog(false)} onSelect={(date) => { setJumpTargetDate(date) setShowJumpDialog(false) @@ -1008,149 +350,19 @@ export default function SnsPage() { currentDate={jumpTargetDate || new Date()} /> - {/* Debug Info Dialog */} {debugPost && (
setDebugPost(null)}>
e.stopPropagation()}>
-

原始数据 - {debugPost.nickname}

+

原始数据

- -
-

ℹ 基本信息

-
- ID: - {debugPost.id} -
-
- 用户名: - {debugPost.username} -
-
- 昵称: - {debugPost.nickname} -
-
- 时间: - {new Date(debugPost.createTime * 1000).toLocaleString()} -
-
- 类型: - {debugPost.type} -
-
- -
-

媒体信息 ({debugPost.media.length} 项)

- {debugPost.media.map((media, idx) => ( -
-
媒体 {idx + 1}
-
- URL: - {media.url} -
-
- 缩略图: - {media.thumb} -
- {media.md5 && ( -
- MD5: - {media.md5} -
- )} - {media.token && ( -
- Token: - {media.token} -
- )} - {media.key && ( -
- Key (解密密钥): - {media.key} -
- )} - {media.encIdx && ( -
- Enc Index: - {media.encIdx} -
- )} - {media.livePhoto && ( -
-
Live Photo 视频部分:
-
- 视频 URL: - {media.livePhoto.url} -
-
- 视频缩略图: - {media.livePhoto.thumb} -
- {media.livePhoto.token && ( -
- 视频 Token: - {media.livePhoto.token} -
- )} - {media.livePhoto.key && ( -
- 视频 Key: - {media.livePhoto.key} -
- )} -
- )} -
- ))} -
- - {/* 原始 XML */} - {debugPost.rawXml && ( -
-

原始 XML 数据

-
{(() => {
-                                        // XML 缩进格式化
-                                        let formatted = '';
-                                        let indent = 0;
-                                        const tab = '  ';
-                                        const parts = debugPost.rawXml.split(/(<[^>]+>)/g).filter(p => p.trim());
-
-                                        for (const part of parts) {
-                                            if (!part.startsWith('<')) {
-                                                if (part.trim()) formatted += part;
-                                                continue;
-                                            }
-
-                                            if (part.startsWith('')) {
-                                                formatted += '\n' + tab.repeat(indent) + part;
-                                            } else {
-                                                formatted += '\n' + tab.repeat(indent) + part;
-                                                indent++;
-                                            }
-                                        }
-
-                                        return formatted.trim();
-                                    })()}
- -
- )} +
+                                {JSON.stringify(debugPost, null, 2)}
+                            
diff --git a/src/types/sns.ts b/src/types/sns.ts new file mode 100644 index 0000000..b909433 --- /dev/null +++ b/src/types/sns.ts @@ -0,0 +1,47 @@ +export interface SnsLivePhoto { + url: string + thumb: string + token?: string + key?: string + encIdx?: string +} + +export interface SnsMedia { + url: string + thumb: string + md5?: string + token?: string + key?: string + encIdx?: string + livePhoto?: SnsLivePhoto +} + +export interface SnsComment { + id: string + nickname: string + content: string + refCommentId: string + refNickname?: string +} + +export interface SnsPost { + id: string + username: string + nickname: string + avatarUrl?: string + createTime: number + contentDesc: string + type?: number + media: SnsMedia[] + likes: string[] + comments: SnsComment[] + rawXml?: string + linkTitle?: string + linkUrl?: string +} + +export interface SnsLinkCardData { + title: string + url: string + thumb?: string +}