Skip to content

Commit 27caa0c

Browse files
authored
feat: stabilize notification order during interactions (#2298)
* feat: stabilize notification order during notification interactions Signed-off-by: Adam Setch <[email protected]> * feat: stabilize notification order during notification interactions Signed-off-by: Adam Setch <[email protected]> * feat: stabilize notification order during notification interactions Signed-off-by: Adam Setch <[email protected]> * feat: stabilize notification order during notification interactions Signed-off-by: Adam Setch <[email protected]> * feat: stabilize notification order during notification interactions Signed-off-by: Adam Setch <[email protected]> --------- Signed-off-by: Adam Setch <[email protected]>
1 parent 0f7f59f commit 27caa0c

File tree

6 files changed

+113
-29
lines changed

6 files changed

+113
-29
lines changed

src/renderer/components/notifications/AccountNotifications.tsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
openGitHubIssues,
1414
openGitHubPulls,
1515
} from '../../utils/links';
16+
import {
17+
groupNotificationsByRepository,
18+
isGroupByRepository,
19+
} from '../../utils/notifications/group';
1620
import { AllRead } from '../AllRead';
1721
import { AvatarWithFallback } from '../avatars/AvatarWithFallback';
1822
import { Oops } from '../Oops';
@@ -31,28 +35,26 @@ interface IAccountNotifications {
3135
export const AccountNotifications: FC<IAccountNotifications> = (
3236
props: IAccountNotifications,
3337
) => {
34-
const { account, showAccountHeader, notifications } = props;
38+
const { account, showAccountHeader, error, notifications } = props;
3539

3640
const { settings } = useContext(AppContext);
3741

3842
const [showAccountNotifications, setShowAccountNotifications] =
3943
useState(true);
4044

41-
const groupedNotifications = Object.values(
42-
notifications.reduce(
43-
(acc: { [key: string]: Notification[] }, notification) => {
44-
const key = notification.repository.full_name;
45-
if (!acc[key]) {
46-
acc[key] = [];
47-
}
48-
49-
acc[key].push(notification);
50-
return acc;
51-
},
52-
{},
53-
),
45+
const sortedNotifications = useMemo(
46+
() => [...notifications].sort((a, b) => a.order - b.order),
47+
[notifications],
5448
);
5549

50+
const groupedNotifications = useMemo(() => {
51+
const map = groupNotificationsByRepository([
52+
{ account, error, notifications: sortedNotifications },
53+
]);
54+
55+
return Array.from(map.values());
56+
}, [account, error, sortedNotifications]);
57+
5658
const hasNotifications = useMemo(
5759
() => notifications.length > 0,
5860
[notifications],
@@ -68,8 +70,6 @@ export const AccountNotifications: FC<IAccountNotifications> = (
6870
'account',
6971
);
7072

71-
const isGroupByRepository = settings.groupBy === 'REPOSITORY';
72-
7373
return (
7474
<>
7575
{showAccountHeader && (
@@ -130,7 +130,7 @@ export const AccountNotifications: FC<IAccountNotifications> = (
130130
<>
131131
{props.error && <Oops error={props.error} fullHeight={false} />}
132132
{!hasNotifications && !props.error && <AllRead fullHeight={false} />}
133-
{isGroupByRepository
133+
{isGroupByRepository(settings)
134134
? Object.values(groupedNotifications).map((repoNotifications) => {
135135
const repoSlug = repoNotifications[0].repository.full_name;
136136

@@ -142,7 +142,7 @@ export const AccountNotifications: FC<IAccountNotifications> = (
142142
/>
143143
);
144144
})
145-
: notifications.map((notification) => (
145+
: sortedNotifications.map((notification) => (
146146
<NotificationRow
147147
key={notification.id}
148148
notification={notification}

src/renderer/routes/Notifications.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,19 @@ export const NotificationsRoute: FC = () => {
3939
return (
4040
<Page testId="notifications">
4141
<Contents paddingHorizontal={false}>
42-
{notifications.map((accountNotifications) => (
43-
<AccountNotifications
44-
account={accountNotifications.account}
45-
error={accountNotifications.error}
46-
key={getAccountUUID(accountNotifications.account)}
47-
notifications={accountNotifications.notifications}
48-
showAccountHeader={
49-
hasMultipleAccounts || settings.showAccountHeader
50-
}
51-
/>
52-
))}
42+
{notifications.map((accountNotification) => {
43+
return (
44+
<AccountNotifications
45+
account={accountNotification.account}
46+
error={accountNotification.error}
47+
key={getAccountUUID(accountNotification.account)}
48+
notifications={accountNotification.notifications}
49+
showAccountHeader={
50+
hasMultipleAccounts || settings.showAccountHeader
51+
}
52+
/>
53+
);
54+
})}
5355
</Contents>
5456
</Page>
5557
);

src/renderer/typesGitHub.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export interface GitHubNotification {
112112
// Note: This is not in the official GitHub API. We add this to make notification interactions easier.
113113
export interface GitifyNotification {
114114
account: Account;
115+
order: number;
115116
}
116117

117118
export type UserDetails = User & UserProfile;

src/renderer/utils/api/__mocks__/response-mocks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const mockNotificationUser: User = {
4545
export const mockGitHubNotifications: Notification[] = [
4646
{
4747
account: mockGitHubCloudAccount,
48+
order: 0,
4849
id: '138661096',
4950
unread: true,
5051
reason: 'subscribed',
@@ -197,6 +198,7 @@ export const mockGitHubNotifications: Notification[] = [
197198
},
198199
{
199200
account: mockGitHubCloudAccount,
201+
order: 1,
200202
id: '148827438',
201203
unread: true,
202204
reason: 'author',
@@ -260,6 +262,7 @@ export const mockGitHubNotifications: Notification[] = [
260262
export const mockEnterpriseNotifications: Notification[] = [
261263
{
262264
account: mockGitHubEnterpriseServerAccount,
265+
order: 0,
263266
id: '3',
264267
unread: true,
265268
reason: 'subscribed',
@@ -316,6 +319,7 @@ export const mockEnterpriseNotifications: Notification[] = [
316319
},
317320
{
318321
account: mockGitHubEnterpriseServerAccount,
322+
order: 1,
319323
id: '4',
320324
unread: true,
321325
reason: 'subscribed',
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { AccountNotifications, SettingsState } from '../../types';
2+
import type { Notification } from '../../typesGitHub';
3+
4+
/**
5+
* Returns true when settings say to group by repository.
6+
*/
7+
export function isGroupByRepository(settings: SettingsState) {
8+
return settings.groupBy === 'REPOSITORY';
9+
}
10+
11+
/**
12+
* Group notifications by repository.full_name preserving first-seen repo order.
13+
* Returns a Map where keys are repo full_names and values are arrays of notifications.
14+
*/
15+
export function groupNotificationsByRepository(
16+
accounts: AccountNotifications[],
17+
): Map<string, Notification[]> {
18+
const repoGroups = new Map<string, Notification[]>();
19+
20+
for (const account of accounts) {
21+
for (const notification of account.notifications) {
22+
const repo = notification.repository?.full_name ?? '';
23+
const group = repoGroups.get(repo);
24+
25+
if (group) {
26+
group.push(notification);
27+
} else {
28+
repoGroups.set(repo, [notification]);
29+
}
30+
}
31+
}
32+
33+
return repoGroups;
34+
}
35+
36+
/**
37+
* Returns a flattened, ordered Notification[] according to repository-first-seen order
38+
* (when grouped) or the natural account->notification order otherwise.
39+
*/
40+
export function getFlattenedNotificationsByRepo(
41+
accounts: AccountNotifications[],
42+
settings: SettingsState,
43+
): Notification[] {
44+
if (isGroupByRepository(settings)) {
45+
const repoGroups = groupNotificationsByRepository(accounts);
46+
return Array.from(repoGroups.values()).flat();
47+
}
48+
49+
return accounts.flatMap((a) => a.notifications);
50+
}

src/renderer/utils/notifications/notifications.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
filterBaseNotifications,
1313
filterDetailedNotifications,
1414
} from './filters/filter';
15+
import { getFlattenedNotificationsByRepo } from './group';
1516
import { createNotificationHandler } from './handlers';
1617

1718
export function setTrayIconColor(notifications: AccountNotifications[]) {
@@ -90,6 +91,9 @@ export async function getAllNotifications(
9091
}),
9192
);
9293

94+
// Set the order property for the notifications
95+
stabilizeNotificationsOrder(notifications, state.settings);
96+
9397
return notifications;
9498
}
9599

@@ -141,3 +145,26 @@ export async function enrichNotification(
141145
},
142146
};
143147
}
148+
149+
/**
150+
* Assign an order property to each notification to stabilize how they are displayed
151+
* during notification interaction events (mark as read, mark as done, etc.)
152+
*
153+
* @param notifications
154+
* @param settings
155+
*/
156+
export function stabilizeNotificationsOrder(
157+
notifications: AccountNotifications[],
158+
settings: SettingsState,
159+
) {
160+
const flattenedNotifications = getFlattenedNotificationsByRepo(
161+
notifications,
162+
settings,
163+
);
164+
165+
let orderIndex = 0;
166+
167+
for (const n of flattenedNotifications) {
168+
n.order = orderIndex++;
169+
}
170+
}

0 commit comments

Comments
 (0)