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

View File

@@ -1228,9 +1228,8 @@
border-radius: var(--sns-border-radius-md);
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
border: 2px solid transparent;
border: 1px solid transparent;
margin-bottom: 4px;
/* Separation for unselected items */
&:hover {
background: var(--hover-bg);
@@ -1238,41 +1237,6 @@
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 {
flex: 1;
min-width: 0;
@@ -1315,13 +1279,6 @@
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 { 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 { SnsPost } from '../types/sns'
import { SnsPostItem } from '../components/Sns/SnsPostItem'
@@ -100,7 +100,6 @@ export default function SnsPage() {
// Filter states
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
// Contacts state
@@ -156,7 +155,6 @@ export default function SnsPage() {
const contactsRef = useRef<Contact[]>([])
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
const cacheScopeKeyRef = useRef('')
@@ -181,9 +179,6 @@ export default function SnsPage() {
useEffect(() => {
overviewStatsStatusRef.current = overviewStatsStatus
}, [overviewStatsStatus])
useEffect(() => {
selectedUsernamesRef.current = selectedUsernames
}, [selectedUsernames])
useEffect(() => {
searchKeywordRef.current = searchKeyword
}, [searchKeyword])
@@ -391,7 +386,7 @@ export default function SnsPage() {
}, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact])
const isDefaultViewNow = useCallback(() => {
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
return !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
}, [])
const ensureSnsCacheScopeKey = useCallback(async () => {
@@ -577,7 +572,7 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.getTimeline(
limit,
0,
selectedUsernames,
undefined,
searchKeyword,
topTs + 1,
undefined
@@ -618,7 +613,7 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.getTimeline(
limit,
0,
selectedUsernames,
undefined,
searchKeyword,
startTs, // default undefined
endTs
@@ -633,7 +628,7 @@ export default function SnsPage() {
// 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);
const checkResult = await window.electronAPI.sns.getTimeline(1, 0, undefined, searchKeyword, topTs + 1, undefined);
setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
} else {
setHasNewer(false);
@@ -660,7 +655,7 @@ export default function SnsPage() {
setLoadingNewer(false)
loadingRef.current = false
}
}, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames])
}, [jumpTargetDate, persistSnsPageCache, searchKeyword])
const stopContactsCountHydration = useCallback((resetProgress = false) => {
contactsCountHydrationTokenRef.current += 1
@@ -950,6 +945,14 @@ export default function SnsPage() {
})
}, [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) => {
setPosts(prev => {
const next = prev.filter(p => p.id !== postId)
@@ -1017,7 +1020,7 @@ export default function SnsPage() {
stopContactsCountHydration(true)
setContacts([])
setPosts([]); setHasMore(true); setHasNewer(false);
setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined);
setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache()
loadContacts();
loadOverviewStats();
@@ -1032,7 +1035,7 @@ export default function SnsPage() {
loadPosts({ reset: true })
}, 500)
return () => clearTimeout(timer)
}, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts])
}, [searchKeyword, jumpTargetDate, loadPosts])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
@@ -1251,21 +1254,16 @@ export default function SnsPage() {
)}
{!hasMore && posts.length > 0 && (
<div className="status-indicator no-more">{
selectedUsernames.length === 1 &&
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
? '在时间的长河里刻舟求剑'
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
}</div>
<div className="status-indicator no-more"></div>
)}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p></p>
{(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && (
{(searchKeyword || jumpTargetDate) && (
<button onClick={() => {
setSearchKeyword(''); setSelectedUsernames([]); setJumpTargetDate(undefined);
setSearchKeyword(''); setJumpTargetDate(undefined);
}} className="reset-inline">
</button>
@@ -1286,13 +1284,12 @@ export default function SnsPage() {
? `${overviewStats.totalFriends} 位好友`
: undefined
}
selectedUsernames={selectedUsernames}
setSelectedUsernames={setSelectedUsernames}
contacts={contacts}
contactSearch={contactSearch}
setContactSearch={setContactSearch}
loading={contactsLoading}
contactsCountProgress={contactsCountProgress}
onOpenContactTimeline={openContactTimeline}
/>
{/* Dialogs and Overlays */}
@@ -1437,17 +1434,10 @@ export default function SnsPage() {
<div className="export-dialog-body">
{/* 筛选条件提示 */}
{(selectedUsernames.length > 0 || searchKeyword) && (
{searchKeyword && (
<div className="export-filter-info">
<span className="filter-badge"></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>
)}
@@ -1584,7 +1574,7 @@ export default function SnsPage() {
{/* 同步提示 */}
<div className="export-sync-hint">
<Info size={14} />
<span></span>
<span></span>
</div>
{/* 进度条 */}
@@ -1626,7 +1616,6 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.exportTimeline({
outputDir: exportFolder,
format: exportFormat,
usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined,
keyword: searchKeyword || undefined,
exportImages,
exportLivePhotos,