diff --git a/src/components/ReleaseCard.test.tsx b/src/components/ReleaseCard.test.tsx new file mode 100644 index 00000000..a5845687 --- /dev/null +++ b/src/components/ReleaseCard.test.tsx @@ -0,0 +1,143 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import ReleaseCard from './ReleaseCard'; +import type { Release } from '../types'; + +vi.mock('../store/useAppStore', () => ({ + useAppStore: vi.fn(() => ({ + rpcDownloadConfig: { enabled: true, host: '', port: 6800 }, + backendApiSecret: null, + aiConfigs: [], + activeAIConfig: null, + })), +})); + +vi.mock('../hooks/useDialog', () => ({ + useDialog: () => ({ toast: vi.fn(), confirm: vi.fn() }), +})); + +vi.mock('../services/rpcDownloadService', () => ({ + // 永不 resolve,避免异步 setState 触发 act() 警告;点击行为本身是同步的 + sendToRpcDownload: vi.fn(() => new Promise(() => {})), +})); + +vi.mock('./MarkdownRenderer', () => ({ + default: () => null, +})); + +vi.mock('../services/aiService', () => ({ + AIService: vi.fn(), +})); + +const makeRelease = (id: number, overrides: Partial = {}): Release => ({ + id, + tag_name: `v${id}`, + name: `Release ${id}`, + body: null, + published_at: '2026-01-01T00:00:00.000Z', + html_url: `https://github.com/owner/repo/releases/tag/v${id}`, + assets: [ + { + id: 101, + name: 'app.dmg', + size: 1000, + download_count: 5, + browser_download_url: 'https://example.com/app.dmg', + content_type: 'application/octet-stream', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-02T00:00:00.000Z', + }, + ], + repository: { id: 1, full_name: 'owner/repo', name: 'repo' }, + ...overrides, +}); + +const renderCard = (props: Partial[0]> = {}) => { + const defaults: Parameters[0] = { + release: makeRelease(1, { updated_asset_ids: [101] }), + downloadLinks: [ + { name: 'app.dmg', url: 'https://example.com/app.dmg', size: 1000, downloadCount: 5, assetId: 101 }, + ], + isUnread: true, + isAssetsExpanded: true, + isReleaseNotesExpanded: false, + isFullContent: false, + truncatedBody: '', + matchesActiveFilters: () => true, + selectedFilters: [], + onToggleAssets: () => {}, + onToggleReleaseNotes: () => {}, + onToggleFullContent: () => {}, + onUnsubscribe: () => {}, + onMarkAsRead: () => {}, + onMarkAssetAsRead: () => {}, + language: 'zh', + formatFileSize: (bytes: number) => `${bytes} B`, + }; + return render(); +}; + +describe('ReleaseCard asset updated indicator', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows container-level and per-asset indicators from the same source (updated_asset_ids)', () => { + renderCard({ isUnread: true }); + // 容器级与资产级标识都会展示 + expect(screen.getAllByText('资产已更新')).toHaveLength(2); + }); + + it('shows no indicator once updated_asset_ids is cleared (release marked read)', () => { + renderCard({ + isUnread: false, + release: makeRelease(1, { updated_asset_ids: [] }), + }); + expect(screen.queryByText('资产已更新')).not.toBeInTheDocument(); + }); + + it('regression: asset updated_at newer than published_at alone shows no indicator', () => { + // makeRelease 的资产 updated_at 晚于 published_at,但没有 updated_asset_ids + // (即资产相对上次拉取未变化)时不得出现任何“资产已更新”标识。 + renderCard({ + isUnread: true, + release: makeRelease(1, { updated_asset_ids: undefined }), + }); + expect(screen.queryByText('资产已更新')).not.toBeInTheDocument(); + }); + + it('does not show the indicator for assets without an asset id (source code links)', () => { + renderCard({ + isUnread: true, + release: makeRelease(1, { updated_asset_ids: [101] }), + downloadLinks: [ + { name: 'Source code (v1.zip)', url: 'https://example.com/v1.zip', size: 0, downloadCount: 0 }, + ], + }); + // 容器级标识仍展示(updated_asset_ids 非空),但源码行没有 assetId,不展示资产级标识 + expect(screen.getAllByText('资产已更新')).toHaveLength(1); + }); + + it('marks only the clicked asset as read via onMarkAssetAsRead', () => { + const onMarkAssetAsRead = vi.fn(); + renderCard({ onMarkAssetAsRead }); + + const assetRow = screen.getByRole('button', { name: /app\.dmg/ }); + fireEvent.click(assetRow); + + expect(onMarkAssetAsRead).toHaveBeenCalledTimes(1); + expect(onMarkAssetAsRead).toHaveBeenCalledWith(101); + }); + + it('does not propagate the asset click to the release-level mark-as-read handler', () => { + const onMarkAsRead = vi.fn(); + const onMarkAssetAsRead = vi.fn(); + renderCard({ onMarkAsRead, onMarkAssetAsRead }); + + const assetRow = screen.getByRole('button', { name: /app\.dmg/ }); + fireEvent.click(assetRow); + + expect(onMarkAsRead).not.toHaveBeenCalled(); + expect(onMarkAssetAsRead).toHaveBeenCalledWith(101); + }); +}); \ No newline at end of file diff --git a/src/components/ReleaseCard.tsx b/src/components/ReleaseCard.tsx index e4c04a83..ff082b6d 100644 --- a/src/components/ReleaseCard.tsx +++ b/src/components/ReleaseCard.tsx @@ -42,6 +42,7 @@ interface ReleaseCardProps { onToggleFullContent: (e: React.MouseEvent) => void; onUnsubscribe: () => void; onMarkAsRead: () => void; + onMarkAssetAsRead: (assetId: number) => void; language: 'zh' | 'en'; formatFileSize: (bytes: number) => string; } @@ -61,13 +62,14 @@ const ReleaseCard: React.FC = memo(({ onToggleFullContent, onUnsubscribe, onMarkAsRead, + onMarkAssetAsRead, language, formatFileSize, }) => { const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); const effectiveTime = effectiveReleaseTime(release); - const showAssetsUpdatedIndicator = shouldShowAssetsUpdatedIndicator(release, isUnread); + const showAssetsUpdatedIndicator = shouldShowAssetsUpdatedIndicator(release); // RPC download support — use refs to avoid stale closure in async handler const { rpcDownloadConfig, backendApiSecret, aiConfigs, activeAIConfig } = useAppStore(); @@ -365,8 +367,7 @@ const ReleaseCard: React.FC = memo(({ const isRpcEnabled = rpcDownloadConfig.enabled; const isDownloading = downloadingRef.current[link.url]; const isDownloaded = downloadedRef.current[link.url]; - const isAssetUpdated = isUnread - && link.assetId !== undefined + const isAssetUpdated = link.assetId !== undefined && release.updated_asset_ids?.includes(link.assetId) === true; if (isRpcEnabled) { @@ -375,6 +376,7 @@ const ReleaseCard: React.FC = memo(({ key={index} onClick={(e) => { e.stopPropagation(); + if (link.assetId !== undefined) onMarkAssetAsRead(link.assetId); handleRpcDownload(link); }} disabled={isDownloading || isDownloaded} @@ -422,7 +424,10 @@ const ReleaseCard: React.FC = memo(({ className={`flex items-center justify-between px-4 py-3 hover:bg-light-surface dark:hover:bg-white/[0.06] transition-colors border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0 ${ link.isSourceCode ? 'bg-gray-100 dark:bg-white/[0.04]' : '' }`} - onClick={(e) => e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + if (link.assetId !== undefined) onMarkAssetAsRead(link.assetId); + }} >
{link.isSourceCode ? ( diff --git a/src/components/ReleaseTimeline.tsx b/src/components/ReleaseTimeline.tsx index d5f9f35b..b97afb89 100644 --- a/src/components/ReleaseTimeline.tsx +++ b/src/components/ReleaseTimeline.tsx @@ -42,6 +42,7 @@ export const ReleaseTimeline: React.FC = () => { addReleases, upsertReleases, markReleaseAsRead, + markAssetAsRead, markAllReleasesAsRead, batchUnsubscribeReleases, removeReleasesByRepoFullName, @@ -1241,6 +1242,7 @@ export const ReleaseTimeline: React.FC = () => { onToggleFullContent={(e) => toggleFullContent(release.id, e)} onUnsubscribe={() => handleUnsubscribeRelease(release.repository.id)} onMarkAsRead={() => markReleaseAsRead(release.id)} + onMarkAssetAsRead={markAssetAsRead} language={language} formatFileSize={formatFileSize} /> @@ -1254,8 +1256,12 @@ export const ReleaseTimeline: React.FC = () => { const latestEffectiveTime = latestUpdatedRelease ? effectiveReleaseTime(latestUpdatedRelease) : null; - const latestAssetsUpdated = latestUpdatedRelease !== null - && shouldShowAssetsUpdatedIndicator(latestUpdatedRelease, isReleaseUnread(latestUpdatedRelease.id)); + // 仓库分组头部的“资产已更新”与资产行共用同一事实来源(updated_asset_ids), + // 只要组内任一 Release 存在未清除的资产级标识就展示,避免“头部有标识、 + // 展开后无任何资产行带标识”的不一致。 + const latestAssetsUpdated = releases.some( + ({ release }) => shouldShowAssetsUpdatedIndicator(release) + ); return (
@@ -1341,6 +1347,7 @@ export const ReleaseTimeline: React.FC = () => { onToggleFullContent={(e) => toggleFullContent(release.id, e)} onUnsubscribe={() => handleUnsubscribeRelease(release.repository.id)} onMarkAsRead={() => markReleaseAsRead(release.id)} + onMarkAssetAsRead={markAssetAsRead} language={language} formatFileSize={formatFileSize} /> diff --git a/src/store/useAppStore.test.ts b/src/store/useAppStore.test.ts index c4c5e488..12eac27e 100644 --- a/src/store/useAppStore.test.ts +++ b/src/store/useAppStore.test.ts @@ -69,30 +69,30 @@ describe('useAppStore release source settings', () => { }); }); -describe('useAppStore release add/upsert actions', () => { - const makeRelease = (id: number, overrides: Partial = {}): Release => ({ - id, - tag_name: `v${id}`, - name: `Release ${id}`, - body: null, - published_at: '2026-01-01T00:00:00.000Z', - html_url: `https://github.com/owner/repo/releases/tag/v${id}`, - assets: [ - { - id: 100 + id, - name: 'app.dmg', - size: 1000, - download_count: 0, - browser_download_url: 'https://example.com/app.dmg', - content_type: 'application/octet-stream', - created_at: '2026-01-01T00:00:00.000Z', - updated_at: '2026-01-01T00:00:00.000Z', - }, - ], - repository: { id: 1, full_name: 'owner/repo', name: 'repo' }, - ...overrides, - }); +const makeRelease = (id: number, overrides: Partial = {}): Release => ({ + id, + tag_name: `v${id}`, + name: `Release ${id}`, + body: null, + published_at: '2026-01-01T00:00:00.000Z', + html_url: `https://github.com/owner/repo/releases/tag/v${id}`, + assets: [ + { + id: 100 + id, + name: 'app.dmg', + size: 1000, + download_count: 0, + browser_download_url: 'https://example.com/app.dmg', + content_type: 'application/octet-stream', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }, + ], + repository: { id: 1, full_name: 'owner/repo', name: 'repo' }, + ...overrides, +}); +describe('useAppStore release add/upsert actions', () => { beforeEach(() => { useAppStore.setState({ releaseSourceSettings: defaultReleaseSourceSettings, @@ -178,12 +178,102 @@ describe('useAppStore release add/upsert actions', () => { expect(merged.is_read).toBe(false); expect(merged.updated_asset_ids).toEqual([101, 999]); expect(isUnread()).toBe(true); - expect(shouldShowAssetsUpdatedIndicator(merged, isUnread())).toBe(true); + expect(shouldShowAssetsUpdatedIndicator(merged)).toBe(true); - // 用户再次点击该条 release → 已读,"资产已更新"消失 + // 用户再次点击该条 release → 已读,"资产已更新"随 updated_asset_ids 清空而消失 useAppStore.getState().markReleaseAsRead(1); expect(isUnread()).toBe(false); - expect(shouldShowAssetsUpdatedIndicator(merged, isUnread())).toBe(false); + const afterRead = useAppStore.getState().releases.find(r => r.id === 1)!; + expect(afterRead.updated_asset_ids).toEqual([]); + expect(shouldShowAssetsUpdatedIndicator(afterRead)).toBe(false); + }); + + it('regression: asset updated_at newer than published_at alone does not show the indicator', () => { + // GitHub 上资产几乎都在 Release 创建后上传(updated_at > published_at 常态), + // 只有"相对上次拉取发生了变化"(updated_asset_ids 非空)才允许展示标识。 + const publishedAt = '2026-01-01T00:00:00.000Z'; + useAppStore.getState().addReleases([makeRelease(1, { + published_at: publishedAt, + assets: [{ ...makeRelease(1).assets[0], updated_at: '2026-01-02T00:00:00.000Z' }], + })]); + + const fresh = useAppStore.getState().releases.find(r => r.id === 1)!; + expect(fresh.updated_asset_ids).toBeUndefined(); + expect(shouldShowAssetsUpdatedIndicator(fresh)).toBe(false); + }); +}); + +describe('useAppStore release asset read actions', () => { + beforeEach(() => { + useAppStore.setState({ + releaseSourceSettings: defaultReleaseSourceSettings, + releaseSubscriptions: new Set(), + releases: [], + readReleases: new Set(), + }); + }); + + it('markAssetAsRead removes only the clicked asset id from updated_asset_ids', () => { + useAppStore.getState().addReleases([ + makeRelease(1, { updated_asset_ids: [101, 202, 303] }), + makeRelease(2, { updated_asset_ids: [404] }), + ]); + + useAppStore.getState().markAssetAsRead(202); + + const first = useAppStore.getState().releases.find(r => r.id === 1)!; + const second = useAppStore.getState().releases.find(r => r.id === 2)!; + expect(first.updated_asset_ids).toEqual([101, 303]); + expect(second.updated_asset_ids).toEqual([404]); + }); + + it('markAssetAsRead does not touch the release read state', () => { + useAppStore.getState().addReleases([makeRelease(1, { updated_asset_ids: [101] })]); + useAppStore.getState().markAssetAsRead(101); + + expect(useAppStore.getState().readReleases.has(1)).toBe(false); + expect(useAppStore.getState().releases[0].is_read).toBeUndefined(); + }); + + it('markAssetAsRead is a no-op for unknown asset ids', () => { + useAppStore.getState().addReleases([makeRelease(1, { updated_asset_ids: [101] })]); + useAppStore.getState().markAssetAsRead(999); + expect(useAppStore.getState().releases[0].updated_asset_ids).toEqual([101]); + }); + + it('markReleaseAsRead clears updated_asset_ids only on the clicked release', () => { + useAppStore.getState().addReleases([ + makeRelease(1, { updated_asset_ids: [101] }), + makeRelease(2, { updated_asset_ids: [202] }), + ]); + + useAppStore.getState().markReleaseAsRead(1); + + expect(useAppStore.getState().releases[0].updated_asset_ids).toEqual([]); + expect(useAppStore.getState().releases[1].updated_asset_ids).toEqual([202]); + }); + + it('markAllReleasesAsRead clears updated_asset_ids and sets is_read across releases', () => { + useAppStore.getState().addReleases([ + makeRelease(1, { updated_asset_ids: [101, 202] }), + makeRelease(2, { updated_asset_ids: [404] }), + ]); + + useAppStore.getState().markAllReleasesAsRead(); + + expect(useAppStore.getState().readReleases.has(1)).toBe(true); + expect(useAppStore.getState().readReleases.has(2)).toBe(true); + expect(useAppStore.getState().releases[0].updated_asset_ids).toEqual([]); + expect(useAppStore.getState().releases[1].updated_asset_ids).toEqual([]); + expect(useAppStore.getState().releases[0].is_read).toBe(true); + expect(useAppStore.getState().releases[1].is_read).toBe(true); + }); + + it('markAllReleasesAsRead leaves releases without updated_asset_ids untouched', () => { + useAppStore.getState().addReleases([makeRelease(1)]); + useAppStore.getState().markAllReleasesAsRead(); + expect(useAppStore.getState().releases[0].updated_asset_ids).toBeUndefined(); + expect(useAppStore.getState().releases[0].is_read).toBe(true); }); }); diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 7f420b74..7a1353eb 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -405,7 +405,10 @@ interface AppActions { batchUnsubscribeReleases: (repoIds: number[]) => void; removeReleasesByRepoId: (repoId: number) => void; removeReleasesByRepoFullName: (fullName: string) => void; + /** 标记 Release 已读;被标记的条目同时清空 updated_asset_ids(“资产已更新”标识随已读消失) */ markReleaseAsRead: (releaseId: number) => void; + /** 点击某条资产后清除其"资产已更新"标识(从 updated_asset_ids 移除),不影响 Release 级未读状态 */ + markAssetAsRead: (assetId: number) => void; markAllReleasesAsRead: () => void; setReleaseSourceSettings: (settings: ReleaseSourceSettings) => void; setReleaseEnabledSources: (sourceIds: ReleaseSourceId[]) => void; @@ -2066,6 +2069,11 @@ export const useAppStore = create()( const newReadReleases = new Set(state.readReleases); newReadReleases.add(releaseId); + // 点击 Release 即视为已看过其资产更新:被标记已读的条目同时清除资产级 + // “资产已更新”标识(updated_asset_ids)。仅改动确有标识的记录,避免 + // 每次点击都生成新的 releases 数组触发多余的重渲染与持久化。 + const clearedIds = new Set([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); @@ -2076,16 +2084,53 @@ export const useAppStore = create()( r.published_at > latest.published_at ? r : latest , repoReleases[0]); if (latestRepoRelease && latestRepoRelease.id === releaseId) { - repoReleases.forEach(r => newReadReleases.add(r.id)); + repoReleases.forEach(r => { + newReadReleases.add(r.id); + clearedIds.add(r.id); + }); } } } - return { readReleases: newReadReleases }; + const releases = state.releases.some(r => clearedIds.has(r.id) && (r.updated_asset_ids?.length ?? 0) > 0) + ? state.releases.map(r => + clearedIds.has(r.id) && (r.updated_asset_ids?.length ?? 0) > 0 + ? { ...r, updated_asset_ids: [] } + : r + ) + : state.releases; + + return { readReleases: newReadReleases, releases }; }), + // 资产级已读:仅从 release.updated_asset_ids 移除该资产 id,不动 readReleases/is_read。 + // 无命中时直接返回,避免多余的重渲染与 autoSync 推送。 + markAssetAsRead: (assetId) => { + const state = get(); + const hasAsset = state.releases.some(release => release.updated_asset_ids?.includes(assetId)); + if (!hasAsset) return; + set((s) => ({ + releases: s.releases.map(release => + release.updated_asset_ids?.includes(assetId) + ? { ...release, updated_asset_ids: release.updated_asset_ids.filter(id => id !== assetId) } + : release + ), + })); + }, markAllReleasesAsRead: () => set((state) => { const allReleaseIds = new Set(state.releases.map(r => r.id)); - return { readReleases: allReleaseIds }; + // "全部已读"视为用户已看过所有更新:一并清除资产级"资产已更新"标识, + // 并同步记录上的 is_read=true,避免后续 autoSync 整表推送时用陈旧的 + // is_read:false 覆盖后端 mark-all-read 已置为已读的状态。 + const releases = state.releases.map(release => { + const hasBadges = !!release.updated_asset_ids && release.updated_asset_ids.length > 0; + if (release.is_read === true && !hasBadges) return release; + return { + ...release, + is_read: true, + ...(hasBadges ? { updated_asset_ids: [] } : {}), + }; + }); + return { readReleases: allReleaseIds, releases }; }), // Category actions diff --git a/src/utils/releaseAssets.test.ts b/src/utils/releaseAssets.test.ts index dc7faa07..c3c674ca 100644 --- a/src/utils/releaseAssets.test.ts +++ b/src/utils/releaseAssets.test.ts @@ -8,7 +8,6 @@ import { findReleasesWithChangedAssets, hasAssetsChanged, latestEffectiveRelease, - hasAssetsUpdatedAfterPublish, shouldShowAssetsUpdatedIndicator, } from './releaseAssets'; @@ -241,81 +240,26 @@ describe('latestEffectiveRelease', () => { }); }); -describe('hasAssetsUpdatedAfterPublish', () => { - const makeRelease = (overrides: Partial = {}): Release => ({ - id: 1, - tag_name: 'v1', - name: 'Release 1', - body: null, - published_at: '2026-01-01T00:00:00Z', - html_url: 'https://github.com/owner/repo/releases/tag/v1', - assets: [], - repository: { id: 1, full_name: 'owner/repo', name: 'repo' }, - ...overrides, - }); - - it('returns true when an asset is updated after publication', () => { - expect(hasAssetsUpdatedAfterPublish(makeRelease({ - assets: [makeAsset({ updated_at: '2026-01-01T00:00:00.001Z' })], - }))).toBe(true); - }); - - it('compares timestamps by time value across equivalent formats', () => { - expect(hasAssetsUpdatedAfterPublish(makeRelease({ - published_at: '2026-01-01T08:00:00+08:00', - assets: [makeAsset({ updated_at: '2026-01-01T00:00:00Z' })], - }))).toBe(false); - }); - - it('returns false when assets are unchanged or updated at publication time', () => { - expect(hasAssetsUpdatedAfterPublish(makeRelease({ - assets: [makeAsset({ updated_at: '2026-01-01T00:00:00Z' })], - }))).toBe(false); - expect(hasAssetsUpdatedAfterPublish(makeRelease())).toBe(false); - }); - - it('treats missing or invalid timestamps as unchanged', () => { - expect(hasAssetsUpdatedAfterPublish(makeRelease({ - published_at: 'not-a-date', - assets: [makeAsset({ updated_at: '2026-01-02T00:00:00Z' })], - }))).toBe(false); - expect(hasAssetsUpdatedAfterPublish(makeRelease({ - published_at: '2026-01-01T00:00:00Z', - assets: [makeAsset({ updated_at: 'not-a-date' })], - }))).toBe(false); - }); - - it('returns false when assets are missing', () => { - expect(hasAssetsUpdatedAfterPublish(makeRelease({ assets: undefined }))).toBe(false); - }); -}); - describe('shouldShowAssetsUpdatedIndicator', () => { - const release: Pick = { - published_at: '2026-01-01T00:00:00Z', - assets: [makeAsset({ updated_at: '2026-01-02T00:00:00Z' })], - }; - - it('shows the indicator only while the release is unread', () => { - expect(shouldShowAssetsUpdatedIndicator(release, true)).toBe(true); - expect(shouldShowAssetsUpdatedIndicator(release, false)).toBe(false); + it('shows the indicator only when there are undismissed updated asset ids', () => { + expect(shouldShowAssetsUpdatedIndicator({ updated_asset_ids: [101] })).toBe(true); + expect(shouldShowAssetsUpdatedIndicator({ updated_asset_ids: [101, 202] })).toBe(true); }); - it('does not show the indicator when no asset was updated after publication', () => { - expect(shouldShowAssetsUpdatedIndicator({ - published_at: '2026-01-01T00:00:00Z', - assets: [makeAsset({ updated_at: '2026-01-01T00:00:00Z' })], - }, true)).toBe(false); + it('does not show the indicator without updated asset ids', () => { + expect(shouldShowAssetsUpdatedIndicator({ updated_asset_ids: [] })).toBe(false); + expect(shouldShowAssetsUpdatedIndicator({})).toBe(false); + expect(shouldShowAssetsUpdatedIndicator({ updated_asset_ids: undefined })).toBe(false); }); - it('does not show the indicator when timestamps are invalid', () => { - expect(shouldShowAssetsUpdatedIndicator({ - published_at: 'not-a-date', - assets: [makeAsset({ updated_at: '2026-01-02T00:00:00Z' })], - }, true)).toBe(false); - expect(shouldShowAssetsUpdatedIndicator({ + it('does not infer the indicator from asset timestamps being newer than published_at', () => { + // 回归:GitHub 资产几乎都在 Release 创建后上传,updated_at > published_at 恒常见。 + // 该条件不代表“资产相对用户上次拉取发生了变化”,不得作为标识依据。 + const release: Pick = { published_at: '2026-01-01T00:00:00Z', - assets: [makeAsset({ updated_at: 'not-a-date' })], - }, true)).toBe(false); + assets: [makeAsset({ updated_at: '2026-01-02T00:00:00Z' })], + updated_asset_ids: undefined, + }; + expect(shouldShowAssetsUpdatedIndicator(release)).toBe(false); }); }); diff --git a/src/utils/releaseAssets.ts b/src/utils/releaseAssets.ts index 4d70107b..3d663445 100644 --- a/src/utils/releaseAssets.ts +++ b/src/utils/releaseAssets.ts @@ -124,27 +124,15 @@ export function latestEffectiveRelease -): boolean { - const publishedTime = new Date(release.published_at).getTime(); - if (Number.isNaN(publishedTime) || !Array.isArray(release.assets)) return false; - - return release.assets.some((asset) => { - const assetTime = new Date(asset.updated_at).getTime(); - return !Number.isNaN(assetTime) && assetTime > publishedTime; - }); -} - -/** - * “资产已更新”是未读更新提示的一部分;Release 被标记为已读后应立即隐藏该提示。 + * 判断 Release 容器是否应展示“资产已更新”标识。 + * + * 唯一事实来源是 `updated_asset_ids`:它只在增量刷新发现“资产相对上次拉取发生了变化” + * 时由 findReleasesWithChangedAssets 写入,且随用户逐条点击资产或点击 Release 而清除。 + * 不能用“资产 updated_at 晚于 published_at”推断——GitHub 上资产几乎总是在 Release + * 创建之后上传的,该条件对大多数 Release 恒为真,会造成大面积误报。 */ export function shouldShowAssetsUpdatedIndicator( - release: Pick, - isUnread: boolean + release: Pick ): boolean { - return isUnread && hasAssetsUpdatedAfterPublish(release); + return (release.updated_asset_ids?.length ?? 0) > 0; }