Repository: gitify-app/gitify
Pull request #565 (2023-05-12 18:24:10)

feat: color coding for PRs and issues
Adds support for similar-to-github color coding for issues and pull requests.

Unfortunately I did not find a way to do this without the extra calls, but it shouldn't be too bad.

![image](https://github.com/manosim/gitify/assets/19473034/969b3cf6-a455-42e1-a2e9-2d1c96b7ff3f)



Patch:
diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml
index 396ab286..c6d015f8 100644
--- a/.github/workflows/build-app.yml
+++ b/.github/workflows/build-app.yml
@@ -4,7 +4,6 @@ on:
   push:
     branches:
       - main
-      - 'release/v**'
   pull_request:
     branches:
       - main
@@ -14,7 +13,7 @@ jobs:
     runs-on: macos-latest
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Install Node.js
         uses: actions/setup-node@v3
@@ -65,7 +64,7 @@ jobs:
     runs-on: windows-latest
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Install Node.js
         uses: actions/setup-node@v3
@@ -113,7 +112,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Install Node.js
         uses: actions/setup-node@v3
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 28cde2a6..1b18bf81 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,16 +1,15 @@
 name: Release App
 
 on:
-  push:
-    branches:
-      - 'release/v**'
+  release:
+    types: [created]
 
 jobs:
   release-macos:
     runs-on: macos-latest
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Install Node.js
         uses: actions/setup-node@v3
@@ -60,7 +59,7 @@ jobs:
     runs-on: windows-latest
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Install Node.js
         uses: actions/setup-node@v3
@@ -105,7 +104,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Install Node.js
         uses: actions/setup-node@v3
diff --git a/src/__mocks__/mock-state.ts b/src/__mocks__/mock-state.ts
index 3b2f00ce..91043e1a 100644
--- a/src/__mocks__/mock-state.ts
+++ b/src/__mocks__/mock-state.ts
@@ -19,4 +19,5 @@ export const mockSettings: SettingsState = {
   markOnClick: false,
   openAtStartup: false,
   appearance: Appearance.SYSTEM,
+  colors: false,
 };
diff --git a/src/__mocks__/mockedData.ts b/src/__mocks__/mockedData.ts
index ab2616d3..7ce0f56b 100644
--- a/src/__mocks__/mockedData.ts
+++ b/src/__mocks__/mockedData.ts
@@ -26,6 +26,7 @@ export const mockedSingleNotification: Notification = {
     url: 'https://api.github.com/repos/manosim/notifications-test/issues/1',
     latest_comment_url: 'https://api.github.com/repos/manosim/notifications-test/issues/comments/302888448',
     type: 'Issue',
+    state: 'open',
   },
   repository: {
     id: 57216596,
diff --git a/src/components/NotificationRow.tsx b/src/components/NotificationRow.tsx
index 199bd3c6..7b88093f 100644
--- a/src/components/NotificationRow.tsx
+++ b/src/components/NotificationRow.tsx
@@ -2,7 +2,11 @@ import React, { useCallback, useContext } from 'react';
 import { formatDistanceToNow, parseISO } from 'date-fns';
 import { CheckIcon, MuteIcon } from '@primer/octicons-react';
 
-import { formatReason, getNotificationTypeIcon } from '../utils/github-api';
+import {
+  formatReason,
+  getNotificationTypeIcon,
+  getNotificationTypeIconColor,
+} from '../utils/github-api';
 import { openInBrowser } from '../utils/helpers';
 import { Notification } from '../typesGithub';
 import { AppContext } from '../context/App';
@@ -41,13 +45,17 @@ export const NotificationRow: React.FC<IProps> = ({
 
   const reason = formatReason(notification.reason);
   const NotificationIcon = getNotificationTypeIcon(notification.subject.type);
+  const iconColor = getNotificationTypeIconColor(notification.subject.state);
+  const realIconColor = settings
+    ? (settings.colors && iconColor) || ''
+    : iconColor;
   const updatedAt = formatDistanceToNow(parseISO(notification.updated_at), {
     addSuffix: true,
   });
 
   return (
     <div className="flex space-x-2 p-2 bg-white dark:bg-gray-dark dark:text-white hover:bg-gray-100 dark:hover:bg-gray-darker border-b border-gray-100 dark:border-gray-darker">
-      <div className="flex justify-center items-center w-8">
+      <div className={`flex justify-center items-center w-8 ${realIconColor}`}>
         <NotificationIcon size={18} aria-label={notification.subject.type} />
       </div>
 
diff --git a/src/context/App.tsx b/src/context/App.tsx
index de4eb624..6641e58b 100644
--- a/src/context/App.tsx
+++ b/src/context/App.tsx
@@ -37,6 +37,7 @@ export const defaultSettings: SettingsState = {
   markOnClick: false,
   openAtStartup: false,
   appearance: Appearance.SYSTEM,
+  colors: true,
 };
 
 interface AppContextState {
@@ -72,7 +73,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
     markNotification,
     unsubscribeNotification,
     markRepoNotifications,
-  } = useNotifications();
+  } = useNotifications(settings.colors);
 
   useEffect(() => {
     restoreSettings();
@@ -161,6 +162,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
 
     if (existing.settings) {
       setSettings({ ...defaultSettings, ...existing.settings });
+      return existing.settings;
     }
   }, []);
 
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 04265c08..a54dedc7 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -3,6 +3,7 @@ import { parse } from 'url';
 import { useCallback, useState } from 'react';
 
 import { AccountNotifications, AuthState, SettingsState } from '../types';
+import { Notification } from '../typesGithub';
 import { apiRequestAuth } from '../utils/api-requests';
 import {
   getEnterpriseAccountToken,
@@ -41,7 +42,7 @@ interface NotificationsState {
   requestFailed: boolean;
 }
 
-export const useNotifications = (): NotificationsState => {
+export const useNotifications = (colors: boolean): NotificationsState => {
   const [isFetching, setIsFetching] = useState(false);
   const [requestFailed, setRequestFailed] = useState(false);
   const [notifications, setNotifications] = useState<AccountNotifications[]>(
@@ -98,9 +99,70 @@ export const useNotifications = (): NotificationsState => {
                 ]
               : [...enterpriseNotifications];
 
-            triggerNativeNotifications(notifications, data, settings, accounts);
-            setNotifications(data);
-            setIsFetching(false);
+            if (!colors) {
+              setNotifications(data);
+              triggerNativeNotifications(
+                notifications,
+                data,
+                settings,
+                accounts
+              );
+              setIsFetching(false);
+              return;
+            }
+            axios
+              .all(
+                data.map(async (accountNotifications) => {
+                  return {
+                    hostname: accountNotifications.hostname,
+                    notifications: await axios.all<Notification>(
+                      accountNotifications.notifications.map(
+                        async (notification: Notification) => {
+                          if (
+                            notification.subject.type !== 'PullRequest' &&
+                            notification.subject.type !== 'Issue'
+                          ) {
+                            return notification;
+                          }
+
+                          const cardinalData = (
+                            await apiRequestAuth(
+                              notification.subject.url,
+                              'GET',
+                              accounts.token
+                            )
+                          ).data;
+
+                          const state =
+                            cardinalData.state === 'closed'
+                              ? cardinalData.state_reason ||
+                                (cardinalData.merged && 'merged') ||
+                                'closed'
+                              : (cardinalData.draft && 'draft') || 'open';
+
+                          return {
+                            ...notification,
+                            subject: {
+                              ...notification.subject,
+                              state,
+                            },
+                          };
+                        }
+                      )
+                    ),
+                  };
+                })
+              )
+              .then((parsedNotifications) => {
+                setNotifications(parsedNotifications);
+                triggerNativeNotifications(
+                  notifications,
+                  parsedNotifications,
+                  settings,
+                  accounts
+                );
+                setIsFetching(false);
+              });
           })
         )
         .catch(() => {
diff --git a/src/routes/Settings.tsx b/src/routes/Settings.tsx
index 883c28bb..24c14b11 100644
--- a/src/routes/Settings.tsx
+++ b/src/routes/Settings.tsx
@@ -71,6 +71,12 @@ export const SettingsRoute: React.FC = () => {
           }}
         />
 
+        <FieldCheckbox
+          name="colors"
+          label="Use colors to indicate state"
+          checked={settings.colors}
+          onChange={(evt) => updateSetting('colors', evt.target.checked)}
+        />
         <FieldCheckbox
           name="showOnlyParticipating"
           label="Show only participating"
diff --git a/src/types.ts b/src/types.ts
index 1d472051..d3e881b9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -13,6 +13,7 @@ export interface SettingsState {
   markOnClick: boolean;
   openAtStartup: boolean;
   appearance: Appearance;
+  colors: boolean;
 }
 
 export enum Appearance {
diff --git a/src/typesGithub.ts b/src/typesGithub.ts
index 3f69440c..3f4ac637 100644
--- a/src/typesGithub.ts
+++ b/src/typesGithub.ts
@@ -22,6 +22,14 @@ export type SubjectType =
   | 'RepositoryInvitation'
   | 'RepositoryVulnerabilityAlert';
 
+export type IssueStateType =
+  | 'closed'
+  | 'open'
+  | 'completed'
+  | 'reopened'
+  | 'not_planned';
+export type PullRequestStateType = 'closed' | 'open' | 'merged' | 'draft';
+export type StateType = IssueStateType | PullRequestStateType;
 export type ViewerSubscription = 'IGNORED' | 'SUBSCRIBED' | 'UNSUBSCRIBED';
 
 export interface Notification {
@@ -115,6 +123,7 @@ export interface Owner {
 export interface Subject {
   title: string;
   url?: string;
+  state: StateType;
   latest_comment_url?: string;
   type: SubjectType;
 }
diff --git a/src/utils/github-api.ts b/src/utils/github-api.ts
index 24a45783..0cbe2a54 100644
--- a/src/utils/github-api.ts
+++ b/src/utils/github-api.ts
@@ -1,4 +1,4 @@
-import { Reason, SubjectType } from '../typesGithub';
+import { Reason, StateType, SubjectType } from '../typesGithub';
 import * as Octicons from '@primer/octicons-react';
 
 // prettier-ignore
@@ -77,3 +77,24 @@ export function getNotificationTypeIcon(
       return Octicons.QuestionIcon;
   }
 }
+
+export function getNotificationTypeIconColor(state: StateType): string {
+  switch (state) {
+    case 'closed':
+      return 'text-red-500';
+    case 'open':
+      return 'text-green-500';
+    case 'merged':
+      return 'text-purple-500';
+    case 'reopened':
+      return 'text-green-500';
+    case 'not_planned':
+      return 'text-gray-300';
+    case 'completed':
+      return 'text-purple-500';
+    case 'draft':
+      return 'text-gray-600';
+    default:
+      return 'text-gray-300';
+  }
+}


Test patch:
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index b83f7c14..3658fd8a 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Install Node.js
         uses: actions/setup-node@v3
diff --git a/src/components/__snapshots__/NotificationRow.test.tsx.snap b/src/components/__snapshots__/NotificationRow.test.tsx.snap
index e9728a96..15fd3fb4 100644
--- a/src/components/__snapshots__/NotificationRow.test.tsx.snap
+++ b/src/components/__snapshots__/NotificationRow.test.tsx.snap
@@ -5,7 +5,7 @@ exports[`components/Notification.js should render itself & its children 1`] = `
   className="flex space-x-2 p-2 bg-white dark:bg-gray-dark dark:text-white hover:bg-gray-100 dark:hover:bg-gray-darker border-b border-gray-100 dark:border-gray-darker"
 >
   <div
-    className="flex justify-center items-center w-8"
+    className="flex justify-center items-center w-8 text-green-500"
   >
     <svg
       aria-hidden="false"
diff --git a/src/context/App.test.tsx b/src/context/App.test.tsx
index 158388cf..cb12bd9a 100644
--- a/src/context/App.test.tsx
+++ b/src/context/App.test.tsx
@@ -253,6 +253,7 @@ describe('context/App.tsx', () => {
         participating: true,
         playSound: true,
         showNotifications: true,
+        colors: true,
       }
     );
   });
@@ -289,6 +290,7 @@ describe('context/App.tsx', () => {
         participating: false,
         playSound: true,
         showNotifications: true,
+        colors: true,
       }
     );
   });
diff --git a/src/hooks/useNotifications.test.ts b/src/hooks/useNotifications.test.ts
index 73f12c42..ad81dff0 100644
--- a/src/hooks/useNotifications.test.ts
+++ b/src/hooks/useNotifications.test.ts
@@ -29,7 +29,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(200, notifications);
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -59,7 +59,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(400, { message });
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -92,7 +92,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(200, notifications);
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -118,7 +118,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(400, { message: 'Oops! Something went wrong.' });
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -149,7 +149,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(200, notifications);
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -173,7 +173,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(400, { message: 'Oops! Something went wrong.' });
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -185,6 +185,95 @@ describe('hooks/useNotifications.ts', () => {
         expect(result.current.requestFailed).toBe(true);
       });
     });
+
+    describe('with colors', () => {
+      it('should fetch notifications with success - with colors', async () => {
+        const accounts: AuthState = {
+          ...mockAccounts,
+          enterpriseAccounts: [],
+          user: mockedUser,
+        };
+
+        const notifications = [
+          {
+            id: 1,
+            title: 'This is a notification.',
+            subject: { type: 'Issue', url: 'https://api.github.com/1' },
+          },
+          {
+            id: 2,
+            title: 'A merged PR.',
+            subject: { type: 'PullRequest', url: 'https://api.github.com/2' },
+          },
+          {
+            id: 3,
+            title: 'A closed PR.',
+            subject: { type: 'PullRequest', url: 'https://api.github.com/3' },
+          },
+          {
+            id: 4,
+            title: 'A draft PR.',
+            subject: { type: 'PullRequest', url: 'https://api.github.com/4' },
+          },
+          {
+            id: 5,
+            title: 'A draft PR.',
+            subject: { type: 'PullRequest', url: 'https://api.github.com/5' },
+          },
+        ];
+
+        nock('https://api.github.com')
+          .get('/notifications?participating=false')
+          .reply(200, notifications);
+
+        nock('https://api.github.com').get('/1').reply(200, { state: 'open' });
+        nock('https://api.github.com')
+          .get('/2')
+          .reply(200, { state: 'closed', merged: true });
+        nock('https://api.github.com')
+          .get('/3')
+          .reply(200, { state: 'closed', merged: false });
+        nock('https://api.github.com')
+          .get('/4')
+          .reply(200, { state: 'open', draft: false });
+        nock('https://api.github.com')
+          .get('/5')
+          .reply(200, { state: 'open', draft: true });
+
+        const { result, waitForNextUpdate } = renderHook(() =>
+          useNotifications(true)
+        );
+
+        act(() => {
+          result.current.fetchNotifications(accounts, {
+            ...mockSettings,
+            colors: true,
+          });
+        });
+
+        expect(result.current.isFetching).toBe(true);
+
+        await waitForNextUpdate();
+
+        expect(result.current.notifications[0].hostname).toBe('github.com');
+        expect(result.current.notifications[0].notifications.length).toBe(5);
+        expect(
+          result.current.notifications[0].notifications[0].subject.state
+        ).toBe('open');
+        expect(
+          result.current.notifications[0].notifications[1].subject.state
+        ).toBe('merged');
+        expect(
+          result.current.notifications[0].notifications[2].subject.state
+        ).toBe('closed');
+        expect(
+          result.current.notifications[0].notifications[3].subject.state
+        ).toBe('open');
+        expect(
+          result.current.notifications[0].notifications[4].subject.state
+        ).toBe('draft');
+      });
+    });
   });
 
   describe('markNotification', () => {
@@ -200,7 +289,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(200);
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -218,7 +307,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(400);
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -241,7 +330,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(200);
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -259,7 +348,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(400);
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -292,7 +381,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(200);
 
         const { result, waitForValueToChange } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -318,7 +407,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(400);
 
         const { result, waitForValueToChange } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -349,7 +438,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(200);
 
         const { result, waitForValueToChange } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -375,7 +464,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(400);
 
         const { result, waitForValueToChange } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -404,7 +493,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(200);
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -422,7 +511,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(400);
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -445,7 +534,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(200);
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
@@ -463,7 +552,7 @@ describe('hooks/useNotifications.ts', () => {
           .reply(400);
 
         const { result, waitForNextUpdate } = renderHook(() =>
-          useNotifications()
+          useNotifications(false)
         );
 
         act(() => {
diff --git a/src/routes/__snapshots__/Notifications.test.tsx.snap b/src/routes/__snapshots__/Notifications.test.tsx.snap
index f878e497..9d474e2f 100644
--- a/src/routes/__snapshots__/Notifications.test.tsx.snap
+++ b/src/routes/__snapshots__/Notifications.test.tsx.snap
@@ -85,6 +85,7 @@ exports[`routes/Notifications.ts should render itself & its children (with notif
           },
           "subject": Object {
             "latest_comment_url": "https://api.github.com/repos/manosim/notifications-test/issues/comments/302888448",
+            "state": "open",
             "title": "I am a robot and this is a test!",
             "type": "Issue",
             "url": "https://api.github.com/repos/manosim/notifications-test/issues/1",
diff --git a/src/routes/__snapshots__/Settings.test.tsx.snap b/src/routes/__snapshots__/Settings.test.tsx.snap
index 6f683026..efa87ffd 100644
--- a/src/routes/__snapshots__/Settings.test.tsx.snap
+++ b/src/routes/__snapshots__/Settings.test.tsx.snap
@@ -118,6 +118,31 @@ exports[`routes/Settings.tsx should render itself & its children 1`] = `
         </div>
       </div>
     </fieldset>
+    <div
+      className="flex items-start mt-1 mb-3"
+    >
+      <div
+        className="flex items-center h-5"
+      >
+        <input
+          checked={false}
+          className="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded"
+          id="colors"
+          onChange={[Function]}
+          type="checkbox"
+        />
+      </div>
+      <div
+        className="ml-3 text-sm"
+      >
+        <label
+          className="font-medium text-gray-700 dark:text-gray-200"
+          htmlFor="colors"
+        >
+          Use colors to indicate state
+        </label>
+      </div>
+    </div>
     <div
       className="flex items-start mt-1 mb-3"
     >