Repository: gitify-app/gitify
Pull request #788 (2024-02-18 12:20:06)

feat: mark repository as done
## Context

Implements a "Mark repository as done" button, as discussed in #706


## Discussion

We had concerns about rate-limiting but [according to Github API's the limit is 5000 calls per hour](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-authenticated-users); so I don't think we need to worry about it.

I added a space between repository icons so they are aligned with `NotificationRow` icons, but it can be removed if https://github.com/gitify-app/gitify/pull/784 is implemented 

<img width="500" alt="image" src="https://github.com/gitify-app/gitify/assets/26418696/145dc485-0345-43cf-ae17-1ea1909a1124">



Patch:
diff --git a/src/components/Repository.tsx b/src/components/Repository.tsx
index 3cdb5820..7cf0ed8c 100644
--- a/src/components/Repository.tsx
+++ b/src/components/Repository.tsx
@@ -1,5 +1,5 @@
 import React, { useCallback, useContext } from 'react';
-import { ReadIcon } from '@primer/octicons-react';
+import { ReadIcon, CheckIcon } from '@primer/octicons-react';
 import { CSSTransition, TransitionGroup } from 'react-transition-group';
 
 import { AppContext } from '../context/App';
@@ -18,7 +18,8 @@ export const RepositoryNotifications: React.FC<IProps> = ({
   repoNotifications,
   hostname,
 }) => {
-  const { markRepoNotifications } = useContext(AppContext);
+  const { markRepoNotifications, markRepoNotificationsDone } =
+    useContext(AppContext);
 
   const openBrowser = useCallback(() => {
     const url = repoNotifications[0].repository.html_url;
@@ -30,6 +31,11 @@ export const RepositoryNotifications: React.FC<IProps> = ({
     markRepoNotifications(repoSlug, hostname);
   }, [repoNotifications, hostname]);
 
+  const markRepoAsDone = useCallback(() => {
+    const repoSlug = repoNotifications[0].repository.full_name;
+    markRepoNotificationsDone(repoSlug, hostname);
+  }, [repoNotifications, hostname]);
+
   const avatarUrl = repoNotifications[0].repository.owner.avatar_url;
 
   return (
@@ -40,7 +46,17 @@ export const RepositoryNotifications: React.FC<IProps> = ({
           <span onClick={openBrowser}>{repoName}</span>
         </div>
 
-        <div className="flex justify-center items-center">
+        <div className="flex justify-center items-center gap-2">
+          <button
+            className="focus:outline-none h-full hover:text-green-500"
+            title="Mark Repository as Done"
+            onClick={markRepoAsDone}
+          >
+            <CheckIcon size={16} aria-label="Mark Repository as Done" />
+          </button>
+
+          <div className="w-[14px]" />
+
           <button
             className="focus:outline-none h-full hover:text-green-500"
             title="Mark Repository as Read"
diff --git a/src/context/App.tsx b/src/context/App.tsx
index 72978d93..2f7b8487 100644
--- a/src/context/App.tsx
+++ b/src/context/App.tsx
@@ -57,6 +57,7 @@ interface AppContextState {
   markNotificationDone: (id: string, hostname: string) => Promise<void>;
   unsubscribeNotification: (id: string, hostname: string) => Promise<void>;
   markRepoNotifications: (id: string, hostname: string) => Promise<void>;
+  markRepoNotificationsDone: (id: string, hostname: string) => Promise<void>;
 
   settings: SettingsState;
   updateSetting: (name: keyof SettingsState, value: any) => void;
@@ -77,6 +78,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
     markNotificationDone,
     unsubscribeNotification,
     markRepoNotifications,
+    markRepoNotificationsDone,
   } = useNotifications(settings.colors);
 
   useEffect(() => {
@@ -199,6 +201,12 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
     [accounts, notifications],
   );
 
+  const markRepoNotificationsDoneWithAccounts = useCallback(
+    async (repoSlug: string, hostname: string) =>
+      await markRepoNotificationsDone(accounts, repoSlug, hostname),
+    [accounts, notifications],
+  );
+
   return (
     <AppContext.Provider
       value={{
@@ -218,6 +226,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
         markNotificationDone: markNotificationDoneWithAccounts,
         unsubscribeNotification: unsubscribeNotificationWithAccounts,
         markRepoNotifications: markRepoNotificationsWithAccounts,
+        markRepoNotificationsDone: markRepoNotificationsDoneWithAccounts,
 
         settings,
         updateSetting,
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 20d1ae88..7ad40514 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -45,6 +45,11 @@ interface NotificationsState {
     repoSlug: string,
     hostname: string,
   ) => Promise<void>;
+  markRepoNotificationsDone: (
+    accounts: AuthState,
+    repoSlug: string,
+    hostname: string,
+  ) => Promise<void>;
   isFetching: boolean;
   requestFailed: boolean;
 }
@@ -314,6 +319,49 @@ export const useNotifications = (colors: boolean): NotificationsState => {
     [notifications],
   );
 
+  const markRepoNotificationsDone = useCallback(
+    async (accounts, repoSlug, hostname) => {
+      setIsFetching(true);
+
+      try {
+        const accountIndex = notifications.findIndex(
+          (accountNotifications) => accountNotifications.hostname === hostname,
+        );
+
+        if (accountIndex !== -1) {
+          const notificationsToRemove = notifications[
+            accountIndex
+          ].notifications.filter(
+            (notification) => notification.repository.full_name === repoSlug,
+          );
+
+          await Promise.all(
+            notificationsToRemove.map((notification) =>
+              markNotificationDone(
+                accounts,
+                notification.id,
+                notifications[accountIndex].hostname,
+              ),
+            ),
+          );
+        }
+
+        const updatedNotifications = removeNotifications(
+          repoSlug,
+          notifications,
+          hostname,
+        );
+
+        setNotifications(updatedNotifications);
+        setTrayIconColor(updatedNotifications);
+        setIsFetching(false);
+      } catch (err) {
+        setIsFetching(false);
+      }
+    },
+    [notifications],
+  );
+
   const removeNotificationFromState = useCallback(
     (id, hostname) => {
       const updatedNotifications = removeNotification(
@@ -339,5 +387,6 @@ export const useNotifications = (colors: boolean): NotificationsState => {
     markNotificationDone,
     unsubscribeNotification,
     markRepoNotifications,
+    markRepoNotificationsDone,
   };
 };


Test patch:
diff --git a/src/components/Repository.test.tsx b/src/components/Repository.test.tsx
index 58a8fdb0..9eaa7aec 100644
--- a/src/components/Repository.test.tsx
+++ b/src/components/Repository.test.tsx
@@ -14,6 +14,7 @@ jest.mock('./NotificationRow', () => ({
 
 describe('components/Repository.tsx', () => {
   const markRepoNotifications = jest.fn();
+  const markRepoNotificationsDone = jest.fn();
 
   const props = {
     hostname: 'github.com',
@@ -52,17 +53,32 @@ describe('components/Repository.tsx', () => {
   });
 
   it('should mark a repo as read', function () {
-    const { getByRole } = render(
+    const { getByTitle } = render(
       <AppContext.Provider value={{ markRepoNotifications }}>
         <RepositoryNotifications {...props} />
       </AppContext.Provider>,
     );
 
-    fireEvent.click(getByRole('button'));
+    fireEvent.click(getByTitle('Mark Repository as Read'));
 
     expect(markRepoNotifications).toHaveBeenCalledWith(
       'manosim/notifications-test',
       'github.com',
     );
   });
+
+  it('should mark a repo as done', function () {
+    const { getByTitle } = render(
+      <AppContext.Provider value={{ markRepoNotificationsDone }}>
+        <RepositoryNotifications {...props} />
+      </AppContext.Provider>,
+    );
+
+    fireEvent.click(getByTitle('Mark Repository as Done'));
+
+    expect(markRepoNotificationsDone).toHaveBeenCalledWith(
+      'manosim/notifications-test',
+      'github.com',
+    );
+  });
 });
diff --git a/src/components/__snapshots__/Repository.test.tsx.snap b/src/components/__snapshots__/Repository.test.tsx.snap
index 2960f4ad..8222bc73 100644
--- a/src/components/__snapshots__/Repository.test.tsx.snap
+++ b/src/components/__snapshots__/Repository.test.tsx.snap
@@ -19,8 +19,40 @@ exports[`components/Repository.tsx should render itself & its children 1`] = `
       </span>
     </div>
     <div
-      className="flex justify-center items-center"
+      className="flex justify-center items-center gap-2"
     >
+      <button
+        className="focus:outline-none h-full hover:text-green-500"
+        onClick={[Function]}
+        title="Mark Repository as Done"
+      >
+        <svg
+          aria-hidden="false"
+          aria-label="Mark Repository as Done"
+          className="octicon octicon-check"
+          fill="currentColor"
+          focusable="false"
+          height={16}
+          role="img"
+          style={
+            {
+              "display": "inline-block",
+              "overflow": "visible",
+              "userSelect": "none",
+              "verticalAlign": "text-bottom",
+            }
+          }
+          viewBox="0 0 16 16"
+          width={16}
+        >
+          <path
+            d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"
+          />
+        </svg>
+      </button>
+      <div
+        className="w-[14px]"
+      />
       <button
         className="focus:outline-none h-full hover:text-green-500"
         onClick={[Function]}
diff --git a/src/hooks/useNotifications.test.ts b/src/hooks/useNotifications.test.ts
index ca789184..2d133363 100644
--- a/src/hooks/useNotifications.test.ts
+++ b/src/hooks/useNotifications.test.ts
@@ -672,4 +672,107 @@ describe('hooks/useNotifications.ts', () => {
       });
     });
   });
+
+  describe('markRepoNotificationsDone', () => {
+    const repoSlug = 'manosim/gitify';
+    const id = 'notification-123';
+
+    describe('github.com', () => {
+      const accounts = { ...mockAccounts, enterpriseAccounts: [] };
+      const hostname = 'github.com';
+
+      it("should mark a repository's notifications as done with success - github.com", async () => {
+        nock('https://api.github.com/')
+          .delete(`/notifications/threads/${id}`)
+          .reply(200);
+
+        const { result } = renderHook(() => useNotifications(false));
+
+        act(() => {
+          result.current.markRepoNotificationsDone(
+            accounts,
+            repoSlug,
+            hostname,
+          );
+        });
+
+        await waitFor(() => {
+          expect(result.current.isFetching).toBe(false);
+        });
+
+        expect(result.current.notifications.length).toBe(0);
+      });
+
+      it("should mark a repository's notifications as done with failure - github.com", async () => {
+        nock('https://api.github.com/')
+          .delete(`/notifications/threads/${id}`)
+          .reply(400);
+
+        const { result } = renderHook(() => useNotifications(false));
+
+        act(() => {
+          result.current.markRepoNotificationsDone(
+            accounts,
+            repoSlug,
+            hostname,
+          );
+        });
+
+        await waitFor(() => {
+          expect(result.current.isFetching).toBe(false);
+        });
+
+        expect(result.current.notifications.length).toBe(0);
+      });
+    });
+
+    describe('enterprise', () => {
+      const accounts = { ...mockAccounts, token: null };
+      const hostname = 'github.gitify.io';
+
+      it("should mark a repository's notifications as done with success - enterprise", async () => {
+        nock('https://api.github.com/')
+          .delete(`/notifications/threads/${id}`)
+          .reply(200);
+
+        const { result } = renderHook(() => useNotifications(false));
+
+        act(() => {
+          result.current.markRepoNotificationsDone(
+            accounts,
+            repoSlug,
+            hostname,
+          );
+        });
+
+        await waitFor(() => {
+          expect(result.current.isFetching).toBe(false);
+        });
+
+        expect(result.current.notifications.length).toBe(0);
+      });
+
+      it("should mark a repository's notifications as done with failure - enterprise", async () => {
+        nock('https://api.github.com/')
+          .delete(`/notifications/threads/${id}`)
+          .reply(400);
+
+        const { result } = renderHook(() => useNotifications(false));
+
+        act(() => {
+          result.current.markRepoNotificationsDone(
+            accounts,
+            repoSlug,
+            hostname,
+          );
+        });
+
+        await waitFor(() => {
+          expect(result.current.isFetching).toBe(false);
+        });
+
+        expect(result.current.notifications.length).toBe(0);
+      });
+    });
+  });
 });