| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308 |
- import { type JSX, useId, useMemo } from 'react';
- import type { HasObjectId } from '@growi/core';
- 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 { useSWRINFxNews } from '~/features/news/client/hooks/use-news';
- import type { INewsItemWithReadStatus } from '~/features/news/interfaces/news-item';
- import type { IInAppNotification } from '~/interfaces/in-app-notification';
- import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
- 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: IInAppNotification & HasObjectId;
- sortKey: Date;
- };
- export const InAppNotificationContent = (
- props: InAppNotificationContentProps,
- ): JSX.Element => {
- const { isUnopendNotificationsVisible, activeFilter } = props;
- const { t } = useTranslation('commons');
- 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 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: (IInAppNotification & HasObjectId)[] =
- useMemo(() => {
- if (!notificationResponse.data) return [];
- return notificationResponse.data.flatMap(
- (page) => page.docs,
- ) as (IInAppNotification & HasObjectId)[];
- }, [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
- const allModeSWRResponse = useMemo(
- () => ({
- data: newsResponse.data,
- error: newsResponse.error ?? notificationResponse.error,
- isValidating:
- newsResponse.isValidating || notificationResponse.isValidating,
- isLoading: newsResponse.isLoading || notificationResponse.isLoading,
- mutate: newsResponse.mutate,
- setSize: (updater: number | ((size: number) => number)) => {
- const promises: Promise<unknown>[] = [];
- if (!newsExhausted) {
- promises.push(
- newsResponse.setSize(
- typeof updater === 'function'
- ? updater(newsResponse.size)
- : updater,
- ),
- );
- }
- if (!notifExhausted) {
- promises.push(
- notificationResponse.setSize(
- typeof updater === 'function'
- ? updater(notificationResponse.size)
- : updater,
- ),
- );
- }
- return Promise.all(promises) as unknown as Promise<
- (typeof newsResponse.data)[]
- >;
- },
- 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();
- };
- if (activeFilter === 'news') {
- if (allNewsItems.length === 0 && !newsResponse.isValidating) {
- return <>{t('in_app_notification.no_news')}</>;
- }
- return (
- <div className="overflow-auto" style={{ maxHeight: '60vh' }}>
- <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="overflow-auto" style={{ maxHeight: '60vh' }}>
- <InfiniteScroll
- swrInifiniteResponse={notificationResponse}
- isReachingEnd={notifExhausted}
- >
- <div className="list-group">
- {allNotificationItems.map((notification) => (
- <InAppNotificationElm
- key={notification._id.toString()}
- notification={notification}
- />
- ))}
- </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="overflow-auto" style={{ maxHeight: '60vh' }}>
- <InfiniteScroll
- swrInifiniteResponse={
- allModeSWRResponse as unknown as Parameters<
- typeof InfiniteScroll
- >[0]['swrInifiniteResponse']
- }
- 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}
- />
- );
- }
- return (
- <InAppNotificationElm
- key={`notif-${entry.item._id.toString()}`}
- notification={entry.item}
- />
- );
- })}
- </div>
- </InfiniteScroll>
- </div>
- );
- };
|