refactor(sns): open contact timeline from sidebar

This commit is contained in:
aits2026
2026-03-06 16:07:48 +08:00
parent 9575ba2a9f
commit f4caa51da5
3 changed files with 30 additions and 97 deletions

View File

@@ -20,43 +20,33 @@ interface SnsFilterPanelProps {
searchKeyword: string searchKeyword: string
setSearchKeyword: (val: string) => void setSearchKeyword: (val: string) => void
totalFriendsLabel?: string totalFriendsLabel?: string
selectedUsernames: string[]
setSelectedUsernames: (val: string[]) => void
contacts: Contact[] contacts: Contact[]
contactSearch: string contactSearch: string
setContactSearch: (val: string) => void setContactSearch: (val: string) => void
loading?: boolean loading?: boolean
contactsCountProgress?: ContactsCountProgress contactsCountProgress?: ContactsCountProgress
onOpenContactTimeline: (contact: Contact) => void
} }
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
searchKeyword, searchKeyword,
setSearchKeyword, setSearchKeyword,
totalFriendsLabel, totalFriendsLabel,
selectedUsernames,
setSelectedUsernames,
contacts, contacts,
contactSearch, contactSearch,
setContactSearch, setContactSearch,
loading, loading,
contactsCountProgress contactsCountProgress,
onOpenContactTimeline
}) => { }) => {
const filteredContacts = contacts.filter(c => const filteredContacts = contacts.filter(c =>
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.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 {
setSelectedUsernames([...selectedUsernames, username])
}
}
const clearFilters = () => { const clearFilters = () => {
setSearchKeyword('') setSearchKeyword('')
setSelectedUsernames([]) setContactSearch('')
} }
const getEmptyStateText = () => { const getEmptyStateText = () => {
@@ -73,7 +63,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
<aside className="sns-filter-panel"> <aside className="sns-filter-panel">
<div className="filter-header"> <div className="filter-header">
<h3></h3> <h3></h3>
{(searchKeyword || selectedUsernames.length > 0) && ( {(searchKeyword || contactSearch) && (
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选"> <button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </button>
@@ -106,9 +96,6 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
<div className="widget-header"> <div className="widget-header">
<User size={14} /> <User size={14} />
<span></span> <span></span>
{selectedUsernames.length > 0 && (
<span className="badge">{selectedUsernames.length}</span>
)}
{totalFriendsLabel && ( {totalFriendsLabel && (
<span className="widget-header-summary">{totalFriendsLabel}</span> <span className="widget-header-summary">{totalFriendsLabel}</span>
)} )}
@@ -141,8 +128,8 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
return ( return (
<div <div
key={contact.username} key={contact.username}
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`} className="contact-row"
onClick={() => toggleUserSelection(contact.username)} onClick={() => onOpenContactTimeline(contact)}
> >
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" /> <Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<div className="contact-meta"> <div className="contact-meta">

View File

@@ -1228,9 +1228,8 @@
border-radius: var(--sns-border-radius-md); border-radius: var(--sns-border-radius-md);
cursor: pointer; cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease; transition: background 0.2s ease, transform 0.2s ease;
border: 2px solid transparent; border: 1px solid transparent;
margin-bottom: 4px; margin-bottom: 4px;
/* Separation for unselected items */
&:hover { &:hover {
background: var(--hover-bg); background: var(--hover-bg);
@@ -1238,41 +1237,6 @@
z-index: 10; z-index: 10;
} }
&.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 */
.contact-meta {
.contact-name {
color: var(--primary);
font-weight: 600;
}
}
/* If the NEXT item is also selected */
&:has(+ .contact-row.selected) {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding-bottom: 12px;
/* Compensate for missing border (+2px) */
}
}
/* 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 */
}
.contact-meta { .contact-meta {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -1315,13 +1279,6 @@
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
} }
&.selected {
.contact-post-count {
color: var(--primary);
font-weight: 600;
}
}
} }
} }

View File

@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react' import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react'
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff, Loader2 } from 'lucide-react' import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, ChevronLeft, ChevronRight, Shield, ShieldOff, Loader2 } from 'lucide-react'
import './SnsPage.scss' import './SnsPage.scss'
import { SnsPost } from '../types/sns' import { SnsPost } from '../types/sns'
import { SnsPostItem } from '../components/Sns/SnsPostItem' import { SnsPostItem } from '../components/Sns/SnsPostItem'
@@ -100,7 +100,6 @@ export default function SnsPage() {
// Filter states // Filter states
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined) const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
// Contacts state // Contacts state
@@ -156,7 +155,6 @@ export default function SnsPage() {
const contactsRef = useRef<Contact[]>([]) const contactsRef = useRef<Contact[]>([])
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats) const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus) const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
const searchKeywordRef = useRef(searchKeyword) const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate) const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
const cacheScopeKeyRef = useRef('') const cacheScopeKeyRef = useRef('')
@@ -181,9 +179,6 @@ export default function SnsPage() {
useEffect(() => { useEffect(() => {
overviewStatsStatusRef.current = overviewStatsStatus overviewStatsStatusRef.current = overviewStatsStatus
}, [overviewStatsStatus]) }, [overviewStatsStatus])
useEffect(() => {
selectedUsernamesRef.current = selectedUsernames
}, [selectedUsernames])
useEffect(() => { useEffect(() => {
searchKeywordRef.current = searchKeyword searchKeywordRef.current = searchKeyword
}, [searchKeyword]) }, [searchKeyword])
@@ -391,7 +386,7 @@ export default function SnsPage() {
}, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact]) }, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact])
const isDefaultViewNow = useCallback(() => { const isDefaultViewNow = useCallback(() => {
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current return !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
}, []) }, [])
const ensureSnsCacheScopeKey = useCallback(async () => { const ensureSnsCacheScopeKey = useCallback(async () => {
@@ -577,7 +572,7 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.getTimeline( const result = await window.electronAPI.sns.getTimeline(
limit, limit,
0, 0,
selectedUsernames, undefined,
searchKeyword, searchKeyword,
topTs + 1, topTs + 1,
undefined undefined
@@ -618,7 +613,7 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.getTimeline( const result = await window.electronAPI.sns.getTimeline(
limit, limit,
0, 0,
selectedUsernames, undefined,
searchKeyword, searchKeyword,
startTs, // default undefined startTs, // default undefined
endTs endTs
@@ -633,7 +628,7 @@ export default function SnsPage() {
// Check for newer items above topTs // Check for newer items above topTs
const topTs = result.timeline[0]?.createTime || 0; const topTs = result.timeline[0]?.createTime || 0;
if (topTs > 0) { if (topTs > 0) {
const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, searchKeyword, topTs + 1, undefined); const checkResult = await window.electronAPI.sns.getTimeline(1, 0, undefined, searchKeyword, topTs + 1, undefined);
setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0)); setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
} else { } else {
setHasNewer(false); setHasNewer(false);
@@ -660,7 +655,7 @@ export default function SnsPage() {
setLoadingNewer(false) setLoadingNewer(false)
loadingRef.current = false loadingRef.current = false
} }
}, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames]) }, [jumpTargetDate, persistSnsPageCache, searchKeyword])
const stopContactsCountHydration = useCallback((resetProgress = false) => { const stopContactsCountHydration = useCallback((resetProgress = false) => {
contactsCountHydrationTokenRef.current += 1 contactsCountHydrationTokenRef.current += 1
@@ -950,6 +945,14 @@ export default function SnsPage() {
}) })
}, [decodeHtmlEntities]) }, [decodeHtmlEntities])
const openContactTimeline = useCallback((contact: Contact) => {
setAuthorTimelineTarget({
username: contact.username,
displayName: contact.displayName || contact.username,
avatarUrl: contact.avatarUrl
})
}, [])
const handlePostDelete = useCallback((postId: string, username: string) => { const handlePostDelete = useCallback((postId: string, username: string) => {
setPosts(prev => { setPosts(prev => {
const next = prev.filter(p => p.id !== postId) const next = prev.filter(p => p.id !== postId)
@@ -1017,7 +1020,7 @@ export default function SnsPage() {
stopContactsCountHydration(true) stopContactsCountHydration(true)
setContacts([]) setContacts([])
setPosts([]); setHasMore(true); setHasNewer(false); setPosts([]); setHasMore(true); setHasNewer(false);
setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined); setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache() void hydrateSnsPageCache()
loadContacts(); loadContacts();
loadOverviewStats(); loadOverviewStats();
@@ -1032,7 +1035,7 @@ export default function SnsPage() {
loadPosts({ reset: true }) loadPosts({ reset: true })
}, 500) }, 500)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts]) }, [searchKeyword, jumpTargetDate, loadPosts])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
@@ -1251,21 +1254,16 @@ export default function SnsPage() {
)} )}
{!hasMore && posts.length > 0 && ( {!hasMore && posts.length > 0 && (
<div className="status-indicator no-more">{ <div className="status-indicator no-more"></div>
selectedUsernames.length === 1 &&
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
? '在时间的长河里刻舟求剑'
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
}</div>
)} )}
{!loading && posts.length === 0 && ( {!loading && posts.length === 0 && (
<div className="no-results"> <div className="no-results">
<div className="no-results-icon"><Search size={48} /></div> <div className="no-results-icon"><Search size={48} /></div>
<p></p> <p></p>
{(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && ( {(searchKeyword || jumpTargetDate) && (
<button onClick={() => { <button onClick={() => {
setSearchKeyword(''); setSelectedUsernames([]); setJumpTargetDate(undefined); setSearchKeyword(''); setJumpTargetDate(undefined);
}} className="reset-inline"> }} className="reset-inline">
</button> </button>
@@ -1286,13 +1284,12 @@ export default function SnsPage() {
? `${overviewStats.totalFriends} 位好友` ? `${overviewStats.totalFriends} 位好友`
: undefined : undefined
} }
selectedUsernames={selectedUsernames}
setSelectedUsernames={setSelectedUsernames}
contacts={contacts} contacts={contacts}
contactSearch={contactSearch} contactSearch={contactSearch}
setContactSearch={setContactSearch} setContactSearch={setContactSearch}
loading={contactsLoading} loading={contactsLoading}
contactsCountProgress={contactsCountProgress} contactsCountProgress={contactsCountProgress}
onOpenContactTimeline={openContactTimeline}
/> />
{/* Dialogs and Overlays */} {/* Dialogs and Overlays */}
@@ -1437,17 +1434,10 @@ export default function SnsPage() {
<div className="export-dialog-body"> <div className="export-dialog-body">
{/* 筛选条件提示 */} {/* 筛选条件提示 */}
{(selectedUsernames.length > 0 || searchKeyword) && ( {searchKeyword && (
<div className="export-filter-info"> <div className="export-filter-info">
<span className="filter-badge"></span> <span className="filter-badge"></span>
{searchKeyword && <span className="filter-tag">: "{searchKeyword}"</span>} {searchKeyword && <span className="filter-tag">: "{searchKeyword}"</span>}
{selectedUsernames.length > 0 && (
<span className="filter-tag">
<Users size={12} />
{selectedUsernames.length}
<span className="sync-hint"></span>
</span>
)}
</div> </div>
)} )}
@@ -1584,7 +1574,7 @@ export default function SnsPage() {
{/* 同步提示 */} {/* 同步提示 */}
<div className="export-sync-hint"> <div className="export-sync-hint">
<Info size={14} /> <Info size={14} />
<span></span> <span></span>
</div> </div>
{/* 进度条 */} {/* 进度条 */}
@@ -1626,7 +1616,6 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.exportTimeline({ const result = await window.electronAPI.sns.exportTimeline({
outputDir: exportFolder, outputDir: exportFolder,
format: exportFormat, format: exportFormat,
usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined,
keyword: searchKeyword || undefined, keyword: searchKeyword || undefined,
exportImages, exportImages,
exportLivePhotos, exportLivePhotos,