pl-fe: refactor to using reusable columns

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-06-29 12:13:23 +02:00
parent 45e1a4cd4e
commit c85158f4ea
2 changed files with 294 additions and 227 deletions

View File

@ -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: <Icon className='size-4' src={require('@tabler/icons/outline/at.svg')} />,
title: intl.formatMessage(messages.mentions),
action: onClick('mention'),
name: 'mention',
});
if (features.accountNotifies) items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/bell-ringing.svg')} />,
title: intl.formatMessage(messages.statuses),
action: onClick('status'),
name: 'status',
});
items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/star.svg')} />,
title: intl.formatMessage(messages.favourites),
action: onClick('favourite'),
name: 'favourite',
});
items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/repeat.svg')} />,
title: intl.formatMessage(messages.boosts),
action: onClick('reblog'),
name: 'reblog',
});
if (features.polls) items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/chart-bar.svg')} />,
title: intl.formatMessage(messages.polls),
action: onClick('poll'),
name: 'poll',
});
if (features.events) items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/calendar.svg')} />,
title: intl.formatMessage(messages.events),
action: onClick('events'),
name: 'events',
});
items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/user-plus.svg')} />,
title: intl.formatMessage(messages.follows),
action: onClick('follow'),
name: 'follow',
});
}
return <Tabs items={items} activeItem={selectedFilter} />;
};
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<string>();
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<VirtuosoHandle>(null);
const scrollableContentRef = useRef<Array<JSX.Element> | null>(null);
// const handleLoadGap = (maxId) => {
// dispatch(expandNotifications({ maxId }));
// };
const handleLoadOlder = useCallback(debounce(() => {
const minId = displayedNotifications.reduce<string | undefined>(
(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<HTMLDivElement>(selector);
if (element) element.focus();
node.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
if (!element) document.querySelector<HTMLDivElement>(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'
? <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
: <FormattedMessage id='empty_column.notifications_filtered' defaultMessage="You don't have any notifications of this type yet." />;
let scrollableContent: Array<JSX.Element> | null = null;
const filterBarContainer = showFilterBar
? (<FilterBar />)
: null;
if (isLoading && scrollableContentRef.current) {
scrollableContent = scrollableContentRef.current;
} else if (displayedNotifications.length > 0 || hasMore) {
scrollableContent = displayedNotifications.map((item) => (
<Notification
key={item.group_key}
notification={item}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
));
} else {
scrollableContent = null;
}
scrollableContentRef.current = scrollableContent;
const scrollContainer = (
<ScrollableList
ref={node}
scrollKey='notifications'
isLoading={isLoading}
showLoading={isLoading && displayedNotifications.length === 0}
hasMore={hasMore}
emptyMessage={emptyMessage}
placeholderComponent={PlaceholderNotification}
placeholderCount={20}
onLoadMore={handleLoadOlder}
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
listClassName={clsx('divide-y divide-solid divide-gray-200 black:divide-gray-800 dark:divide-primary-800', {
'animate-pulse': displayedNotifications.length === 0,
})}
>
{scrollableContent!}
</ScrollableList>
);
return (
<>
{filterBarContainer}
<PullToRefresh onRefresh={handleRefresh}>
{scrollContainer}
</PullToRefresh>
</>
);
};
export { NotificationsColumn as default };

View File

@ -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: <Icon className='size-4' src={require('@tabler/icons/outline/at.svg')} />,
title: intl.formatMessage(messages.mentions),
action: onClick('mention'),
name: 'mention',
});
if (features.accountNotifies) items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/bell-ringing.svg')} />,
title: intl.formatMessage(messages.statuses),
action: onClick('status'),
name: 'status',
});
items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/star.svg')} />,
title: intl.formatMessage(messages.favourites),
action: onClick('favourite'),
name: 'favourite',
});
items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/repeat.svg')} />,
title: intl.formatMessage(messages.boosts),
action: onClick('reblog'),
name: 'reblog',
});
if (features.polls) items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/chart-bar.svg')} />,
title: intl.formatMessage(messages.polls),
action: onClick('poll'),
name: 'poll',
});
if (features.events) items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/calendar.svg')} />,
title: intl.formatMessage(messages.events),
action: onClick('events'),
name: 'events',
});
items.push({
text: <Icon className='size-4' src={require('@tabler/icons/outline/user-plus.svg')} />,
title: intl.formatMessage(messages.follows),
action: onClick('follow'),
name: 'follow',
});
}
return <Tabs items={items} activeItem={selectedFilter} />;
};
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<string>();
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<VirtuosoHandle>(null);
const scrollableContentRef = useRef<Array<JSX.Element> | null>(null);
// const handleLoadGap = (maxId) => {
// dispatch(expandNotifications({ maxId }));
// };
const handleLoadOlder = useCallback(debounce(() => {
const minId = displayedNotifications.reduce<string | undefined>(
(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<HTMLDivElement>(selector);
if (element) element.focus();
node.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
if (!element) document.querySelector<HTMLDivElement>(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'
? <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
: <FormattedMessage id='empty_column.notifications_filtered' defaultMessage="You don't have any notifications of this type yet." />;
let scrollableContent: Array<JSX.Element> | null = null;
const filterBarContainer = showFilterBar
? (<FilterBar />)
: null;
if (isLoading && scrollableContentRef.current) {
scrollableContent = scrollableContentRef.current;
} else if (displayedNotifications.length > 0 || hasMore) {
scrollableContent = displayedNotifications.map((item) => (
<Notification
key={item.group_key}
notification={item}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
));
} else {
scrollableContent = null;
}
scrollableContentRef.current = scrollableContent;
const scrollContainer = (
<ScrollableList
ref={node}
scrollKey='notifications'
isLoading={isLoading}
showLoading={isLoading && displayedNotifications.length === 0}
hasMore={hasMore}
emptyMessage={emptyMessage}
placeholderComponent={PlaceholderNotification}
placeholderCount={20}
onLoadMore={handleLoadOlder}
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
listClassName={clsx('divide-y divide-solid divide-gray-200 black:divide-gray-800 dark:divide-primary-800', {
'animate-pulse': displayedNotifications.length === 0,
})}
>
{scrollableContent!}
</ScrollableList>
);
return (
<Column label={intl.formatMessage(messages.title)} withHeader={!showFilterBar}>
{filterBarContainer}
<Portal>
<ScrollTopButton
onClick={handleDequeueNotifications}
@ -291,9 +71,7 @@ const NotificationsPage = () => {
/>
</Portal>
<PullToRefresh onRefresh={handleRefresh}>
{scrollContainer}
</PullToRefresh>
<NotificationsColumn />
</Column>
);
};