From 89b95187876199f9a9d95aabaef5694d5895f04a Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 11 Jun 2026 19:26:28 +0800 Subject: [PATCH 1/2] feat: add 'Latest Only' dropdown to release timeline Add a new dropdown next to existing filters that allows showing only the latest release per repository. The setting is persisted and works together with existing filters and unread-only toggle. When user marks the latest release of a repo as read in 'Latest Only' mode, all other releases of that repo are also marked as read. Changes: - Add releaseLatestMode state to AppState and persist it - Add setReleaseLatestMode action to store - Add latest-only filtering logic in ReleaseTimeline - Add dropdown UI with mutual exclusion with other dropdowns - Cascade mark-as-read when in latest mode --- src/components/ReleaseTimeline.tsx | 90 ++++++++++++++++++++++++++++-- src/store/useAppStore.ts | 20 +++++++ src/types/index.ts | 1 + 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/components/ReleaseTimeline.tsx b/src/components/ReleaseTimeline.tsx index 028fc44..4073984 100644 --- a/src/components/ReleaseTimeline.tsx +++ b/src/components/ReleaseTimeline.tsx @@ -56,6 +56,8 @@ export const ReleaseTimeline: React.FC = () => { setIncludePreRelease, releaseShowMode, setReleaseShowMode, + releaseLatestMode, + setReleaseLatestMode, } = useAppStore(); const { toast, confirm } = useDialog(); @@ -70,6 +72,7 @@ export const ReleaseTimeline: React.FC = () => { // 视图切换下拉菜单状态(本地UI状态) const [isViewDropdownOpen, setIsViewDropdownOpen] = useState(false); const [isShowModeDropdownOpen, setIsShowModeDropdownOpen] = useState(false); + const [isLatestModeDropdownOpen, setIsLatestModeDropdownOpen] = useState(false); const [isReleaseSourceSettingsOpen, setIsReleaseSourceSettingsOpen] = useState(false); const [isMarkingAllRead, setIsMarkingAllRead] = useState(false); @@ -233,7 +236,7 @@ export const ReleaseTimeline: React.FC = () => { }); unreadSnapshotRef.current = ids; setSnapshotVersion(v => v + 1); - }, [releases, resolvedReleaseSources, includePreRelease, releaseShowMode]); + }, [releases, resolvedReleaseSources, includePreRelease, releaseShowMode, releaseLatestMode]); // 预计算每个 release 的下载链接和过滤后的链接 const releasesWithLinks = useMemo(() => { @@ -282,15 +285,30 @@ export const ReleaseTimeline: React.FC = () => { })); }, [releasesWithLinks, searchQuery, selectedFilters]); + // 仅最新模式过滤:每个仓库只保留最新的 release + const latestModeReleases = useMemo(() => { + if (releaseLatestMode !== 'latest') return preUnreadFilteredReleases; + + const repoMap = new Map(); + for (const item of preUnreadFilteredReleases) { + const repoId = item.release.repository.id; + const existing = repoMap.get(repoId); + if (!existing || new Date(item.release.published_at) > new Date(existing.release.published_at)) { + repoMap.set(repoId, item); + } + } + return Array.from(repoMap.values()); + }, [preUnreadFilteredReleases, releaseLatestMode]); + // 未读模式过滤(使用快照,标记已读后不会立即消失,刷新页面后才更新) const filteredReleases = useMemo(() => { if (releaseShowMode === 'unread') { - return preUnreadFilteredReleases.filter(({ release }) => unreadSnapshotRef.current.has(release.id)); + return latestModeReleases.filter(({ release }) => unreadSnapshotRef.current.has(release.id)); } - return preUnreadFilteredReleases; + return latestModeReleases; // snapshotVersion 触发快照更新后重算;readReleases 不在此处以避免标记已读立即消失 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [preUnreadFilteredReleases, releaseShowMode, snapshotVersion]); + }, [latestModeReleases, releaseShowMode, snapshotVersion]); const unreadCount = useMemo(() => { return subscribedReleases.filter(r => !readReleases.has(r.id)).length; @@ -459,6 +477,12 @@ export const ReleaseTimeline: React.FC = () => { setIsShowModeDropdownOpen(false); }; + const handleLatestModeChange = (mode: 'all' | 'latest') => { + setReleaseLatestMode(mode); + setCurrentPage(1); + setIsLatestModeDropdownOpen(false); + }; + const handleMarkAllRead = async () => { setIsMarkingAllRead(true); try { @@ -879,6 +903,7 @@ export const ReleaseTimeline: React.FC = () => { onClick={() => { setIsViewDropdownOpen(!isViewDropdownOpen); setIsShowModeDropdownOpen(false); + setIsLatestModeDropdownOpen(false); }} className="flex items-center space-x-2 px-3 py-2 bg-light-surface dark:bg-white/[0.04] rounded-lg hover:bg-gray-200 dark:hover:bg-white/10 transition-all" title={viewMode === 'timeline' ? t('按日期排序视图', 'Timeline View') : t('仓库分类视图', 'Repository View')} @@ -976,6 +1001,11 @@ export const ReleaseTimeline: React.FC = () => { ({t('已筛选', 'filtered')}) )} + {releaseLatestMode === 'latest' && ( + + ({t('仅最新', 'latest only')}) + + )}
@@ -985,6 +1015,7 @@ export const ReleaseTimeline: React.FC = () => { onClick={() => { setIsShowModeDropdownOpen(!isShowModeDropdownOpen); setIsViewDropdownOpen(false); + setIsLatestModeDropdownOpen(false); }} className="flex items-center space-x-2 px-3 py-2 bg-light-surface dark:bg-white/[0.04] rounded-lg hover:bg-gray-200 dark:hover:bg-white/10 transition-all" title={releaseShowMode === 'all' ? t('显示全部', 'Show All') : t('仅显示未读', 'Show Unread Only')} @@ -1029,6 +1060,57 @@ export const ReleaseTimeline: React.FC = () => { )}
+ {/* Latest Mode Dropdown */} +
+ + + {isLatestModeDropdownOpen && ( + <> +
setIsLatestModeDropdownOpen(false)} /> +
+ + +
+ + )} +
+ {/* Items per page selector */}
{t('每页:', 'Per page:')} diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 3637f91..73bb3f6 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -346,6 +346,7 @@ interface AppActions { // Release Timeline View actions setReleaseViewMode: (mode: 'timeline' | 'repository') => void; setReleaseShowMode: (mode: 'all' | 'unread') => void; + setReleaseLatestMode: (mode: 'all' | 'latest') => void; setReleaseSelectedFilters: (filters: string[]) => void; toggleReleaseSelectedFilter: (filterId: string) => void; clearReleaseSelectedFilters: () => void; @@ -439,6 +440,7 @@ type PersistedAppState = Partial< | 'forkExpandedRepositories' | 'releaseViewMode' | 'releaseShowMode' + | 'releaseLatestMode' | 'releaseSelectedFilters' | 'releaseSearchQuery' | 'includePreRelease' @@ -562,6 +564,7 @@ const normalizePersistedState = ( isAuthenticated: !!(safePersisted.user && safePersisted.githubToken), releaseViewMode: safePersisted.releaseViewMode || 'timeline', releaseShowMode: safePersisted.releaseShowMode === 'unread' ? 'unread' : 'all', + releaseLatestMode: safePersisted.releaseLatestMode === 'latest' ? 'latest' : 'all', releaseSelectedFilters: Array.isArray(safePersisted.releaseSelectedFilters) ? safePersisted.releaseSelectedFilters : [], releaseSearchQuery: typeof safePersisted.releaseSearchQuery === 'string' ? safePersisted.releaseSearchQuery : '', discoveryChannels: (() => { @@ -1270,6 +1273,22 @@ export const useAppStore = create()( markReleaseAsRead: (releaseId) => set((state) => { const newReadReleases = new Set(state.readReleases); newReadReleases.add(releaseId); + + // In 'latest' mode, marking the latest release as read also marks all other releases of that repo + if (state.releaseLatestMode === 'latest') { + const markedRelease = state.releases.find(r => r.id === releaseId); + if (markedRelease) { + const repoId = markedRelease.repository.id; + const repoReleases = state.releases.filter(r => r.repository.id === repoId); + const latestRepoRelease = repoReleases.reduce((latest, r) => + new Date(r.published_at) > new Date(latest.published_at) ? r : latest + , repoReleases[0]); + if (latestRepoRelease && latestRepoRelease.id === releaseId) { + repoReleases.forEach(r => newReadReleases.add(r.id)); + } + } + } + return { readReleases: newReadReleases }; }), markAllReleasesAsRead: () => set((state) => { @@ -1569,6 +1588,7 @@ export const useAppStore = create()( // Release Timeline View actions setReleaseViewMode: (releaseViewMode) => set({ releaseViewMode }), setReleaseShowMode: (releaseShowMode) => set({ releaseShowMode }), + setReleaseLatestMode: (releaseLatestMode) => set({ releaseLatestMode }), setReleaseSelectedFilters: (releaseSelectedFilters) => set({ releaseSelectedFilters }), toggleReleaseSelectedFilter: (filterId) => set((state) => ({ releaseSelectedFilters: state.releaseSelectedFilters.includes(filterId) diff --git a/src/types/index.ts b/src/types/index.ts index 57cc731..63cb672 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -325,6 +325,7 @@ export interface AppState { // Release Timeline View releaseViewMode: 'timeline' | 'repository'; releaseShowMode: 'all' | 'unread'; + releaseLatestMode: 'all' | 'latest'; releaseSelectedFilters: string[]; releaseSearchQuery: string; releaseExpandedRepositories: Set; From f36e04763a8d3945422d37147b2446fe6e5167a8 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 11 Jun 2026 19:37:12 +0800 Subject: [PATCH 2/2] fix: address CodeRabbit and Gemini review findings - Add releaseLatestMode to initial state (prevents undefined before hydration) - Add releaseLatestMode to partialize block (enables persistence) - Optimize Date comparisons: use ISO 8601 string comparison instead of Date objects --- src/components/ReleaseTimeline.tsx | 2 +- src/store/useAppStore.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/ReleaseTimeline.tsx b/src/components/ReleaseTimeline.tsx index 4073984..ddb61ef 100644 --- a/src/components/ReleaseTimeline.tsx +++ b/src/components/ReleaseTimeline.tsx @@ -293,7 +293,7 @@ export const ReleaseTimeline: React.FC = () => { for (const item of preUnreadFilteredReleases) { const repoId = item.release.repository.id; const existing = repoMap.get(repoId); - if (!existing || new Date(item.release.published_at) > new Date(existing.release.published_at)) { + if (!existing || item.release.published_at > existing.release.published_at) { repoMap.set(repoId, item); } } diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 73bb3f6..d1f5f39 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -938,6 +938,7 @@ export const useAppStore = create()( readmeModalOpen: false, releaseViewMode: 'timeline', releaseShowMode: 'all', + releaseLatestMode: 'all', releaseSelectedFilters: [], releaseSearchQuery: '', releaseExpandedRepositories: new Set(), @@ -1281,7 +1282,7 @@ export const useAppStore = create()( const repoId = markedRelease.repository.id; const repoReleases = state.releases.filter(r => r.repository.id === repoId); const latestRepoRelease = repoReleases.reduce((latest, r) => - new Date(r.published_at) > new Date(latest.published_at) ? r : latest + r.published_at > latest.published_at ? r : latest , repoReleases[0]); if (latestRepoRelease && latestRepoRelease.id === releaseId) { repoReleases.forEach(r => newReadReleases.add(r.id)); @@ -1801,6 +1802,7 @@ export const useAppStore = create()( // 持久化Release页面视图设置 releaseViewMode: state.releaseViewMode, releaseShowMode: state.releaseShowMode, + releaseLatestMode: state.releaseLatestMode, releaseSelectedFilters: state.releaseSelectedFilters, releaseSearchQuery: state.releaseSearchQuery, releaseExpandedRepositories: Array.from(state.releaseExpandedRepositories),