Просмотр исходного кода

Merge pull request #8372 from weseek/feat/137967-notification-count-badge

feat: Notification count badge
Shun Miyazawa 2 лет назад
Родитель
Сommit
f9c81fc399

+ 3 - 1
apps/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -10,11 +10,12 @@ import { useModelNotification } from './PageNotification';
 
 interface Props {
   notification: IInAppNotification & HasObjectId
+  onUnopenedNotificationOpend?: () => void,
 }
 
 const InAppNotificationElm: FC<Props> = (props: Props) => {
 
-  const { notification } = props;
+  const { notification, onUnopenedNotificationOpend } = props;
 
   const modelNotificationUtils = useModelNotification(notification);
 
@@ -29,6 +30,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
     if (notification.status === InAppNotificationStatuses.STATUS_UNOPENED) {
       // set notification status "OPEND"
       await apiv3Post('/in-app-notification/open', { id: notification._id });
+      onUnopenedNotificationOpend?.();
     }
 
     publishOpen();

+ 7 - 2
apps/app/src/components/InAppNotification/InAppNotificationList.tsx

@@ -9,10 +9,11 @@ import InAppNotificationElm from './InAppNotificationElm';
 
 type Props = {
   inAppNotificationData?: PaginateResult<IInAppNotification>,
+  onUnopenedNotificationOpend?: () => void,
 };
 
 const InAppNotificationList: FC<Props> = (props: Props) => {
-  const { inAppNotificationData } = props;
+  const { inAppNotificationData, onUnopenedNotificationOpend } = props;
 
   if (inAppNotificationData == null) {
     return (
@@ -30,7 +31,11 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
     <div className="list-group">
       { notifications.map((notification: IInAppNotification & HasObjectId) => {
         return (
-          <InAppNotificationElm key={notification._id} notification={notification} />
+          <InAppNotificationElm
+            key={notification._id}
+            notification={notification}
+            onUnopenedNotificationOpend={onUnopenedNotificationOpend}
+          />
         );
       }) }
     </div>

+ 3 - 3
apps/app/src/components/Sidebar/InAppNotification/InAppNotification.tsx

@@ -12,7 +12,7 @@ const InAppNotificationContent = dynamic(() => import('./InAppNotificationSubsta
 export const InAppNotification = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const [isUnreadNotificationsVisible, setUnreadNotificationsVisible] = useState(false);
+  const [isUnopendNotificationsVisible, setUnopendNotificationsVisible] = useState(false);
 
   return (
     <div className="px-3">
@@ -23,11 +23,11 @@ export const InAppNotification = (): JSX.Element => {
       </div>
 
       <InAppNotificationForms
-        onChangeUnreadNotificationsVisible={() => { setUnreadNotificationsVisible(!isUnreadNotificationsVisible) }}
+        onChangeUnopendNotificationsVisible={() => { setUnopendNotificationsVisible(!isUnopendNotificationsVisible) }}
       />
 
       <Suspense fallback={<ItemsTreeContentSkeleton />}>
-        <InAppNotificationContent isUnreadNotificationsVisible={isUnreadNotificationsVisible} />
+        <InAppNotificationContent isUnopendNotificationsVisible={isUnopendNotificationsVisible} />
       </Suspense>
     </div>
   );

+ 13 - 10
apps/app/src/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx

@@ -8,10 +8,10 @@ import { useSWRxInAppNotifications } from '~/stores/in-app-notification';
 
 
 type InAppNotificationFormsProps = {
-  onChangeUnreadNotificationsVisible: () => void
+  onChangeUnopendNotificationsVisible: () => void
 }
 export const InAppNotificationForms = (props: InAppNotificationFormsProps): JSX.Element => {
-  const { onChangeUnreadNotificationsVisible } = props;
+  const { onChangeUnopendNotificationsVisible } = props;
 
   return (
     <div className="my-2">
@@ -22,7 +22,7 @@ export const InAppNotificationForms = (props: InAppNotificationFormsProps): JSX.
           className="form-check-input"
           type="checkbox"
           role="switch"
-          onChange={onChangeUnreadNotificationsVisible}
+          onChange={onChangeUnopendNotificationsVisible}
         />
       </div>
     </div>
@@ -31,28 +31,31 @@ export const InAppNotificationForms = (props: InAppNotificationFormsProps): JSX.
 
 
 type InAppNotificationContentProps = {
-  isUnreadNotificationsVisible: boolean
+  isUnopendNotificationsVisible: boolean
 }
 export const InAppNotificationContent = (props: InAppNotificationContentProps): JSX.Element => {
-  const { isUnreadNotificationsVisible } = props;
+  const { isUnopendNotificationsVisible } = props;
   const { t } = useTranslation('commons');
 
   // TODO: Infinite scroll implemented (https://redmine.weseek.co.jp/issues/138057)
-  const { data: inAppNotificationData } = useSWRxInAppNotifications(
+  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(
     6,
     undefined,
-    isUnreadNotificationsVisible ? InAppNotificationStatuses.STATUS_UNREAD : undefined,
-    { revalidateOnFocus: true },
+    isUnopendNotificationsVisible ? InAppNotificationStatuses.STATUS_UNOPENED : undefined,
+    { keepPreviousData: true },
   );
 
   return (
     <>
       {inAppNotificationData != null && inAppNotificationData.docs.length === 0
       // no items
-        ? t('in_app_notification.mark_all_as_read')
+        ? t('in_app_notification.no_notification')
       // render list-group
         : (
-          <InAppNotificationList inAppNotificationData={inAppNotificationData} />
+          <InAppNotificationList
+            inAppNotificationData={inAppNotificationData}
+            onUnopenedNotificationOpend={mutateInAppNotificationData}
+          />
         )
       }
     </>

+ 64 - 0
apps/app/src/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx

@@ -0,0 +1,64 @@
+import { memo, useCallback, useEffect } from 'react';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
+import { useDefaultSocket } from '~/stores/socket-io';
+import loggerFactory from '~/utils/logger';
+
+import { PrimaryItem, type Props } from '../SidebarNav/PrimaryItem';
+
+const logger = loggerFactory('growi:PrimaryItemsForNotification');
+
+type PrimaryItemForNotificationProps = Omit<Props, 'onClick' | 'label' | 'iconName' | 'contents' | 'badgeContents' >
+
+// TODO(after v7 release): https://redmine.weseek.co.jp/issues/138463
+export const PrimaryItemForNotification = memo((props: PrimaryItemForNotificationProps) => {
+  const { sidebarMode, onHover } = props;
+
+  const { data: socket } = useDefaultSocket();
+
+  const { data: notificationCount, mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
+
+  const badgeContents = notificationCount != null && notificationCount > 0 ? notificationCount : undefined;
+
+  const updateNotificationStatus = useCallback(async() => {
+    try {
+      await apiv3Post('/in-app-notification/read');
+      mutateNotificationCount();
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }, [mutateNotificationCount]);
+
+  const itemHoverHandler = useCallback((contents: SidebarContentsType) => {
+    onHover?.(contents);
+    updateNotificationStatus();
+  }, [onHover, updateNotificationStatus]);
+
+  useEffect(() => {
+    if (socket != null) {
+      socket.on('notificationUpdated', () => {
+        mutateNotificationCount();
+      });
+
+      // clean up
+      return () => {
+        socket.off('notificationUpdated');
+      };
+    }
+  }, [mutateNotificationCount, socket]);
+
+  return (
+    <PrimaryItem
+      sidebarMode={sidebarMode}
+      contents={SidebarContentsType.NOTIFICATION}
+      label="In-App Notification"
+      iconName="notifications"
+      badgeContents={badgeContents}
+      onClick={updateNotificationStatus}
+      onHover={itemHoverHandler}
+    />
+  );
+});

+ 80 - 0
apps/app/src/components/Sidebar/SidebarNav/PrimaryItem.tsx

@@ -0,0 +1,80 @@
+import { FC, useCallback } from 'react';
+
+import { SidebarContentsType, SidebarMode } from '~/interfaces/ui';
+import { useCollapsedContentsOpened, useCurrentSidebarContents } from '~/stores/ui';
+
+
+const useIndicator = (sidebarMode: SidebarMode, isSelected: boolean): string => {
+  const { data: isCollapsedContentsOpened } = useCollapsedContentsOpened();
+
+  if (sidebarMode === SidebarMode.COLLAPSED && !isCollapsedContentsOpened) {
+    return '';
+  }
+
+  return isSelected ? 'active' : '';
+};
+
+export type Props = {
+  contents: SidebarContentsType,
+  label: string,
+  iconName: string,
+  sidebarMode: SidebarMode,
+  badgeContents?: number,
+  onHover?: (contents: SidebarContentsType) => void,
+  onClick?: () => void,
+}
+
+export const PrimaryItem: FC<Props> = (props: Props) => {
+  const {
+    contents, label, iconName, sidebarMode, badgeContents,
+    onClick, onHover,
+  } = props;
+
+  const { data: currentContents, mutateAndSave: mutateContents } = useCurrentSidebarContents();
+
+  const indicatorClass = useIndicator(sidebarMode, contents === currentContents);
+
+  const selectThisItem = useCallback(() => {
+    mutateContents(contents, false);
+  }, [contents, mutateContents]);
+
+  const itemClickedHandler = useCallback(() => {
+    // do nothing ONLY WHEN the collapse mode
+    if (sidebarMode === SidebarMode.COLLAPSED) {
+      return;
+    }
+
+    selectThisItem();
+    onClick?.();
+  }, [onClick, selectThisItem, sidebarMode]);
+
+  const mouseEnteredHandler = useCallback(() => {
+    // ignore other than collapsed mode
+    if (sidebarMode !== SidebarMode.COLLAPSED) {
+      return;
+    }
+
+    selectThisItem();
+    onHover?.(contents);
+  }, [contents, onHover, selectThisItem, sidebarMode]);
+
+
+  const labelForTestId = label.toLowerCase().replace(' ', '-');
+
+  return (
+    <button
+      type="button"
+      data-testid={`grw-sidebar-nav-primary-${labelForTestId}`}
+      className={`btn btn-primary ${indicatorClass}`}
+      onClick={itemClickedHandler}
+      onMouseEnter={mouseEnteredHandler}
+    >
+      <div className="position-relative">
+        { badgeContents != null && (
+          <span className="position-absolute badge rounded-pill bg-primary">{badgeContents}</span>
+        )}
+        <span className="material-symbols-outlined">{iconName}</span>
+      </div>
+    </button>
+  );
+};

+ 5 - 0
apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.module.scss

@@ -42,6 +42,11 @@
       }
     }
   }
+
+  .badge :global {
+    left: 26px;
+    font-size: 8px;
+  }
 }
 
 // == Colors

+ 11 - 80
apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.tsx

@@ -1,81 +1,18 @@
-import { FC, memo, useCallback } from 'react';
+import { memo } from 'react';
 
-import { SidebarContentsType, SidebarMode } from '~/interfaces/ui';
-import { useCollapsedContentsOpened, useCurrentSidebarContents, useSidebarMode } from '~/stores/ui';
+import dynamic from 'next/dynamic';
 
-import styles from './PrimaryItems.module.scss';
-
-/**
- * @returns String for className to switch the indicator is active or not
- */
-const useIndicator = (sidebarMode: SidebarMode, isSelected: boolean): string => {
-  const { data: isCollapsedContentsOpened } = useCollapsedContentsOpened();
-
-  if (sidebarMode === SidebarMode.COLLAPSED && !isCollapsedContentsOpened) {
-    return '';
-  }
-
-  return isSelected ? 'active' : '';
-};
-
-
-type PrimaryItemProps = {
-  contents: SidebarContentsType,
-  label: string,
-  iconName: string,
-  sidebarMode: SidebarMode,
-  onHover?: (contents: SidebarContentsType) => void,
-}
-
-const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
-  const {
-    contents, label, iconName, sidebarMode,
-    onHover,
-  } = props;
-
-  const { data: currentContents, mutateAndSave: mutateContents } = useCurrentSidebarContents();
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useSidebarMode } from '~/stores/ui';
 
-  const indicatorClass = useIndicator(sidebarMode, contents === currentContents);
+import { PrimaryItem } from './PrimaryItem';
 
-  const selectThisItem = useCallback(() => {
-    mutateContents(contents, false);
-  }, [contents, mutateContents]);
-
-  const itemClickedHandler = useCallback(() => {
-    // do nothing ONLY WHEN the collapse mode
-    if (sidebarMode === SidebarMode.COLLAPSED) {
-      return;
-    }
-
-    selectThisItem();
-  }, [selectThisItem, sidebarMode]);
-
-  const mouseEnteredHandler = useCallback(() => {
-    // ignore other than collapsed mode
-    if (sidebarMode !== SidebarMode.COLLAPSED) {
-      return;
-    }
-
-    selectThisItem();
-    onHover?.(contents);
-  }, [contents, onHover, selectThisItem, sidebarMode]);
-
-
-  const labelForTestId = label.toLowerCase().replace(' ', '-');
-
-  return (
-    <button
-      type="button"
-      data-testid={`grw-sidebar-nav-primary-${labelForTestId}`}
-      className={`btn btn-primary ${indicatorClass}`}
-      onClick={itemClickedHandler}
-      onMouseEnter={mouseEnteredHandler}
-    >
-      <span className="material-symbols-outlined">{iconName}</span>
-    </button>
-  );
-};
+import styles from './PrimaryItems.module.scss';
 
+// Do not SSR Socket.io to make it work
+const PrimaryItemForNotification = dynamic(
+  () => import('../InAppNotification/PrimaryItemForNotification').then(mod => mod.PrimaryItemForNotification), { ssr: false },
+);
 
 type Props = {
   onItemHover?: (contents: SidebarContentsType) => void,
@@ -97,13 +34,7 @@ export const PrimaryItems = memo((props: Props) => {
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onHover={onItemHover} />
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.BOOKMARKS} label="Bookmarks" iconName="bookmarks" onHover={onItemHover} />
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onHover={onItemHover} />
-      <PrimaryItem
-        sidebarMode={sidebarMode}
-        contents={SidebarContentsType.NOTIFICATION}
-        label="In-App Notification"
-        iconName="notifications"
-        onHover={onItemHover}
-      />
+      <PrimaryItemForNotification sidebarMode={sidebarMode} onHover={onItemHover} />
     </div>
   );
 });