diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 63ad1f1c0..15f057f96 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -43,6 +43,7 @@ const mockNotificationSettings: NotificationSettingsState = { showPills: true, showNumber: true, participating: false, + showReadNotifications: false, markAsDoneOnOpen: false, markAsDoneOnUnsubscribe: false, delayNotificationState: false, diff --git a/src/renderer/components/notifications/NotificationRow.test.tsx b/src/renderer/components/notifications/NotificationRow.test.tsx index 14ed55fc6..58e22aadc 100644 --- a/src/renderer/components/notifications/NotificationRow.test.tsx +++ b/src/renderer/components/notifications/NotificationRow.test.tsx @@ -154,6 +154,24 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { expect(markNotificationsAsReadMock).toHaveBeenCalledTimes(1); }); + it('should hide mark as read button when notification is already read', async () => { + const readNotification = { + ...mockSingleNotification, + unread: false, + }; + + const props = { + notification: readNotification, + account: mockGitHubCloudAccount, + }; + + renderWithAppContext(); + + expect( + screen.queryByTestId('notification-mark-as-read'), + ).not.toBeInTheDocument(); + }); + it('should mark notifications as done', async () => { const markNotificationsAsDoneMock = jest.fn(); diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx index c158689c6..6f61ea8ba 100644 --- a/src/renderer/components/notifications/NotificationRow.tsx +++ b/src/renderer/components/notifications/NotificationRow.tsx @@ -148,6 +148,7 @@ export const NotificationRow: FC = ({ - - - - - - - - - - { ); }); + it('should toggle the showReadNotifications checkbox', async () => { + await act(async () => { + renderWithAppContext(, { + updateSetting: updateSettingMock, + }); + }); + + await userEvent.click(screen.getByTestId('checkbox-showReadNotifications')); + + expect(updateSettingMock).toHaveBeenCalledTimes(1); + expect(updateSettingMock).toHaveBeenCalledWith( + 'showReadNotifications', + true, + ); + }); + it('should toggle the markAsDoneOnOpen checkbox', async () => { await act(async () => { renderWithAppContext(, { diff --git a/src/renderer/components/settings/NotificationSettings.tsx b/src/renderer/components/settings/NotificationSettings.tsx index 66bb02de7..a042e08fd 100644 --- a/src/renderer/components/settings/NotificationSettings.tsx +++ b/src/renderer/components/settings/NotificationSettings.tsx @@ -328,6 +328,31 @@ export const NotificationSettings: FC = () => { } /> + + updateSetting('showReadNotifications', evt.target.checked) + } + tooltip={ + + + When checked, {APPLICATION.NAME} will fetch + and display both read and unread notifications. + + + When unchecked, {APPLICATION.NAME} will only + fetch and display unread notifications. + + + ⚠️ Enabling this setting will increase API usage and may cause + rate limiting for users with many notifications. + + + } + /> + + + + + Show read notifications + + + + + + + @@ -1988,7 +2037,7 @@ exports[`renderer/routes/Settings.tsx should render itself & its children 1`] = data-wrap="nowrap" > Accounts diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 7939504a5..cff635f35 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -109,6 +109,7 @@ export interface NotificationSettingsState { showPills: boolean; showNumber: boolean; participating: boolean; + showReadNotifications: boolean; markAsDoneOnOpen: boolean; markAsDoneOnUnsubscribe: boolean; delayNotificationState: boolean; diff --git a/src/renderer/utils/api/client.test.ts b/src/renderer/utils/api/client.test.ts index 0b16d0467..967a5f43c 100644 --- a/src/renderer/utils/api/client.test.ts +++ b/src/renderer/utils/api/client.test.ts @@ -75,6 +75,44 @@ describe('renderer/utils/api/client.ts', () => { data: {}, }); }); + + it('should include all=true when showReadNotifications is enabled', async () => { + const mockSettings: Partial = { + participating: false, + showReadNotifications: true, + }; + + await listNotificationsForAuthenticatedUser( + mockGitHubCloudAccount, + mockSettings as SettingsState, + ); + + expect(axios).toHaveBeenCalledWith({ + url: 'https://api.github.com/notifications?participating=false&all=true', + headers: mockNonCachedAuthHeaders, + method: 'GET', + data: {}, + }); + }); + + it('should not include all parameter when showReadNotifications is disabled', async () => { + const mockSettings: Partial = { + participating: false, + showReadNotifications: false, + }; + + await listNotificationsForAuthenticatedUser( + mockGitHubCloudAccount, + mockSettings as SettingsState, + ); + + expect(axios).toHaveBeenCalledWith({ + url: 'https://api.github.com/notifications?participating=false', + headers: mockNonCachedAuthHeaders, + method: 'GET', + data: {}, + }); + }); }); it('markNotificationThreadAsRead - should mark notification thread as read', async () => { diff --git a/src/renderer/utils/api/client.ts b/src/renderer/utils/api/client.ts index 7806dc75b..be43454cc 100644 --- a/src/renderer/utils/api/client.ts +++ b/src/renderer/utils/api/client.ts @@ -67,6 +67,10 @@ export function listNotificationsForAuthenticatedUser( url.pathname += 'notifications'; url.searchParams.append('participating', String(settings.participating)); + if (settings.showReadNotifications) { + url.searchParams.append('all', 'true'); + } + return apiRequestAuth( url.toString() as Link, 'GET', diff --git a/src/renderer/utils/notifications/filters/filter.test.ts b/src/renderer/utils/notifications/filters/filter.test.ts index 3d53bcef6..e9ff59d33 100644 --- a/src/renderer/utils/notifications/filters/filter.test.ts +++ b/src/renderer/utils/notifications/filters/filter.test.ts @@ -63,11 +63,40 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { ]; describe('filterBaseNotifications', () => { + it('should show all notifications when showReadNotifications is enabled', () => { + const notifications = [ + { ...mockNotifications[0], unread: true }, + { ...mockNotifications[1], unread: false }, + ]; + const result = filterBaseNotifications(notifications, { + ...mockSettings, + showReadNotifications: true, + }); + + expect(result.length).toBe(2); + expect(result).toEqual(notifications); + }); + + it('should filter out read notifications when showReadNotifications is disabled', () => { + const notifications = [ + { ...mockNotifications[0], unread: true }, + { ...mockNotifications[1], unread: false }, + ]; + const result = filterBaseNotifications(notifications, { + ...mockSettings, + showReadNotifications: false, + }); + + expect(result.length).toBe(1); + expect(result[0].unread).toBe(true); + }); + it('should filter notifications by subject type when provided', async () => { mockNotifications[0].subject.type = 'Issue'; mockNotifications[1].subject.type = 'PullRequest'; const result = filterBaseNotifications(mockNotifications, { ...mockSettings, + showReadNotifications: true, filterSubjectTypes: ['Issue'], }); @@ -80,6 +109,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { mockNotifications[1].reason = 'manual'; const result = filterBaseNotifications(mockNotifications, { ...mockSettings, + showReadNotifications: true, filterReasons: ['manual'], }); @@ -90,6 +120,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('should filter notifications that match include organization', async () => { const result = filterBaseNotifications(mockNotifications, { ...mockSettings, + showReadNotifications: true, filterIncludeSearchTokens: ['org:gitify-app' as SearchToken], }); @@ -100,6 +131,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('should filter notifications that match exclude organization', async () => { const result = filterBaseNotifications(mockNotifications, { ...mockSettings, + showReadNotifications: true, filterExcludeSearchTokens: ['org:github' as SearchToken], }); @@ -110,6 +142,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('should filter notifications that match include repository', async () => { const result = filterBaseNotifications(mockNotifications, { ...mockSettings, + showReadNotifications: true, filterIncludeSearchTokens: ['repo:gitify-app/gitify' as SearchToken], }); @@ -120,6 +153,7 @@ describe('renderer/utils/notifications/filters/filter.ts', () => { it('should filter notifications that match exclude repository', async () => { const result = filterBaseNotifications(mockNotifications, { ...mockSettings, + showReadNotifications: true, filterExcludeSearchTokens: ['repo:github/github' as SearchToken], }); diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index a48619093..bc8b713b0 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -24,6 +24,11 @@ export function filterBaseNotifications( return notifications.filter((notification) => { let passesFilters = true; + // Filter out read notifications if showReadNotifications is disabled + if (!settings.showReadNotifications && !notification.unread) { + return false; + } + // Apply base qualifier include/exclude filters (org, repo, etc.) for (const qualifier of BASE_SEARCH_QUALIFIERS) { if (!passesFilters) {