Skip to content

Commit 867d299

Browse files
committed
feat: stabilize notification order during notification interactions
Signed-off-by: Adam Setch <[email protected]>
1 parent ea570a9 commit 867d299

File tree

6 files changed

+112
-29
lines changed

6 files changed

+112
-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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ 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;
116+
groupName: string | null;
115117
}
116118

117119
export type UserDetails = User & UserProfile;

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export const mockNotificationUser: User = {
4545
export const mockGitHubNotifications: Notification[] = [
4646
{
4747
account: mockGitHubCloudAccount,
48+
groupName: null,
49+
order: 0,
4850
id: '138661096',
4951
unread: true,
5052
reason: 'subscribed',
@@ -197,6 +199,8 @@ export const mockGitHubNotifications: Notification[] = [
197199
},
198200
{
199201
account: mockGitHubCloudAccount,
202+
groupName: null,
203+
order: 1,
200204
id: '148827438',
201205
unread: true,
202206
reason: 'author',
@@ -260,6 +264,8 @@ export const mockGitHubNotifications: Notification[] = [
260264
export const mockEnterpriseNotifications: Notification[] = [
261265
{
262266
account: mockGitHubEnterpriseServerAccount,
267+
groupName: null,
268+
order: 0,
263269
id: '3',
264270
unread: true,
265271
reason: 'subscribed',
@@ -316,6 +322,8 @@ export const mockEnterpriseNotifications: Notification[] = [
316322
},
317323
{
318324
account: mockGitHubEnterpriseServerAccount,
325+
groupName: null,
326+
order: 1,
319327
id: '4',
320328
unread: true,
321329
reason: 'subscribed',
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { AccountNotifications, SettingsState } from '../../types';
2+
import type { Notification } from '../../typesGitHub';
3+
4+
export function isGroupByRepository(settings: SettingsState) {
5+
return settings.groupBy === 'REPOSITORY';
6+
}
7+
8+
/**
9+
* Group notifications by repository.full_name preserving first-seen repo order.
10+
* Returns a Map where keys are repo full_names and values are arrays of notifications.
11+
*/
12+
export function groupNotificationsByRepository(
13+
accounts: AccountNotifications[],
14+
): Map<string, Notification[]> {
15+
const repoGroups = new Map<string, Notification[]>();
16+
17+
for (const account of accounts) {
18+
for (const notification of account.notifications) {
19+
const repo = notification.repository?.full_name ?? '';
20+
const group = repoGroups.get(repo);
21+
22+
if (group) {
23+
group.push(notification);
24+
} else {
25+
repoGroups.set(repo, [notification]);
26+
}
27+
}
28+
}
29+
30+
return repoGroups;
31+
}
32+
33+
/**
34+
* Flatten the Map values into a single array, preserving Map insertion order (first-seen repo order).
35+
*/
36+
export function flattenRepoGroups(repoGroups: Map<string, Notification[]>) {
37+
return Array.from(repoGroups.values()).flat();
38+
}

src/renderer/utils/notifications/notifications.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import {
1212
filterBaseNotifications,
1313
filterDetailedNotifications,
1414
} from './filters/filter';
15+
import {
16+
flattenRepoGroups,
17+
groupNotificationsByRepository,
18+
isGroupByRepository,
19+
} from './group';
1520
import { createNotificationHandler } from './handlers';
1621

1722
export function setTrayIconColor(notifications: AccountNotifications[]) {
@@ -90,6 +95,9 @@ export async function getAllNotifications(
9095
}),
9196
);
9297

98+
// Set the order property for the notifications
99+
stabilizeNotificationsOrder(notifications, state.settings);
100+
93101
return notifications;
94102
}
95103

@@ -141,3 +149,28 @@ export async function enrichNotification(
141149
},
142150
};
143151
}
152+
153+
export function stabilizeNotificationsOrder(
154+
notifications: AccountNotifications[],
155+
settings: SettingsState,
156+
) {
157+
if (isGroupByRepository(settings)) {
158+
const repoGroups = groupNotificationsByRepository(notifications);
159+
const flattened = flattenRepoGroups(repoGroups);
160+
161+
let orderIndex = 0;
162+
163+
for (const n of flattened) {
164+
n.order = orderIndex++;
165+
}
166+
} else {
167+
// Non-repository grouping: assign sequential order across all notifications
168+
let orderIndex = 0;
169+
170+
notifications.forEach((accountNotifications) => {
171+
accountNotifications.notifications.forEach((notification) => {
172+
notification.order = orderIndex++;
173+
});
174+
});
175+
}
176+
}

0 commit comments

Comments
 (0)