InAppNotificationSubstance.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import { type JSX, useId, useMemo } from 'react';
  2. import type { HasObjectId } from '@growi/core';
  3. import { useTranslation } from 'next-i18next';
  4. import InAppNotificationElm from '~/client/components/InAppNotification/InAppNotificationElm';
  5. import InfiniteScroll from '~/client/components/InfiniteScroll';
  6. import { NewsItem } from '~/features/news/client/components/NewsItem';
  7. import { useSWRINFxNews } from '~/features/news/client/hooks/use-news';
  8. import type { INewsItemWithReadStatus } from '~/features/news/interfaces/news-item';
  9. import type { IInAppNotification } from '~/interfaces/in-app-notification';
  10. import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
  11. import { useSWRINFxInAppNotifications } from '~/stores/in-app-notification';
  12. import type { FilterType } from './InAppNotification';
  13. const NEWS_PER_PAGE = 10;
  14. type InAppNotificationFormsProps = {
  15. isUnopendNotificationsVisible: boolean;
  16. onChangeUnopendNotificationsVisible: () => void;
  17. activeFilter: FilterType;
  18. onChangeFilter: (filter: FilterType) => void;
  19. };
  20. export const InAppNotificationForms = (
  21. props: InAppNotificationFormsProps,
  22. ): JSX.Element => {
  23. const {
  24. isUnopendNotificationsVisible,
  25. onChangeUnopendNotificationsVisible,
  26. activeFilter,
  27. onChangeFilter,
  28. } = props;
  29. const { t } = useTranslation('commons');
  30. const toggleId = useId();
  31. return (
  32. <div className="my-2">
  33. {/* Filter tabs */}
  34. <fieldset className="btn-group w-100 mb-2">
  35. <button
  36. type="button"
  37. className={`btn btn-sm ${activeFilter === 'all' ? 'btn-primary' : 'btn-outline-secondary'}`}
  38. onClick={() => onChangeFilter('all')}
  39. >
  40. {t('in_app_notification.filter_all')}
  41. </button>
  42. <button
  43. type="button"
  44. className={`btn btn-sm ${activeFilter === 'notifications' ? 'btn-primary' : 'btn-outline-secondary'}`}
  45. onClick={() => onChangeFilter('notifications')}
  46. >
  47. {t('in_app_notification.notifications')}
  48. </button>
  49. <button
  50. type="button"
  51. className={`btn btn-sm ${activeFilter === 'news' ? 'btn-primary' : 'btn-outline-secondary'}`}
  52. onClick={() => onChangeFilter('news')}
  53. >
  54. {t('in_app_notification.news')}
  55. </button>
  56. </fieldset>
  57. {/* Unread-only toggle */}
  58. <div className="form-check form-switch">
  59. <label className="form-check-label" htmlFor={toggleId}>
  60. {t('in_app_notification.only_unread')}
  61. </label>
  62. <input
  63. id={toggleId}
  64. className="form-check-input"
  65. type="checkbox"
  66. role="switch"
  67. aria-checked={isUnopendNotificationsVisible}
  68. checked={isUnopendNotificationsVisible}
  69. onChange={onChangeUnopendNotificationsVisible}
  70. />
  71. </div>
  72. </div>
  73. );
  74. };
  75. type InAppNotificationContentProps = {
  76. isUnopendNotificationsVisible: boolean;
  77. activeFilter: FilterType;
  78. };
  79. type MergedItem =
  80. | { type: 'news'; item: INewsItemWithReadStatus; sortKey: Date }
  81. | {
  82. type: 'notification';
  83. item: IInAppNotification & HasObjectId;
  84. sortKey: Date;
  85. };
  86. export const InAppNotificationContent = (
  87. props: InAppNotificationContentProps,
  88. ): JSX.Element => {
  89. const { isUnopendNotificationsVisible, activeFilter } = props;
  90. const { t } = useTranslation('commons');
  91. const notificationStatus = isUnopendNotificationsVisible
  92. ? InAppNotificationStatuses.STATUS_UNOPENED
  93. : undefined;
  94. // Always call both hooks (React rules of hooks)
  95. const newsResponse = useSWRINFxNews(
  96. NEWS_PER_PAGE,
  97. { onlyUnread: isUnopendNotificationsVisible },
  98. { keepPreviousData: true },
  99. );
  100. const notificationResponse = useSWRINFxInAppNotifications(
  101. NEWS_PER_PAGE,
  102. { status: notificationStatus },
  103. { keepPreviousData: true },
  104. );
  105. const allNewsItems: INewsItemWithReadStatus[] = useMemo(() => {
  106. if (!newsResponse.data) return [];
  107. return newsResponse.data.flatMap((page) => page.docs);
  108. }, [newsResponse.data]);
  109. const allNotificationItems: (IInAppNotification & HasObjectId)[] =
  110. useMemo(() => {
  111. if (!notificationResponse.data) return [];
  112. return notificationResponse.data.flatMap(
  113. (page) => page.docs,
  114. ) as (IInAppNotification & HasObjectId)[];
  115. }, [notificationResponse.data]);
  116. // Determine if each stream has exhausted its pages
  117. const newsExhausted = useMemo(
  118. () =>
  119. newsResponse.data != null &&
  120. newsResponse.data.length > 0 &&
  121. !newsResponse.data[newsResponse.data.length - 1].hasNextPage,
  122. [newsResponse.data],
  123. );
  124. const notifExhausted = useMemo(
  125. () =>
  126. notificationResponse.data != null &&
  127. notificationResponse.data.length > 0 &&
  128. !notificationResponse.data[notificationResponse.data.length - 1]
  129. .hasNextPage,
  130. [notificationResponse.data],
  131. );
  132. // Synthetic SWRInfiniteResponse for InfiniteScroll in 'all' mode
  133. const allModeSWRResponse = useMemo(
  134. () => ({
  135. data: newsResponse.data,
  136. error: newsResponse.error ?? notificationResponse.error,
  137. isValidating:
  138. newsResponse.isValidating || notificationResponse.isValidating,
  139. isLoading: newsResponse.isLoading || notificationResponse.isLoading,
  140. mutate: newsResponse.mutate,
  141. setSize: (updater: number | ((size: number) => number)) => {
  142. const promises: Promise<unknown>[] = [];
  143. if (!newsExhausted) {
  144. promises.push(
  145. newsResponse.setSize(
  146. typeof updater === 'function'
  147. ? updater(newsResponse.size)
  148. : updater,
  149. ),
  150. );
  151. }
  152. if (!notifExhausted) {
  153. promises.push(
  154. notificationResponse.setSize(
  155. typeof updater === 'function'
  156. ? updater(notificationResponse.size)
  157. : updater,
  158. ),
  159. );
  160. }
  161. return Promise.all(promises) as unknown as Promise<
  162. (typeof newsResponse.data)[]
  163. >;
  164. },
  165. size: Math.max(newsResponse.size, notificationResponse.size),
  166. }),
  167. [newsResponse, notificationResponse, newsExhausted, notifExhausted],
  168. );
  169. // Merged and sorted items for 'all' filter
  170. const mergedItems: MergedItem[] = useMemo(() => {
  171. const newsEntries: MergedItem[] = allNewsItems.map((item) => ({
  172. type: 'news',
  173. item,
  174. sortKey:
  175. item.publishedAt instanceof Date
  176. ? item.publishedAt
  177. : new Date(item.publishedAt),
  178. }));
  179. const notifEntries: MergedItem[] = allNotificationItems.map((item) => ({
  180. type: 'notification',
  181. item,
  182. sortKey:
  183. item.createdAt instanceof Date
  184. ? item.createdAt
  185. : new Date(item.createdAt),
  186. }));
  187. return [...newsEntries, ...notifEntries].sort(
  188. (a, b) => b.sortKey.getTime() - a.sortKey.getTime(),
  189. );
  190. }, [allNewsItems, allNotificationItems]);
  191. const handleReadMutate = () => {
  192. newsResponse.mutate();
  193. };
  194. if (activeFilter === 'news') {
  195. if (allNewsItems.length === 0 && !newsResponse.isValidating) {
  196. return <>{t('in_app_notification.no_news')}</>;
  197. }
  198. return (
  199. <div className="overflow-auto" style={{ maxHeight: '60vh' }}>
  200. <InfiniteScroll
  201. swrInifiniteResponse={newsResponse}
  202. isReachingEnd={newsExhausted}
  203. >
  204. <div className="list-group">
  205. {allNewsItems.map((item) => (
  206. <NewsItem
  207. key={item._id.toString()}
  208. item={item}
  209. onReadMutate={handleReadMutate}
  210. />
  211. ))}
  212. </div>
  213. </InfiniteScroll>
  214. </div>
  215. );
  216. }
  217. if (activeFilter === 'notifications') {
  218. if (
  219. allNotificationItems.length === 0 &&
  220. !notificationResponse.isValidating
  221. ) {
  222. return <>{t('in_app_notification.no_notification')}</>;
  223. }
  224. return (
  225. <div className="overflow-auto" style={{ maxHeight: '60vh' }}>
  226. <InfiniteScroll
  227. swrInifiniteResponse={notificationResponse}
  228. isReachingEnd={notifExhausted}
  229. >
  230. <div className="list-group">
  231. {allNotificationItems.map((notification) => (
  232. <InAppNotificationElm
  233. key={notification._id.toString()}
  234. notification={notification}
  235. />
  236. ))}
  237. </div>
  238. </InfiniteScroll>
  239. </div>
  240. );
  241. }
  242. // 'all' filter: merged view
  243. if (
  244. mergedItems.length === 0 &&
  245. !newsResponse.isValidating &&
  246. !notificationResponse.isValidating
  247. ) {
  248. return <>{t('in_app_notification.no_notification')}</>;
  249. }
  250. return (
  251. <div className="overflow-auto" style={{ maxHeight: '60vh' }}>
  252. <InfiniteScroll
  253. swrInifiniteResponse={
  254. allModeSWRResponse as unknown as Parameters<
  255. typeof InfiniteScroll
  256. >[0]['swrInifiniteResponse']
  257. }
  258. isReachingEnd={newsExhausted && notifExhausted}
  259. >
  260. <div className="list-group">
  261. {mergedItems.map((entry) => {
  262. if (entry.type === 'news') {
  263. return (
  264. <NewsItem
  265. key={`news-${entry.item._id.toString()}`}
  266. item={entry.item}
  267. onReadMutate={handleReadMutate}
  268. />
  269. );
  270. }
  271. return (
  272. <InAppNotificationElm
  273. key={`notif-${entry.item._id.toString()}`}
  274. notification={entry.item}
  275. />
  276. );
  277. })}
  278. </div>
  279. </InfiniteScroll>
  280. </div>
  281. );
  282. };