Repository: gitify-app/gitify
Pull request #1365 (2024-07-12 03:57:38)

feat: repo-level transition and delayed notifications
Add repository level interaction support for:
* transition animations
* delay notification state mgmt


Patch:
diff --git a/src/components/NotificationRow.tsx b/src/components/NotificationRow.tsx
index 9d802f0f..e5d211c0 100644
--- a/src/components/NotificationRow.tsx
+++ b/src/components/NotificationRow.tsx
@@ -23,10 +23,12 @@ import { NotificationHeader } from './notification/NotificationHeader';
 
 interface INotificationRow {
   notification: Notification;
+  isRead?: boolean;
 }
 
 export const NotificationRow: FC<INotificationRow> = ({
   notification,
+  isRead = false,
 }: INotificationRow) => {
   const {
     settings,
@@ -56,6 +58,7 @@ export const NotificationRow: FC<INotificationRow> = ({
     removeNotificationFromState,
     settings,
   ]);
+
   const unsubscribeFromThread = (event: MouseEvent<HTMLElement>) => {
     // Don't trigger onClick of parent element.
     event.stopPropagation();
@@ -88,6 +91,7 @@ export const NotificationRow: FC<INotificationRow> = ({
         animateExit &&
           'translate-x-full opacity-0 transition duration-[350ms] ease-in-out',
         showAsRead && Opacity.READ,
+        isRead && Opacity.READ,
       )}
     >
       <div
diff --git a/src/components/RepositoryNotifications.tsx b/src/components/RepositoryNotifications.tsx
index c0d49d26..a7f1e997 100644
--- a/src/components/RepositoryNotifications.tsx
+++ b/src/components/RepositoryNotifications.tsx
@@ -5,13 +5,7 @@ import {
   MarkGithubIcon,
   ReadIcon,
 } from '@primer/octicons-react';
-import {
-  type FC,
-  type MouseEvent,
-  useCallback,
-  useContext,
-  useState,
-} from 'react';
+import { type FC, type MouseEvent, useContext, useState } from 'react';
 import { AppContext } from '../context/App';
 import { Opacity, Size } from '../types';
 import type { Notification } from '../typesGitHub';
@@ -31,22 +25,15 @@ export const RepositoryNotifications: FC<IRepositoryNotifications> = ({
   repoName,
   repoNotifications,
 }) => {
-  const { markRepoNotificationsRead, markRepoNotificationsDone } =
+  const { settings, markRepoNotificationsRead, markRepoNotificationsDone } =
     useContext(AppContext);
-
-  const markRepoAsRead = useCallback(() => {
-    markRepoNotificationsRead(repoNotifications[0]);
-  }, [repoNotifications, markRepoNotificationsRead]);
-
-  const markRepoAsDone = useCallback(() => {
-    markRepoNotificationsDone(repoNotifications[0]);
-  }, [repoNotifications, markRepoNotificationsDone]);
-
-  const avatarUrl = repoNotifications[0].repository.owner.avatar_url;
-
+  const [animateExit, setAnimateExit] = useState(false);
+  const [showAsRead, setShowAsRead] = useState(false);
   const [showRepositoryNotifications, setShowRepositoryNotifications] =
     useState(true);
 
+  const avatarUrl = repoNotifications[0].repository.owner.avatar_url;
+
   const toggleRepositoryNotifications = () => {
     setShowRepositoryNotifications(!showRepositoryNotifications);
   };
@@ -68,7 +55,9 @@ export const RepositoryNotifications: FC<IRepositoryNotifications> = ({
         <div
           className={cn(
             'flex flex-1 gap-4 items-center truncate text-sm font-medium',
-            Opacity.MEDIUM,
+            animateExit &&
+              'translate-x-full opacity-0 transition duration-[350ms] ease-in-out',
+            showAsRead ? Opacity.READ : Opacity.MEDIUM,
           )}
         >
           <AvatarIcon
@@ -94,13 +83,25 @@ export const RepositoryNotifications: FC<IRepositoryNotifications> = ({
             title="Mark Repository as Done"
             icon={CheckIcon}
             size={Size.MEDIUM}
-            onClick={markRepoAsDone}
+            onClick={(event: MouseEvent<HTMLElement>) => {
+              // Don't trigger onClick of parent element.
+              event.stopPropagation();
+              setAnimateExit(!settings.delayNotificationState);
+              setShowAsRead(settings.delayNotificationState);
+              markRepoNotificationsDone(repoNotifications[0]);
+            }}
           />
           <InteractionButton
             title="Mark Repository as Read"
             icon={ReadIcon}
             size={Size.SMALL}
-            onClick={markRepoAsRead}
+            onClick={(event: MouseEvent<HTMLElement>) => {
+              // Don't trigger onClick of parent element.
+              event.stopPropagation();
+              setAnimateExit(!settings.delayNotificationState);
+              setShowAsRead(settings.delayNotificationState);
+              markRepoNotificationsRead(repoNotifications[0]);
+            }}
           />
           <InteractionButton
             title={toggleRepositoryNotificationsLabel}
@@ -113,7 +114,11 @@ export const RepositoryNotifications: FC<IRepositoryNotifications> = ({
 
       {showRepositoryNotifications &&
         repoNotifications.map((notification) => (
-          <NotificationRow key={notification.id} notification={notification} />
+          <NotificationRow
+            key={notification.id}
+            notification={notification}
+            isRead={showAsRead}
+          />
         ))}
     </>
   );
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 2b4b604c..ece63873 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -154,7 +154,7 @@ export const useNotifications = (): NotificationsState => {
   );
 
   const markRepoNotificationsRead = useCallback(
-    async (_state: GitifyState, notification: Notification) => {
+    async (state: GitifyState, notification: Notification) => {
       setStatus('loading');
 
       const repoSlug = notification.repository.full_name;
@@ -166,7 +166,9 @@ export const useNotifications = (): NotificationsState => {
           hostname,
           notification.account.token,
         );
+
         const updatedNotifications = removeNotifications(
+          state.settings,
           notification,
           notifications,
         );
@@ -209,6 +211,7 @@ export const useNotifications = (): NotificationsState => {
         }
 
         const updatedNotifications = removeNotifications(
+          state.settings,
           notification,
           notifications,
         );
diff --git a/src/utils/remove-notifications.ts b/src/utils/remove-notifications.ts
index ee819ebf..507d46fa 100644
--- a/src/utils/remove-notifications.ts
+++ b/src/utils/remove-notifications.ts
@@ -1,11 +1,16 @@
-import type { AccountNotifications } from '../types';
+import type { AccountNotifications, SettingsState } from '../types';
 import type { Notification } from '../typesGitHub';
 import { getAccountUUID } from './auth/utils';
 
 export function removeNotifications(
+  settings: SettingsState,
   notification: Notification,
   notifications: AccountNotifications[],
 ): AccountNotifications[] {
+  if (settings.delayNotificationState) {
+    return notifications;
+  }
+
   const repoSlug = notification.repository.full_name;
 
   const accountIndex = notifications.findIndex(


Test patch:
diff --git a/src/components/RepositoryNotifications.test.tsx b/src/components/RepositoryNotifications.test.tsx
index 4a240333..86dc87fb 100644
--- a/src/components/RepositoryNotifications.test.tsx
+++ b/src/components/RepositoryNotifications.test.tsx
@@ -1,5 +1,5 @@
 import { act, fireEvent, render, screen } from '@testing-library/react';
-import { mockGitHubCloudAccount } from '../__mocks__/state-mocks';
+import { mockGitHubCloudAccount, mockSettings } from '../__mocks__/state-mocks';
 import { AppContext } from '../context/App';
 import type { Link } from '../types';
 import {
@@ -57,7 +57,9 @@ describe('components/Repository.tsx', () => {
 
   it('should mark a repo as read', () => {
     render(
-      <AppContext.Provider value={{ markRepoNotificationsRead }}>
+      <AppContext.Provider
+        value={{ settings: { ...mockSettings }, markRepoNotificationsRead }}
+      >
         <RepositoryNotifications {...props} />
       </AppContext.Provider>,
     );
@@ -71,7 +73,9 @@ describe('components/Repository.tsx', () => {
 
   it('should mark a repo as done', () => {
     render(
-      <AppContext.Provider value={{ markRepoNotificationsDone }}>
+      <AppContext.Provider
+        value={{ settings: { ...mockSettings }, markRepoNotificationsDone }}
+      >
         <RepositoryNotifications {...props} />
       </AppContext.Provider>,
     );
diff --git a/src/utils/remove-notifications.test.ts b/src/utils/remove-notifications.test.ts
index 9ed87a90..98d476de 100644
--- a/src/utils/remove-notifications.test.ts
+++ b/src/utils/remove-notifications.test.ts
@@ -2,6 +2,7 @@ import {
   mockAccountNotifications,
   mockSingleAccountNotifications,
 } from '../__mocks__/notifications-mocks';
+import { mockSettings } from '../__mocks__/state-mocks';
 import { mockSingleNotification } from './api/__mocks__/response-mocks';
 import { removeNotifications } from './remove-notifications';
 
@@ -10,6 +11,7 @@ describe('utils/remove-notifications.ts', () => {
     expect(mockSingleAccountNotifications[0].notifications.length).toBe(1);
 
     const result = removeNotifications(
+      mockSettings,
       mockSingleNotification,
       mockSingleAccountNotifications,
     );
@@ -22,6 +24,7 @@ describe('utils/remove-notifications.ts', () => {
     expect(mockAccountNotifications[1].notifications.length).toBe(2);
 
     const result = removeNotifications(
+      mockSettings,
       mockSingleNotification,
       mockAccountNotifications,
     );
@@ -29,4 +32,16 @@ describe('utils/remove-notifications.ts', () => {
     expect(result[0].notifications.length).toBe(0);
     expect(result[1].notifications.length).toBe(2);
   });
+
+  it('should skip notification removal if delay state enabled', () => {
+    expect(mockSingleAccountNotifications[0].notifications.length).toBe(1);
+
+    const result = removeNotifications(
+      { ...mockSettings, delayNotificationState: true },
+      mockSingleNotification,
+      mockSingleAccountNotifications,
+    );
+
+    expect(result[0].notifications.length).toBe(1);
+  });
 });