Skip to content

Commit cfa12a2

Browse files
authored
fix: rollback failed notification interaction with visual warning (#3145)
Signed-off-by: Adam Setch <adam.setch@outlook.com>
1 parent e594f20 commit cfa12a2

17 files changed

Lines changed: 1102 additions & 101 deletions

src/renderer/components/notifications/NotificationRow.test.tsx

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ import { screen } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33

44
import { renderWithProviders } from '../../__helpers__/test-utils';
5+
import { mockGitHubEnterpriseServerAccount } from '../../__mocks__/account-mocks';
56
import {
67
mockGiteaGitifyNotification,
78
mockGitifyNotification,
89
} from '../../__mocks__/notifications-mocks';
910
import { mockSettings } from '../../__mocks__/state-mocks';
1011

12+
import { getNotificationFailureKey, useNotificationActionFailuresStore } from '../../stores';
13+
1114
import { GroupBy } from '../../types';
1215

16+
import { Errors } from '../../utils/core/errors';
1317
import * as comms from '../../utils/system/comms';
1418
import * as links from '../../utils/system/links';
1519
import { NotificationRow, type NotificationRowProps } from './NotificationRow';
@@ -263,4 +267,183 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => {
263267
expect(screen.queryByTestId('notification-unsubscribe-from-thread')).not.toBeInTheDocument();
264268
});
265269
});
270+
271+
describe('failure recovery', () => {
272+
const failureKey = getNotificationFailureKey(
273+
mockGitifyNotification.account,
274+
mockGitifyNotification.id,
275+
);
276+
277+
afterEach(() => {
278+
useNotificationActionFailuresStore.getState().reset();
279+
});
280+
281+
it('shows hover actions in their normal (non-danger) state when there is no recorded failure', () => {
282+
const props: NotificationRowProps = {
283+
notification: mockGitifyNotification,
284+
isRepositoryAnimatingExit: false,
285+
};
286+
287+
renderWithProviders(<NotificationRow {...props} />);
288+
289+
expect(screen.getByTestId('notification-mark-as-read')).toHaveAttribute(
290+
'title',
291+
'Mark as read',
292+
);
293+
});
294+
295+
it('styles and explains only the action that failed', () => {
296+
const props: NotificationRowProps = {
297+
notification: mockGitifyNotification,
298+
isRepositoryAnimatingExit: false,
299+
};
300+
301+
useNotificationActionFailuresStore.getState().setFailure(failureKey, {
302+
action: 'markAsRead',
303+
error: Errors.ACTION_FORBIDDEN,
304+
});
305+
renderWithProviders(<NotificationRow {...props} />);
306+
307+
const markAsReadButton = screen.getByTestId('notification-mark-as-read');
308+
309+
// The row's actions remain available - the row is not in a broken state
310+
expect(markAsReadButton).toBeInTheDocument();
311+
expect(markAsReadButton).toHaveAttribute(
312+
'title',
313+
expect.stringContaining(Errors.ACTION_FORBIDDEN.title),
314+
);
315+
expect(markAsReadButton).toHaveAttribute(
316+
'title',
317+
expect.stringContaining('You can also try opening this notification in the browser.'),
318+
);
319+
expect(screen.getByTestId('notification-mark-as-done')).toHaveAttribute(
320+
'title',
321+
'Mark as done',
322+
);
323+
expect(screen.getByTestId('notification-unsubscribe-from-thread')).toHaveAttribute(
324+
'title',
325+
'Unsubscribe from thread',
326+
);
327+
});
328+
329+
it('isolates failures for notifications with the same id across accounts', () => {
330+
const sameIdOtherAccount = {
331+
...mockGitifyNotification,
332+
account: mockGitHubEnterpriseServerAccount,
333+
};
334+
useNotificationActionFailuresStore.getState().setFailure(failureKey, {
335+
action: 'markAsRead',
336+
error: Errors.ACTION_FORBIDDEN,
337+
});
338+
339+
renderWithProviders(
340+
<>
341+
<NotificationRow
342+
isRepositoryAnimatingExit={false}
343+
notification={mockGitifyNotification}
344+
/>
345+
<NotificationRow isRepositoryAnimatingExit={false} notification={sameIdOtherAccount} />
346+
</>,
347+
);
348+
349+
const [failedButton, unaffectedButton] = screen.getAllByTestId('notification-mark-as-read');
350+
expect(failedButton).toHaveAttribute(
351+
'title',
352+
expect.stringContaining(Errors.ACTION_FORBIDDEN.title),
353+
);
354+
expect(unaffectedButton).toHaveAttribute('title', 'Mark as read');
355+
});
356+
357+
it('re-invokes the same action on click, acting as a retry, when a failure is recorded', async () => {
358+
const markNotificationsAsDoneMock = vi.fn();
359+
360+
const props: NotificationRowProps = {
361+
notification: mockGitifyNotification,
362+
isRepositoryAnimatingExit: false,
363+
};
364+
365+
useNotificationActionFailuresStore.getState().setFailure(failureKey, {
366+
action: 'markAsDone',
367+
error: Errors.ACTION_FORBIDDEN,
368+
});
369+
renderWithProviders(<NotificationRow {...props} />, {
370+
markNotificationsAsDone: markNotificationsAsDoneMock,
371+
});
372+
373+
await userEvent.click(screen.getByTestId('notification-mark-as-done'));
374+
375+
expect(markNotificationsAsDoneMock).toHaveBeenCalledTimes(1);
376+
expect(markNotificationsAsDoneMock).toHaveBeenCalledWith([mockGitifyNotification]);
377+
});
378+
379+
it('does not disable retrying even for a permanently-failing classification like ACTION_FORBIDDEN', async () => {
380+
const markNotificationsAsReadMock = vi.fn();
381+
382+
const props: NotificationRowProps = {
383+
notification: mockGitifyNotification,
384+
isRepositoryAnimatingExit: false,
385+
};
386+
387+
useNotificationActionFailuresStore.getState().setFailure(failureKey, {
388+
action: 'markAsRead',
389+
error: Errors.ACTION_FORBIDDEN,
390+
});
391+
renderWithProviders(<NotificationRow {...props} />, {
392+
markNotificationsAsRead: markNotificationsAsReadMock,
393+
});
394+
395+
const markAsReadButton = screen.getByTestId('notification-mark-as-read');
396+
expect(markAsReadButton).toBeEnabled();
397+
398+
await userEvent.click(markAsReadButton);
399+
400+
expect(markNotificationsAsReadMock).toHaveBeenCalledTimes(1);
401+
});
402+
403+
it('gives a retry its own exit-animation cycle even though the previous failure is still recorded', async () => {
404+
// Regression test: the revert logic reads the *current* failure store
405+
// state after each action settles, rather than an effect keyed off a
406+
// (possibly stale, still-present-from-the-previous-attempt) failure
407+
// map - so a retry always gets to animate out and, if it fails again,
408+
// animate back in, instead of being short-circuited immediately.
409+
useNotificationActionFailuresStore.getState().setFailure(failureKey, {
410+
action: 'markAsRead',
411+
error: Errors.ACTION_FORBIDDEN,
412+
});
413+
414+
let resolveRetry: () => void = () => {};
415+
const markNotificationsAsReadMock = vi.fn().mockImplementation(
416+
() =>
417+
new Promise<void>((resolve) => {
418+
resolveRetry = resolve;
419+
}),
420+
);
421+
422+
const props: NotificationRowProps = {
423+
notification: mockGitifyNotification,
424+
isRepositoryAnimatingExit: false,
425+
};
426+
427+
renderWithProviders(<NotificationRow {...props} />, {
428+
settings: { ...mockSettings, delayNotificationState: false, fetchReadNotifications: false },
429+
markNotificationsAsRead: markNotificationsAsReadMock,
430+
});
431+
432+
await userEvent.click(screen.getByTestId('notification-mark-as-read'));
433+
434+
// While the retry is still in flight, the row is animating out again -
435+
// its hover actions are hidden, exactly like the very first attempt.
436+
expect(screen.queryByTestId('notification-mark-as-read')).not.toBeInTheDocument();
437+
438+
// The retry fails again; the store still has a (new) failure entry for
439+
// this notification once the mutation resolves.
440+
useNotificationActionFailuresStore.getState().setFailure(failureKey, {
441+
action: 'markAsRead',
442+
error: Errors.ACTION_FORBIDDEN,
443+
});
444+
resolveRetry();
445+
446+
await screen.findByTestId('notification-mark-as-read');
447+
});
448+
});
266449
});

