feat: filter SNS feed by selected contacts

This commit is contained in:
aits2026
2026-03-10 11:38:38 +08:00
parent d4915e1a62
commit c02bc753fd
2 changed files with 110 additions and 9 deletions

View File

@@ -1,7 +1,7 @@
/* Global Variables */ /* Global Variables */
:root { :root {
--sns-max-width: 800px; --sns-max-width: 800px;
--sns-panel-width: 320px; --sns-panel-width: 380px;
--sns-bg-color: var(--bg-primary); --sns-bg-color: var(--bg-primary);
--sns-card-bg: var(--bg-secondary); --sns-card-bg: var(--bg-secondary);
--sns-border-radius-lg: 16px; --sns-border-radius-lg: 16px;
@@ -263,6 +263,48 @@
padding-top: 16px; padding-top: 16px;
} }
.feed-contact-filter-bar {
margin: 10px 4px 0;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border-color));
border-radius: 12px;
background: rgba(var(--primary-rgb), 0.08);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.feed-contact-filter-label {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.feed-contact-filter-summary {
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
min-width: 0;
}
.feed-contact-filter-clear {
margin-left: auto;
border: none;
background: transparent;
color: var(--primary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0;
white-space: nowrap;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
}
}
.posts-list { .posts-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -173,9 +173,11 @@ export default function SnsPage() {
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus) const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
const searchKeywordRef = useRef(searchKeyword) const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate) const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
const selectedContactUsernamesRef = useRef<string[]>(selectedContactUsernames)
const cacheScopeKeyRef = useRef('') const cacheScopeKeyRef = useRef('')
const snsUserPostCountsCacheScopeKeyRef = useRef('') const snsUserPostCountsCacheScopeKeyRef = useRef('')
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const pendingResetFeedRef = useRef(false)
const contactsLoadTokenRef = useRef(0) const contactsLoadTokenRef = useRef(0)
const contactsCountHydrationTokenRef = useRef(0) const contactsCountHydrationTokenRef = useRef(0)
const contactsCountBatchTimerRef = useRef<number | null>(null) const contactsCountBatchTimerRef = useRef<number | null>(null)
@@ -208,6 +210,9 @@ export default function SnsPage() {
useEffect(() => { useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate]) }, [jumpTargetDate])
useEffect(() => {
selectedContactUsernamesRef.current = selectedContactUsernames
}, [selectedContactUsernames])
useEffect(() => { useEffect(() => {
if (!showJumpPopover) { if (!showJumpPopover) {
setJumpPopoverDate(jumpTargetDate || new Date()) setJumpPopoverDate(jumpTargetDate || new Date())
@@ -394,6 +399,14 @@ export default function SnsPage() {
return `${names.slice(0, 2).join('、')}${names.length} 位联系人` return `${names.slice(0, 2).join('、')}${names.length} 位联系人`
}, [contacts, exportScope]) }, [contacts, exportScope])
const selectedFeedContactsSummary = useMemo(() => {
if (selectedContactUsernames.length === 0) return ''
const contactMap = new Map(contacts.map((contact) => [contact.username, contact]))
const names = selectedContactUsernames.map((username) => contactMap.get(username)?.displayName || username)
if (names.length <= 2) return names.join('、')
return `${names.slice(0, 2).join('、')}${names.length}`
}, [contacts, selectedContactUsernames])
const myTimelineCount = useMemo(() => { const myTimelineCount = useMemo(() => {
if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') { if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') {
return normalizePostCount(resolvedCurrentUserContact.postCount) return normalizePostCount(resolvedCurrentUserContact.postCount)
@@ -421,7 +434,11 @@ export default function SnsPage() {
}, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact]) }, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact])
const isDefaultViewNow = useCallback(() => { const isDefaultViewNow = useCallback(() => {
return !searchKeywordRef.current.trim() && !jumpTargetDateRef.current return (
!searchKeywordRef.current.trim() &&
!jumpTargetDateRef.current &&
selectedContactUsernamesRef.current.length === 0
)
}, []) }, [])
const ensureSnsCacheScopeKey = useCallback(async () => { const ensureSnsCacheScopeKey = useCallback(async () => {
@@ -594,7 +611,12 @@ export default function SnsPage() {
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options const { reset = false, direction = 'older' } = options
if (loadingRef.current) return if (loadingRef.current) {
if (reset) {
pendingResetFeedRef.current = true
}
return
}
loadingRef.current = true loadingRef.current = true
if (direction === 'newer') setLoadingNewer(true) if (direction === 'newer') setLoadingNewer(true)
@@ -602,6 +624,7 @@ export default function SnsPage() {
try { try {
const limit = 20 const limit = 20
const selectedUsernames = selectedContactUsernames.length > 0 ? selectedContactUsernames : undefined
let startTs: number | undefined = undefined let startTs: number | undefined = undefined
let endTs: number | undefined = undefined let endTs: number | undefined = undefined
@@ -618,7 +641,7 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.getTimeline( const result = await window.electronAPI.sns.getTimeline(
limit, limit,
0, 0,
undefined, selectedUsernames,
searchKeyword, searchKeyword,
topTs + 1, topTs + 1,
undefined undefined
@@ -659,7 +682,7 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.getTimeline( const result = await window.electronAPI.sns.getTimeline(
limit, limit,
0, 0,
undefined, selectedUsernames,
searchKeyword, searchKeyword,
startTs, // default undefined startTs, // default undefined
endTs endTs
@@ -674,7 +697,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, undefined, searchKeyword, topTs + 1, undefined); const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, 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);
@@ -700,8 +723,12 @@ export default function SnsPage() {
setLoading(false) setLoading(false)
setLoadingNewer(false) setLoadingNewer(false)
loadingRef.current = false loadingRef.current = false
if (pendingResetFeedRef.current) {
pendingResetFeedRef.current = false
void loadPosts({ reset: true })
}
} }
}, [jumpTargetDate, persistSnsPageCache, searchKeyword]) }, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedContactUsernames])
const stopContactsCountHydration = useCallback((resetProgress = false) => { const stopContactsCountHydration = useCallback((resetProgress = false) => {
contactsCountHydrationTokenRef.current += 1 contactsCountHydrationTokenRef.current += 1
@@ -1155,6 +1182,7 @@ export default function SnsPage() {
stopContactsCountHydration(true) stopContactsCountHydration(true)
setContacts([]) setContacts([])
setPosts([]); setHasMore(true); setHasNewer(false); setPosts([]); setHasMore(true); setHasNewer(false);
setSelectedContactUsernames([])
setSearchKeyword(''); setJumpTargetDate(undefined); setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache() void hydrateSnsPageCache()
loadContacts(); loadContacts();
@@ -1172,6 +1200,21 @@ export default function SnsPage() {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [searchKeyword, jumpTargetDate, loadPosts]) }, [searchKeyword, jumpTargetDate, loadPosts])
const selectedContactUsernamesKey = useMemo(
() => selectedContactUsernames.join('||'),
[selectedContactUsernames]
)
const hasInitializedSelectedFeedFilterRef = useRef(false)
useEffect(() => {
if (!hasInitializedSelectedFeedFilterRef.current) {
hasInitializedSelectedFeedFilterRef.current = true
return
}
loadPosts({ reset: true })
}, [loadPosts, selectedContactUsernamesKey])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
@@ -1334,6 +1377,20 @@ export default function SnsPage() {
</div> </div>
</div> </div>
{selectedContactUsernames.length > 0 && (
<div className="feed-contact-filter-bar">
<span className="feed-contact-filter-label"></span>
<span className="feed-contact-filter-summary">{selectedFeedContactsSummary} </span>
<button
type="button"
className="feed-contact-filter-clear"
onClick={clearSelectedContacts}
>
</button>
</div>
)}
<div className="sns-posts-scroll" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}> <div className="sns-posts-scroll" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
{loadingNewer && ( {loadingNewer && (
<div className="status-indicator loading-newer"> <div className="status-indicator loading-newer">
@@ -1391,9 +1448,11 @@ export default function SnsPage() {
<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>
{(searchKeyword || jumpTargetDate) && ( {(searchKeyword || jumpTargetDate || selectedContactUsernames.length > 0) && (
<button onClick={() => { <button onClick={() => {
setSearchKeyword(''); setJumpTargetDate(undefined); setSearchKeyword('')
setJumpTargetDate(undefined)
clearSelectedContacts()
}} className="reset-inline"> }} className="reset-inline">
</button> </button>