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