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 (
{/* Filter tabs */}
{/* Unread-only toggle */}
); }; 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 receives a // well-typed response without `as unknown as` casts. const allModeSWRResponse = useMemo< SWRInfiniteResponse, 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 (
{allNewsItems.map((item) => ( ))}
); } if (activeFilter === 'notifications') { if ( allNotificationItems.length === 0 && !notificationResponse.isValidating ) { return <>{t('in_app_notification.no_notification')}; } return (
{allNotificationItems.map((notification) => { const id = notification._id.toString(); return ( handleNotificationRead(id)} /> ); })}
); } // 'all' filter: merged view if ( mergedItems.length === 0 && !newsResponse.isValidating && !notificationResponse.isValidating ) { return <>{t('in_app_notification.no_notification')}; } return (
{mergedItems.map((entry) => { if (entry.type === 'news') { return ( ); } const id = entry.item._id.toString(); return ( handleNotificationRead(id)} /> ); })}
); };