Add a new experimental notifications route

feature-grouped-notifications-ui
Renaud Chaput 2024-05-22 17:22:29 +02:00
parent bb516ba1e7
commit 62088f51bd
No known key found for this signature in database
GPG Key ID: BCFC859D49B46990
14 changed files with 515 additions and 12 deletions

View File

@ -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);
}
});

View File

@ -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');

View File

@ -33,7 +33,7 @@ export interface BaseNotificationGroupJSON {
interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {
type: NotificationWithStatusType;
target_status: ApiStatusJSON;
status: ApiStatusJSON;
}
interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON {

View File

@ -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 <NotificationReblog notification={notificationGroup} />;
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 (
<div>
<pre>{JSON.stringify(notificationGroup, undefined, 2)}</pre>
<hr />
</div>
);
}
};

View File

@ -0,0 +1,7 @@
import type { NotificationGroupReblog } from 'mastodon/models/notification_group';
export const NotificationReblog: React.FC<{
notification: NotificationGroupReblog;
}> = ({ notification }) => {
return <div>reblog {notification.group_key}</div>;
};

View File

@ -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<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();
}
}, []);
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 = (
<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 filterBarContainer = signedIn ? <FilterBarContainer /> : null;
const scrollableContent = useMemo(() => {
if (notifications.length === 0 && !hasMore) return null;
return notifications.map((item) =>
item.type === 'gap' ? (
<LoadGap
key={item.id}
disabled={isLoading}
maxId={item.maxId}
onClick={handleLoadGap}
/>
) : (
<NotificationGroup
key={item.group_key}
notificationGroupId={item.group_key}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
unread={
lastReadId !== '0' && compareId(item.group_key, lastReadId) > 0
}
/>
),
);
}, [
notifications,
isLoading,
hasMore,
lastReadId,
handleLoadGap,
handleMoveUp,
handleMoveDown,
]);
const scrollContainer = signedIn ? (
<ScrollableList
scrollKey={`notifications-${columnId}`}
trackScroll={!pinned}
isLoading={isLoading}
showLoading={isLoading && notifications.length === 0}
hasMore={hasMore}
numPending={numPending}
prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
alwaysPrepend
emptyMessage={emptyMessage}
onLoadMore={handleLoadOlder}
onLoadPending={handleLoadPending}
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
bindToDocument={!multiColumn}
>
{scrollableContent}
</ScrollableList>
) : (
<NotSignedInIndicator />
);
const extraButton = 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>
) : null;
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.title)}
>
{/* @ts-expect-error This component is not yet Typescript */}
<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>
{filterBarContainer}
<FilteredNotificationsBanner />
{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;

View File

@ -49,6 +49,7 @@ import {
DirectTimeline,
HashtagTimeline,
Notifications,
Notifications_v2,
NotificationRequests,
NotificationRequest,
FollowRequests,
@ -203,6 +204,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
<WrappedRoute path='/notifications_v2' component={Notifications_v2} content={children} exact />
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />

View File

@ -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');
}

View File

@ -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<BaseNotificationGroupJSON, 'sample_accounts'> {
sampleAccountsIds: string[];
}
interface BaseNotificationWithStatus<Type extends NotificationWithStatusType>
extends BaseNotificationGroup {
type: Type;
status: ApiStatusJSON;
statusId: string;
}
interface BaseNotification<Type extends NotificationType>
@ -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,
};
}

View File

@ -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,

View File

@ -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<NotificationGroupsState>(
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;
});
},
);

View File

@ -28,6 +28,7 @@ Rails.application.routes.draw do
/conversations
/lists/(*any)
/notifications/(*any)
/notifications_v2/(*any)
/favourites
/bookmarks
/pinned

View File

@ -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",

View File

@ -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"