339 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			339 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
import { useCallback, useEffect, useMemo, useRef } from 'react';
 | 
						|
 | 
						|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
 | 
						|
 | 
						|
import { Helmet } from 'react-helmet';
 | 
						|
 | 
						|
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 'flavours/glitch/actions/notification_groups';
 | 
						|
import { compareId } from 'flavours/glitch/compare_id';
 | 
						|
import { Icon } from 'flavours/glitch/components/icon';
 | 
						|
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
 | 
						|
import { useIdentity } from 'flavours/glitch/identity_context';
 | 
						|
import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups';
 | 
						|
import {
 | 
						|
  selectUnreadNotificationGroupsCount,
 | 
						|
  selectPendingNotificationGroupsCount,
 | 
						|
  selectAnyPendingNotification,
 | 
						|
  selectNotificationGroups,
 | 
						|
} from 'flavours/glitch/selectors/notifications';
 | 
						|
import {
 | 
						|
  selectNeedsNotificationPermission,
 | 
						|
  selectSettingsNotificationsShowUnread,
 | 
						|
} from 'flavours/glitch/selectors/settings';
 | 
						|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/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);
 | 
						|
  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 isUnread = unreadNotificationsCount > 0;
 | 
						|
 | 
						|
  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(() => {
 | 
						|
    dispatch(mountNotifications());
 | 
						|
 | 
						|
    return () => {
 | 
						|
      dispatch(unmountNotifications());
 | 
						|
      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(() => {
 | 
						|
    dispatch(updateScrollPosition({ top: true }));
 | 
						|
  }, 100);
 | 
						|
 | 
						|
  const handleScroll = useDebouncedCallback(() => {
 | 
						|
    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;
 |