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