Browse Source

Merge pull request #11050 from growilabs/refactor/181356-182436-news-inappnotification

refactor(news-inappnotification): apply PR #10986 review feedback
ryotaro-nagahara 3 weeks ago
parent
commit
a289f44b0f

+ 4 - 2
apps/app/public/static/locales/en_US/commons.json

@@ -171,7 +171,8 @@
         "share_link": "Grants permission to view share link features.",
         "bookmark": "Grants permission to view bookmark features.",
         "attachment": "Grants permission to view attachment features.",
-        "page_bulk_export": "Grants permission to view page bulk export features."
+        "page_bulk_export": "Grants permission to view page bulk export features.",
+        "in_app_notification": "Grants permission to view in-app notification features."
       }
     },
     "write": {
@@ -216,7 +217,8 @@
         "share_link": "Grants permission to edit share link features.",
         "bookmark": "Grants permission to edit bookmark features.",
         "attachment": "Grants permission to edit attachment features.",
-        "page_bulk_export": "Grants permission to edit page bulk export features."
+        "page_bulk_export": "Grants permission to edit page bulk export features.",
+        "in_app_notification": "Grants permission to edit in-app notification features."
       }
     }
   }

+ 4 - 2
apps/app/public/static/locales/fr_FR/commons.json

@@ -172,7 +172,8 @@
         "share_link": "Accorde la permission de voir les fonctionnalités de lien de partage.",
         "bookmark": "Accorde la permission de voir les fonctionnalités de signet.",
         "attachment": "Accorde la permission de voir les fonctionnalités de pièce jointe.",
-        "page_bulk_export": "Accorde la permission de voir les fonctionnalités d'exportation en masse de pages."
+        "page_bulk_export": "Accorde la permission de voir les fonctionnalités d'exportation en masse de pages.",
+        "in_app_notification": "Accorde la permission de voir les fonctionnalités de notification intégrée à l'application."
       }
     },
     "write": {
@@ -217,7 +218,8 @@
         "share_link": "Accorde la permission de modifier les fonctionnalités de lien de partage.",
         "bookmark": "Accorde la permission de modifier les fonctionnalités de signet.",
         "attachment": "Accorde la permission de modifier les fonctionnalités de pièce jointe.",
-        "page_bulk_export": "Accorde la permission de modifier les fonctionnalités d'exportation en masse de pages."
+        "page_bulk_export": "Accorde la permission de modifier les fonctionnalités d'exportation en masse de pages.",
+        "in_app_notification": "Accorde la permission de modifier les fonctionnalités de notification intégrée à l'application."
       }
     }
   }

+ 4 - 2
apps/app/public/static/locales/ja_JP/commons.json

@@ -175,7 +175,8 @@
         "share_link": "共有リンク機能の閲覧権限を付与できます。",
         "bookmark": "ブックマーク機能の閲覧権限を付与できます。",
         "attachment": "添付ファイル機能の閲覧権限を付与できます。",
-        "page_bulk_export": "ページの一括エクスポート機能の閲覧権限を付与できます。"
+        "page_bulk_export": "ページの一括エクスポート機能の閲覧権限を付与できます。",
+        "in_app_notification": "アプリ内通知機能の閲覧権限を付与できます。"
       }
     },
     "write": {
@@ -220,7 +221,8 @@
         "share_link": "共有リンク機能の編集権限を付与できます。",
         "bookmark": "ブックマーク機能の編集権限を付与できます。",
         "attachment": "添付ファイル機能の編集権限を付与できます。",
-        "page_bulk_export": "ページの一括エクスポート機能の編集権限を付与できます。"
+        "page_bulk_export": "ページの一括エクスポート機能の編集権限を付与できます。",
+        "in_app_notification": "アプリ内通知機能の編集権限を付与できます。"
       }
     }
   }

+ 4 - 2
apps/app/public/static/locales/zh_CN/commons.json

@@ -174,7 +174,8 @@
         "share_link": "授予查看共享链接功能的权限。",
         "bookmark": "授予查看书签功能的权限。",
         "attachment": "授予查看附件功能的权限。",
-        "page_bulk_export": "授予查看页面批量导出功能的权限。"
+        "page_bulk_export": "授予查看页面批量导出功能的权限。",
+        "in_app_notification": "授予查看应用内通知功能的权限。"
       }
     },
     "write": {
@@ -219,7 +220,8 @@
         "share_link": "授予编辑共享链接功能的权限。",
         "bookmark": "授予编辑书签功能的权限。",
         "attachment": "授予编辑附件功能的权限。",
-        "page_bulk_export": "授予编辑页面批量导出功能的权限。"
+        "page_bulk_export": "授予编辑页面批量导出功能的权限。",
+        "in_app_notification": "授予编辑应用内通知功能的权限。"
       }
     }
   }

+ 2 - 2
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx

@@ -3,13 +3,13 @@ import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 
 import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