src/renderer/components/notifications/NotificationRow.tsx

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { BellSlashIcon, CheckIcon, ReadIcon } from '@primer/octicons-react';
44
import { Stack, Text, Tooltip } from '@primer/react';
55

66
import { useNotifications } from '../../hooks/useNotifications';
7-
import { useSettingsStore } from '../../stores';
7+
import {
8+
getNotificationFailureKey,
9+
useNotificationActionFailuresStore,
10+
useSettingsStore,
11+
} from '../../stores';
812

913
import { HoverButton } from '../primitives/HoverButton';
1014
import { HoverGroup } from '../primitives/HoverGroup';
@@ -43,31 +47,50 @@ export const NotificationRow: FC<NotificationRowProps> = ({
4347

4448
const shouldAnimateExit = shouldRemoveNotificationsFromState();
4549

46-
const actionNotificationInteraction = () => {
50+
const notificationFailureKey = getNotificationFailureKey(notification.account, notification.id);
51+
const failure = useNotificationActionFailuresStore(
52+
(state) => state.failures[notificationFailureKey],
53+
);
54+
55+
// Explains the failed action and suggests the browser as a fallback,
56+
// rather than a dedicated retry control - clicking the (now red) hover
57+
// action again re-attempts it. Phrased as "You can also..." rather than
58+
// "...instead", since some descriptions already suggest waiting/retrying
59+
// (e.g. `RATE_LIMITED`), which "instead" would read as contradicting.
60+
const failureTooltip = failure
61+
? `${failure.error.title}: ${failure.error.descriptions.join(' ')} You can also try opening this notification in the browser.`
62+
: undefined;
63+
64+
// Starts the exit animation immediately, then reverts it if this specific
65+
// action failed, checked directly against the failure store once it
66+
// settles. Checking a stale value (e.g. via an effect watching the failure
67+
// map) would wrongly revert a retry's animation using the previous
68+
// attempt's still-present entry.
69+
const runAction = async (action: () => Promise<void>) => {
4770
setShouldAnimateNotificationExit(shouldAnimateExit);
48-
openNotification(notification);
4971

50-
if (markAsDoneOnOpen) {
51-
markNotificationsAsDone([notification]);
52-
} else {
53-
markNotificationsAsRead([notification]);
72+
await action();
73+
74+
if (useNotificationActionFailuresStore.getState().failures[notificationFailureKey]) {
75+
setShouldAnimateNotificationExit(false);
5476
}
5577
};
5678

57-
const actionMarkAsDone = () => {
58-
setShouldAnimateNotificationExit(shouldAnimateExit);
59-
markNotificationsAsDone([notification]);
60-
};
79+
const actionNotificationInteraction = () => {
80+
openNotification(notification);
6181

62-
const actionMarkAsRead = () => {
63-
setShouldAnimateNotificationExit(shouldAnimateExit);
64-
markNotificationsAsRead([notification]);
82+
runAction(() =>
83+
markAsDoneOnOpen
84+
? markNotificationsAsDone([notification])
85+
: markNotificationsAsRead([notification]),
86+
);
6587
};
6688

67-
const actionUnsubscribeFromThread = () => {
68-
setShouldAnimateNotificationExit(shouldAnimateExit);
69-
unsubscribeNotification(notification);
70-
};
89+
const actionMarkAsDone = () => runAction(() => markNotificationsAsDone([notification]));
90+
91+
const actionMarkAsRead = () => runAction(() => markNotificationsAsRead([notification]));
92+
93+
const actionUnsubscribeFromThread = () => runAction(() => unsubscribeNotification(notification));
7194

7295
const NotificationIcon = notification.display.icon.type;
7396
const isNotificationRead = !notification.unread;
@@ -143,24 +166,35 @@ export const NotificationRow: FC<NotificationRowProps> = ({
143166
action={actionMarkAsRead}
144167
enabled={!isNotificationRead}
145168
icon={ReadIcon}
146-
label="Mark as read"
169+
label={
170+
failure?.action === 'markAsRead' ? (failureTooltip ?? 'Mark as read') : 'Mark as read'
171+
}
147172
testid="notification-mark-as-read"
173+
variant={failure?.action === 'markAsRead' ? 'danger' : 'invisible'}
148174
/>
149175

150176
<HoverButton
151177
action={actionMarkAsDone}
152178
enabled={isMarkAsDoneFeatureSupported(notification.account) && notification.unread}
153179
icon={CheckIcon}
154-
label="Mark as done"
180+
label={
181+
failure?.action === 'markAsDone' ? (failureTooltip ?? 'Mark as done') : 'Mark as done'
182+
}
155183
testid="notification-mark-as-done"
184+
variant={failure?.action === 'markAsDone' ? 'danger' : 'invisible'}
156185
/>
157186

158187
<HoverButton
159188
action={actionUnsubscribeFromThread}
160189
enabled={isUnsubscribeThreadSupported(notification.account)}
161190
icon={BellSlashIcon}
162-
label="Unsubscribe from thread"
191+
label={
192+
failure?.action === 'unsubscribe'
193+
? (failureTooltip ?? 'Unsubscribe from thread')
194+
: 'Unsubscribe from thread'
195+
}
163196
testid="notification-unsubscribe-from-thread"
197+
variant={failure?.action === 'unsubscribe' ? 'danger' : 'invisible'}
164198
/>
165199
</HoverGroup>
166200
)}

src/renderer/components/notifications/RepositoryNotifications.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { renderWithProviders } from '../../__helpers__/test-utils';
55
import { mockGitHubCloudGitifyNotifications } from '../../__mocks__/notifications-mocks';
66
import { mockSettings } from '../../__mocks__/state-mocks';
77

8+
import { getNotificationFailureKey, useNotificationActionFailuresStore } from '../../stores';
9+
810
import type { Link } from '../../types';
911

1012
import * as comms from '../../utils/system/comms';
@@ -124,4 +126,46 @@ describe('renderer/components/notifications/RepositoryNotifications.tsx', () =>
124126
const tree = renderWithProviders(<RepositoryNotifications {...props} />);
125127
expect(tree.container).toMatchSnapshot();
126128
});
129+
130+
describe('partial bulk failure', () => {
131+
afterEach(() => {
132+
useNotificationActionFailuresStore.getState().reset();
133+
});
134+
135+
it('reverts the group exit animation when a notification within the bulk action failed', async () => {
136+
const props: RepositoryNotificationsProps = {
137+
repoName: 'gitify-app/notifications-test',
138+
repoNotifications: mockGitHubCloudGitifyNotifications,
139+
};
140+
141+
const [, secondNotification] = mockGitHubCloudGitifyNotifications;
142+
143+
// Simulate the mutation reconciliation that records a failure in the
144+
// real (non-mocked) failure store, since `runGroupAction` reads
145+
// directly from it rather than through the mocked `useNotifications`
146+
// hook.
147+
const markNotificationsAsReadWithFailure = vi.fn().mockImplementation(async () => {
148+
const failureKey = getNotificationFailureKey(
149+
secondNotification.account,
150+
secondNotification.id,
151+
);
152+
useNotificationActionFailuresStore.getState().setFailure(failureKey, {
153+
action: 'markAsRead',
154+
error: { title: 'Action Forbidden', descriptions: [], emojis: [] },
155+
});
156+
});
157+
158+
renderWithProviders(<RepositoryNotifications {...props} />, {
159+
settings: { ...mockSettings },
160+
markNotificationsAsRead: markNotificationsAsReadWithFailure,
161+
});
162+
163+
await userEvent.click(screen.getByTestId('repository-mark-as-read'));
164+
165+
// Since one of this group's notifications has a recorded failure, the
166+
// repository row's own exit animation is reverted - its hover actions
167+
// remain reachable rather than staying hidden.
168+
expect(screen.getByTestId('repository-mark-as-read')).toBeInTheDocument();
169+
});
170+
});
127171
});

0 commit comments

Comments
 (0)