From 62088f51bd5912af4522a855c6e32b23a3f92f22 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Wed, 22 May 2024 17:22:29 +0200 Subject: [PATCH] Add a new experimental notifications route --- .../mastodon/actions/notification_groups.ts | 6 +- .../mastodon/actions/notifications.js | 2 +- .../mastodon/api_types/notifications.ts | 2 +- .../components/notification_group.tsx | 42 ++ .../components/notification_reblog.tsx | 7 + .../features/notifications_v2/index.tsx | 397 ++++++++++++++++++ app/javascript/mastodon/features/ui/index.jsx | 2 + .../features/ui/util/async-components.js | 4 + .../mastodon/models/notification_group.ts | 26 +- app/javascript/mastodon/reducers/index.ts | 2 + .../mastodon/reducers/notifications_groups.ts | 25 +- config/routes.rb | 1 + package.json | 1 + yarn.lock | 10 + 14 files changed, 515 insertions(+), 12 deletions(-) create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_reblog.tsx create mode 100644 app/javascript/mastodon/features/notifications_v2/index.tsx diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index b804148eeb..5d584b021e 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -7,7 +7,7 @@ import { importFetchedAccounts, importFetchedStatuses } from './importer'; export const fetchNotifications = createDataLoadingThunk( 'notificationGroups/fetch', - apiFetchNotifications, + () => apiFetchNotifications(), (notifications, { dispatch }) => { const fetchedAccounts: ApiAccountJSON[] = []; const fetchedStatuses: ApiStatusJSON[] = []; @@ -21,8 +21,8 @@ export const fetchNotifications = createDataLoadingThunk( // fetchedAccounts.push(...notification.report.target_account); // } - if ('target_status' in notification) { - fetchedStatuses.push(notification.target_status); + if ('status' in notification) { + fetchedStatuses.push(notification.status); } }); diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index fe728aa26e..63d56d289d 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -178,7 +178,7 @@ const noOp = () => {}; let expandNotificationsController = new AbortController(); -export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { +export function expandNotifications({ maxId, forceLoad = false } = {}, done = noOp) { return (dispatch, getState) => { const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); const notifications = getState().get('notifications'); diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts index 779c16d373..483615ca25 100644 --- a/app/javascript/mastodon/api_types/notifications.ts +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -33,7 +33,7 @@ export interface BaseNotificationGroupJSON { interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { type: NotificationWithStatusType; - target_status: ApiStatusJSON; + status: ApiStatusJSON; } interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON { diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx new file mode 100644 index 0000000000..48ea2282b0 --- /dev/null +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx @@ -0,0 +1,42 @@ +import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group'; +import { useAppSelector } from 'mastodon/store'; + +import { NotificationReblog } from './notification_reblog'; + +export const NotificationGroup: React.FC<{ + notificationGroupId: NotificationGroupModel['group_key']; + unread: boolean; + onMoveUp: unknown; + onMoveDown: unknown; +}> = ({ notificationGroupId }) => { + const notificationGroup = useAppSelector((state) => + state.notificationsGroups.groups.find( + (item) => item.type !== 'gap' && item.group_key === notificationGroupId, + ), + ); + + if (!notificationGroup || notificationGroup.type === 'gap') return null; + + switch (notificationGroup.type) { + case 'reblog': + return ; + case 'follow': + case 'follow_request': + case 'favourite': + case 'mention': + case 'poll': + case 'status': + case 'update': + case 'admin.sign_up': + case 'admin.report': + case 'moderation_warning': + case 'severed_relationships': + default: + return ( +
+
{JSON.stringify(notificationGroup, undefined, 2)}
+
+
+ ); + } +}; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_reblog.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_reblog.tsx new file mode 100644 index 0000000000..0f06511ac1 --- /dev/null +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_reblog.tsx @@ -0,0 +1,7 @@ +import type { NotificationGroupReblog } from 'mastodon/models/notification_group'; + +export const NotificationReblog: React.FC<{ + notification: NotificationGroupReblog; +}> = ({ notification }) => { + return
reblog {notification.group_key}
; +}; diff --git a/app/javascript/mastodon/features/notifications_v2/index.tsx b/app/javascript/mastodon/features/notifications_v2/index.tsx new file mode 100644 index 0000000000..f28d5da1bc --- /dev/null +++ b/app/javascript/mastodon/features/notifications_v2/index.tsx @@ -0,0 +1,397 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { createSelector } from '@reduxjs/toolkit'; +import type { Map as ImmutableMap } from 'immutable'; +import { List as ImmutableList } from 'immutable'; + +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 { fetchNotifications } 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 { useAppDispatch, useAppSelector } from 'mastodon/store'; +import type { RootState } from 'mastodon/store'; + +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import { submitMarkers } from '../../actions/markers'; +import { + expandNotifications, + scrollTopNotifications, + loadPending, + mountNotifications, + unmountNotifications, + markNotificationsAsRead, +} from '../../actions/notifications'; +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 } from '../notifications/components/filtered_notifications_banner'; +import NotificationsPermissionBanner from '../notifications/components/notifications_permission_banner'; +import ColumnSettingsContainer from '../notifications/containers/column_settings_container'; +import FilterBarContainer from '../notifications/containers/filter_bar_container'; + +import { NotificationGroup } from './components/notification_group'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + markAsRead: { + id: 'notifications.mark_as_read', + defaultMessage: 'Mark every notification as read', + }, +}); + +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +// state.settings is not yet typed, so we disable some ESLint checks for those selectors +const selectSettingsNotificationsShow = (state: RootState) => + state.settings.getIn(['notifications', 'shows']) as ImmutableMap< + string, + boolean + >; + +const selectSettingsNotificationsQuickFilterShow = (state: RootState) => + state.settings.getIn(['notifications', 'quickFilter', 'show']) as boolean; + +const selectSettingsNotificationsQuickFilterActive = (state: RootState) => + state.settings.getIn(['notifications', 'quickFilter', 'active']) as string; + +const selectSettingsNotificationsShowUnread = (state: RootState) => + state.settings.getIn(['notifications', 'showUnread']) as boolean; + +const selectNeedsNotificationPermission = (state: RootState) => + (state.settings.getIn(['notifications', 'alerts']).includes(true) && + state.notifications.get('browserSupport') && + state.notifications.get('browserPermission') === 'default' && + !state.settings.getIn([ + 'notifications', + 'dismissPermissionBanner', + ])) as boolean; + +/* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + +const getExcludedTypes = createSelector( + [selectSettingsNotificationsShow], + (shows) => { + return ImmutableList(shows.filter((item) => !item).keys()); + }, +); + +const getNotifications = createSelector( + [ + selectSettingsNotificationsQuickFilterShow, + selectSettingsNotificationsQuickFilterActive, + getExcludedTypes, + (state: RootState) => state.notificationsGroups.groups, + ], + (showFilterBar, allowedType, excludedTypes, notifications) => { + if (!showFilterBar || allowedType === 'all') { + // used if user changed the notification settings after loading the notifications from the server + // otherwise a list of notifications will come pre-filtered from the backend + // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category + return notifications.filter( + (item) => item.type !== 'gap' || !excludedTypes.includes(item.type), + ); + } + return notifications.filter( + (item) => item.type !== 'gap' || allowedType === item.type, + ); + }, +); + +// const mapStateToProps = (state) => ({ +// isUnread: +// state.getIn(['notifications', 'unread']) > 0 || +// state.getIn(['notifications', 'pendingItems']).size > 0, +// numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()) +// .size, +// canMarkAsRead: +// state.getIn(['settings', 'notifications', 'showUnread']) && +// state.getIn(['notifications', 'readMarkerId']) !== '0' && +// getNotifications(state).some( +// (item) => +// item !== null && +// compareId( +// item.get('id'), +// state.getIn(['notifications', 'readMarkerId']), +// ) > 0, +// ), +// }); + +export const Notifications: React.FC<{ + columnId?: string; + isUnread?: boolean; + multiColumn?: boolean; + numPending: number; +}> = ({ isUnread, columnId, multiColumn, numPending }) => { + const intl = useIntl(); + const notifications = useAppSelector(getNotifications); + const dispatch = useAppDispatch(); + const isLoading = useAppSelector((s) => s.notificationsGroups.isLoading); + const hasMore = useAppSelector((s) => s.notificationsGroups.hasMore); + const readMarkerId = useAppSelector( + (s) => s.notificationsGroups.readMarkerId, + ); + const lastReadId = useAppSelector((s) => + selectSettingsNotificationsShowUnread(s) + ? s.notificationsGroups.readMarkerId + : '0', + ); + const canMarkAsRead = useAppSelector( + (s) => + selectSettingsNotificationsShowUnread(s) && + s.notificationsGroups.readMarkerId !== '0' && + notifications.some( + (item) => + item.type !== 'gap' && compareId(item.group_key, readMarkerId) > 0, + ), + ); + const needsNotificationPermission = useAppSelector( + selectNeedsNotificationPermission, + ); + + const columnRef = useRef(null); + + const selectChild = useCallback((index: number, alignTop: boolean) => { + const container = columnRef.current?.node as HTMLElement | undefined; + + if (!container) return; + + const element = container.querySelector( + `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(); + } + }, []); + + useEffect(() => { + dispatch(mountNotifications()); + + // FIXME: remove once this becomes the main implementation + void dispatch(fetchNotifications()); + + return () => { + dispatch(unmountNotifications()); + dispatch(scrollTopNotifications(false)); + }; + }, [dispatch]); + + const handleLoadGap = useCallback( + (maxId: string) => { + dispatch(expandNotifications({ maxId })); + }, + [dispatch], + ); + + // TODO: fix this, probably incorrect + const handleLoadOlder = useDebouncedCallback( + () => { + const last = notifications[notifications.length - 1]; + if (last && last.type !== 'gap') + dispatch(expandNotifications({ maxId: last.group_key })); + }, + 300, + { leading: true }, + ); + + const handleLoadPending = useCallback(() => { + dispatch(loadPending()); + }, [dispatch]); + + const handleScrollToTop = useDebouncedCallback(() => { + dispatch(scrollTopNotifications(true)); + }, 100); + + const handleScroll = useDebouncedCallback(() => { + dispatch(scrollTopNotifications(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 = ( + + ); + + const { signedIn } = useIdentity(); + + const filterBarContainer = signedIn ? : null; + + const scrollableContent = useMemo(() => { + if (notifications.length === 0 && !hasMore) return null; + + return notifications.map((item) => + item.type === 'gap' ? ( + + ) : ( + 0 + } + /> + ), + ); + }, [ + notifications, + isLoading, + hasMore, + lastReadId, + handleLoadGap, + handleMoveUp, + handleMoveDown, + ]); + + const scrollContainer = signedIn ? ( + } + alwaysPrepend + emptyMessage={emptyMessage} + onLoadMore={handleLoadOlder} + onLoadPending={handleLoadPending} + onScrollToTop={handleScrollToTop} + onScroll={handleScroll} + bindToDocument={!multiColumn} + > + {scrollableContent} + + ) : ( + + ); + + const extraButton = canMarkAsRead ? ( + + ) : null; + + return ( + + {/* @ts-expect-error This component is not yet Typescript */} + + + + + {filterBarContainer} + + + + {scrollContainer} + + + {intl.formatMessage(messages.title)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Notifications; diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 7742f64860..7379820cbc 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -49,6 +49,7 @@ import { DirectTimeline, HashtagTimeline, Notifications, + Notifications_v2, NotificationRequests, NotificationRequest, FollowRequests, @@ -203,6 +204,7 @@ class SwitchingColumnsArea extends PureComponent { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index e1f5bfdaf6..29cc6a9099 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -10,6 +10,10 @@ export function Notifications () { return import(/* webpackChunkName: "features/notifications" */'../../notifications'); } +export function Notifications_v2 () { + return import(/* webpackChunkName: "features/notifications_v2" */'../../notifications_v2'); +} + export function HomeTimeline () { return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline'); } diff --git a/app/javascript/mastodon/models/notification_group.ts b/app/javascript/mastodon/models/notification_group.ts index c983e24233..b75460d796 100644 --- a/app/javascript/mastodon/models/notification_group.ts +++ b/app/javascript/mastodon/models/notification_group.ts @@ -4,14 +4,16 @@ import type { NotificationType, NotificationWithStatusType, } from 'mastodon/api_types/notifications'; -import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; -type BaseNotificationGroup = BaseNotificationGroupJSON; +interface BaseNotificationGroup + extends Omit { + sampleAccountsIds: string[]; +} interface BaseNotificationWithStatus extends BaseNotificationGroup { type: Type; - status: ApiStatusJSON; + statusId: string; } interface BaseNotification @@ -54,6 +56,20 @@ export type NotificationGroup = export function createNotificationGroupFromJSON( groupJson: NotificationGroupJSON, ): NotificationGroup { - // @ts-expect-error -- FIXME: properly convert the special notifications here - return groupJson; + const { sample_accounts, ...group } = groupJson; + const sampleAccountsIds = sample_accounts.map((account) => account.id); + + if ('status' in group) { + const { status, ...groupWithoutStatus } = group; + return { + statusId: status.id, + sampleAccountsIds, + ...groupWithoutStatus, + }; + } + + return { + sampleAccountsIds, + ...group, + }; } diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 6296ef2026..8e3af28143 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -27,6 +27,7 @@ import { modalReducer } from './modal'; import { notificationPolicyReducer } from './notification_policy'; import { notificationRequestsReducer } from './notification_requests'; import notifications from './notifications'; +import { notificationGroupsReducer } from './notifications_groups'; import { pictureInPictureReducer } from './picture_in_picture'; import polls from './polls'; import push_notifications from './push_notifications'; @@ -65,6 +66,7 @@ const reducers = { search, media_attachments, notifications, + notificationsGroups: notificationGroupsReducer, height_cache, custom_emojis, lists, diff --git a/app/javascript/mastodon/reducers/notifications_groups.ts b/app/javascript/mastodon/reducers/notifications_groups.ts index 13b9c74ebe..e4cf452d6e 100644 --- a/app/javascript/mastodon/reducers/notifications_groups.ts +++ b/app/javascript/mastodon/reducers/notifications_groups.ts @@ -4,23 +4,44 @@ import { fetchNotifications } from 'mastodon/actions/notification_groups'; import { createNotificationGroupFromJSON } from 'mastodon/models/notification_group'; import type { NotificationGroup } from 'mastodon/models/notification_group'; +interface Gap { + type: 'gap'; + id: string; + maxId: string; +} + interface NotificationGroupsState { - groups: NotificationGroup[]; + groups: (NotificationGroup | Gap)[]; unread: number; + isLoading: boolean; + hasMore: boolean; + readMarkerId: string; } const initialState: NotificationGroupsState = { groups: [], unread: 0, + isLoading: false, + hasMore: false, + readMarkerId: '0', }; -export const notificationGroupsReducer = createReducer( +export const notificationGroupsReducer = createReducer( initialState, (builder) => { + builder.addCase(fetchNotifications.pending, (state) => { + state.isLoading = true; + }); + builder.addCase(fetchNotifications.fulfilled, (state, action) => { state.groups = action.payload.map((json) => createNotificationGroupFromJSON(json), ); + state.isLoading = false; + }); + + builder.addCase(fetchNotifications.rejected, (state) => { + state.isLoading = false; }); }, ); diff --git a/config/routes.rb b/config/routes.rb index f4662dd5da..de88139842 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -28,6 +28,7 @@ Rails.application.routes.draw do /conversations /lists/(*any) /notifications/(*any) + /notifications_v2/(*any) /favourites /bookmarks /pinned diff --git a/package.json b/package.json index f84d45c32e..7db12c2e43 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "tesseract.js": "^2.1.5", "tiny-queue": "^0.2.1", "twitter-text": "3.1.0", + "use-debounce": "^10.0.0", "webpack": "^4.47.0", "webpack-assets-manifest": "^4.0.6", "webpack-bundle-analyzer": "^4.8.0", diff --git a/yarn.lock b/yarn.lock index 162f0cb359..97bfd48680 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2887,6 +2887,7 @@ __metadata: tiny-queue: "npm:^0.2.1" twitter-text: "npm:3.1.0" typescript: "npm:^5.0.4" + use-debounce: "npm:^10.0.0" webpack: "npm:^4.47.0" webpack-assets-manifest: "npm:^4.0.6" webpack-bundle-analyzer: "npm:^4.8.0" @@ -17385,6 +17386,15 @@ __metadata: languageName: node linkType: hard +"use-debounce@npm:^10.0.0": + version: 10.0.0 + resolution: "use-debounce@npm:10.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/c1166cba52dedeab17e3e29275af89c57a3e8981b75f6e38ae2896ac36ecd4ed7d8fff5f882ba4b2f91eac7510d5ae0dd89fa4f7d081622ed436c3c89eda5cd1 + languageName: node + linkType: hard + "use-isomorphic-layout-effect@npm:^1.1.1, use-isomorphic-layout-effect@npm:^1.1.2": version: 1.1.2 resolution: "use-isomorphic-layout-effect@npm:1.1.2"