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"