diff --git a/src/renderer/components/notifications/NotificationRow.test.tsx b/src/renderer/components/notifications/NotificationRow.test.tsx index 50d04e1eb..b16451430 100644 --- a/src/renderer/components/notifications/NotificationRow.test.tsx +++ b/src/renderer/components/notifications/NotificationRow.test.tsx @@ -2,14 +2,18 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../__helpers__/test-utils'; +import { mockGitHubEnterpriseServerAccount } from '../../__mocks__/account-mocks'; import { mockGiteaGitifyNotification, mockGitifyNotification, } from '../../__mocks__/notifications-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; +import { getNotificationFailureKey, useNotificationActionFailuresStore } from '../../stores'; + import { GroupBy } from '../../types'; +import { Errors } from '../../utils/core/errors'; import * as comms from '../../utils/system/comms'; import * as links from '../../utils/system/links'; import { NotificationRow, type NotificationRowProps } from './NotificationRow'; @@ -263,4 +267,183 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { expect(screen.queryByTestId('notification-unsubscribe-from-thread')).not.toBeInTheDocument(); }); }); + + describe('failure recovery', () => { + const failureKey = getNotificationFailureKey( + mockGitifyNotification.account, + mockGitifyNotification.id, + ); + + afterEach(() => { + useNotificationActionFailuresStore.getState().reset(); + }); + + it('shows hover actions in their normal (non-danger) state when there is no recorded failure', () => { + const props: NotificationRowProps = { + notification: mockGitifyNotification, + isRepositoryAnimatingExit: false, + }; + + renderWithProviders(); + + expect(screen.getByTestId('notification-mark-as-read')).toHaveAttribute( + 'title', + 'Mark as read', + ); + }); + + it('styles and explains only the action that failed', () => { + const props: NotificationRowProps = { + notification: mockGitifyNotification, + isRepositoryAnimatingExit: false, + }; + + useNotificationActionFailuresStore.getState().setFailure(failureKey, { + action: 'markAsRead', + error: Errors.ACTION_FORBIDDEN, + }); + renderWithProviders(); + + const markAsReadButton = screen.getByTestId('notification-mark-as-read'); + + // The row's actions remain available - the row is not in a broken state + expect(markAsReadButton).toBeInTheDocument(); + expect(markAsReadButton).toHaveAttribute( + 'title', + expect.stringContaining(Errors.ACTION_FORBIDDEN.title), + ); + expect(markAsReadButton).toHaveAttribute( + 'title', + expect.stringContaining('You can also try opening this notification in the browser.'), + ); + expect(screen.getByTestId('notification-mark-as-done')).toHaveAttribute( + 'title', + 'Mark as done', + ); + expect(screen.getByTestId('notification-unsubscribe-from-thread')).toHaveAttribute( + 'title', + 'Unsubscribe from thread', + ); + }); + + it('isolates failures for notifications with the same id across accounts', () => { + const sameIdOtherAccount = { + ...mockGitifyNotification, + account: mockGitHubEnterpriseServerAccount, + }; + useNotificationActionFailuresStore.getState().setFailure(failureKey, { + action: 'markAsRead', + error: Errors.ACTION_FORBIDDEN, + }); + + renderWithProviders( + <> + + + , + ); + + const [failedButton, unaffectedButton] = screen.getAllByTestId('notification-mark-as-read'); + expect(failedButton).toHaveAttribute( + 'title', + expect.stringContaining(Errors.ACTION_FORBIDDEN.title), + ); + expect(unaffectedButton).toHaveAttribute('title', 'Mark as read'); + }); + + it('re-invokes the same action on click, acting as a retry, when a failure is recorded', async () => { + const markNotificationsAsDoneMock = vi.fn(); + + const props: NotificationRowProps = { + notification: mockGitifyNotification, + isRepositoryAnimatingExit: false, + }; + + useNotificationActionFailuresStore.getState().setFailure(failureKey, { + action: 'markAsDone', + error: Errors.ACTION_FORBIDDEN, + }); + renderWithProviders(, { + markNotificationsAsDone: markNotificationsAsDoneMock, + }); + + await userEvent.click(screen.getByTestId('notification-mark-as-done')); + + expect(markNotificationsAsDoneMock).toHaveBeenCalledTimes(1); + expect(markNotificationsAsDoneMock).toHaveBeenCalledWith([mockGitifyNotification]); + }); + + it('does not disable retrying even for a permanently-failing classification like ACTION_FORBIDDEN', async () => { + const markNotificationsAsReadMock = vi.fn(); + + const props: NotificationRowProps = { + notification: mockGitifyNotification, + isRepositoryAnimatingExit: false, + }; + + useNotificationActionFailuresStore.getState().setFailure(failureKey, { + action: 'markAsRead', + error: Errors.ACTION_FORBIDDEN, + }); + renderWithProviders(, { + markNotificationsAsRead: markNotificationsAsReadMock, + }); + + const markAsReadButton = screen.getByTestId('notification-mark-as-read'); + expect(markAsReadButton).toBeEnabled(); + + await userEvent.click(markAsReadButton); + + expect(markNotificationsAsReadMock).toHaveBeenCalledTimes(1); + }); + + it('gives a retry its own exit-animation cycle even though the previous failure is still recorded', async () => { + // Regression test: the revert logic reads the *current* failure store + // state after each action settles, rather than an effect keyed off a + // (possibly stale, still-present-from-the-previous-attempt) failure + // map - so a retry always gets to animate out and, if it fails again, + // animate back in, instead of being short-circuited immediately. + useNotificationActionFailuresStore.getState().setFailure(failureKey, { + action: 'markAsRead', + error: Errors.ACTION_FORBIDDEN, + }); + + let resolveRetry: () => void = () => {}; + const markNotificationsAsReadMock = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRetry = resolve; + }), + ); + + const props: NotificationRowProps = { + notification: mockGitifyNotification, + isRepositoryAnimatingExit: false, + }; + + renderWithProviders(, { + settings: { ...mockSettings, delayNotificationState: false, fetchReadNotifications: false }, + markNotificationsAsRead: markNotificationsAsReadMock, + }); + + await userEvent.click(screen.getByTestId('notification-mark-as-read')); + + // While the retry is still in flight, the row is animating out again - + // its hover actions are hidden, exactly like the very first attempt. + expect(screen.queryByTestId('notification-mark-as-read')).not.toBeInTheDocument(); + + // The retry fails again; the store still has a (new) failure entry for + // this notification once the mutation resolves. + useNotificationActionFailuresStore.getState().setFailure(failureKey, { + action: 'markAsRead', + error: Errors.ACTION_FORBIDDEN, + }); + resolveRetry(); + + await screen.findByTestId('notification-mark-as-read'); + }); + }); }); diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx index 2e1c478c1..4e0bf5777 100644 --- a/src/renderer/components/notifications/NotificationRow.tsx +++ b/src/renderer/components/notifications/NotificationRow.tsx @@ -4,7 +4,11 @@ import { BellSlashIcon, CheckIcon, ReadIcon } from '@primer/octicons-react'; import { Stack, Text, Tooltip } from '@primer/react'; import { useNotifications } from '../../hooks/useNotifications'; -import { useSettingsStore } from '../../stores'; +import { + getNotificationFailureKey, + useNotificationActionFailuresStore, + useSettingsStore, +} from '../../stores'; import { HoverButton } from '../primitives/HoverButton'; import { HoverGroup } from '../primitives/HoverGroup'; @@ -43,31 +47,50 @@ export const NotificationRow: FC = ({ const shouldAnimateExit = shouldRemoveNotificationsFromState(); - const actionNotificationInteraction = () => { + const notificationFailureKey = getNotificationFailureKey(notification.account, notification.id); + const failure = useNotificationActionFailuresStore( + (state) => state.failures[notificationFailureKey], + ); + + // Explains the failed action and suggests the browser as a fallback, + // rather than a dedicated retry control - clicking the (now red) hover + // action again re-attempts it. Phrased as "You can also..." rather than + // "...instead", since some descriptions already suggest waiting/retrying + // (e.g. `RATE_LIMITED`), which "instead" would read as contradicting. + const failureTooltip = failure + ? `${failure.error.title}: ${failure.error.descriptions.join(' ')} You can also try opening this notification in the browser.` + : undefined; + + // Starts the exit animation immediately, then reverts it if this specific + // action failed, checked directly against the failure store once it + // settles. Checking a stale value (e.g. via an effect watching the failure + // map) would wrongly revert a retry's animation using the previous + // attempt's still-present entry. + const runAction = async (action: () => Promise) => { setShouldAnimateNotificationExit(shouldAnimateExit); - openNotification(notification); - if (markAsDoneOnOpen) { - markNotificationsAsDone([notification]); - } else { - markNotificationsAsRead([notification]); + await action(); + + if (useNotificationActionFailuresStore.getState().failures[notificationFailureKey]) { + setShouldAnimateNotificationExit(false); } }; - const actionMarkAsDone = () => { - setShouldAnimateNotificationExit(shouldAnimateExit); - markNotificationsAsDone([notification]); - }; + const actionNotificationInteraction = () => { + openNotification(notification); - const actionMarkAsRead = () => { - setShouldAnimateNotificationExit(shouldAnimateExit); - markNotificationsAsRead([notification]); + runAction(() => + markAsDoneOnOpen + ? markNotificationsAsDone([notification]) + : markNotificationsAsRead([notification]), + ); }; - const actionUnsubscribeFromThread = () => { - setShouldAnimateNotificationExit(shouldAnimateExit); - unsubscribeNotification(notification); - }; + const actionMarkAsDone = () => runAction(() => markNotificationsAsDone([notification])); + + const actionMarkAsRead = () => runAction(() => markNotificationsAsRead([notification])); + + const actionUnsubscribeFromThread = () => runAction(() => unsubscribeNotification(notification)); const NotificationIcon = notification.display.icon.type; const isNotificationRead = !notification.unread; @@ -143,24 +166,35 @@ export const NotificationRow: FC = ({ action={actionMarkAsRead} enabled={!isNotificationRead} icon={ReadIcon} - label="Mark as read" + label={ + failure?.action === 'markAsRead' ? (failureTooltip ?? 'Mark as read') : 'Mark as read' + } testid="notification-mark-as-read" + variant={failure?.action === 'markAsRead' ? 'danger' : 'invisible'} /> )} diff --git a/src/renderer/components/notifications/RepositoryNotifications.test.tsx b/src/renderer/components/notifications/RepositoryNotifications.test.tsx index b2f521a24..121edc216 100644 --- a/src/renderer/components/notifications/RepositoryNotifications.test.tsx +++ b/src/renderer/components/notifications/RepositoryNotifications.test.tsx @@ -5,6 +5,8 @@ import { renderWithProviders } from '../../__helpers__/test-utils'; import { mockGitHubCloudGitifyNotifications } from '../../__mocks__/notifications-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; +import { getNotificationFailureKey, useNotificationActionFailuresStore } from '../../stores'; + import type { Link } from '../../types'; import * as comms from '../../utils/system/comms'; @@ -124,4 +126,46 @@ describe('renderer/components/notifications/RepositoryNotifications.tsx', () => const tree = renderWithProviders(); expect(tree.container).toMatchSnapshot(); }); + + describe('partial bulk failure', () => { + afterEach(() => { + useNotificationActionFailuresStore.getState().reset(); + }); + + it('reverts the group exit animation when a notification within the bulk action failed', async () => { + const props: RepositoryNotificationsProps = { + repoName: 'gitify-app/notifications-test', + repoNotifications: mockGitHubCloudGitifyNotifications, + }; + + const [, secondNotification] = mockGitHubCloudGitifyNotifications; + + // Simulate the mutation reconciliation that records a failure in the + // real (non-mocked) failure store, since `runGroupAction` reads + // directly from it rather than through the mocked `useNotifications` + // hook. + const markNotificationsAsReadWithFailure = vi.fn().mockImplementation(async () => { + const failureKey = getNotificationFailureKey( + secondNotification.account, + secondNotification.id, + ); + useNotificationActionFailuresStore.getState().setFailure(failureKey, { + action: 'markAsRead', + error: { title: 'Action Forbidden', descriptions: [], emojis: [] }, + }); + }); + + renderWithProviders(, { + settings: { ...mockSettings }, + markNotificationsAsRead: markNotificationsAsReadWithFailure, + }); + + await userEvent.click(screen.getByTestId('repository-mark-as-read')); + + // Since one of this group's notifications has a recorded failure, the + // repository row's own exit animation is reverted - its hover actions + // remain reachable rather than staying hidden. + expect(screen.getByTestId('repository-mark-as-read')).toBeInTheDocument(); + }); + }); }); diff --git a/src/renderer/components/notifications/RepositoryNotifications.tsx b/src/renderer/components/notifications/RepositoryNotifications.tsx index 989d14fdc..acc62c5f7 100644 --- a/src/renderer/components/notifications/RepositoryNotifications.tsx +++ b/src/renderer/components/notifications/RepositoryNotifications.tsx @@ -4,6 +4,7 @@ import { CheckIcon, ReadIcon } from '@primer/octicons-react'; import { Button, Stack } from '@primer/react'; import { useNotifications } from '../../hooks/useNotifications'; +import { getNotificationFailureKey, useNotificationActionFailuresStore } from '../../stores'; import { HoverButton } from '../primitives/HoverButton'; import { HoverGroup } from '../primitives/HoverGroup'; @@ -39,16 +40,30 @@ export const RepositoryNotifications: FC = ({ openRepository(repoNotifications[0].repository); }; - const actionMarkAsDone = () => { + // Starts the group's exit animation immediately, then reverts it if any + // notification in this bulk action failed, checked directly against the + // failure store once it settles (see `NotificationRow`'s `runAction` for + // why not a stale-state effect). There is no group-level rollup indicator; + // only the specific failed row(s) recolor their own hover actions. + const runGroupAction = async (action: () => Promise) => { setShouldAnimateRepositoryExit(shouldAnimateExit); - markNotificationsAsDone(repoNotifications); - }; - const actionMarkAsRead = () => { - setShouldAnimateRepositoryExit(shouldAnimateExit); - markNotificationsAsRead(repoNotifications); + await action(); + + const { failures } = useNotificationActionFailuresStore.getState(); + const hasFailure = repoNotifications.some( + (notification) => failures[getNotificationFailureKey(notification.account, notification.id)], + ); + + if (hasFailure) { + setShouldAnimateRepositoryExit(false); + } }; + const actionMarkAsDone = () => runGroupAction(() => markNotificationsAsDone(repoNotifications)); + + const actionMarkAsRead = () => runGroupAction(() => markNotificationsAsRead(repoNotifications)); + const actionToggleRepositoryNotifications = () => { setIsRepositoryNotificationsVisible(!isRepositoryNotificationsVisible); }; diff --git a/src/renderer/components/primitives/HoverButton.tsx b/src/renderer/components/primitives/HoverButton.tsx index 5071d1676..bbc3f82a6 100644 --- a/src/renderer/components/primitives/HoverButton.tsx +++ b/src/renderer/components/primitives/HoverButton.tsx @@ -3,16 +3,20 @@ import type { FC } from 'react'; import type { Icon } from '@primer/octicons-react'; import { IconButton } from '@primer/react'; +import type { VariantType } from '../../types'; + interface HoverButtonProps { label: string; icon: Icon; enabled?: boolean; testid: string; action: () => void; + variant?: VariantType; } export const HoverButton: FC = ({ enabled = true, + variant = 'invisible', ...props }: HoverButtonProps) => { return ( @@ -28,7 +32,7 @@ export const HoverButton: FC = ({ size="small" title={props.label} unsafeDisableTooltip={true} - variant="invisible" + variant={variant} /> ) ); diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index ad1d58c5d..8eb2b6f98 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -65,6 +65,7 @@ export const Constants = { EMOJIS: { ALL_READ: ['🎉', '🎊', '🥳', '👏', '🙌', '😎', '🏖️', '🚀', '✨', '🏆'], ERRORS: { + ACTION_FORBIDDEN: ['🚫'], BAD_CREDENTIALS: ['🔓'], MISSING_SCOPES: ['🔭'], NETWORK: ['🛜'], diff --git a/src/renderer/hooks/useNotifications.test.tsx b/src/renderer/hooks/useNotifications.test.tsx index 24a9c7443..4068f7619 100644 --- a/src/renderer/hooks/useNotifications.test.tsx +++ b/src/renderer/hooks/useNotifications.test.tsx @@ -8,12 +8,19 @@ import { mockGitHubEnterpriseServerAccount, } from '../__mocks__/account-mocks'; import { + mockGitHubCloudGitifyNotifications, mockGitifyNotification, mockMultipleAccountNotifications, mockSingleAccountNotifications, } from '../__mocks__/notifications-mocks'; -import { useAccountsStore, useFiltersStore, useSettingsStore } from '../stores'; +import { + getNotificationFailureKey, + useAccountsStore, + useFiltersStore, + useNotificationActionFailuresStore, + useSettingsStore, +} from '../stores'; import type { AccountNotifications, Percentage } from '../types'; @@ -87,6 +94,7 @@ describe('renderer/hooks/useNotifications.ts', () => { clearServerPollIntervals(); useAccountsStore.setState({ accounts: [mockGitHubCloudAccount] }); + useNotificationActionFailuresStore.getState().reset(); // Reset mock notification state between tests since it's mutated mockGitifyNotification.unread = true; @@ -479,6 +487,67 @@ describe('renderer/hooks/useNotifications.ts', () => { expect(rendererLogErrorSpy).toHaveBeenCalled(); }); + + it('rolls back the cache for a failed notification while a failed request does not affect it', async () => { + vi.spyOn(githubAdapter, 'markThreadAsRead').mockRejectedValue(new Error('boom')); + getAllNotificationsMock.mockResolvedValue(mockSingleAccountNotifications); + + const { result } = renderNotificationsHook(); + await waitFor(() => expect(result.current.hasNotifications).toBe(true)); + + await act(async () => { + await result.current.markNotificationsAsRead([mockGitifyNotification]).catch(() => {}); + }); + + // The notification remains in the cache since its action failed + await waitFor(() => expect(result.current.notificationCount).toBe(1)); + const failureKey = getNotificationFailureKey( + mockGitifyNotification.account, + mockGitifyNotification.id, + ); + expect(useNotificationActionFailuresStore.getState().failures[failureKey]).toBeDefined(); + }); + + it('tracks succeeded and failed notifications independently within a single bulk call', async () => { + const [succeedsNotification, failsNotification] = mockGitHubCloudGitifyNotifications; + + getAllNotificationsMock.mockResolvedValue([ + { + account: succeedsNotification.account, + notifications: [succeedsNotification, failsNotification], + error: null, + }, + ]); + + vi.spyOn(githubAdapter, 'markThreadAsRead').mockImplementation(async (_account, id) => { + if (id === failsNotification.id) { + throw new Error('boom'); + } + }); + + const { result } = renderNotificationsHook(); + await waitFor(() => expect(result.current.notificationCount).toBe(2)); + + await act(async () => { + await result.current + .markNotificationsAsRead([succeedsNotification, failsNotification]) + .catch(() => {}); + }); + + // The succeeded notification is removed; the failed one remains and is + // recorded in the failure map, not the other way around. + await waitFor(() => expect(result.current.notificationCount).toBe(1)); + expect( + result.current.notifications[0]?.notifications.some((n) => n.id === failsNotification.id), + ).toBe(true); + const failsKey = getNotificationFailureKey(failsNotification.account, failsNotification.id); + const succeedsKey = getNotificationFailureKey( + succeedsNotification.account, + succeedsNotification.id, + ); + expect(useNotificationActionFailuresStore.getState().failures[failsKey]).toBeDefined(); + expect(useNotificationActionFailuresStore.getState().failures[succeedsKey]).toBeUndefined(); + }); }); describe('markNotificationsAsDone', () => { @@ -528,6 +597,32 @@ describe('renderer/hooks/useNotifications.ts', () => { markAsDoneCapabilitySpy.mockRestore(); }); + + it('reconciles a failed mark-as-read fallback only once', async () => { + const markAsDoneCapabilitySpy = vi + .spyOn(githubAdapter.capabilities, 'markAsDone') + .mockReturnValue(false); + vi.spyOn(githubAdapter, 'markThreadAsRead').mockRejectedValue(new Error('boom')); + getAllNotificationsMock.mockResolvedValue(mockSingleAccountNotifications); + + const { result } = renderNotificationsHook(); + await waitFor(() => expect(result.current.hasNotifications).toBe(true)); + + await act(async () => { + await result.current.markNotificationsAsDone([mockGitifyNotification]); + }); + + const failureKey = getNotificationFailureKey( + mockGitifyNotification.account, + mockGitifyNotification.id, + ); + expect(rendererLogErrorSpy).toHaveBeenCalledTimes(1); + expect(useNotificationActionFailuresStore.getState().failures[failureKey]?.action).toBe( + 'markAsRead', + ); + + markAsDoneCapabilitySpy.mockRestore(); + }); }); describe('unsubscribeNotification', () => { @@ -583,6 +678,26 @@ describe('renderer/hooks/useNotifications.ts', () => { expect(markThreadAsDoneSpy).toHaveBeenCalledTimes(1); expect(markThreadAsReadSpy).not.toHaveBeenCalled(); }); + + it('keeps a failed follow-up action recorded after unsubscribe succeeds', async () => { + vi.spyOn(githubAdapter, 'unsubscribeThread').mockResolvedValue(undefined); + vi.spyOn(githubAdapter, 'markThreadAsRead').mockRejectedValue(new Error('boom')); + getAllNotificationsMock.mockResolvedValue(mockSingleAccountNotifications); + + const { result } = renderNotificationsHook(); + await waitFor(() => expect(result.current.hasNotifications).toBe(true)); + + await act(async () => { + await result.current.unsubscribeNotification(mockGitifyNotification); + }); + + const failureKey = getNotificationFailureKey( + mockGitifyNotification.account, + mockGitifyNotification.id, + ); + expect(useNotificationActionFailuresStore.getState().failures[failureKey]).toBeDefined(); + expect(result.current.notificationCount).toBe(1); + }); }); describe('removeAccountNotifications', () => { diff --git a/src/renderer/hooks/useNotifications.ts b/src/renderer/hooks/useNotifications.ts index d33ba797e..5cfd87f01 100644 --- a/src/renderer/hooks/useNotifications.ts +++ b/src/renderer/hooks/useNotifications.ts @@ -10,14 +10,21 @@ import { import { Constants } from '../constants'; -import { useAccountsStore, useFiltersStore, useSettingsStore } from '../stores'; - import { - type Account, - type AccountNotifications, - type GitifyError, - type GitifyNotification, - type Status, + type NotificationFailedActionType, + getNotificationFailureKey, + useAccountsStore, + useFiltersStore, + useNotificationActionFailuresStore, + useSettingsStore, +} from '../stores'; + +import type { + Account, + AccountNotifications, + GitifyError, + GitifyNotification, + Status, } from '../types'; import { isMarkAsDoneFeatureSupported, isUnsubscribeThreadSupported } from '../utils/api/features'; @@ -30,6 +37,12 @@ import { filterBaseNotifications, filterDetailedNotifications, } from '../utils/notifications/filters/filter'; +import { + type FailedNotificationAction, + restoreFailedNotifications, + settleNotificationActions, + type NotificationQuerySnapshot, +} from '../utils/notifications/mutations'; import { getAllNotifications, getNotificationCount, @@ -349,34 +362,147 @@ export const useNotifications = ({ notificationsQueryKey, ]); + // Session-local failure entries are independent of the notifications + // cache, so they must be pruned separately once a notification no longer + // appears in the (unfiltered) list - e.g. actioned successfully elsewhere, + // or its account was removed. Owned by the singleton side-effects host so + // it runs once per notifications update rather than once per mounted + // row/consumer. + useEffect(() => { + if (!withSideEffects) { + return; + } + + const unfilteredNotifications = + queryClient.getQueryData(notificationsQueryKey) || []; + + const currentNotificationKeys = unfilteredNotifications.flatMap((accountNotifications) => + accountNotifications.notifications.map((notification) => + getNotificationFailureKey(notification.account, notification.id), + ), + ); + + useNotificationActionFailuresStore.getState().pruneFailures(currentNotificationKeys); + }, [withSideEffects, notifications, queryClient, notificationsQueryKey]); + + // Shared by all three mutations' `onSuccess`. Records each failure's + // classified error in the session-local failure map and logs it; also + // re-applies `snapshot` for any failed notification missing from the + // cache, as a defensive guard (these mutations don't remove notifications + // until they succeed, so this is normally a no-op). + const reconcileFailedNotifications = useCallback( + ( + failed: FailedNotificationAction[], + snapshot: NotificationQuerySnapshot | undefined, + action: NotificationFailedActionType, + context: string, + ) => { + if (failed.length === 0) { + return; + } + + const failedNotifications = failed.map((f) => f.notification); + + if (snapshot) { + for (const [queryKey, snapshotData] of snapshot) { + queryClient.setQueryData(queryKey, (existing) => + restoreFailedNotifications(failedNotifications, snapshotData ?? [], existing ?? []), + ); + } + } + + for (const { notification, error, rawError } of failed) { + const notificationKey = getNotificationFailureKey(notification.account, notification.id); + useNotificationActionFailuresStore + .getState() + .setFailure(notificationKey, { action, error }); + rendererLogError( + context, + `Error occurred while processing notification ${notification.id}`, + rawError, + ); + } + }, + [queryClient], + ); + + // Full-cache restore for `onError`, covering the rare case where a + // mutation function throws directly (e.g. a bug) rather than resolving via + // `settleNotificationActions`. Per-notification failures are handled by + // `reconcileFailedNotifications` above instead. + const restoreSnapshot = useCallback( + (snapshot?: NotificationQuerySnapshot) => { + if (!snapshot) { + return; + } + + for (const [queryKey, data] of snapshot) { + queryClient.setQueryData(queryKey, data); + } + }, + [queryClient], + ); + + const snapshotNotifications = useCallback(async () => { + // Prevent an older in-flight poll from overwriting the mutation's eventual cache update. + await queryClient.cancelQueries({ queryKey: notificationsKeys.all }); + + const snapshot = queryClient.getQueriesData({ + queryKey: notificationsKeys.all, + }); + + return { snapshot }; + }, [queryClient]); + + const createMutationErrorHandler = useCallback( + (logContext: string, message: string) => + (err: Error, _variables: unknown, context?: { snapshot: NotificationQuerySnapshot }) => { + restoreSnapshot(context?.snapshot); + rendererLogError(logContext, message, toError(err)); + }, + [restoreSnapshot], + ); + const markNotificationsAsReadMutation = useMutation({ mutationFn: async ({ readNotifications }: { readNotifications: GitifyNotification[] }) => { - await Promise.all( - readNotifications.map((notification) => - getAdapter(notification.account).markThreadAsRead(notification.account, notification.id), - ), + return await settleNotificationActions(readNotifications, (notification) => + getAdapter(notification.account).markThreadAsRead(notification.account, notification.id), ); }, - onSuccess: (_, { readNotifications }) => { - // Update the cached (unfiltered) data in place so filtered-out - // notifications are preserved and concurrent mutations compose. - queryClient.setQueryData(notificationsQueryKey, (existing) => - removeNotificationsForAccount( - readNotifications[0].account, - readNotifications, - existing ?? [], - ), - ); - }, + onMutate: snapshotNotifications, + + onSuccess: ({ succeeded, failed }, _variables, context) => { + // Cache removal happens here (once the request resolves) rather than + // optimistically in `onMutate`, so the row's exit animation - started + // synchronously on click - has time to play before the notification + // disappears from the list. + if (succeeded.length > 0) { + queryClient.setQueryData(notificationsQueryKey, (existing) => + removeNotificationsForAccount(succeeded[0].account, succeeded, existing ?? []), + ); + } - onError: (err) => { - rendererLogError( + useNotificationActionFailuresStore + .getState() + .clearFailures( + succeeded.map((notification) => + getNotificationFailureKey(notification.account, notification.id), + ), + ); + + reconcileFailedNotifications( + failed, + context?.snapshot, + 'markAsRead', 'markNotificationsAsRead', - 'Error occurred while marking notifications as read', - toError(err), ); }, + + onError: createMutationErrorHandler( + 'markNotificationsAsRead', + 'Error occurred while marking notifications as read', + ), }); const markNotificationsAsDoneMutation = useMutation({ @@ -386,43 +512,49 @@ export const useNotifications = ({ // Forges that don't support a distinct "done" state fall back to // marking as read so the user-visible action still removes the thread. if (!isMarkAsDoneFeatureSupported(account)) { - await markNotificationsAsReadMutation.mutateAsync({ + return await markNotificationsAsReadMutation.mutateAsync({ readNotifications: doneNotifications, }); - return false; } - await Promise.all( - doneNotifications.map((notification) => - getAdapter(notification.account).markThreadAsDone(notification.account, notification.id), - ), + return await settleNotificationActions(doneNotifications, (notification) => + getAdapter(notification.account).markThreadAsDone(notification.account, notification.id), ); - - return true; }, - onSuccess: (didMarkAsDone, { doneNotifications }) => { - // The mark-as-read fallback already updated the cache. - if (!didMarkAsDone) { - return; + onMutate: snapshotNotifications, + + onSuccess: ({ succeeded, failed }, { doneNotifications }, context) => { + // The mark-as-read fallback (for forges without a distinct "done" + // state) already updated the cache via its own mutation/onSuccess. + if (succeeded.length > 0 && isMarkAsDoneFeatureSupported(doneNotifications[0].account)) { + queryClient.setQueryData(notificationsQueryKey, (existing) => + removeNotificationsForAccount(succeeded[0].account, succeeded, existing ?? []), + ); } - queryClient.setQueryData(notificationsQueryKey, (existing) => - removeNotificationsForAccount( - doneNotifications[0].account, - doneNotifications, - existing ?? [], - ), - ); + useNotificationActionFailuresStore + .getState() + .clearFailures( + succeeded.map((notification) => + getNotificationFailureKey(notification.account, notification.id), + ), + ); + + if (isMarkAsDoneFeatureSupported(doneNotifications[0].account)) { + reconcileFailedNotifications( + failed, + context?.snapshot, + 'markAsDone', + 'markNotificationsAsDone', + ); + } }, - onError: (err) => { - rendererLogError( - 'markNotificationsAsDone', - 'Error occurred while marking notifications as done', - toError(err), - ); - }, + onError: createMutationErrorHandler( + 'markNotificationsAsDone', + 'Error occurred while marking notifications as done', + ), }); const unsubscribeNotificationMutation = useMutation({ @@ -430,36 +562,53 @@ export const useNotifications = ({ // Forges without thread-subscription support cannot unsubscribe; the UI // already hides the action, but treat duplicate calls as no-ops. if (!isUnsubscribeThreadSupported(notification.account)) { - return; + return { succeeded: [notification], failed: [] }; } - await getAdapter(notification.account).unsubscribeThread( - notification.account, - notification.id, + const result = await settleNotificationActions([notification], (n) => + getAdapter(n.account).unsubscribeThread(n.account, n.id), ); - if (markAsDoneOnUnsubscribe) { - await markNotificationsAsDoneMutation.mutateAsync({ - doneNotifications: [notification], - }); - } else { - await markNotificationsAsReadMutation.mutateAsync({ - readNotifications: [notification], - }); + if (result.failed.length > 0) { + return result; } + + const followUp = markAsDoneOnUnsubscribe + ? await markNotificationsAsDoneMutation.mutateAsync({ + doneNotifications: [notification], + }) + : await markNotificationsAsReadMutation.mutateAsync({ + readNotifications: [notification], + }); + + return followUp.failed.length > 0 ? followUp : result; }, - onError: (err) => { - rendererLogError( + onMutate: snapshotNotifications, + + onSuccess: ({ succeeded, failed }, _variables, context) => { + useNotificationActionFailuresStore + .getState() + .clearFailures( + succeeded.map((notification) => + getNotificationFailureKey(notification.account, notification.id), + ), + ); + + reconcileFailedNotifications( + failed, + context?.snapshot, + 'unsubscribe', 'unsubscribeNotification', - 'Error occurred while unsubscribing from notification thread', - toError(err), ); }, + + onError: createMutationErrorHandler( + 'unsubscribeNotification', + 'Error occurred while unsubscribing from notification thread', + ), }); - // Mutation failures are logged via each mutation's onError handler and - // swallowed here so UI callers can fire-and-forget these actions. const markNotificationsAsRead = useCallback( async (readNotifications: GitifyNotification[]) => { await markNotificationsAsReadMutation.mutateAsync({ readNotifications }).catch(() => {}); diff --git a/src/renderer/stores/index.ts b/src/renderer/stores/index.ts index 3282752b9..7fcb033af 100644 --- a/src/renderer/stores/index.ts +++ b/src/renderer/stores/index.ts @@ -3,4 +3,8 @@ export * from './types'; export * from './defaults'; export { default as useAccountsStore } from './useAccountsStore'; export { default as useFiltersStore } from './useFiltersStore'; +export { + default as useNotificationActionFailuresStore, + getNotificationFailureKey, +} from './useNotificationActionFailuresStore'; export { default as useSettingsStore } from './useSettingsStore'; diff --git a/src/renderer/stores/types.ts b/src/renderer/stores/types.ts index e5502b999..e7ec8eeb6 100644 --- a/src/renderer/stores/types.ts +++ b/src/renderer/stores/types.ts @@ -3,6 +3,7 @@ import type { AccountUUID, FilterStateType, Forge, + GitifyError, Hostname, Reason, ReviewRequestType, @@ -196,3 +197,79 @@ export interface SettingsActions { * Complete settings store type. */ export type SettingsStore = SettingsState & SettingsActions; + +// ============================================================================ +// Notification Action Failures Store Types +// ============================================================================ + +/** + * The notification action that most recently failed for a given notification. + */ +export type NotificationFailedActionType = 'markAsRead' | 'markAsDone' | 'unsubscribe'; + +/** + * A recorded action failure for a single notification. + */ +export interface NotificationActionFailure { + /** + * The action that failed (used to know what to re-invoke on retry). + */ + action: NotificationFailedActionType; + + /** + * The classified error for the failure. + */ + error: GitifyError; +} + +/** + * Ephemeral, session-local state tracking per-notification action failures. + * + * Not persisted and not part of the TanStack Query cache - a failed + * mark-as-read/mark-as-done/unsubscribe action for a notification is + * recorded here so `NotificationRow` can recolor/re-label that row's hover + * actions, independent of the notification data itself. + */ +export interface NotificationActionFailuresState { + /** + * Map of notification ID to the details of its most recent failed action attempt. + */ + failures: Record; +} + +/** + * Actions for managing per-notification action failures. + */ +export interface NotificationActionFailuresActions { + /** + * Records a failed action for a notification. + */ + setFailure: (notificationKey: string, failure: NotificationActionFailure) => void; + + /** + * Clears a recorded failure for a notification (e.g. after a successful retry). + */ + clearFailure: (notificationKey: string) => void; + + /** + * Clears recorded failures for multiple notification keys. + */ + clearFailures: (notificationKeys: string[]) => void; + + /** + * Clears any recorded failures for notification IDs not present in `notificationIds` + * (e.g. once a notification no longer appears in the notifications list). + */ + pruneFailures: (notificationKeys: string[]) => void; + + /** + * Resets the store to its default (empty) state. + */ + reset: () => void; +} + +/** + * Complete notification action failures store type. + */ +export type NotificationActionFailuresStore = NotificationActionFailuresState & + NotificationActionFailuresActions; diff --git a/src/renderer/stores/useNotificationActionFailuresStore.ts b/src/renderer/stores/useNotificationActionFailuresStore.ts new file mode 100644 index 000000000..3cb19d77a --- /dev/null +++ b/src/renderer/stores/useNotificationActionFailuresStore.ts @@ -0,0 +1,73 @@ +import { create } from 'zustand'; + +import type { Account } from '../types'; +import type { NotificationActionFailuresStore } from './types'; + +import { getAccountUUID } from '../utils/auth/utils'; + +export function getNotificationFailureKey(account: Account, notificationId: string): string { + return `${getAccountUUID(account)}:${notificationId}`; +} + +/** + * Gitify Notification Action Failures store. + * + * Ephemeral, session-local state (not persisted, not part of the TanStack + * Query cache) tracking which notifications had their most recent + * mark-as-read/mark-as-done/unsubscribe action fail, and with what + * classified error. Cleared on successful retry or when a notification no + * longer appears in the notifications list. + */ +const useNotificationActionFailuresStore = create((set, get) => ({ + failures: {}, + + setFailure: (notificationKey, failure) => { + set((state) => ({ failures: { ...state.failures, [notificationKey]: failure } })); + }, + + clearFailure: (notificationKey) => { + const { failures } = get(); + if (!(notificationKey in failures)) { + return; + } + + const nextFailures = { ...failures }; + delete nextFailures[notificationKey]; + set({ failures: nextFailures }); + }, + + clearFailures: (notificationKeys) => { + const { failures } = get(); + const keysToClear = new Set(notificationKeys); + const remainingEntries = Object.entries(failures).filter( + ([notificationKey]) => !keysToClear.has(notificationKey), + ); + + if (remainingEntries.length === Object.keys(failures).length) { + return; + } + + set({ failures: Object.fromEntries(remainingEntries) }); + }, + + pruneFailures: (notificationKeys) => { + const { failures } = get(); + const keysToKeep = new Set(notificationKeys); + + const remainingEntries = Object.entries(failures).filter(([notificationKey]) => + keysToKeep.has(notificationKey), + ); + + if (remainingEntries.length === Object.keys(failures).length) { + return; + } + + set({ failures: Object.fromEntries(remainingEntries) }); + }, + + reset: () => { + set({ failures: {} }); + }, +})); + +export default useNotificationActionFailuresStore; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 85e61bbdb..95a02c52b 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -4,7 +4,7 @@ import type { Icon, OcticonProps } from '@primer/octicons-react'; import type { Button } from '@primer/react'; // Derived from public @primer/react component props rather than internal types -type VariantType = NonNullable['variant']>; +export type VariantType = NonNullable['variant']>; import type { AuthMethod, PlatformType } from './utils/auth/types'; @@ -241,6 +241,7 @@ export interface GitifyErrorAction { * The different types of errors which may be encountered. */ export type ErrorType = + | 'ACTION_FORBIDDEN' | 'BAD_CREDENTIALS' | 'MISSING_SCOPES' | 'NETWORK' diff --git a/src/renderer/utils/api/errors.test.ts b/src/renderer/utils/api/errors.test.ts index 897cf8795..99d944a2d 100644 --- a/src/renderer/utils/api/errors.test.ts +++ b/src/renderer/utils/api/errors.test.ts @@ -74,6 +74,22 @@ describe('renderer/utils/api/errors.ts', () => { expect(result).toBe(Errors.RATE_LIMITED); }); + it('action forbidden - unmatched 403', () => { + const mockError = new RequestError( + 'As an Enterprise Managed User, you cannot access this content', + 403, + { + request: { + method: 'GET', + url: 'https://api.github.com', + headers: {}, + }, + }, + ); + const result = determineFailureType(mockError); + expect(result).toBe(Errors.ACTION_FORBIDDEN); + }); + it('network error - no status', () => { const mockError = new RequestError('Network error', 500, { request: { diff --git a/src/renderer/utils/api/errors.ts b/src/renderer/utils/api/errors.ts index 96feafb27..f45047d1b 100644 --- a/src/renderer/utils/api/errors.ts +++ b/src/renderer/utils/api/errors.ts @@ -44,7 +44,7 @@ export function determineFailureType( return Errors.RATE_LIMITED; } - break; + return Errors.ACTION_FORBIDDEN; case 500: return Errors.NETWORK; default: diff --git a/src/renderer/utils/core/errors.ts b/src/renderer/utils/core/errors.ts index 8d58a7afe..fcc278b0e 100644 --- a/src/renderer/utils/core/errors.ts +++ b/src/renderer/utils/core/errors.ts @@ -5,6 +5,11 @@ import { Constants } from '../../constants'; import type { AccountNotifications, ErrorType, GitifyError } from '../../types'; export const Errors: Record = { + ACTION_FORBIDDEN: { + title: 'Action Forbidden', + descriptions: ['GitHub rejected this request for this account via Gitify.'], + emojis: Constants.EMOJIS.ERRORS.ACTION_FORBIDDEN, + }, BAD_CREDENTIALS: { title: 'Bad Credentials', descriptions: ['Your credentials are either invalid or expired.'], diff --git a/src/renderer/utils/notifications/mutations.test.ts b/src/renderer/utils/notifications/mutations.test.ts new file mode 100644 index 000000000..460c433b9 --- /dev/null +++ b/src/renderer/utils/notifications/mutations.test.ts @@ -0,0 +1,143 @@ +import { RequestError } from '@octokit/request-error'; + +import { + mockGitHubCloudGitifyNotifications, + mockGithubEnterpriseGitifyNotifications, +} from '../../__mocks__/notifications-mocks'; + +import type { AccountNotifications } from '../../types'; + +import { Errors } from '../core/errors'; +import { restoreFailedNotifications, settleNotificationActions } from './mutations'; + +describe('renderer/utils/notifications/mutations.ts', () => { + describe('settleNotificationActions', () => { + it('tracks all notifications as succeeded when every action resolves', async () => { + const notifications = mockGitHubCloudGitifyNotifications; + const action = vi.fn().mockResolvedValue(undefined); + + const result = await settleNotificationActions(notifications, action); + + expect(result.succeeded).toEqual(notifications); + expect(result.failed).toEqual([]); + expect(action).toHaveBeenCalledTimes(notifications.length); + }); + + it('tracks a partial failure within a bulk action independently', async () => { + const [first, second] = mockGitHubCloudGitifyNotifications; + const forbiddenError = new RequestError('Forbidden', 403, { + request: { method: 'GET', url: 'https://api.github.com', headers: {} }, + }); + + const action = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(forbiddenError); + + const result = await settleNotificationActions([first, second], action); + + expect(result.succeeded).toEqual([first]); + expect(result.failed).toHaveLength(1); + expect(result.failed[0].notification).toEqual(second); + expect(result.failed[0].error).toBe(Errors.ACTION_FORBIDDEN); + expect(result.failed[0].rawError).toBe(forbiddenError); + }); + + it('classifies each failure independently using determineFailureType', async () => { + const [first, second] = mockGitHubCloudGitifyNotifications; + + const action = vi + .fn() + .mockRejectedValueOnce( + new RequestError("Missing the 'notifications' scope", 403, { + request: { method: 'GET', url: 'https://api.github.com', headers: {} }, + }), + ) + .mockRejectedValueOnce( + new RequestError('Forbidden', 403, { + request: { method: 'GET', url: 'https://api.github.com', headers: {} }, + }), + ); + + const result = await settleNotificationActions([first, second], action); + + expect(result.succeeded).toEqual([]); + expect(result.failed[0].error).toBe(Errors.MISSING_SCOPES); + expect(result.failed[1].error).toBe(Errors.ACTION_FORBIDDEN); + }); + }); + + describe('restoreFailedNotifications', () => { + it('returns current data unchanged when there are no failed notifications', () => { + const current: AccountNotifications[] = [ + { account: mockGitHubCloudGitifyNotifications[0].account, notifications: [], error: null }, + ]; + + const result = restoreFailedNotifications([], current, current); + + expect(result).toBe(current); + }); + + it('restores a failed notification back into its account entry, preserving original data', () => { + const [first, second] = mockGitHubCloudGitifyNotifications; + const account = first.account; + + const snapshot: AccountNotifications[] = [ + { account, notifications: [first, second], error: null }, + ]; + + // `current` simulates both notifications already being absent from + // this account entry (e.g. removed by a prior cache update). + const current: AccountNotifications[] = [{ account, notifications: [], error: null }]; + + const result = restoreFailedNotifications([second], snapshot, current); + + expect(result[0].notifications).toEqual([second]); + }); + + it('leaves succeeded (still-removed) notifications out and keeps unrelated accounts untouched', () => { + const [first, second] = mockGitHubCloudGitifyNotifications; + const account = first.account; + const otherAccount = mockGithubEnterpriseGitifyNotifications[0].account; + + const snapshot: AccountNotifications[] = [ + { account, notifications: [first, second], error: null }, + { + account: otherAccount, + notifications: mockGithubEnterpriseGitifyNotifications, + error: null, + }, + ]; + + // `first` succeeded (absent from `current`), `second` failed and must + // be restored from `snapshot`. + const current: AccountNotifications[] = [ + { account, notifications: [], error: null }, + { + account: otherAccount, + notifications: mockGithubEnterpriseGitifyNotifications, + error: null, + }, + ]; + + const result = restoreFailedNotifications([second], snapshot, current); + + expect(result[0].notifications).toEqual([second]); + expect(result[1].notifications).toEqual(mockGithubEnterpriseGitifyNotifications); + }); + + it('preserves notifications added to current after the snapshot was taken', () => { + const [first, second] = mockGitHubCloudGitifyNotifications; + const concurrent = { ...first, id: 'concurrent-notification' }; + const account = first.account; + + const snapshot: AccountNotifications[] = [ + { account, notifications: [first, second], error: null }, + ]; + const current: AccountNotifications[] = [ + { account, notifications: [first, concurrent], error: null }, + ]; + + const result = restoreFailedNotifications([second], snapshot, current); + + expect(result[0].notifications).toEqual([first, concurrent, second]); + }); + }); +}); diff --git a/src/renderer/utils/notifications/mutations.ts b/src/renderer/utils/notifications/mutations.ts new file mode 100644 index 000000000..7d51b15b4 --- /dev/null +++ b/src/renderer/utils/notifications/mutations.ts @@ -0,0 +1,137 @@ +import type { QueryKey } from '@tanstack/react-query'; + +import type { AccountNotifications, GitifyError, GitifyNotification } from '../../types'; + +import { determineFailureType } from '../api/errors'; +import { getAccountUUID } from '../auth/utils'; +import { toError } from '../core/logger'; + +/** + * The classified outcome of a single notification's failed action request + * within a bulk/group mutation. + */ +export interface FailedNotificationAction { + notification: GitifyNotification; + error: GitifyError; + rawError: Error; +} + +/** + * The per-notification outcome of a bulk/group mutation: which notifications + * succeeded, and which failed (with their classified error). + */ +export interface SettledNotificationActions { + succeeded: GitifyNotification[]; + failed: FailedNotificationAction[]; +} + +/** + * A query key paired with its snapshotted `AccountNotifications[]` data, as + * returned by `queryClient.getQueriesData`. + */ +export type NotificationQuerySnapshot = [QueryKey, AccountNotifications[] | undefined][]; + +/** + * Run an action against each notification independently via + * `Promise.allSettled`, so one failing notification doesn't obscure the + * outcome of the rest of a bulk/group action. + * + * @param notifications The notifications to execute the action against. + * @param action The per-notification async action to execute. + * @returns The notifications that succeeded, and the notifications that + * failed alongside their classified error. + */ +export async function settleNotificationActions( + notifications: GitifyNotification[], + action: (notification: GitifyNotification) => Promise, +): Promise { + const results = await Promise.allSettled( + notifications.map((notification) => action(notification)), + ); + + const succeeded: GitifyNotification[] = []; + const failed: FailedNotificationAction[] = []; + + results.forEach((result, index) => { + const notification = notifications[index]; + + if (result.status === 'fulfilled') { + succeeded.push(notification); + return; + } + + const rawError = toError(result.reason); + failed.push({ + notification, + error: determineFailureType(rawError), + rawError, + }); + }); + + return { succeeded, failed }; +} + +/** + * Restore notifications that failed their action back into a query's cached + * data, using a pre-mutation snapshot as the source of truth for their + * original data (e.g. `unread` state, ordering). Notifications that + * succeeded or were otherwise already absent from `current` are left as-is. + * + * Cache changes for these mutations are applied in `onSuccess` (not + * optimistically), so in practice failed notifications are rarely absent + * from `current` to begin with - this exists as a defensive guard for any + * concurrent cache change that removed them in the meantime. + * + * @param failedNotifications The notifications whose action failed and should be present. + * @param snapshot The pre-mutation snapshot of `AccountNotifications[]` to restore from. + * @param current The current `AccountNotifications[]`. + * @returns A new `AccountNotifications[]` with failed notifications present. + */ +export function restoreFailedNotifications( + failedNotifications: GitifyNotification[], + snapshot: AccountNotifications[], + current: AccountNotifications[], +): AccountNotifications[] { + if (failedNotifications.length === 0) { + return current; + } + + const failedIdsByAccount = new Map>(); + for (const notification of failedNotifications) { + const accountKey = getAccountUUID(notification.account); + if (!failedIdsByAccount.has(accountKey)) { + failedIdsByAccount.set(accountKey, new Set()); + } + failedIdsByAccount.get(accountKey)?.add(notification.id); + } + + return current.map((accountEntry) => { + const accountKey = getAccountUUID(accountEntry.account); + const failedIds = failedIdsByAccount.get(accountKey); + + if (!failedIds) { + return accountEntry; + } + + const snapshotEntry = snapshot.find((entry) => getAccountUUID(entry.account) === accountKey); + + if (!snapshotEntry) { + return accountEntry; + } + + const currentIds = new Set(accountEntry.notifications.map((notification) => notification.id)); + + const missingNotifications = snapshotEntry.notifications.filter( + (notification) => failedIds.has(notification.id) && !currentIds.has(notification.id), + ); + + if (missingNotifications.length === 0) { + return accountEntry; + } + + return { + ...accountEntry, + notifications: [...accountEntry.notifications, ...missingNotifications], + }; + }); +}