344 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			344 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { useCallback, useEffect, useMemo, useRef } from 'react';
 | |
| 
 | |
| import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
 | |
| 
 | |
| import { Helmet } from 'react-helmet';
 | |
| 
 | |
| import { isEqual } from 'lodash';
 | |
| import { useDebouncedCallback } from 'use-debounce';
 | |
| 
 | |
| import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
 | |
| import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
 | |
| import {
 | |
|   fetchNotificationsGap,
 | |
|   updateScrollPosition,
 | |
|   loadPending,
 | |
|   markNotificationsAsRead,
 | |
|   mountNotifications,
 | |
|   unmountNotifications,
 | |
| } from 'mastodon/actions/notification_groups';
 | |
| import { compareId } from 'mastodon/compare_id';
 | |
| import { Icon } from 'mastodon/components/icon';
 | |
| import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
 | |
| import { useIdentity } from 'mastodon/identity_context';
 | |
| import type { NotificationGap } from 'mastodon/reducers/notification_groups';
 | |
| import {
 | |
|   selectUnreadNotificationGroupsCount,
 | |
|   selectPendingNotificationGroupsCount,
 | |
|   selectAnyPendingNotification,
 | |
|   selectNotificationGroups,
 | |
| } from 'mastodon/selectors/notifications';
 | |
| import {
 | |
|   selectNeedsNotificationPermission,
 | |
|   selectSettingsNotificationsShowUnread,
 | |
| } from 'mastodon/selectors/settings';
 | |
| import { useAppDispatch, useAppSelector } from 'mastodon/store';
 | |
| 
 | |
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 | |
| import { submitMarkers } from '../../actions/markers';
 | |
| import Column from '../../components/column';
 | |
| import { ColumnHeader } from '../../components/column_header';
 | |
| import { LoadGap } from '../../components/load_gap';
 | |
| import ScrollableList from '../../components/scrollable_list';
 | |
| import {
 | |
|   FilteredNotificationsBanner,
 | |
|   FilteredNotificationsIconButton,
 | |
| } from '../notifications/components/filtered_notifications_banner';
 | |
| import NotificationsPermissionBanner from '../notifications/components/notifications_permission_banner';
 | |
| import ColumnSettingsContainer from '../notifications/containers/column_settings_container';
 | |
| 
 | |
| import { NotificationGroup } from './components/notification_group';
 | |
| import { FilterBar } from './filter_bar';
 | |
| 
 | |
| const messages = defineMessages({
 | |
|   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
 | |
|   markAsRead: {
 | |
|     id: 'notifications.mark_as_read',
 | |
|     defaultMessage: 'Mark every notification as read',
 | |
|   },
 | |
| });
 | |
| 
 | |
