Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 86 additions & 4 deletions src/components/ReleaseTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export const ReleaseTimeline: React.FC = () => {
setIncludePreRelease,
releaseShowMode,
setReleaseShowMode,
releaseLatestMode,
setReleaseLatestMode,
} = useAppStore();

const { toast, confirm } = useDialog();
Expand All @@ -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);

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<number, typeof preUnreadFilteredReleases[0]>();
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]);
Comment on lines +289 to +301

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since preUnreadFilteredReleases is already sorted in descending order by published_at (newest first), the first release encountered for each repository is guaranteed to be the latest one. We can simplify this logic by checking if the repository ID is already in the map, completely avoiding the overhead of parsing and comparing Date objects.

  const latestModeReleases = useMemo(() => {
    if (releaseLatestMode !== 'latest') return preUnreadFilteredReleases;

    const repoMap = new Map<number, typeof preUnreadFilteredReleases[0]>();
    for (const item of preUnreadFilteredReleases) {
      const repoId = item.release.repository.id;
      if (!repoMap.has(repoId)) {
        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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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')}
Expand Down Expand Up @@ -976,6 +1001,11 @@ export const ReleaseTimeline: React.FC = () => {
({t('已筛选', 'filtered')})
</span>
)}
{releaseLatestMode === 'latest' && (
<span className="text-sm text-brand-violet dark:text-brand-violet">
({t('仅最新', 'latest only')})
</span>
)}
</div>

<div className="flex flex-wrap items-center gap-3">
Expand All @@ -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')}
Expand Down Expand Up @@ -1029,6 +1060,57 @@ export const ReleaseTimeline: React.FC = () => {
)}
</div>

{/* Latest Mode Dropdown */}
<div className="relative">
<button
onClick={() => {
setIsLatestModeDropdownOpen(!isLatestModeDropdownOpen);
setIsViewDropdownOpen(false);
setIsShowModeDropdownOpen(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={releaseLatestMode === 'all' ? t('显示全部', 'Show All') : t('仅显示最新', 'Latest Only')}
>
<Package className="w-4 h-4 text-gray-700 dark:text-text-tertiary" />
<span className="text-sm font-medium text-gray-900 dark:text-text-secondary">
{releaseLatestMode === 'all' ? t('全部版本', 'All Versions') : t('仅最新', 'Latest')}
</span>
<ChevronDown className={`w-4 h-4 text-gray-500 dark:text-text-tertiary transition-transform ${isLatestModeDropdownOpen ? 'rotate-180' : ''}`} />
</button>

{isLatestModeDropdownOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsLatestModeDropdownOpen(false)} />
<div className="absolute left-0 mt-2 w-48 bg-white dark:bg-panel-dark rounded-lg shadow-lg border border-black/[0.06] dark:border-white/[0.04] z-50 py-1">
<button
onClick={() => handleLatestModeChange('all')}
className={`w-full flex items-center space-x-3 px-4 py-2.5 text-left hover:bg-light-surface dark:hover:bg-white/10 transition-colors ${
releaseLatestMode === 'all' ? 'bg-gray-100 dark:bg-white/[0.08] text-gray-900 dark:text-text-primary font-medium' : 'text-gray-700 dark:text-text-secondary'
}`}
>
<Package className={`w-4 h-4 ${releaseLatestMode === 'all' ? 'text-gray-900 dark:text-text-primary' : 'text-gray-500 dark:text-text-tertiary'}`} />
<div>
<div className="text-sm font-medium">{t('显示全部版本', 'Show All Versions')}</div>
<div className="text-xs text-gray-500 dark:text-text-tertiary">{t('显示所有Release记录', 'Show all release records')}</div>
</div>
</button>
<button
onClick={() => handleLatestModeChange('latest')}
className={`w-full flex items-center space-x-3 px-4 py-2.5 text-left hover:bg-light-surface dark:hover:bg-white/10 transition-colors ${
releaseLatestMode === 'latest' ? 'bg-gray-100 dark:bg-white/[0.08] text-gray-900 dark:text-text-primary font-medium' : 'text-gray-700 dark:text-text-secondary'
}`}
>
<Package className={`w-4 h-4 ${releaseLatestMode === 'latest' ? 'text-gray-900 dark:text-text-primary' : 'text-gray-500 dark:text-text-tertiary'}`} />
<div>
<div className="text-sm font-medium">{t('仅显示最新版本', 'Latest Version Only')}</div>
<div className="text-xs text-gray-500 dark:text-text-tertiary">{t('每个仓库仅显示最新Release', 'Show only the latest release per repo')}</div>
</div>
</button>
</div>
</>
)}
</div>

{/* Items per page selector */}
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-700 dark:text-text-tertiary">{t('每页:', 'Per page:')}</span>
Expand Down
20 changes: 20 additions & 0 deletions src/store/useAppStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -439,6 +440,7 @@ type PersistedAppState = Partial<
| 'forkExpandedRepositories'
| 'releaseViewMode'
| 'releaseShowMode'
| 'releaseLatestMode'
| 'releaseSelectedFilters'
| 'releaseSearchQuery'
| 'includePreRelease'
Expand Down Expand Up @@ -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: (() => {
Expand Down Expand Up @@ -1270,6 +1273,22 @@ export const useAppStore = create<AppState & AppActions>()(
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]);
Comment on lines +1284 to +1286

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since published_at is an ISO 8601 string (always returned in UTC format from the GitHub API), we can compare the strings directly instead of parsing them into Date objects on every iteration of reduce. This is much more efficient and cleaner.

Suggested change
const latestRepoRelease = repoReleases.reduce((latest, r) =>
new Date(r.published_at) > new Date(latest.published_at) ? r : latest
, repoReleases[0]);
const latestRepoRelease = repoReleases.reduce((latest, r) =>
r.published_at > 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) => {
Expand Down Expand Up @@ -1569,6 +1588,7 @@ export const useAppStore = create<AppState & AppActions>()(
// 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)
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
Expand Down
Loading