-import { InAppNotificationForms } from './InAppNotificationSubstance';
+import { InAppNotificationForms } from './InAppNotificationForms';
 
 export type FilterType = 'all' | 'news' | 'notifications';
 
 const InAppNotificationContent = dynamic(
   () =>
-    import('./InAppNotificationSubstance').then(
+    import('./InAppNotificationContent').then(
       (mod) => mod.InAppNotificationContent,
     ),
   { ssr: false },

+ 138 - 0
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationContent.tsx

@@ -0,0 +1,138 @@
+import type { JSX } from 'react';
+import { useTranslation } from 'next-i18next';
+
+import InAppNotificationElm from '~/client/components/InAppNotification/InAppNotificationElm';
+import InfiniteScroll from '~/client/components/InfiniteScroll';
+import { NewsItem } from '~/features/news/client/components/NewsItem';
+import { useSidebarMode } from '~/states/ui/sidebar';
+
+import { useMergedInAppNotifications } from './hooks/useMergedInAppNotifications';
+import type { FilterType } from './InAppNotification';
+
+type InAppNotificationContentProps = {
+  isUnopendNotificationsVisible: boolean;
+  activeFilter: FilterType;
+};
+
+export const InAppNotificationContent = (
+  props: InAppNotificationContentProps,
+): JSX.Element => {
+  const { isUnopendNotificationsVisible, activeFilter } = props;
+  const { t } = useTranslation('commons');
+  const { isCollapsedMode } = useSidebarMode();
+
+  // In collapsed mode (hover panel): constrain height + own scrollbar.
+  // In dock/drawer mode: no constraints — outer SimpleBar handles all scrolling.
+  const collapsed = isCollapsedMode();
+  const scrollAreaClassName = collapsed ? 'overflow-auto' : undefined;
+  const scrollAreaStyle = collapsed ? { maxHeight: '60vh' } : undefined;
+
+  const {
+    newsResponse,
+    allNewsItems,
+    newsExhausted,
+    notificationResponse,
+    allNotificationItems,
+    notifExhausted,
+    allModeSWRResponse,
+    mergedItems,
+    handleReadMutate,
+    handleNotificationRead,
+  } = useMergedInAppNotifications(isUnopendNotificationsVisible);
+
+  if (activeFilter === 'news') {
+    if (allNewsItems.length === 0 && !newsResponse.isValidating) {
+      return <>{t('in_app_notification.no_news')}</>;
+    }
+
+    return (
+      <div className={scrollAreaClassName} style={scrollAreaStyle}>
+        <InfiniteScroll
+          swrInifiniteResponse={newsResponse}
+          isReachingEnd={newsExhausted}
+        >
+          <div className="list-group">
+            {allNewsItems.map((item) => (
+              <NewsItem
+                key={item._id.toString()}
+                item={item}
+                onReadMutate={handleReadMutate}
+              />
+            ))}
+          </div>
+        </InfiniteScroll>
+      </div>
+    );
+  }
+
+  if (activeFilter === 'notifications') {
+    if (
+      allNotificationItems.length === 0 &&
+      !notificationResponse.isValidating
+    ) {
+      return <>{t('in_app_notification.no_notification')}</>;
+    }
+
+    return (
+      <div className={scrollAreaClassName} style={scrollAreaStyle}>
+        <InfiniteScroll
+          swrInifiniteResponse={notificationResponse}
+          isReachingEnd={notifExhausted}
+        >
+          <div className="list-group">
+            {allNotificationItems.map((notification) => {
+              const id = notification._id.toString();
+              return (
+                <InAppNotificationElm
+                  key={id}
+                  notification={notification}
+                  onUnopenedNotificationOpend={() => handleNotificationRead(id)}
+                />
+              );
+            })}
+          </div>
+        </InfiniteScroll>
+      </div>
+    );
+  }
+
+  // 'all' filter: merged view
+  if (
+    mergedItems.length === 0 &&
+    !newsResponse.isValidating &&
+    !notificationResponse.isValidating
+  ) {
+    return <>{t('in_app_notification.no_notification')}</>;
+  }
+
+  return (
+    <div className={scrollAreaClassName} style={scrollAreaStyle}>
+      <InfiniteScroll
+        swrInifiniteResponse={allModeSWRResponse}
+        isReachingEnd={newsExhausted && notifExhausted}
+      >
+        <div className="list-group">
+          {mergedItems.map((entry) => {
+            if (entry.type === 'news') {
+              return (
+                <NewsItem
+                  key={`news-${entry.item._id.toString()}`}
+                  item={entry.item}
+                  onReadMutate={handleReadMutate}
+                />
+              );
+            }
+            const id = entry.item._id.toString();
+            return (
+              <InAppNotificationElm
+                key={`notif-${id}`}
+                notification={entry.item}
+                onUnopenedNotificationOpend={() => handleNotificationRead(id)}
+              />
+            );
+          })}
+        </div>
+      </InfiniteScroll>
+    </div>
+  );
+};

+ 1 - 1
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationForms.spec.tsx

@@ -6,7 +6,7 @@ vi.mock('next-i18next', () => ({
   }),
 }));
 