| export const Notifications: React.FC<{
 | |
|   columnId?: string;
 | |
|   multiColumn?: boolean;
 | |
| }> = ({ columnId, multiColumn }) => {
 | |
|   const intl = useIntl();
 | |
|   const notifications = useAppSelector(selectNotificationGroups, isEqual);
 | |
|   const dispatch = useAppDispatch();
 | |
|   const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
 | |
|   const hasMore = notifications.at(-1)?.type === 'gap';
 | |
| 
 | |
|   const lastReadId = useAppSelector((s) =>
 | |
|     selectSettingsNotificationsShowUnread(s)
 | |
|       ? s.notificationGroups.readMarkerId
 | |
|       : '0',
 | |
|   );
 | |
| 
 | |
|   const numPending = useAppSelector(selectPendingNotificationGroupsCount);
 | |
| 
 | |
|   const unreadNotificationsCount = useAppSelector(
 | |
|     selectUnreadNotificationGroupsCount,
 | |
|   );
 | |
| 
 | |
|   const anyPendingNotification = useAppSelector(selectAnyPendingNotification);
 | |
| 
 | |
|   const needsReload = useAppSelector(
 | |
|     (state) => state.notificationGroups.mergedNotifications === 'needs-reload',
 | |
|   );
 | |
| 
 | |
|   const isUnread = unreadNotificationsCount > 0 || needsReload;
 | |
| 
 | |
|   const canMarkAsRead =
 | |
|     useAppSelector(selectSettingsNotificationsShowUnread) &&
 | |
|     anyPendingNotification;
 | |
| 
 | |
|   const needsNotificationPermission = useAppSelector(
 | |
|     selectNeedsNotificationPermission,
 | |
|   );
 | |
| 
 | |
|   const columnRef = useRef<Column>(null);
 | |
| 
 | |
|   const selectChild = useCallback((index: number, alignTop: boolean) => {
 | |
|     const container = columnRef.current?.node as HTMLElement | undefined;
 | |
| 
 | |
|     if (!container) return;
 | |
| 
 | |
|     const element = container.querySelector<HTMLElement>(
 | |
|       `article:nth-of-type(${index + 1}) .focusable`,
 | |
|     );
 | |
| 
 | |
|     if (element) {
 | |
|       if (alignTop && container.scrollTop > element.offsetTop) {
 | |
|         element.scrollIntoView(true);
 | |
|       } else if (
 | |
|         !alignTop &&
 | |
|         container.scrollTop + container.clientHeight <
 | |
|           element.offsetTop + element.offsetHeight
 | |
|       ) {
 | |
|         element.scrollIntoView(false);
 | |
|       }
 | |
|       element.focus();
 | |
|     }
 | |
|   }, []);
 | |
| 
 | |
|   // Keep track of mounted components for unread notification handling
 | |
|   useEffect(() => {
 | |
|     void dispatch(mountNotifications());
 | |
| 
 | |
|     return () => {
 | |
|       dispatch(unmountNotifications());
 | |
|       void dispatch(updateScrollPosition({ top: false }));
 | |
|     };
 | |
|   }, [dispatch]);
 | |
| 
 | |
|   const handleLoadGap = useCallback(
 | |
|     (gap: NotificationGap) => {
 | |
|       void dispatch(fetchNotificationsGap({ gap }));
 | |
|     },
 | |
|     [dispatch],
 | |
|   );
 | |
| 
 | |
|   const handleLoadOlder = useDebouncedCallback(
 | |
|     () => {
 | |
|       const gap = notifications.at(-1);
 | |
|       if (gap?.type === 'gap') void dispatch(fetchNotificationsGap({ gap }));
 | |
|     },
 | |
|     300,
 | |
|     { leading: true },
 | |
|   );
 | |
| 
 | |
|   const handleLoadPending = useCallback(() => {
 | |
|     dispatch(loadPending());
 | |
|   }, [dispatch]);
 | |
| 
 | |
|   const handleScrollToTop = useDebouncedCallback(() => {
 | |
|     void dispatch(updateScrollPosition({ top: true }));
 | |
|   }, 100);
 | |
| 
 | |
|   const handleScroll = useDebouncedCallback(() => {
 | |
|     void dispatch(updateScrollPosition({ top: false }));
 | |
|   }, 100);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     return () => {
 | |
|       handleLoadOlder.cancel();
 | |
|       handleScrollToTop.cancel();
 | |
|       handleScroll.cancel();
 | |
|     };
 | |
|   }, [handleLoadOlder, handleScrollToTop, handleScroll]);
 | |
| 
 | |
|   const handlePin = useCallback(() => {
 | |
|     if (columnId) {
 | |
|       dispatch(removeColumn(columnId));
 | |
|     } else {
 | |
|       dispatch(addColumn('NOTIFICATIONS', {}));
 | |
|     }
 | |
|   }, [columnId, dispatch]);
 | |
| 
 | |
|   const handleMove = useCallback(
 | |
|     (dir: unknown) => {
 | |
|       dispatch(moveColumn(columnId, dir));
 | |
|     },
 | |
|     [dispatch, columnId],
 | |
|   );
 | |
| 
 | |
|   const handleHeaderClick = useCallback(() => {
 | |
|     columnRef.current?.scrollTop();
 | |
|   }, []);
 | |
| 
 | |
|   const handleMoveUp = useCallback(
 | |
|     (id: string) => {
 | |
|       const elementIndex =
 | |
|         notifications.findIndex(
 | |
|           (item) => item.type !== 'gap' && item.group_key === id,
 | |
|         ) - 1;
 | |
|       selectChild(elementIndex, true);
 | |
|     },
 | |
|     [notifications, selectChild],
 | |
|   );
 | |
| 
 | |
|   const handleMoveDown = useCallback(
 | |
|     (id: string) => {
 | |
|       const elementIndex =
 | |
|         notifications.findIndex(
 | |
|           (item) => item.type !== 'gap' && item.group_key === id,
 | |
|         ) + 1;
 | |
|       selectChild(elementIndex, false);
 | |
|     },
 | |
|     [notifications, selectChild],
 | |
|   );
 | |
| 
 | |
|   const handleMarkAsRead = useCallback(() => {
 | |
|     dispatch(markNotificationsAsRead());
 | |
|     void dispatch(submitMarkers({ immediate: true }));
 | |
|   }, [dispatch]);
 | |
| 
 | |
|   const pinned = !!columnId;
 | |
|   const emptyMessage = (
 | |
|     <FormattedMessage
 | |
|       id='empty_column.notifications'
 | |
|       defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here."
 | |
|     />
 | |
|   );
 | |
| 
 | |
