glitch-soc/mastodon

View on GitHub
app/javascript/flavours/glitch/features/notifications_v2/index.tsx

Summary

Maintainability
F
1 wk
Test Coverage
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 '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, 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;