-import { InAppNotificationForms } from './InAppNotificationSubstance';
+import { InAppNotificationForms } from './InAppNotificationForms';
 
 describe('InAppNotificationForms', () => {
   const defaultProps = {

+ 69 - 0
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationForms.tsx

@@ -0,0 +1,69 @@
+import { type JSX, useId } from 'react';
+import { useTranslation } from 'next-i18next';
+
+import type { FilterType } from './InAppNotification';
+
+type InAppNotificationFormsProps = {
+  isUnopendNotificationsVisible: boolean;
+  onChangeUnopendNotificationsVisible: () => void;
+  activeFilter: FilterType;
+  onChangeFilter: (filter: FilterType) => void;
+};
+
+export const InAppNotificationForms = (
+  props: InAppNotificationFormsProps,
+): JSX.Element => {
+  const {
+    isUnopendNotificationsVisible,
+    onChangeUnopendNotificationsVisible,
+    activeFilter,
+    onChangeFilter,
+  } = props;
+  const { t } = useTranslation('commons');
+  const toggleId = useId();
+
+  return (
+    <div className="my-2">
+      {/* Filter tabs */}
+      <fieldset className="btn-group w-100 mb-2">
+        <button
+          type="button"
+          className={`btn btn-sm ${activeFilter === 'all' ? 'btn-primary' : 'btn-outline-secondary'}`}
+          onClick={() => onChangeFilter('all')}
+        >
+          {t('in_app_notification.filter_all')}
+        </button>
+        <button
+          type="button"
+          className={`btn btn-sm ${activeFilter === 'notifications' ? 'btn-primary' : 'btn-outline-secondary'}`}
+          onClick={() => onChangeFilter('notifications')}
+        >
+          {t('in_app_notification.notifications')}
+        </button>
+        <button
+          type="button"
+          className={`btn btn-sm ${activeFilter === 'news' ? 'btn-primary' : 'btn-outline-secondary'}`}
+          onClick={() => onChangeFilter('news')}
+        >
+          {t('in_app_notification.news')}
+        </button>
+      </fieldset>
+
+      {/* Unread-only toggle */}
+      <div className="form-check form-switch">
+        <label className="form-check-label" htmlFor={toggleId}>
+          {t('in_app_notification.only_unread')}
+        </label>
+        <input
+          id={toggleId}
+          className="form-check-input"
+          type="checkbox"
+          role="switch"
+          aria-checked={isUnopendNotificationsVisible}
+          checked={isUnopendNotificationsVisible}
+          onChange={onChangeUnopendNotificationsVisible}
+        />
+      </div>
+    </div>
+  );
+};

+ 0 - 339
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx

@@ -1,339 +0,0 @@
-import { type JSX, useId, useMemo } from 'react';
-import { useTranslation } from 'next-i18next';
-import type { SWRInfiniteResponse } from 'swr/infinite';
-
-import InAppNotificationElm from '~/client/components/InAppNotification/InAppNotificationElm';
-import InfiniteScroll from '~/client/components/InfiniteScroll';
-import { NewsItem } from '~/features/news/client/components/NewsItem';
-import {
-  useSWRINFxNews,
-  useSWRxNewsUnreadCount,
-} from '~/features/news/client/hooks/use-news';
-import type { INewsItemWithReadStatus } from '~/features/news/interfaces/news-item';
-import type {
-  IInAppNotificationHasId,
-  PaginateResult,
-} from '~/interfaces/in-app-notification';
-import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
-import { useSidebarMode } from '~/states/ui/sidebar';
-import { useSWRINFxInAppNotifications } from '~/stores/in-app-notification';
-
-import type { FilterType } from './InAppNotification';
-
-const NEWS_PER_PAGE = 10;
-
-type InAppNotificationFormsProps = {
-  isUnopendNotificationsVisible: boolean;
-  onChangeUnopendNotificationsVisible: () => void;
-  activeFilter: FilterType;
-  onChangeFilter: (filter: FilterType) => void;
-};
-
-export const InAppNotificationForms = (
-  props: InAppNotificationFormsProps,
-): JSX.Element => {
-  const {
-    isUnopendNotificationsVisible,
-    onChangeUnopendNotificationsVisible,
-    activeFilter,
-    onChangeFilter,
-  } = props;
-  const { t } = useTranslation('commons');
-  const toggleId = useId();
-
-  return (
-    <div className="my-2">
-      {/* Filter tabs */}
-      <fieldset className="btn-group w-100 mb-2">
-        <button
-          type="button"
-          className={`btn btn-sm ${activeFilter === 'all' ? 'btn-primary' : 'btn-outline-secondary'}`}
-          onClick={() => onChangeFilter('all')}
-        >
-          {t('in_app_notification.filter_all')}
-        </button>
-        <button
-          type="button"
-          className={`btn btn-sm ${activeFilter === 'notifications' ? 'btn-primary' : 'btn-outline-secondary'}`}
-          onClick={() => onChangeFilter('notifications')}
-        >
-          {t('in_app_notification.notifications')}
-        </button>
-        <button
-          type="button"
-          className={`btn btn-sm ${activeFilter === 'news' ? 'btn-primary' : 'btn-outline-secondary'}`}
-          onClick={() => onChangeFilter('news')}
-        >
-          {t('in_app_notification.news')}
-        </button>
-      </fieldset>
-
-      {/* Unread-only toggle */}
-      <div className="form-check form-switch">
-        <label className="form-check-label" htmlFor={toggleId}>
-          {t('in_app_notification.only_unread')}
-        </label>
-        <input
-          id={toggleId}
-          className="form-check-input"
-          type="checkbox"
-          role="switch"
-          aria-checked={isUnopendNotificationsVisible}
-          checked={isUnopendNotificationsVisible}
-          onChange={onChangeUnopendNotificationsVisible}
-        />
-      </div>
-    </div>
-  );
-};
-
-type InAppNotificationContentProps = {
-  isUnopendNotificationsVisible: boolean;
-  activeFilter: FilterType;
-};
-
-type MergedItem =
-  | { type: 'news'; item: INewsItemWithReadStatus; sortKey: Date }
-  | {
-      type: 'notification';
-      item: IInAppNotificationHasId;
-      sortKey: Date;
-    };
-
-export const InAppNotificationContent = (
-  props: InAppNotificationContentProps,
-): JSX.Element => {
-  const { isUnopendNotificationsVisible, activeFilter } = props;
-  const { t } = useTranslation('commons');
-  const { isCollapsedMode } = useSidebarMode();
-
-  // In collapsed mode (hover panel): constrain height + own scrollbar
-  // In dock/drawer mode: no constraints — outer SimpleBar handles all scrolling
-  const collapsed = isCollapsedMode();
-  const scrollAreaClassName = collapsed ? 'overflow-auto' : undefined;
-  const scrollAreaStyle = collapsed ? { maxHeight: '60vh' } : undefined;
-
-  const notificationStatus = isUnopendNotificationsVisible
-    ? InAppNotificationStatuses.STATUS_UNOPENED
-    : undefined;
-
-  // Always call both hooks (React rules of hooks)
-  const newsResponse = useSWRINFxNews(
-    NEWS_PER_PAGE,
-    { onlyUnread: isUnopendNotificationsVisible },
-    { keepPreviousData: true },
-  );
-  const { mutate: mutateNewsUnreadCount } = useSWRxNewsUnreadCount();
-
-  const notificationResponse = useSWRINFxInAppNotifications(
-    NEWS_PER_PAGE,
-    { status: notificationStatus },
-    { keepPreviousData: true },
-  );
-
-  const allNewsItems: INewsItemWithReadStatus[] = useMemo(() => {
-    if (!newsResponse.data) return [];
-    return newsResponse.data.flatMap((page) => page.docs);
-  }, [newsResponse.data]);
-
-  const allNotificationItems: IInAppNotificationHasId[] = useMemo(() => {
-    if (!notificationResponse.data) return [];
-    return notificationResponse.data.flatMap((page) => page.docs);
-  }, [notificationResponse.data]);
-
-  // Determine if each stream has exhausted its pages
-  const newsExhausted = useMemo(
-    () =>
-      newsResponse.data != null &&
-      newsResponse.data.length > 0 &&
-      !newsResponse.data[newsResponse.data.length - 1].hasNextPage,
-    [newsResponse.data],
-  );
-
-  const notifExhausted = useMemo(
-    () =>
-      notificationResponse.data != null &&
-      notificationResponse.data.length > 0 &&
-      !notificationResponse.data[notificationResponse.data.length - 1]
-        .hasNextPage,
-    [notificationResponse.data],
-  );
-
-  // Synthetic SWRInfiniteResponse for InfiniteScroll in 'all' mode.
-  // Typed to match newsResponse's shape so InfiniteScroll<E> receives a
-  // well-typed response without `as unknown as` casts.
-  const allModeSWRResponse = useMemo<
-    SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error>
-  >(
-    () => ({
-      data: newsResponse.data,
-      error: newsResponse.error ?? notificationResponse.error,
-      isValidating:
-        newsResponse.isValidating || notificationResponse.isValidating,
-      isLoading: newsResponse.isLoading || notificationResponse.isLoading,
-      mutate: newsResponse.mutate,
-      setSize: async (updater) => {
-        const nextNewsSize =
-          typeof updater === 'function' ? updater(newsResponse.size) : updater;
-        const nextNotifSize =
-          typeof updater === 'function'
-            ? updater(notificationResponse.size)
-            : updater;
-        const [newsResult] = await Promise.all([
-          newsExhausted
-            ? Promise.resolve(newsResponse.data)
-            : newsResponse.setSize(nextNewsSize),
-          notifExhausted
-            ? Promise.resolve(notificationResponse.data)
-            : notificationResponse.setSize(nextNotifSize),
-        ]);
-        return newsResult;
-      },
-      size: Math.max(newsResponse.size, notificationResponse.size),
-    }),
-    [newsResponse, notificationResponse, newsExhausted, notifExhausted],
-  );
-
-  // Merged and sorted items for 'all' filter
-  const mergedItems: MergedItem[] = useMemo(() => {
-    const newsEntries: MergedItem[] = allNewsItems.map((item) => ({
-      type: 'news',
-      item,
-      sortKey:
-        item.publishedAt instanceof Date
-          ? item.publishedAt
-          : new Date(item.publishedAt),
-    }));
-    const notifEntries: MergedItem[] = allNotificationItems.map((item) => ({
-      type: 'notification',
-      item,
-      sortKey:
-        item.createdAt instanceof Date
-          ? item.createdAt
-          : new Date(item.createdAt),
-    }));
-    return [...newsEntries, ...notifEntries].sort(
-      (a, b) => b.sortKey.getTime() - a.sortKey.getTime(),
-    );
-  }, [allNewsItems, allNotificationItems]);
-
-  const handleReadMutate = () => {
-    newsResponse.mutate();
-    mutateNewsUnreadCount();
-  };
-
-  // SWR-idiomatic optimistic update: rewrite the per-page cache in place and
-  // suppress revalidation so the dot stays removed across unmount/remount.
-  // The useSWRInfinite cache is held in the global SWR provider keyed by the
-  // composite list key, so subsequent mounts read this updated cache directly.
-  const handleNotificationRead = (notificationId: string) => {
-    notificationResponse.mutate(
-      (pages) =>
-        pages?.map((page) => ({
-          ...page,
-          docs: page.docs.map((doc) =>
-            doc._id.toString() === notificationId
-              ? { ...doc, status: InAppNotificationStatuses.STATUS_OPENED }
-              : doc,
-          ),
-        })),
-      { revalidate: false },
-    );
-  };
-
-  if (activeFilter === 'news') {
-    if (allNewsItems.length === 0 && !newsResponse.isValidating) {
-      return <>{t('in_app_notification.no_news')}</>;
-    }
-
-    return (
-      <div className={scrollAreaClassName} style={scrollAreaStyle}>
-        <InfiniteScroll
-          swrInifiniteResponse={newsResponse}
-          isReachingEnd={newsExhausted}
-        >
-          <div className="list-group">
-            {allNewsItems.map((item) => (
-              <NewsItem
-                key={item._id.toString()}
-                item={item}
-                onReadMutate={handleReadMutate}
-              />
-            ))}
-          </div>
-        </InfiniteScroll>
-      </div>
-    );
-  }
-
-  if (activeFilter === 'notifications') {
-    if (
-      allNotificationItems.length === 0 &&
-      !notificationResponse.isValidating
-    ) {
-      return <>{t('in_app_notification.no_notification')}</>;
-    }
-
-    return (
-      <div className={scrollAreaClassName} style={scrollAreaStyle}>
-        <InfiniteScroll
-          swrInifiniteResponse={notificationResponse}
-          isReachingEnd={notifExhausted}
-        >
-          <div className="list-group">
-            {allNotificationItems.map((notification) => {
-              const id = notification._id.toString();
-              return (
-                <InAppNotificationElm
-                  key={id}
-                  notification={notification}
-                  onUnopenedNotificationOpend={() => handleNotificationRead(id)}
-                />
-              );
-            })}
-          </div>
-        </InfiniteScroll>
-      </div>
-    );
-  }
-
-  // 'all' filter: merged view
-  if (
-    mergedItems.length === 0 &&
-    !newsResponse.isValidating &&
-    !notificationResponse.isValidating
-  ) {
-    return <>{t('in_app_notification.no_notification')}</>;
-  }
-
-  return (
-    <div className={scrollAreaClassName} style={scrollAreaStyle}>
-      <InfiniteScroll
-        swrInifiniteResponse={allModeSWRResponse}
-        isReachingEnd={newsExhausted && notifExhausted}
-      >
-        <div className="list-group">
-          {mergedItems.map((entry) => {
-            if (entry.type === 'news') {
-              return (
-                <NewsItem
-                  key={`news-${entry.item._id.toString()}`}
-                  item={entry.item}
-                  onReadMutate={handleReadMutate}
-                />
-              );
-            }
-            const id = entry.item._id.toString();
-            return (
-              <InAppNotificationElm
-                key={`notif-${id}`}
-                notification={entry.item}
-                onUnopenedNotificationOpend={() => handleNotificationRead(id)}
-              />
-            );
-          })}
-        </div>
-      </InfiniteScroll>
-    </div>
-  );
-};

+ 201 - 0
apps/app/src/client/components/Sidebar/InAppNotification/hooks/useMergedInAppNotifications.ts

@@ -0,0 +1,201 @@
+import { useCallback, useMemo } from 'react';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+
+import {
+  useSWRINFxNews,
+  useSWRxNewsUnreadCount,
+} from '~/features/news/client/hooks/use-news';
+import type { INewsItemWithReadStatus } from '~/features/news/interfaces/news-item';
+import type {
+  IInAppNotificationHasId,
+  PaginateResult,
+} from '~/interfaces/in-app-notification';
+import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+import { useSWRINFxInAppNotifications } from '~/stores/in-app-notification';
+
+const PER_PAGE = 10;
+
+export type MergedItem =
+  | { type: 'news'; item: INewsItemWithReadStatus; sortKey: Date }
+  | { type: 'notification'; item: IInAppNotificationHasId; sortKey: Date };
+
+export type UseMergedInAppNotificationsResult = {
+  newsResponse: SWRInfiniteResponse<
+    PaginateResult<INewsItemWithReadStatus>,
+    Error
+  >;
+  allNewsItems: INewsItemWithReadStatus[];
+  newsExhausted: boolean;
+
+  notificationResponse: SWRInfiniteResponse<
+    PaginateResult<IInAppNotificationHasId>,
+    Error
+  >;
+  allNotificationItems: IInAppNotificationHasId[];
+  notifExhausted: boolean;
+
+  allModeSWRResponse: SWRInfiniteResponse<
+    PaginateResult<INewsItemWithReadStatus>,
+    Error
+  >;
+  mergedItems: MergedItem[];
+
+  handleReadMutate: () => void;
+  handleNotificationRead: (notificationId: string) => void;
+};
+
+/**
+ * Encapsulates the data layer for the InAppNotification sidebar panel:
+ * - Two SWRInfinite streams (news + notifications)
+ * - Pagination exhaustion detection
+ * - A synthetic SWRInfiniteResponse for the merged "all" view
+ * - Client-side merge + sort by time
+ * - Read-state mutation handlers (SWR-native optimistic update)
+ */
+export const useMergedInAppNotifications = (
+  isUnopendNotificationsVisible: boolean,
+): UseMergedInAppNotificationsResult => {
+  const notificationStatus = isUnopendNotificationsVisible
+    ? InAppNotificationStatuses.STATUS_UNOPENED
+    : undefined;
+
+  const newsResponse = useSWRINFxNews(
+    PER_PAGE,
+    { onlyUnread: isUnopendNotificationsVisible },
+    { keepPreviousData: true },
+  );
+  const { mutate: mutateNewsUnreadCount } = useSWRxNewsUnreadCount();
+
+  const notificationResponse = useSWRINFxInAppNotifications(
+    PER_PAGE,
+    { status: notificationStatus },
+    { keepPreviousData: true },
+  );
+
+  const allNewsItems: INewsItemWithReadStatus[] = useMemo(() => {
+    if (!newsResponse.data) return [];
+    return newsResponse.data.flatMap((page) => page.docs);
+  }, [newsResponse.data]);
+
+  const allNotificationItems: IInAppNotificationHasId[] = useMemo(() => {
+    if (!notificationResponse.data) return [];
+    return notificationResponse.data.flatMap((page) => page.docs);
+  }, [notificationResponse.data]);
+
+  const newsExhausted = useMemo(
+    () =>
+      newsResponse.data != null &&
+      newsResponse.data.length > 0 &&
+      !newsResponse.data[newsResponse.data.length - 1].hasNextPage,
+    [newsResponse.data],
+  );
+
+  const notifExhausted = useMemo(
+    () =>
+      notificationResponse.data != null &&
+      notificationResponse.data.length > 0 &&
+      !notificationResponse.data[notificationResponse.data.length - 1]
+        .hasNextPage,
+    [notificationResponse.data],
+  );
+
+  // Synthetic SWRInfiniteResponse for InfiniteScroll in 'all' mode.
+  // Typed to match newsResponse's shape so InfiniteScroll<E> receives a
+  // well-typed response without `as unknown as` casts.
+  const allModeSWRResponse = useMemo<
+    SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error>
+  >(
+    () => ({
+      data: newsResponse.data,
+      error: newsResponse.error ?? notificationResponse.error,
+      isValidating:
+        newsResponse.isValidating || notificationResponse.isValidating,
+      isLoading: newsResponse.isLoading || notificationResponse.isLoading,
+      mutate: newsResponse.mutate,
+      setSize: async (updater) => {
+        const nextNewsSize =
+          typeof updater === 'function' ? updater(newsResponse.size) : updater;
+        const nextNotifSize =
+          typeof updater === 'function'
+            ? updater(notificationResponse.size)
+            : updater;
+        const [newsResult] = await Promise.all([
+          newsExhausted
+            ? Promise.resolve(newsResponse.data)
+            : newsResponse.setSize(nextNewsSize),
+          notifExhausted
+            ? Promise.resolve(notificationResponse.data)
+            : notificationResponse.setSize(nextNotifSize),
+        ]);
+        return newsResult;
+      },
+      size: Math.max(newsResponse.size, notificationResponse.size),
+    }),
+    [newsResponse, notificationResponse, newsExhausted, notifExhausted],
+  );
+
+  const mergedItems: MergedItem[] = useMemo(() => {
+    const newsEntries: MergedItem[] = allNewsItems.map((item) => ({
+      type: 'news',
+      item,
+      sortKey:
+        item.publishedAt instanceof Date
+          ? item.publishedAt
+          : new Date(item.publishedAt),
+    }));
+    const notifEntries: MergedItem[] = allNotificationItems.map((item) => ({
+      type: 'notification',
+      item,
+      sortKey:
+        item.createdAt instanceof Date
+          ? item.createdAt
+          : new Date(item.createdAt),
+    }));
+    return [...newsEntries, ...notifEntries].sort(
+      (a, b) => b.sortKey.getTime() - a.sortKey.getTime(),
+    );
+  }, [allNewsItems, allNotificationItems]);
+
+  // SWR's mutate is stable per cache key — destructure once and depend on it
+  // rather than the whole response object (which may carry unstable identity).
+  const { mutate: mutateNews } = newsResponse;
+  const { mutate: mutateNotifications } = notificationResponse;
+
+  const handleReadMutate = useCallback(() => {
+    mutateNews();
+    mutateNewsUnreadCount();
+  }, [mutateNews, mutateNewsUnreadCount]);
+
+  // SWR-idiomatic optimistic update: rewrite the per-page cache in place and
+  // suppress revalidation so the dot stays removed across unmount/remount.
+  const handleNotificationRead = useCallback(
+    (notificationId: string) => {
+      mutateNotifications(
+        (pages) =>
+          pages?.map((page) => ({
+            ...page,
+            docs: page.docs.map((doc) =>
+              doc._id.toString() === notificationId
+                ? { ...doc, status: InAppNotificationStatuses.STATUS_OPENED }
+                : doc,
+            ),
+          })),
+        { revalidate: false },
+      );
+    },
+    [mutateNotifications],
+  );
+
+  return {
+    newsResponse,
+    allNewsItems,
+    newsExhausted,
+    notificationResponse,
+    allNotificationItems,
+    notifExhausted,
+    allModeSWRResponse,
+    mergedItems,
+    handleReadMutate,
+    handleNotificationRead,
+  };
+};

+ 4 - 1
apps/app/src/features/news/client/components/NewsItem.tsx

@@ -1,4 +1,5 @@
 import type { FC } from 'react';
+import { memo } from 'react';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 
@@ -30,7 +31,7 @@ type Props = {
   onReadMutate: () => void;
 };
 
-export const NewsItem: FC<Props> = ({ item, onReadMutate }) => {
+const NewsItemInner: FC<Props> = ({ item, onReadMutate }) => {
   const { i18n } = useTranslation();
   const locale = i18n.language;
   const title = resolveTitle(item.title, locale);
@@ -78,3 +79,5 @@ export const NewsItem: FC<Props> = ({ item, onReadMutate }) => {
     </button>
   );
 };
+
+export const NewsItem = memo(NewsItemInner);

+ 4 - 4
apps/app/src/features/news/server/routes/news.ts

@@ -41,7 +41,7 @@ export const createNewsRouter = (crowi?: Crowi): express.Router => {
    */
   router.get(
     '/list',
-    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.READ.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
@@ -81,7 +81,7 @@ export const createNewsRouter = (crowi?: Crowi): express.Router => {
    */
   router.get(
     '/unread-count',
-    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.READ.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
@@ -107,7 +107,7 @@ export const createNewsRouter = (crowi?: Crowi): express.Router => {
    */
   router.post(
     '/mark-read',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.WRITE.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
@@ -142,7 +142,7 @@ export const createNewsRouter = (crowi?: Crowi): express.Router => {
    */
   router.post(
     '/mark-all-read',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.WRITE.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,

+ 2 - 2
apps/app/src/features/news/server/services/news-cron-service.spec.ts

@@ -66,8 +66,8 @@ describe('NewsCronService', () => {
   });
 
   describe('getCronSchedule', () => {
-    test('should return daily schedule at 1AM', () => {
-      expect(service.getCronSchedule()).toBe('0 1 * * *');
+    test('should return daily schedule at midnight', () => {
+      expect(service.getCronSchedule()).toBe('0 0 * * *');
     });
   });
 

+ 3 - 3
apps/app/src/features/news/server/services/news-cron-service.ts

@@ -7,8 +7,8 @@ import { NewsService } from './news-service';
 
 const logger = loggerFactory('growi:feature:news:cron');
 
-/** Maximum random sleep in ms (5 minutes) */
-const MAX_RANDOM_SLEEP_MS = 5 * 60 * 1000;
+/** Maximum random sleep in ms (5 hours) */
+const MAX_RANDOM_SLEEP_MS = 5 * 60 * 60 * 1000;
 
 /** HTTP fetch timeout in ms */
 const FETCH_TIMEOUT_MS = 10_000;
@@ -74,7 +74,7 @@ const randomSleep = (maxMs: number): Promise<void> => {
 
 export class NewsCronService extends CronService {
   override getCronSchedule(): string {
-    return '0 1 * * *';
+    return '0 0 * * *';
   }
 
   override async executeJob(): Promise<void> {

+ 24 - 13
apps/app/src/features/news/server/services/news-service.spec.ts

@@ -3,7 +3,7 @@ import mongoose from 'mongoose';
 // Use vi.hoisted so these variables are accessible inside vi.mock factory
 const mocks = vi.hoisted(() => {
   const newsItemFind = vi.fn();
-  const newsItemUpdateMany = vi.fn();
+  const newsItemBulkWrite = vi.fn();
   const newsItemDeleteMany = vi.fn();
   const newsItemCountDocuments = vi.fn();
 
@@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => {
   return {
     NewsItem: {
       find: newsItemFind,
-      updateMany: newsItemUpdateMany,
+      bulkWrite: newsItemBulkWrite,
       deleteMany: newsItemDeleteMany,
       countDocuments: newsItemCountDocuments,
     },
@@ -24,7 +24,7 @@ const mocks = vi.hoisted(() => {
       insertMany: newsReadStatusInsertMany,
     },
     newsItemFind,
-    newsItemUpdateMany,
+    newsItemBulkWrite,
     newsItemDeleteMany,
     newsItemCountDocuments,
     newsReadStatusDistinct,
@@ -372,8 +372,8 @@ describe('NewsService', () => {
   });
 
   describe('upsertNewsItems', () => {
-    test('should call updateMany with upsert for each item', async () => {
-      mocks.newsItemUpdateMany.mockResolvedValue({ upsertedCount: 1 });
+    test('should call bulkWrite with upsert for each item', async () => {
+      mocks.newsItemBulkWrite.mockResolvedValue({ upsertedCount: 1 });
 
       await service.upsertNewsItems([
         {
@@ -383,15 +383,17 @@ describe('NewsService', () => {
         },
       ]);
 
-      expect(mocks.newsItemUpdateMany).toHaveBeenCalledTimes(1);
-      const [filter, update, opts] = mocks.newsItemUpdateMany.mock.calls[0];
-      expect(filter).toEqual({ externalId: 'ext-001' });
-      expect(update.$set.externalId).toBe('ext-001');
-      expect(opts).toEqual({ upsert: true });
+      expect(mocks.newsItemBulkWrite).toHaveBeenCalledTimes(1);
+      const [ops, opts] = mocks.newsItemBulkWrite.mock.calls[0];
+      expect(ops).toHaveLength(1);
+      expect(ops[0].updateOne.filter).toEqual({ externalId: 'ext-001' });
+      expect(ops[0].updateOne.update.$set.externalId).toBe('ext-001');
+      expect(ops[0].updateOne.upsert).toBe(true);
+      expect(opts).toEqual({ ordered: false });
     });
 
-    test('should upsert multiple items', async () => {
-      mocks.newsItemUpdateMany.mockResolvedValue({ upsertedCount: 1 });
+    test('should batch multiple items into a single bulkWrite call', async () => {
+      mocks.newsItemBulkWrite.mockResolvedValue({ upsertedCount: 2 });
 
       await service.upsertNewsItems([
         {
@@ -406,7 +408,16 @@ describe('NewsService', () => {
         },
       ]);
 
-      expect(mocks.newsItemUpdateMany).toHaveBeenCalledTimes(2);
+      expect(mocks.newsItemBulkWrite).toHaveBeenCalledTimes(1);
+      const [ops] = mocks.newsItemBulkWrite.mock.calls[0];
+      expect(ops).toHaveLength(2);
+      expect(ops[0].updateOne.filter).toEqual({ externalId: 'ext-001' });
+      expect(ops[1].updateOne.filter).toEqual({ externalId: 'ext-002' });
+    });
+
+    test('should do nothing when items is empty', async () => {
+      await service.upsertNewsItems([]);
+      expect(mocks.newsItemBulkWrite).not.toHaveBeenCalled();
     });
   });
 

+ 11 - 8
apps/app/src/features/news/server/services/news-service.ts

@@ -143,13 +143,15 @@ export class NewsService {
    * Upsert news items from feed (keyed by externalId)
    */
   async upsertNewsItems(items: INewsItemInput[]): Promise<void> {
+    if (items.length === 0) return;
+
     const now = new Date();
 
-    await Promise.all(
-      items.map((item) =>
-        NewsItem.updateMany(
-          { externalId: item.id },
-          {
+    await NewsItem.bulkWrite(
+      items.map((item) => ({
+        updateOne: {
+          filter: { externalId: item.id },
+          update: {
             $set: {
               externalId: item.id,
               title: item.title,
@@ -161,9 +163,10 @@ export class NewsService {
               conditions: item.conditions,
             },
           },
-          { upsert: true },
-        ),
-      ),
+          upsert: true,
+        },
+      })),
+      { ordered: false },
     );
   }
 

+ 4 - 4
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -133,7 +133,7 @@ module.exports = (crowi: Crowi) => {
    */
   router.get(
     '/list',
-    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.READ.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
@@ -222,7 +222,7 @@ module.exports = (crowi: Crowi) => {
    */
   router.get(
     '/status',
-    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.READ.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
@@ -272,7 +272,7 @@ module.exports = (crowi: Crowi) => {
    */
   router.post(
     '/open',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.WRITE.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
@@ -309,7 +309,7 @@ module.exports = (crowi: Crowi) => {
    */
   router.put(
     '/all-statuses-open',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.WRITE.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,

+ 3 - 3
packages/core/src/interfaces/scope.spec.ts

@@ -63,9 +63,9 @@ describe('Scope type', () => {
     // Expected count based on the SCOPE_SEED structure:
     // Admin: 17 leaf scopes + 1 wildcard = 18
     // User Settings: 6 leaf + 2 nested (api) + 2 wildcards = 10
-    // Features: 6 leaf scopes + 1 wildcard = 7
-    // Total per action: 35
-    // Total: 35 * 2 (read/write) = 70
+    // Features: 7 leaf scopes + 1 wildcard = 8
+    // Total per action: 36
+    // Total: 36 * 2 (read/write) = 72
     // But some wildcards are at category level, so actual count may vary
 
     // Just ensure we have a reasonable number of scopes

+ 3 - 0
packages/core/src/interfaces/scope.ts

@@ -48,6 +48,7 @@ const SCOPE_SEED_USER = {
     bookmark: {},
     attachment: {},
     page_bulk_export: {},
+    in_app_notification: {},
   },
 } as const;
 
@@ -124,6 +125,7 @@ type ReadFeaturesScope =
   | 'read:features:bookmark'
   | 'read:features:attachment'
   | 'read:features:page_bulk_export'
+  | 'read:features:in_app_notification'
   | 'read:features:*';
 
 // Write scopes - Admin
@@ -167,6 +169,7 @@ type WriteFeaturesScope =
   | 'write:features:bookmark'
   | 'write:features:attachment'
   | 'write:features:page_bulk_export'
+  | 'write:features:in_app_notification'
   | 'write:features:*';
 
 // Combined Scope type - all valid scope strings