|   const { signedIn } = useIdentity();
 | |
| 
 | |
|   const filterBar = signedIn ? <FilterBar /> : null;
 | |
| 
 | |
|   const scrollableContent = useMemo(() => {
 | |
|     if (notifications.length === 0 && !hasMore) return null;
 | |
| 
 | |
|     return notifications.map((item) =>
 | |
|       item.type === 'gap' ? (
 | |
|         <LoadGap
 | |
|           key={`${item.maxId}-${item.sinceId}`}
 | |
|           disabled={isLoading}
 | |
|           param={item}
 | |
|           onClick={handleLoadGap}
 | |
|         />
 | |
|       ) : (
 | |
|         <NotificationGroup
 | |
|           key={item.group_key}
 | |
|           notificationGroupId={item.group_key}
 | |
|           onMoveUp={handleMoveUp}
 | |
|           onMoveDown={handleMoveDown}
 | |
|           unread={
 | |
|             lastReadId !== '0' &&
 | |
|             !!item.page_max_id &&
 | |
|             compareId(item.page_max_id, lastReadId) > 0
 | |
|           }
 | |
|         />
 | |
|       ),
 | |
|     );
 | |
|   }, [
 | |
|     notifications,
 | |
|     isLoading,
 | |
|     hasMore,
 | |
|     lastReadId,
 | |
|     handleLoadGap,
 | |
|     handleMoveUp,
 | |
|     handleMoveDown,
 | |
|   ]);
 | |
| 
 | |
|   const prepend = (
 | |
|     <>
 | |
|       {needsNotificationPermission && <NotificationsPermissionBanner />}
 | |
|       <FilteredNotificationsBanner />
 | |
|     </>
 | |
|   );
 | |
| 
 | |
|   const scrollContainer = signedIn ? (
 | |
|     <ScrollableList
 | |
|       scrollKey={`notifications-${columnId}`}
 | |
|       trackScroll={!pinned}
 | |
|       isLoading={isLoading}
 | |
|       showLoading={isLoading && notifications.length === 0}
 | |
|       hasMore={hasMore}
 | |
|       numPending={numPending}
 | |
|       prepend={prepend}
 | |
|       alwaysPrepend
 | |
|       emptyMessage={emptyMessage}
 | |
|       onLoadMore={handleLoadOlder}
 | |
|       onLoadPending={handleLoadPending}
 | |
|       onScrollToTop={handleScrollToTop}
 | |
|       onScroll={handleScroll}
 | |
|       bindToDocument={!multiColumn}
 | |
|     >
 | |
|       {scrollableContent}
 | |
|     </ScrollableList>
 | |
|   ) : (
 | |
|     <NotSignedInIndicator />
 | |
|   );
 | |
| 
 | |
|   const extraButton = (
 | |
|     <>
 | |
|       <FilteredNotificationsIconButton className='column-header__button' />
 | |
|       {canMarkAsRead && (
 | |
|         <button
 | |
|           aria-label={intl.formatMessage(messages.markAsRead)}
 | |
|           title={intl.formatMessage(messages.markAsRead)}
 | |
|           onClick={handleMarkAsRead}
 | |
|           className='column-header__button'
 | |
|         >
 | |
|           <Icon id='done-all' icon={DoneAllIcon} />
 | |
|         </button>
 | |
|       )}
 | |
|     </>
 | |
|   );
 | |
| 
 | |
|   return (
 | |
|     <Column
 | |
|       bindToDocument={!multiColumn}
 | |
|       ref={columnRef}
 | |
|       label={intl.formatMessage(messages.title)}
 | |
|     >
 | |
|       <ColumnHeader
 | |
|         icon='bell'
 | |
|         iconComponent={NotificationsIcon}
 | |
|         active={isUnread}
 | |
|         title={intl.formatMessage(messages.title)}
 | |
|         onPin={handlePin}
 | |
|         onMove={handleMove}
 | |
|         onClick={handleHeaderClick}
 | |
|         pinned={pinned}
 | |
|         multiColumn={multiColumn}
 | |
|         extraButton={extraButton}
 | |
|       >
 | |
|         <ColumnSettingsContainer />
 | |
|       </ColumnHeader>
 | |
| 
 | |
|       {filterBar}
 | |
| 
 | |
|       {scrollContainer}
 | |
| 
 | |
|       <Helmet>
 | |
|         <title>{intl.formatMessage(messages.title)}</title>
 | |
|         <meta name='robots' content='noindex' />
 | |
|       </Helmet>
 | |
|     </Column>
 | |
|   );
 | |
| };
 | |
| 
 | |
| // eslint-disable-next-line import/no-default-export
 | |
| export default Notifications;
 |