Browse Source

refactor(spec): split InAppNotificationSubstance into forms, content, and data hook

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ryotaro Nagahara 3 weeks ago
parent
commit
0f683673cd

+ 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>
-  );
-};

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

@@ -0,0 +1,193 @@
+import { 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]);
+
+  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.
+  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 },
+    );
+  };
+
+  return {
+    newsResponse,
+    allNewsItems,
+    newsExhausted,
+    notificationResponse,
+    allNotificationItems,
+    notifExhausted,
+    allModeSWRResponse,
+    mergedItems,
+    handleReadMutate,
+    handleNotificationRead,
+  };
+};