diff --git a/packages/pl-fe/src/columns/notifications.tsx b/packages/pl-fe/src/columns/notifications.tsx new file mode 100644 index 000000000..666edcc0f --- /dev/null +++ b/packages/pl-fe/src/columns/notifications.tsx @@ -0,0 +1,289 @@ +import clsx from 'clsx'; +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { createSelector } from 'reselect'; + +import { + type FilterType, + expandNotifications, + markReadNotifications, + scrollTopNotifications, + setFilter, +} from 'pl-fe/actions/notifications'; +import PullToRefresh from 'pl-fe/components/pull-to-refresh'; +import ScrollableList from 'pl-fe/components/scrollable-list'; +import Icon from 'pl-fe/components/ui/icon'; +import Tabs from 'pl-fe/components/ui/tabs'; +import Notification from 'pl-fe/features/notifications/components/notification'; +import PlaceholderNotification from 'pl-fe/features/placeholder/components/placeholder-notification'; +import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; +import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; +import { useFeatures } from 'pl-fe/hooks/use-features'; +import { useSettings } from 'pl-fe/hooks/use-settings'; + +import type { Item } from 'pl-fe/components/ui/tabs'; +import type { RootState } from 'pl-fe/store'; +import type { VirtuosoHandle } from 'react-virtuoso'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + queue: { id: 'notifications.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {notification} other {notifications}}' }, + all: { id: 'notifications.filter.all', defaultMessage: 'All' }, + mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, + statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' }, + favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' }, + boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' }, + polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, + events: { id: 'notifications.filter.events', defaultMessage: 'Events' }, + follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, +}); + +const FilterBar = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const settings = useSettings(); + const features = useFeatures(); + + const selectedFilter = settings.notifications.quickFilter.active; + const advancedMode = settings.notifications.quickFilter.advanced; + + const onClick = (notificationType: FilterType) => () => { + try { + dispatch(setFilter(notificationType, true)); + } catch (e) { + console.error(e); + } + }; + + const items: Item[] = [ + { + text: intl.formatMessage(messages.all), + action: onClick('all'), + name: 'all', + }, + ]; + + if (!advancedMode) { + items.push({ + text: intl.formatMessage(messages.mentions), + action: onClick('mention'), + name: 'mention', + }); + } else { + items.push({ + text: , + title: intl.formatMessage(messages.mentions), + action: onClick('mention'), + name: 'mention', + }); + if (features.accountNotifies) items.push({ + text: , + title: intl.formatMessage(messages.statuses), + action: onClick('status'), + name: 'status', + }); + items.push({ + text: , + title: intl.formatMessage(messages.favourites), + action: onClick('favourite'), + name: 'favourite', + }); + items.push({ + text: , + title: intl.formatMessage(messages.boosts), + action: onClick('reblog'), + name: 'reblog', + }); + if (features.polls) items.push({ + text: , + title: intl.formatMessage(messages.polls), + action: onClick('poll'), + name: 'poll', + }); + if (features.events) items.push({ + text: , + title: intl.formatMessage(messages.events), + action: onClick('events'), + name: 'events', + }); + items.push({ + text: , + title: intl.formatMessage(messages.follows), + action: onClick('follow'), + name: 'follow', + }); + } + + return ; +}; + +const getNotifications = createSelector([ + (state: RootState) => state.notifications.items, + (_, topNotification?: string) => topNotification, +], (notifications, topNotificationId) => { + if (topNotificationId) { + const queuedNotificationCount = notifications.findIndex((notification) => + notification.most_recent_notification_id <= topNotificationId, + ); + const displayedNotifications = notifications.slice(queuedNotificationCount); + + return { + queuedNotificationCount, + displayedNotifications, + }; + } + + return { + queuedNotificationCount: 0, + displayedNotifications: notifications, + }; +}); + +const NotificationsColumn = () => { + const dispatch = useAppDispatch(); + const features = useFeatures(); + const settings = useSettings(); + + const showFilterBar = (features.notificationsExcludeTypes || features.notificationsIncludeTypes) && settings.notifications.quickFilter.show; + const activeFilter = settings.notifications.quickFilter.active; + const [topNotification, setTopNotification] = useState(); + const { displayedNotifications } = useAppSelector(state => getNotifications(state, topNotification)); + const isLoading = useAppSelector(state => state.notifications.isLoading); + // const isUnread = useAppSelector(state => state.notifications.unread > 0); + const hasMore = useAppSelector(state => state.notifications.hasMore); + + const node = useRef(null); + const scrollableContentRef = useRef | null>(null); + + // const handleLoadGap = (maxId) => { + // dispatch(expandNotifications({ maxId })); + // }; + + const handleLoadOlder = useCallback(debounce(() => { + const minId = displayedNotifications.reduce( + (minId, notification) => minId && notification.page_min_id && notification.page_min_id > minId + ? minId + : notification.page_min_id, + undefined, + ); + dispatch(expandNotifications({ maxId: minId })); + }, 300, { leading: true }), [displayedNotifications]); + + const handleScrollToTop = useCallback(debounce(() => { + dispatch(scrollTopNotifications(true)); + }, 100), []); + + const handleScroll = useCallback(debounce(() => { + dispatch(scrollTopNotifications(false)); + }, 100), []); + + const handleMoveUp = (id: string) => { + const elementIndex = displayedNotifications.findIndex(item => item !== null && item.group_key === id) - 1; + _selectChild(elementIndex); + }; + + const handleMoveDown = (id: string) => { + const elementIndex = displayedNotifications.findIndex(item => item !== null && item.group_key === id) + 1; + _selectChild(elementIndex); + }; + + const _selectChild = (index: number) => { + const selector = `[data-index="${index}"] .focusable`; + const element = document.querySelector(selector); + + if (element) element.focus(); + + node.current?.scrollIntoView({ + index, + behavior: 'smooth', + done: () => { + if (!element) document.querySelector(selector)?.focus(); + }, + }); + }; + + const handleDequeueNotifications = useCallback(() => { + setTopNotification(undefined); + dispatch(markReadNotifications()); + }, []); + + const handleRefresh = useCallback(() => dispatch(expandNotifications()), []); + + useEffect(() => { + handleDequeueNotifications(); + dispatch(scrollTopNotifications(true)); + + return () => { + handleLoadOlder.cancel?.(); + handleScrollToTop.cancel(); + handleScroll.cancel?.(); + dispatch(scrollTopNotifications(false)); + }; + }, []); + + useEffect(() => { + if (topNotification || displayedNotifications.length === 0) return; + setTopNotification(displayedNotifications[0].most_recent_notification_id); + }, [displayedNotifications.length]); + + const emptyMessage = activeFilter === 'all' + ? + : ; + + let scrollableContent: Array | null = null; + + const filterBarContainer = showFilterBar + ? () + : null; + + if (isLoading && scrollableContentRef.current) { + scrollableContent = scrollableContentRef.current; + } else if (displayedNotifications.length > 0 || hasMore) { + scrollableContent = displayedNotifications.map((item) => ( + + )); + } else { + scrollableContent = null; + } + + scrollableContentRef.current = scrollableContent; + + const scrollContainer = ( + + {scrollableContent!} + + ); + + return ( + <> + {filterBarContainer} + + + {scrollContainer} + + + ); +}; + +export { NotificationsColumn as default }; diff --git a/packages/pl-fe/src/pages/notifications/notifications.tsx b/packages/pl-fe/src/pages/notifications/notifications.tsx index 2bffd338b..eeaa81405 100644 --- a/packages/pl-fe/src/pages/notifications/notifications.tsx +++ b/packages/pl-fe/src/pages/notifications/notifications.tsx @@ -1,126 +1,24 @@ -import clsx from 'clsx'; -import debounce from 'lodash/debounce'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import React, { useCallback, useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; import { createSelector } from 'reselect'; -import { - type FilterType, - expandNotifications, - markReadNotifications, - scrollTopNotifications, - setFilter, -} from 'pl-fe/actions/notifications'; -import PullToRefresh from 'pl-fe/components/pull-to-refresh'; +import { markReadNotifications } from 'pl-fe/actions/notifications'; +import NotificationsColumn from 'pl-fe/columns/notifications'; import ScrollTopButton from 'pl-fe/components/scroll-top-button'; -import ScrollableList from 'pl-fe/components/scrollable-list'; import Column from 'pl-fe/components/ui/column'; -import Icon from 'pl-fe/components/ui/icon'; import Portal from 'pl-fe/components/ui/portal'; -import Tabs from 'pl-fe/components/ui/tabs'; -import Notification from 'pl-fe/features/notifications/components/notification'; -import PlaceholderNotification from 'pl-fe/features/placeholder/components/placeholder-notification'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; import { useFeatures } from 'pl-fe/hooks/use-features'; import { useSettings } from 'pl-fe/hooks/use-settings'; -import type { Item } from 'pl-fe/components/ui/tabs'; import type { RootState } from 'pl-fe/store'; -import type { VirtuosoHandle } from 'react-virtuoso'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, queue: { id: 'notifications.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {notification} other {notifications}}' }, - all: { id: 'notifications.filter.all', defaultMessage: 'All' }, - mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, - statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' }, - favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' }, - boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' }, - polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, - events: { id: 'notifications.filter.events', defaultMessage: 'Events' }, - follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, }); -const FilterBar = () => { - const intl = useIntl(); - const dispatch = useAppDispatch(); - const settings = useSettings(); - const features = useFeatures(); - - const selectedFilter = settings.notifications.quickFilter.active; - const advancedMode = settings.notifications.quickFilter.advanced; - - const onClick = (notificationType: FilterType) => () => { - try { - dispatch(setFilter(notificationType, true)); - } catch (e) { - console.error(e); - } - }; - - const items: Item[] = [ - { - text: intl.formatMessage(messages.all), - action: onClick('all'), - name: 'all', - }, - ]; - - if (!advancedMode) { - items.push({ - text: intl.formatMessage(messages.mentions), - action: onClick('mention'), - name: 'mention', - }); - } else { - items.push({ - text: , - title: intl.formatMessage(messages.mentions), - action: onClick('mention'), - name: 'mention', - }); - if (features.accountNotifies) items.push({ - text: , - title: intl.formatMessage(messages.statuses), - action: onClick('status'), - name: 'status', - }); - items.push({ - text: , - title: intl.formatMessage(messages.favourites), - action: onClick('favourite'), - name: 'favourite', - }); - items.push({ - text: , - title: intl.formatMessage(messages.boosts), - action: onClick('reblog'), - name: 'reblog', - }); - if (features.polls) items.push({ - text: , - title: intl.formatMessage(messages.polls), - action: onClick('poll'), - name: 'poll', - }); - if (features.events) items.push({ - text: , - title: intl.formatMessage(messages.events), - action: onClick('events'), - name: 'events', - }); - items.push({ - text: , - title: intl.formatMessage(messages.follows), - action: onClick('follow'), - name: 'follow', - }); - } - - return ; -}; - const getNotifications = createSelector([ (state: RootState) => state.notifications.items, (_, topNotification?: string) => topNotification, @@ -150,139 +48,21 @@ const NotificationsPage = () => { const settings = useSettings(); const showFilterBar = (features.notificationsExcludeTypes || features.notificationsIncludeTypes) && settings.notifications.quickFilter.show; - const activeFilter = settings.notifications.quickFilter.active; const [topNotification, setTopNotification] = useState(); const { queuedNotificationCount, displayedNotifications } = useAppSelector(state => getNotifications(state, topNotification)); - const isLoading = useAppSelector(state => state.notifications.isLoading); - // const isUnread = useAppSelector(state => state.notifications.unread > 0); - const hasMore = useAppSelector(state => state.notifications.hasMore); - - const node = useRef(null); - const scrollableContentRef = useRef | null>(null); - - // const handleLoadGap = (maxId) => { - // dispatch(expandNotifications({ maxId })); - // }; - - const handleLoadOlder = useCallback(debounce(() => { - const minId = displayedNotifications.reduce( - (minId, notification) => minId && notification.page_min_id && notification.page_min_id > minId - ? minId - : notification.page_min_id, - undefined, - ); - dispatch(expandNotifications({ maxId: minId })); - }, 300, { leading: true }), [displayedNotifications]); - - const handleScrollToTop = useCallback(debounce(() => { - dispatch(scrollTopNotifications(true)); - }, 100), []); - - const handleScroll = useCallback(debounce(() => { - dispatch(scrollTopNotifications(false)); - }, 100), []); - - const handleMoveUp = (id: string) => { - const elementIndex = displayedNotifications.findIndex(item => item !== null && item.group_key === id) - 1; - _selectChild(elementIndex); - }; - - const handleMoveDown = (id: string) => { - const elementIndex = displayedNotifications.findIndex(item => item !== null && item.group_key === id) + 1; - _selectChild(elementIndex); - }; - - const _selectChild = (index: number) => { - const selector = `[data-index="${index}"] .focusable`; - const element = document.querySelector(selector); - - if (element) element.focus(); - - node.current?.scrollIntoView({ - index, - behavior: 'smooth', - done: () => { - if (!element) document.querySelector(selector)?.focus(); - }, - }); - }; const handleDequeueNotifications = useCallback(() => { setTopNotification(undefined); dispatch(markReadNotifications()); }, []); - const handleRefresh = useCallback(() => dispatch(expandNotifications()), []); - - useEffect(() => { - handleDequeueNotifications(); - dispatch(scrollTopNotifications(true)); - - return () => { - handleLoadOlder.cancel?.(); - handleScrollToTop.cancel(); - handleScroll.cancel?.(); - dispatch(scrollTopNotifications(false)); - }; - }, []); - useEffect(() => { if (topNotification || displayedNotifications.length === 0) return; setTopNotification(displayedNotifications[0].most_recent_notification_id); }, [displayedNotifications.length]); - const emptyMessage = activeFilter === 'all' - ? - : ; - - let scrollableContent: Array | null = null; - - const filterBarContainer = showFilterBar - ? () - : null; - - if (isLoading && scrollableContentRef.current) { - scrollableContent = scrollableContentRef.current; - } else if (displayedNotifications.length > 0 || hasMore) { - scrollableContent = displayedNotifications.map((item) => ( - - )); - } else { - scrollableContent = null; - } - - scrollableContentRef.current = scrollableContent; - - const scrollContainer = ( - - {scrollableContent!} - - ); - return ( - {filterBarContainer} - { /> - - {scrollContainer} - + ); };