From f587ff643f552a32a1c43e103a474a5065cd3657 Mon Sep 17 00:00:00 2001
From: Renaud Chaput <renchap@gmail.com>
Date: Thu, 18 Jul 2024 16:36:09 +0200
Subject: [PATCH] Grouped Notifications UI (#30440)

Co-authored-by: Eugen Rochko <eugen@zeonfederated.com>
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
---
 .../api/v2_alpha/notifications_controller.rb  |  53 +-
 app/javascript/mastodon/actions/markers.ts    |  14 +-
 .../mastodon/actions/notification_groups.ts   | 144 +++++
 .../mastodon/actions/notifications.js         |  13 +-
 .../actions/notifications_migration.tsx       |  18 +
 .../mastodon/actions/notifications_typed.ts   |   9 +-
 app/javascript/mastodon/actions/streaming.js  |  11 +-
 app/javascript/mastodon/api/notifications.ts  |  18 +
 .../mastodon/api_types/notifications.ts       | 145 +++++
 app/javascript/mastodon/api_types/reports.ts  |  16 +
 .../mastodon/components/load_gap.tsx          |  12 +-
 app/javascript/mastodon/components/status.jsx |   8 +-
 .../mastodon/components/status_list.jsx       |   2 +-
 .../compose/components/edit_indicator.jsx     |  10 +-
 .../compose/components/reply_indicator.jsx    |  10 +-
 .../components/column_settings.jsx            |  11 +
 .../filtered_notifications_banner.tsx         |   4 +-
 .../components/moderation_warning.tsx         |  51 +-
 .../notifications/components/notification.jsx |   4 +-
 .../relationships_severance_event.jsx         |  15 +-
 .../containers/column_settings_container.js   |   8 +-
 .../mastodon/features/notifications/index.jsx |   2 +-
 .../components/avatar_group.tsx               |  31 ++
 .../components/embedded_status.tsx            |  93 ++++
 .../components/embedded_status_content.tsx    | 165 ++++++
 .../components/names_list.tsx                 |  51 ++
 .../components/notification_admin_report.tsx  | 132 +++++
 .../components/notification_admin_sign_up.tsx |  31 ++
 .../components/notification_favourite.tsx     |  45 ++
 .../components/notification_follow.tsx        |  31 ++
 .../notification_follow_request.tsx           |  78 +++
 .../components/notification_group.tsx         | 134 +++++
 .../notification_group_with_status.tsx        |  91 ++++
 .../components/notification_mention.tsx       |  55 ++
 .../notification_moderation_warning.tsx       |  13 +
 .../components/notification_poll.tsx          |  41 ++
 .../components/notification_reblog.tsx        |  45 ++
 .../notification_severed_relationships.tsx    |  15 +
 .../components/notification_status.tsx        |  31 ++
 .../components/notification_update.tsx        |  31 ++
 .../components/notification_with_status.tsx   |  73 +++
 .../features/notifications_v2/filter_bar.tsx  | 145 +++++
 .../features/notifications_v2/index.tsx       | 354 ++++++++++++
 .../features/notifications_wrapper.jsx        |  13 +
 .../features/ui/components/columns_area.jsx   |   4 +-
 .../ui/components/navigation_panel.jsx        |   9 +-
 app/javascript/mastodon/features/ui/index.jsx |   9 +-
 .../features/ui/util/async-components.js      |  10 +-
 app/javascript/mastodon/locales/en.json       |  15 +-
 .../mastodon/models/notification_group.ts     | 207 +++++++
 app/javascript/mastodon/reducers/index.ts     |   2 +
 app/javascript/mastodon/reducers/markers.ts   |  22 +-
 .../mastodon/reducers/notification_groups.ts  | 508 ++++++++++++++++++
 .../mastodon/reducers/notifications.js        |   4 +-
 .../mastodon/selectors/notifications.ts       |  34 ++
 app/javascript/mastodon/selectors/settings.ts |  40 ++
 .../styles/mastodon/components.scss           | 293 +++++++++-
 app/models/notification.rb                    |   1 +
 app/models/notification_group.rb              |   8 +-
 .../rest/notification_group_serializer.rb     |   1 +
 .../rest/notification_serializer.rb           |   1 +
 app/services/notify_service.rb                |   4 +-
 config/routes.rb                              |   1 +
 package.json                                  |   1 +
 yarn.lock                                     |  10 +
 65 files changed, 3329 insertions(+), 131 deletions(-)
 create mode 100644 app/javascript/mastodon/actions/notification_groups.ts
 create mode 100644 app/javascript/mastodon/actions/notifications_migration.tsx
 create mode 100644 app/javascript/mastodon/api/notifications.ts
 create mode 100644 app/javascript/mastodon/api_types/notifications.ts
 create mode 100644 app/javascript/mastodon/api_types/reports.ts
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/avatar_group.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/names_list.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_admin_report.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_admin_sign_up.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_favourite.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_moderation_warning.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_poll.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_reblog.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_severed_relationships.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_status.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_update.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/filter_bar.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_v2/index.tsx
 create mode 100644 app/javascript/mastodon/features/notifications_wrapper.jsx
 create mode 100644 app/javascript/mastodon/models/notification_group.ts
 create mode 100644 app/javascript/mastodon/reducers/notification_groups.ts
 create mode 100644 app/javascript/mastodon/selectors/notifications.ts
 create mode 100644 app/javascript/mastodon/selectors/settings.ts

diff --git a/app/controllers/api/v2_alpha/notifications_controller.rb b/app/controllers/api/v2_alpha/notifications_controller.rb
index edba23ab4..83d40a088 100644
--- a/app/controllers/api/v2_alpha/notifications_controller.rb
+++ b/app/controllers/api/v2_alpha/notifications_controller.rb
@@ -12,10 +12,27 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
     with_read_replica do
       @notifications = load_notifications
       @group_metadata = load_group_metadata
+      @grouped_notifications = load_grouped_notifications
       @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
+      @sample_accounts = @grouped_notifications.flat_map(&:sample_accounts)
+
+      # Preload associations to avoid N+1s
+      ActiveRecord::Associations::Preloader.new(records: @sample_accounts, associations: [:account_stat, { user: :role }]).call
     end
 
-    render json: @notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
+    MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#index rendering') do |span|
+      statuses = @grouped_notifications.filter_map { |group| group.target_status&.id }
+
+      span.add_attributes(
+        'app.notification_grouping.count' => @grouped_notifications.size,
+        'app.notification_grouping.sample_account.count' => @sample_accounts.size,
+        'app.notification_grouping.sample_account.unique_count' => @sample_accounts.pluck(:id).uniq.size,
+        'app.notification_grouping.status.count' => statuses.size,
+        'app.notification_grouping.status.unique_count' => statuses.uniq.size
+      )
+
+      render json: @grouped_notifications, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
+    end
   end
 
   def show
@@ -36,25 +53,35 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
   private
 
   def load_notifications
-    notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id(
-      limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
-      params_slice(:max_id, :since_id, :min_id)
-    )
+    MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_notifications') do
+      notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id(
+        limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
+        params_slice(:max_id, :since_id, :min_id)
+      )
 
-    Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses|
-      preload_collection(target_statuses, Status)
+      Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses|
+        preload_collection(target_statuses, Status)
+      end
     end
   end
 
   def load_group_metadata
     return {} if @notifications.empty?
 
-    browserable_account_notifications
-      .where(group_key: @notifications.filter_map(&:group_key))
-      .where(id: (@notifications.last.id)..(@notifications.first.id))
-      .group(:group_key)
-      .pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at')
-      .to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] }
+    MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_group_metadata') do
+      browserable_account_notifications
+        .where(group_key: @notifications.filter_map(&:group_key))
+        .where(id: (@notifications.last.id)..(@notifications.first.id))
+        .group(:group_key)
+        .pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at')
+        .to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] }
+    end
+  end
+
+  def load_grouped_notifications
+    MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_grouped_notifications') do
+      @notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }
+    end
   end
 
   def browserable_account_notifications
diff --git a/app/javascript/mastodon/actions/markers.ts b/app/javascript/mastodon/actions/markers.ts
index 03f577c54..77d91d9b9 100644
--- a/app/javascript/mastodon/actions/markers.ts
+++ b/app/javascript/mastodon/actions/markers.ts
@@ -75,9 +75,17 @@ interface MarkerParam {
 }
 
 function getLastNotificationId(state: RootState): string | undefined {
-  // @ts-expect-error state.notifications is not yet typed
-  // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
-  return state.getIn(['notifications', 'lastReadId']);
+  // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
+  const enableBeta = state.settings.getIn(
+    ['notifications', 'groupingBeta'],
+    false,
+  ) as boolean;
+  // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+  return enableBeta
+    ? state.notificationGroups.lastReadId
+    : // @ts-expect-error state.notifications is not yet typed
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+      state.getIn(['notifications', 'lastReadId']);
 }
 
 const buildPostMarkersParams = (state: RootState) => {
diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts
new file mode 100644
index 000000000..8fdec6e48
--- /dev/null
+++ b/app/javascript/mastodon/actions/notification_groups.ts
@@ -0,0 +1,144 @@
+import { createAction } from '@reduxjs/toolkit';
+
+import {
+  apiClearNotifications,
+  apiFetchNotifications,
+} from 'mastodon/api/notifications';
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+import type {
+  ApiNotificationGroupJSON,
+  ApiNotificationJSON,
+} from 'mastodon/api_types/notifications';
+import { allNotificationTypes } from 'mastodon/api_types/notifications';
+import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
+import type { NotificationGap } from 'mastodon/reducers/notification_groups';
+import {
+  selectSettingsNotificationsExcludedTypes,
+  selectSettingsNotificationsQuickFilterActive,
+} from 'mastodon/selectors/settings';
+import type { AppDispatch } from 'mastodon/store';
+import {
+  createAppAsyncThunk,
+  createDataLoadingThunk,
+} from 'mastodon/store/typed_functions';
+
+import { importFetchedAccounts, importFetchedStatuses } from './importer';
+import { NOTIFICATIONS_FILTER_SET } from './notifications';
+import { saveSettings } from './settings';
+
+function excludeAllTypesExcept(filter: string) {
+  return allNotificationTypes.filter((item) => item !== filter);
+}
+
+function dispatchAssociatedRecords(
+  dispatch: AppDispatch,
+  notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[],
+) {
+  const fetchedAccounts: ApiAccountJSON[] = [];
+  const fetchedStatuses: ApiStatusJSON[] = [];
+
+  notifications.forEach((notification) => {
+    if ('sample_accounts' in notification) {
+      fetchedAccounts.push(...notification.sample_accounts);
+    }
+
+    if (notification.type === 'admin.report') {
+      fetchedAccounts.push(notification.report.target_account);
+    }
+
+    if (notification.type === 'moderation_warning') {
+      fetchedAccounts.push(notification.moderation_warning.target_account);
+    }
+
+    if ('status' in notification) {
+      fetchedStatuses.push(notification.status);
+    }
+  });
+
+  if (fetchedAccounts.length > 0)
+    dispatch(importFetchedAccounts(fetchedAccounts));
+
+  if (fetchedStatuses.length > 0)
+    dispatch(importFetchedStatuses(fetchedStatuses));
+}
+
+export const fetchNotifications = createDataLoadingThunk(
+  'notificationGroups/fetch',
+  async (_params, { getState }) => {
+    const activeFilter =
+      selectSettingsNotificationsQuickFilterActive(getState());
+
+    return apiFetchNotifications({
+      exclude_types:
+        activeFilter === 'all'
+          ? selectSettingsNotificationsExcludedTypes(getState())
+          : excludeAllTypesExcept(activeFilter),
+    });
+  },
+  ({ notifications }, { dispatch }) => {
+    dispatchAssociatedRecords(dispatch, notifications);
+    const payload: (ApiNotificationGroupJSON | NotificationGap)[] =
+      notifications;
+
+    // TODO: might be worth not using gaps for that…
+    // if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri });
+    if (notifications.length > 1)
+      payload.push({ type: 'gap', maxId: notifications.at(-1)?.page_min_id });
+
+    return payload;
+    // dispatch(submitMarkers());
+  },
+);
+
+export const fetchNotificationsGap = createDataLoadingThunk(
+  'notificationGroups/fetchGap',
+  async (params: { gap: NotificationGap }) =>
+    apiFetchNotifications({ max_id: params.gap.maxId }),
+
+  ({ notifications }, { dispatch }) => {
+    dispatchAssociatedRecords(dispatch, notifications);
+
+    return { notifications };
+  },
+);
+
+export const processNewNotificationForGroups = createAppAsyncThunk(
+  'notificationGroups/processNew',
+  (notification: ApiNotificationJSON, { dispatch }) => {
+    dispatchAssociatedRecords(dispatch, [notification]);
+
+    return notification;
+  },
+);
+
+export const loadPending = createAction('notificationGroups/loadPending');
+
+export const updateScrollPosition = createAction<{ top: boolean }>(
+  'notificationGroups/updateScrollPosition',
+);
+
+export const setNotificationsFilter = createAppAsyncThunk(
+  'notifications/filter/set',
+  ({ filterType }: { filterType: string }, { dispatch }) => {
+    dispatch({
+      type: NOTIFICATIONS_FILTER_SET,
+      path: ['notifications', 'quickFilter', 'active'],
+      value: filterType,
+    });
+    // dispatch(expandNotifications({ forceLoad: true }));
+    void dispatch(fetchNotifications());
+    dispatch(saveSettings());
+  },
+);
+
+export const clearNotifications = createDataLoadingThunk(
+  'notifications/clear',
+  () => apiClearNotifications(),
+);
+
+export const markNotificationsAsRead = createAction(
+  'notificationGroups/markAsRead',
+);
+
+export const mountNotifications = createAction('notificationGroups/mount');
+export const unmountNotifications = createAction('notificationGroups/unmount');
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 6a59d5624..7e4320c27 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -32,7 +32,6 @@ export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
 
 export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
 
-export const NOTIFICATIONS_CLEAR        = 'NOTIFICATIONS_CLEAR';
 export const NOTIFICATIONS_SCROLL_TOP   = 'NOTIFICATIONS_SCROLL_TOP';
 export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
 
@@ -174,7 +173,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');
@@ -257,16 +256,6 @@ export function expandNotificationsFail(error, isLoadingMore) {
   };
 }
 
-export function clearNotifications() {
-  return (dispatch) => {
-    dispatch({
-      type: NOTIFICATIONS_CLEAR,
-    });
-
-    api().post('/api/v1/notifications/clear');
-  };
-}
-
 export function scrollTopNotifications(top) {
   return {
     type: NOTIFICATIONS_SCROLL_TOP,
diff --git a/app/javascript/mastodon/actions/notifications_migration.tsx b/app/javascript/mastodon/actions/notifications_migration.tsx
new file mode 100644
index 000000000..f856e5682
--- /dev/null
+++ b/app/javascript/mastodon/actions/notifications_migration.tsx
@@ -0,0 +1,18 @@
+import { createAppAsyncThunk } from 'mastodon/store';
+
+import { fetchNotifications } from './notification_groups';
+import { expandNotifications } from './notifications';
+
+export const initializeNotifications = createAppAsyncThunk(
+  'notifications/initialize',
+  (_, { dispatch, getState }) => {
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
+    const enableBeta = getState().settings.getIn(
+      ['notifications', 'groupingBeta'],
+      false,
+    ) as boolean;
+
+    if (enableBeta) void dispatch(fetchNotifications());
+    else dispatch(expandNotifications());
+  },
+);
diff --git a/app/javascript/mastodon/actions/notifications_typed.ts b/app/javascript/mastodon/actions/notifications_typed.ts
index 176362f4b..88d942d45 100644
--- a/app/javascript/mastodon/actions/notifications_typed.ts
+++ b/app/javascript/mastodon/actions/notifications_typed.ts
@@ -1,11 +1,6 @@
 import { createAction } from '@reduxjs/toolkit';
 
-import type { ApiAccountJSON } from '../api_types/accounts';
-// To be replaced once ApiNotificationJSON type exists
-interface FakeApiNotificationJSON {
-  type: string;
-  account: ApiAccountJSON;
-}
+import type { ApiNotificationJSON } from 'mastodon/api_types/notifications';
 
 export const notificationsUpdate = createAction(
   'notifications/update',
@@ -13,7 +8,7 @@ export const notificationsUpdate = createAction(
     playSound,
     ...args
   }: {
-    notification: FakeApiNotificationJSON;
+    notification: ApiNotificationJSON;
     usePendingItems: boolean;
     playSound: boolean;
   }) => ({
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index e7fe1c53e..f50f41b0d 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -10,6 +10,7 @@ import {
   deleteAnnouncement,
 } from './announcements';
 import { updateConversations } from './conversations';
+import { processNewNotificationForGroups } from './notification_groups';
 import { updateNotifications, expandNotifications } from './notifications';
 import { updateStatus } from './statuses';
 import {
@@ -98,10 +99,16 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
         case 'delete':
           dispatch(deleteFromTimelines(data.payload));
           break;
-        case 'notification':
+        case 'notification': {
           // @ts-expect-error
-          dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
+          const notificationJSON = JSON.parse(data.payload);
+          dispatch(updateNotifications(notificationJSON, messages, locale));
+          // TODO: remove this once the groups feature replaces the previous one
+          if(getState().notificationGroups.groups.length > 0) {
+            dispatch(processNewNotificationForGroups(notificationJSON));
+          }
           break;
+        }
         case 'conversation':
           // @ts-expect-error
           dispatch(updateConversations(JSON.parse(data.payload)));
diff --git a/app/javascript/mastodon/api/notifications.ts b/app/javascript/mastodon/api/notifications.ts
new file mode 100644
index 000000000..c1ab6f70c
--- /dev/null
+++ b/app/javascript/mastodon/api/notifications.ts
@@ -0,0 +1,18 @@
+import api, { apiRequest, getLinks } from 'mastodon/api';
+import type { ApiNotificationGroupJSON } from 'mastodon/api_types/notifications';
+
+export const apiFetchNotifications = async (params?: {
+  exclude_types?: string[];
+  max_id?: string;
+}) => {
+  const response = await api().request<ApiNotificationGroupJSON[]>({
+    method: 'GET',
+    url: '/api/v2_alpha/notifications',
+    params,
+  });
+
+  return { notifications: response.data, links: getLinks(response) };
+};
+
+export const apiClearNotifications = () =>
+  apiRequest<undefined>('POST', 'v1/notifications/clear');
diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts
new file mode 100644
index 000000000..d7cbbca73
--- /dev/null
+++ b/app/javascript/mastodon/api_types/notifications.ts
@@ -0,0 +1,145 @@
+// See app/serializers/rest/notification_group_serializer.rb
+
+import type { AccountWarningAction } from 'mastodon/models/notification_group';
+
+import type { ApiAccountJSON } from './accounts';
+import type { ApiReportJSON } from './reports';
+import type { ApiStatusJSON } from './statuses';
+
+// See app/model/notification.rb
+export const allNotificationTypes = [
+  'follow',
+  'follow_request',
+  'favourite',
+  'reblog',
+  'mention',
+  'poll',
+  'status',
+  'update',
+  'admin.sign_up',
+  'admin.report',
+  'moderation_warning',
+  'severed_relationships',
+];
+
+export type NotificationWithStatusType =
+  | 'favourite'
+  | 'reblog'
+  | 'status'
+  | 'mention'
+  | 'poll'
+  | 'update';
+
+export type NotificationType =
+  | NotificationWithStatusType
+  | 'follow'
+  | 'follow_request'
+  | 'moderation_warning'
+  | 'severed_relationships'
+  | 'admin.sign_up'
+  | 'admin.report';
+
+export interface BaseNotificationJSON {
+  id: string;
+  type: NotificationType;
+  created_at: string;
+  group_key: string;
+  account: ApiAccountJSON;
+}
+
+export interface BaseNotificationGroupJSON {
+  group_key: string;
+  notifications_count: number;
+  type: NotificationType;
+  sample_accounts: ApiAccountJSON[];
+  latest_page_notification_at: string; // FIXME: This will only be present if the notification group is returned in a paginated list, not requested directly
+  most_recent_notification_id: string;
+  page_min_id?: string;
+  page_max_id?: string;
+}
+
+interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {
+  type: NotificationWithStatusType;
+  status: ApiStatusJSON;
+}
+
+interface NotificationWithStatusJSON extends BaseNotificationJSON {
+  type: NotificationWithStatusType;
+  status: ApiStatusJSON;
+}
+
+interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON {
+  type: 'admin.report';
+  report: ApiReportJSON;
+}
+
+interface ReportNotificationJSON extends BaseNotificationJSON {
+  type: 'admin.report';
+  report: ApiReportJSON;
+}
+
+type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up';
+interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON {
+  type: SimpleNotificationTypes;
+}
+
+interface SimpleNotificationJSON extends BaseNotificationJSON {
+  type: SimpleNotificationTypes;
+}
+
+export interface ApiAccountWarningJSON {
+  id: string;
+  action: AccountWarningAction;
+  text: string;
+  status_ids: string[];
+  created_at: string;
+  target_account: ApiAccountJSON;
+  appeal: unknown;
+}
+
+interface ModerationWarningNotificationGroupJSON
+  extends BaseNotificationGroupJSON {
+  type: 'moderation_warning';
+  moderation_warning: ApiAccountWarningJSON;
+}
+
+interface ModerationWarningNotificationJSON extends BaseNotificationJSON {
+  type: 'moderation_warning';
+  moderation_warning: ApiAccountWarningJSON;
+}
+
+export interface ApiAccountRelationshipSeveranceEventJSON {
+  id: string;
+  type: 'account_suspension' | 'domain_block' | 'user_domain_block';
+  purged: boolean;
+  target_name: string;
+  followers_count: number;
+  following_count: number;
+  created_at: string;
+}
+
+interface AccountRelationshipSeveranceNotificationGroupJSON
+  extends BaseNotificationGroupJSON {
+  type: 'severed_relationships';
+  event: ApiAccountRelationshipSeveranceEventJSON;
+}
+
+interface AccountRelationshipSeveranceNotificationJSON
+  extends BaseNotificationJSON {
+  type: 'severed_relationships';
+  event: ApiAccountRelationshipSeveranceEventJSON;
+}
+
+export type ApiNotificationJSON =
+  | SimpleNotificationJSON
+  | ReportNotificationJSON
+  | AccountRelationshipSeveranceNotificationJSON
+  | NotificationWithStatusJSON
+  | ModerationWarningNotificationJSON;
+
+export type ApiNotificationGroupJSON =
+  | SimpleNotificationGroupJSON
+  | ReportNotificationGroupJSON
+  | AccountRelationshipSeveranceNotificationGroupJSON
+  | NotificationGroupWithStatusJSON
+  | ModerationWarningNotificationGroupJSON;
diff --git a/app/javascript/mastodon/api_types/reports.ts b/app/javascript/mastodon/api_types/reports.ts
new file mode 100644
index 000000000..b11cfdd2e
--- /dev/null
+++ b/app/javascript/mastodon/api_types/reports.ts
@@ -0,0 +1,16 @@
+import type { ApiAccountJSON } from './accounts';
+
+export type ReportCategory = 'other' | 'spam' | 'legal' | 'violation';
+
+export interface ApiReportJSON {
+  id: string;
+  action_taken: unknown;
+  action_taken_at: unknown;
+  category: ReportCategory;
+  comment: string;
+  forwarded: boolean;
+  created_at: string;
+  status_ids: string[];
+  rule_ids: string[];
+  target_account: ApiAccountJSON;
+}
diff --git a/app/javascript/mastodon/components/load_gap.tsx b/app/javascript/mastodon/components/load_gap.tsx
index 1d4193a35..544b5e146 100644
--- a/app/javascript/mastodon/components/load_gap.tsx
+++ b/app/javascript/mastodon/components/load_gap.tsx
@@ -9,18 +9,18 @@ const messages = defineMessages({
   load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
 });
 
-interface Props {
+interface Props<T> {
   disabled: boolean;
-  maxId: string;
-  onClick: (maxId: string) => void;
+  param: T;
+  onClick: (params: T) => void;
 }
 
-export const LoadGap: React.FC<Props> = ({ disabled, maxId, onClick }) => {
+export const LoadGap = <T,>({ disabled, param, onClick }: Props<T>) => {
   const intl = useIntl();
 
   const handleClick = useCallback(() => {
-    onClick(maxId);
-  }, [maxId, onClick]);
+    onClick(param);
+  }, [param, onClick]);
 
   return (
     <button
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx
index 502dc8bee..1723080e9 100644
--- a/app/javascript/mastodon/components/status.jsx
+++ b/app/javascript/mastodon/components/status.jsx
@@ -116,6 +116,8 @@ class Status extends ImmutablePureComponent {
     cacheMediaWidth: PropTypes.func,
     cachedMediaWidth: PropTypes.number,
     scrollKey: PropTypes.string,
+    skipPrepend: PropTypes.bool,
+    avatarSize: PropTypes.number,
     deployPictureInPicture: PropTypes.func,
     pictureInPicture: ImmutablePropTypes.contains({
       inUse: PropTypes.bool,
@@ -353,7 +355,7 @@ class Status extends ImmutablePureComponent {
   };
 
   render () {
-    const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props;
+    const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props;
 
     let { status, account, ...other } = this.props;
 
@@ -539,7 +541,7 @@ class Status extends ImmutablePureComponent {
     }
 
     if (account === undefined || account === null) {
-      statusAvatar = <Avatar account={status.get('account')} size={46} />;
+      statusAvatar = <Avatar account={status.get('account')} size={avatarSize} />;
     } else {
       statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
     }
@@ -550,7 +552,7 @@ class Status extends ImmutablePureComponent {
     return (
       <HotKeys handlers={handlers}>
         <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
-          {prepend}
+          {!skipPrepend && prepend}
 
           <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
             {(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx
index fee6675fa..c6cacbd2b 100644
--- a/app/javascript/mastodon/components/status_list.jsx
+++ b/app/javascript/mastodon/components/status_list.jsx
@@ -107,7 +107,7 @@ export default class StatusList extends ImmutablePureComponent {
             <LoadGap
               key={'gap:' + statusIds.get(index + 1)}
               disabled={isLoading}
-              maxId={index > 0 ? statusIds.get(index - 1) : null}
+              param={index > 0 ? statusIds.get(index - 1) : null}
               onClick={onLoadMore}
             />
           );
diff --git a/app/javascript/mastodon/features/compose/components/edit_indicator.jsx b/app/javascript/mastodon/features/compose/components/edit_indicator.jsx
index cc37d2d7d..106ff7bda 100644
--- a/app/javascript/mastodon/features/compose/components/edit_indicator.jsx
+++ b/app/javascript/mastodon/features/compose/components/edit_indicator.jsx
@@ -13,6 +13,7 @@ import { cancelReplyCompose } from 'mastodon/actions/compose';
 import { Icon } from 'mastodon/components/icon';
 import { IconButton } from 'mastodon/components/icon_button';
 import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
+import { EmbeddedStatusContent } from 'mastodon/features/notifications_v2/components/embedded_status_content';
 
 const messages = defineMessages({
   cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
@@ -33,8 +34,6 @@ export const EditIndicator = () => {
     return null;
   }
 
-  const content = { __html: status.get('contentHtml') };
-
   return (
     <div className='edit-indicator'>
       <div className='edit-indicator__header'>
@@ -49,7 +48,12 @@ export const EditIndicator = () => {
         </div>
       </div>
 
-      <div className='edit-indicator__content translate' dangerouslySetInnerHTML={content} />
+      <EmbeddedStatusContent
+        className='edit-indicator__content translate'
+        content={status.get('contentHtml')}
+        language={status.get('language')}
+        mentions={status.get('mentions')}
+      />
 
       {(status.get('poll') || status.get('media_attachments').size > 0) && (
         <div className='edit-indicator__attachments'>
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.jsx b/app/javascript/mastodon/features/compose/components/reply_indicator.jsx
index b7959e211..cf5bae2e0 100644
--- a/app/javascript/mastodon/features/compose/components/reply_indicator.jsx
+++ b/app/javascript/mastodon/features/compose/components/reply_indicator.jsx
@@ -9,6 +9,7 @@ import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'
 import { Avatar } from 'mastodon/components/avatar';
 import { DisplayName } from 'mastodon/components/display_name';
 import { Icon } from 'mastodon/components/icon';
+import { EmbeddedStatusContent } from 'mastodon/features/notifications_v2/components/embedded_status_content';
 
 export const ReplyIndicator = () => {
   const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to']));
@@ -19,8 +20,6 @@ export const ReplyIndicator = () => {
     return null;
   }
 
-  const content = { __html: status.get('contentHtml') };
-
   return (
     <div className='reply-indicator'>
       <div className='reply-indicator__line' />
@@ -34,7 +33,12 @@ export const ReplyIndicator = () => {
           <DisplayName account={account} />
         </Link>
 
-        <div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} />
+        <EmbeddedStatusContent
+          className='reply-indicator__content translate'
+          content={status.get('contentHtml')}
+          language={status.get('language')}
+          mentions={status.get('mentions')}
+        />
 
         {(status.get('poll') || status.get('media_attachments').size > 0) && (
           <div className='reply-indicator__attachments'>
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.jsx b/app/javascript/mastodon/features/notifications/components/column_settings.jsx
index 39e394e44..d35e62f8f 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.jsx
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.jsx
@@ -53,6 +53,7 @@ class ColumnSettings extends PureComponent {
 
     const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
     const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
+    const groupingShowStr = <FormattedMessage id='notifications.column_settings.beta.grouping' defaultMessage='Group notifications' />;
     const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
     const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
     const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
@@ -104,6 +105,16 @@ class ColumnSettings extends PureComponent {
           </div>
         </section>
 
+        <section role='group' aria-labelledby='notifications-beta'>
+          <h3 id='notifications-beta'>
+            <FormattedMessage id='notifications.column_settings.beta.category' defaultMessage='Experimental features' />
+          </h3>
+
+          <div className='column-settings__row'>
+            <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['groupingBeta']} onChange={onChange} label={groupingShowStr} />
+          </div>
+        </section>
+
         <section role='group' aria-labelledby='notifications-unread-markers'>
           <h3 id='notifications-unread-markers'>
             <FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
diff --git a/app/javascript/mastodon/features/notifications/components/filtered_notifications_banner.tsx b/app/javascript/mastodon/features/notifications/components/filtered_notifications_banner.tsx
index 2c4b3b971..be1ea2c55 100644
--- a/app/javascript/mastodon/features/notifications/components/filtered_notifications_banner.tsx
+++ b/app/javascript/mastodon/features/notifications/components/filtered_notifications_banner.tsx
@@ -35,7 +35,9 @@ export const FilteredNotificationsBanner: React.FC = () => {
       className='filtered-notifications-banner'
       to='/notifications/requests'
     >
-      <Icon icon={InventoryIcon} id='filtered-notifications' />
+      <div className='notification-group__icon'>
+        <Icon icon={InventoryIcon} id='filtered-notifications' />
+      </div>
 
       <div className='filtered-notifications-banner__text'>
         <strong>
diff --git a/app/javascript/mastodon/features/notifications/components/moderation_warning.tsx b/app/javascript/mastodon/features/notifications/components/moderation_warning.tsx
index 2c1683e21..827ec3b37 100644
--- a/app/javascript/mastodon/features/notifications/components/moderation_warning.tsx
+++ b/app/javascript/mastodon/features/notifications/components/moderation_warning.tsx
@@ -1,7 +1,10 @@
 import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
 
+import classNames from 'classnames';
+
 import GavelIcon from '@/material-icons/400-24px/gavel.svg?react';
 import { Icon } from 'mastodon/components/icon';
+import type { AccountWarningAction } from 'mastodon/models/notification_group';
 
 // This needs to be kept in sync with app/models/account_warning.rb
 const messages = defineMessages({
@@ -36,19 +39,18 @@ const messages = defineMessages({
 });
 
 interface Props {
-  action:
-    | 'none'
-    | 'disable'
-    | 'mark_statuses_as_sensitive'
-    | 'delete_statuses'
-    | 'sensitive'
-    | 'silence'
-    | 'suspend';
+  action: AccountWarningAction;
   id: string;
-  hidden: boolean;
+  hidden?: boolean;
+  unread?: boolean;
 }
 
-export const ModerationWarning: React.FC<Props> = ({ action, id, hidden }) => {
+export const ModerationWarning: React.FC<Props> = ({
+  action,
+  id,
+  hidden,
+  unread,
+}) => {
   const intl = useIntl();
 
   if (hidden) {
@@ -56,23 +58,32 @@ export const ModerationWarning: React.FC<Props> = ({ action, id, hidden }) => {
   }
 
   return (
-    <a
-      href={`/disputes/strikes/${id}`}
-      target='_blank'
-      rel='noopener noreferrer'
-      className='notification__moderation-warning'
+    <div
+      role='button'
+      className={classNames(
+        'notification-group notification-group--link notification-group--moderation-warning focusable',
+        { 'notification-group--unread': unread },
+      )}
+      tabIndex={0}
     >
-      <Icon id='warning' icon={GavelIcon} />
+      <div className='notification-group__icon'>
+        <Icon id='warning' icon={GavelIcon} />
+      </div>
 
-      <div className='notification__moderation-warning__content'>
+      <div className='notification-group__main'>
         <p>{intl.formatMessage(messages[action])}</p>
-        <span className='link-button'>
+        <a
+          href={`/disputes/strikes/${id}`}
+          target='_blank'
+          rel='noopener noreferrer'
+          className='link-button'
+        >
           <FormattedMessage
             id='notification.moderation-warning.learn_more'
             defaultMessage='Learn more'
           />
-        </span>
+        </a>
       </div>
-    </a>
+    </div>
   );
 };
diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx
index 986628fdc..70a325250 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.jsx
+++ b/app/javascript/mastodon/features/notifications/components/notification.jsx
@@ -34,7 +34,7 @@ const messages = defineMessages({
   favourite: { id: 'notification.favourite', defaultMessage: '{name} favorited your status' },
   follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
   ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
-  poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
+  poll: { id: 'notification.poll', defaultMessage: 'A poll you voted in has ended' },
   reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
   status: { id: 'notification.status', defaultMessage: '{name} just posted' },
   update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
@@ -340,7 +340,7 @@ class Notification extends ImmutablePureComponent {
               {ownPoll ? (
                 <FormattedMessage id='notification.own_poll' defaultMessage='Your poll has ended' />
               ) : (
-                <FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' />
+                <FormattedMessage id='notification.poll' defaultMessage='A poll you voted in has ended' />
               )}
             </span>
           </div>
diff --git a/app/javascript/mastodon/features/notifications/components/relationships_severance_event.jsx b/app/javascript/mastodon/features/notifications/components/relationships_severance_event.jsx
index 738159fc5..3075aff31 100644
--- a/app/javascript/mastodon/features/notifications/components/relationships_severance_event.jsx
+++ b/app/javascript/mastodon/features/notifications/components/relationships_severance_event.jsx
@@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
 
 import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
 
+import classNames from 'classnames';
+
 import HeartBrokenIcon from '@/material-icons/400-24px/heart_broken-fill.svg?react';
 import { Icon }  from 'mastodon/components/icon';
 import { domain } from 'mastodon/initial_state';
@@ -13,7 +15,7 @@ const messages = defineMessages({
   user_domain_block: { id: 'notification.relationships_severance_event.user_domain_block', defaultMessage: 'You have blocked {target}, removing {followersCount} of your followers and {followingCount, plural, one {# account} other {# accounts}} you follow.' },
 });
 
-export const RelationshipsSeveranceEvent = ({ type, target, followingCount, followersCount, hidden }) => {
+export const RelationshipsSeveranceEvent = ({ type, target, followingCount, followersCount, hidden, unread }) => {
   const intl = useIntl();
 
   if (hidden) {
@@ -21,14 +23,14 @@ export const RelationshipsSeveranceEvent = ({ type, target, followingCount, foll
   }
 
   return (
-    <a href='/severed_relationships' target='_blank' rel='noopener noreferrer' className='notification__relationships-severance-event'>
-      <Icon id='heart_broken' icon={HeartBrokenIcon} />
+    <div role='button' className={classNames('notification-group notification-group--link notification-group--relationships-severance-event focusable', { 'notification-group--unread': unread })} tabIndex='0'>
+      <div className='notification-group__icon'><Icon id='heart_broken' icon={HeartBrokenIcon} /></div>
 
-      <div className='notification__relationships-severance-event__content'>
+      <div className='notification-group__main'>
         <p>{intl.formatMessage(messages[type], { from: <strong>{domain}</strong>, target: <strong>{target}</strong>, followingCount, followersCount })}</p>
-        <span className='link-button'><FormattedMessage id='notification.relationships_severance_event.learn_more' defaultMessage='Learn more' /></span>
+        <a href='/severed_relationships' target='_blank' rel='noopener noreferrer' className='link-button'><FormattedMessage id='notification.relationships_severance_event.learn_more' defaultMessage='Learn more' /></a>
       </div>
-    </a>
+    </div>
   );
 };
 
@@ -42,4 +44,5 @@ RelationshipsSeveranceEvent.propTypes = {
   followersCount: PropTypes.number.isRequired,
   followingCount: PropTypes.number.isRequired,
   hidden: PropTypes.bool,
+  unread: PropTypes.bool,
 };
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
index 94383d0bb..2434c3982 100644
--- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
@@ -2,10 +2,13 @@ import { defineMessages, injectIntl } from 'react-intl';
 
 import { connect } from 'react-redux';
 
+import { initializeNotifications } from 'mastodon/actions/notifications_migration';
+
 import { showAlert } from '../../../actions/alerts';
 import { openModal } from '../../../actions/modal';
+import { clearNotifications } from '../../../actions/notification_groups';
 import { updateNotificationsPolicy } from '../../../actions/notification_policies';
-import { setFilter, clearNotifications, requestBrowserPermission } from '../../../actions/notifications';
+import { setFilter, requestBrowserPermission } from '../../../actions/notifications';
 import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
 import { changeSetting } from '../../../actions/settings';
 import ColumnSettings from '../components/column_settings';
@@ -58,6 +61,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
       } else {
         dispatch(changeSetting(['notifications', ...path], checked));
       }
+    } else if(path[0] === 'groupingBeta') {
+      dispatch(changeSetting(['notifications', ...path], checked));
+      dispatch(initializeNotifications());
     } else {
       dispatch(changeSetting(['notifications', ...path], checked));
     }
diff --git a/app/javascript/mastodon/features/notifications/index.jsx b/app/javascript/mastodon/features/notifications/index.jsx
index 54883096e..f5ebe6fe9 100644
--- a/app/javascript/mastodon/features/notifications/index.jsx
+++ b/app/javascript/mastodon/features/notifications/index.jsx
@@ -202,7 +202,7 @@ class Notifications extends PureComponent {
         <LoadGap
           key={'gap:' + notifications.getIn([index + 1, 'id'])}
           disabled={isLoading}
-          maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
+          param={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
           onClick={this.handleLoadGap}
         />
       ) : (
diff --git a/app/javascript/mastodon/features/notifications_v2/components/avatar_group.tsx b/app/javascript/mastodon/features/notifications_v2/components/avatar_group.tsx
new file mode 100644
index 000000000..b5da8914a
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/avatar_group.tsx
@@ -0,0 +1,31 @@
+import { Link } from 'react-router-dom';
+
+import { Avatar } from 'mastodon/components/avatar';
+import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group';
+import { useAppSelector } from 'mastodon/store';
+
+const AvatarWrapper: React.FC<{ accountId: string }> = ({ accountId }) => {
+  const account = useAppSelector((state) => state.accounts.get(accountId));
+
+  if (!account) return null;
+
+  return (
+    <Link
+      to={`/@${account.acct}`}
+      title={`@${account.acct}`}
+      data-hover-card-account={account.id}
+    >
+      <Avatar account={account} size={28} />
+    </Link>
+  );
+};
+
+export const AvatarGroup: React.FC<{ accountIds: string[] }> = ({
+  accountIds,
+}) => (
+  <div className='notification-group__avatar-group'>
+    {accountIds.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS).map((accountId) => (
+      <AvatarWrapper key={accountId} accountId={accountId} />
+    ))}
+  </div>
+);
diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx
new file mode 100644
index 000000000..0881e24e3
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx
@@ -0,0 +1,93 @@
+import { useCallback } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { useHistory } from 'react-router-dom';
+
+import type { List as ImmutableList, RecordOf } from 'immutable';
+
+import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
+import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
+import { Avatar } from 'mastodon/components/avatar';
+import { DisplayName } from 'mastodon/components/display_name';
+import { Icon } from 'mastodon/components/icon';
+import type { Status } from 'mastodon/models/status';
+import { useAppSelector } from 'mastodon/store';
+
+import { EmbeddedStatusContent } from './embedded_status_content';
+
+export type Mention = RecordOf<{ url: string; acct: string }>;
+
+export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
+  statusId,
+}) => {
+  const history = useHistory();
+
+  const status = useAppSelector(
+    (state) => state.statuses.get(statusId) as Status | undefined,
+  );
+
+  const account = useAppSelector((state) =>
+    state.accounts.get(status?.get('account') as string),
+  );
+
+  const handleClick = useCallback(() => {
+    if (!account) return;
+
+    history.push(`/@${account.acct}/${statusId}`);
+  }, [statusId, account, history]);
+
+  if (!status) {
+    return null;
+  }
+
+  // Assign status attributes to variables with a forced type, as status is not yet properly typed
+  const contentHtml = status.get('contentHtml') as string;
+  const poll = status.get('poll');
+  const language = status.get('language') as string;
+  const mentions = status.get('mentions') as ImmutableList<Mention>;
+  const mediaAttachmentsSize = (
+    status.get('media_attachments') as ImmutableList<unknown>
+  ).size;
+
+  return (
+    <div className='notification-group__embedded-status'>
+      <div className='notification-group__embedded-status__account'>
+        <Avatar account={account} size={16} />
+        <DisplayName account={account} />
+      </div>
+
+      <EmbeddedStatusContent
+        className='notification-group__embedded-status__content reply-indicator__content translate'
+        content={contentHtml}
+        language={language}
+        mentions={mentions}
+        onClick={handleClick}
+      />
+
+      {(poll || mediaAttachmentsSize > 0) && (
+        <div className='notification-group__embedded-status__attachments reply-indicator__attachments'>
+          {!!poll && (
+            <>
+              <Icon icon={BarChart4BarsIcon} id='bar-chart-4-bars' />
+              <FormattedMessage
+                id='reply_indicator.poll'
+                defaultMessage='Poll'
+              />
+            </>
+          )}
+          {mediaAttachmentsSize > 0 && (
+            <>
+              <Icon icon={PhotoLibraryIcon} id='photo-library' />
+              <FormattedMessage
+                id='reply_indicator.attachments'
+                defaultMessage='{count, plural, one {# attachment} other {# attachments}}'
+                values={{ count: mediaAttachmentsSize }}
+              />
+            </>
+          )}
+        </div>
+      )}
+    </div>
+  );
+};
diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx
new file mode 100644
index 000000000..310a68571
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx
@@ -0,0 +1,165 @@
+import { useCallback, useRef } from 'react';
+
+import { useHistory } from 'react-router-dom';
+
+import type { List } from 'immutable';
+
+import type { History } from 'history';
+
+import type { Mention } from './embedded_status';
+
+const handleMentionClick = (
+  history: History,
+  mention: Mention,
+  e: MouseEvent,
+) => {
+  if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+    e.preventDefault();
+    history.push(`/@${mention.get('acct')}`);
+  }
+};
+
+const handleHashtagClick = (
+  history: History,
+  hashtag: string,
+  e: MouseEvent,
+) => {
+  if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+    e.preventDefault();
+    history.push(`/tags/${hashtag.replace(/^#/, '')}`);
+  }
+};
+
+export const EmbeddedStatusContent: React.FC<{
+  content: string;
+  mentions: List<Mention>;
+  language: string;
+  onClick?: () => void;
+  className?: string;
+}> = ({ content, mentions, language, onClick, className }) => {
+  const clickCoordinatesRef = useRef<[number, number] | null>();
+  const history = useHistory();
+
+  const handleMouseDown = useCallback<React.MouseEventHandler<HTMLDivElement>>(
+    ({ clientX, clientY }) => {
+      clickCoordinatesRef.current = [clientX, clientY];
+    },
+    [clickCoordinatesRef],
+  );
+
+  const handleMouseUp = useCallback<React.MouseEventHandler<HTMLDivElement>>(
+    ({ clientX, clientY, target, button }) => {
+      const [startX, startY] = clickCoordinatesRef.current ?? [0, 0];
+      const [deltaX, deltaY] = [
+        Math.abs(clientX - startX),
+        Math.abs(clientY - startY),
+      ];
+
+      let element: HTMLDivElement | null = target as HTMLDivElement;
+
+      while (element) {
+        if (
+          element.localName === 'button' ||
+          element.localName === 'a' ||
+          element.localName === 'label'
+        ) {
+          return;
+        }
+
+        element = element.parentNode as HTMLDivElement | null;
+      }
+
+      if (deltaX + deltaY < 5 && button === 0 && onClick) {
+        onClick();
+      }
+
+      clickCoordinatesRef.current = null;
+    },
+    [clickCoordinatesRef, onClick],
+  );
+
+  const handleMouseEnter = useCallback<React.MouseEventHandler<HTMLDivElement>>(
+    ({ currentTarget }) => {
+      const emojis =
+        currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
+
+      for (const emoji of emojis) {
+        const newSrc = emoji.getAttribute('data-original');
+        if (newSrc) emoji.src = newSrc;
+      }
+    },
+    [],
+  );
+
+  const handleMouseLeave = useCallback<React.MouseEventHandler<HTMLDivElement>>(
+    ({ currentTarget }) => {
+      const emojis =
+        currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
+
+      for (const emoji of emojis) {
+        const newSrc = emoji.getAttribute('data-static');
+        if (newSrc) emoji.src = newSrc;
+      }
+    },
+    [],
+  );
+
+  const handleContentRef = useCallback(
+    (node: HTMLDivElement | null) => {
+      if (!node) {
+        return;
+      }
+
+      const links = node.querySelectorAll<HTMLAnchorElement>('a');
+
+      for (const link of links) {
+        if (link.classList.contains('status-link')) {
+          continue;
+        }
+
+        link.classList.add('status-link');
+
+        const mention = mentions.find((item) => link.href === item.get('url'));
+
+        if (mention) {
+          link.addEventListener(
+            'click',
+            handleMentionClick.bind(null, history, mention),
+            false,
+          );
+          link.setAttribute('title', `@${mention.get('acct')}`);
+          link.setAttribute('href', `/@${mention.get('acct')}`);
+        } else if (
+          link.textContent?.[0] === '#' ||
+          link.previousSibling?.textContent?.endsWith('#')
+        ) {
+          link.addEventListener(
+            'click',
+            handleHashtagClick.bind(null, history, link.text),
+            false,
+          );
+          link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
+        } else {
+          link.setAttribute('title', link.href);
+          link.classList.add('unhandled-link');
+        }
+      }
+    },
+    [mentions, history],
+  );
+
+  return (
+    <div
+      role='button'
+      tabIndex={0}
+      className={className}
+      ref={handleContentRef}
+      lang={language}
+      dangerouslySetInnerHTML={{ __html: content }}
+      onMouseDown={handleMouseDown}
+      onMouseUp={handleMouseUp}
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+    />
+  );
+};
diff --git a/app/javascript/mastodon/features/notifications_v2/components/names_list.tsx b/app/javascript/mastodon/features/notifications_v2/components/names_list.tsx
new file mode 100644
index 000000000..3d70cc0b6
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/names_list.tsx
@@ -0,0 +1,51 @@
+import { FormattedMessage } from 'react-intl';
+
+import { Link } from 'react-router-dom';
+
+import { useAppSelector } from 'mastodon/store';
+
+export const NamesList: React.FC<{
+  accountIds: string[];
+  total: number;
+  seeMoreHref?: string;
+}> = ({ accountIds, total, seeMoreHref }) => {
+  const lastAccountId = accountIds[0] ?? '0';
+  const account = useAppSelector((state) => state.accounts.get(lastAccountId));
+
+  if (!account) return null;
+
+  const displayedName = (
+    <Link
+      to={`/@${account.acct}`}
+      title={`@${account.acct}`}
+      data-hover-card-account={account.id}
+    >
+      <bdi dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
+    </Link>
+  );
+
+  if (total === 1) {
+    return displayedName;
+  }
+
+  if (seeMoreHref)
+    return (
+      <FormattedMessage
+        id='name_and_others_with_link'
+        defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a>'
+        values={{
+          name: displayedName,
+          count: total - 1,
+          a: (chunks) => <Link to={seeMoreHref}>{chunks}</Link>,
+        }}
+      />
+    );
+
+  return (
+    <FormattedMessage
+      id='name_and_others'
+      defaultMessage='{name} and {count, plural, one {# other} other {# others}}'
+      values={{ name: displayedName, count: total - 1 }}
+    />
+  );
+};
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_admin_report.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_admin_report.tsx
new file mode 100644
index 000000000..fda5798ae
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_admin_report.tsx
@@ -0,0 +1,132 @@
+import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
+
+import classNames from 'classnames';
+
+import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
+import { Icon } from 'mastodon/components/icon';
+import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
+import type { NotificationGroupAdminReport } from 'mastodon/models/notification_group';
+import { useAppSelector } from 'mastodon/store';
+
+// This needs to be kept in sync with app/models/report.rb
+const messages = defineMessages({
+  other: {
+    id: 'report_notification.categories.other_sentence',
+    defaultMessage: 'other',
+  },
+  spam: {
+    id: 'report_notification.categories.spam_sentence',
+    defaultMessage: 'spam',
+  },
+  legal: {
+    id: 'report_notification.categories.legal_sentence',
+    defaultMessage: 'illegal content',
+  },
+  violation: {
+    id: 'report_notification.categories.violation_sentence',
+    defaultMessage: 'rule violation',
+  },
+});
+
+export const NotificationAdminReport: React.FC<{
+  notification: NotificationGroupAdminReport;
+  unread?: boolean;
+}> = ({ notification, notification: { report }, unread }) => {
+  const intl = useIntl();
+  const targetAccount = useAppSelector((state) =>
+    state.accounts.get(report.targetAccountId),
+  );
+  const account = useAppSelector((state) =>
+    state.accounts.get(notification.sampleAccountIds[0] ?? '0'),
+  );
+
+  if (!account || !targetAccount) return null;
+
+  const values = {
+    name: (
+      <bdi
+        dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }}
+      />
+    ),
+    target: (
+      <bdi
+        dangerouslySetInnerHTML={{
+          __html: targetAccount.get('display_name_html'),
+        }}
+      />
+    ),
+    category: intl.formatMessage(messages[report.category]),
+    count: report.status_ids.length,
+  };
+
+  let message;
+
+  if (report.status_ids.length > 0) {
+    if (report.category === 'other') {
+      message = (
+        <FormattedMessage
+          id='notification.admin.report_account_other'
+          defaultMessage='{name} reported {count, plural, one {one post} other {# posts}} from {target}'
+          values={values}
+        />
+      );
+    } else {
+      message = (
+        <FormattedMessage
+          id='notification.admin.report_account'
+          defaultMessage='{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}'
+          values={values}
+        />
+      );
+    }
+  } else {
+    if (report.category === 'other') {
+      message = (
+        <FormattedMessage
+          id='notification.admin.report_statuses_other'
+          defaultMessage='{name} reported {target}'
+          values={values}
+        />
+      );
+    } else {
+      message = (
+        <FormattedMessage
+          id='notification.admin.report_statuses'
+          defaultMessage='{name} reported {target} for {category}'
+          values={values}
+        />
+      );
+    }
+  }
+
+  return (
+    <a
+      href={`/admin/reports/${report.id}`}
+      target='_blank'
+      rel='noopener noreferrer'
+      className={classNames(
+        'notification-group notification-group--link notification-group--admin-report focusable',
+        { 'notification-group--unread': unread },
+      )}
+    >
+      <div className='notification-group__icon'>
+        <Icon id='flag' icon={FlagIcon} />
+      </div>
+
+      <div className='notification-group__main'>
+        <div className='notification-group__main__header'>
+          <div className='notification-group__main__header__label'>
+            {message}
+            <RelativeTimestamp timestamp={report.created_at} />
+          </div>
+        </div>
+
+        {report.comment.length > 0 && (
+          <div className='notification-group__embedded-status__content'>
+            “{report.comment}”
+          </div>
+        )}
+      </div>
+    </a>
+  );
+};
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_admin_sign_up.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_admin_sign_up.tsx
new file mode 100644
index 000000000..9f7afc63f
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_admin_sign_up.tsx
@@ -0,0 +1,31 @@
+import { FormattedMessage } from 'react-intl';
+
+import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
+import type { NotificationGroupAdminSignUp } from 'mastodon/models/notification_group';
+
+import type { LabelRenderer } from './notification_group_with_status';
+import { NotificationGroupWithStatus } from './notification_group_with_status';
+
+const labelRenderer: LabelRenderer = (values) => (
+  <FormattedMessage
+    id='notification.admin.sign_up'
+    defaultMessage='{name} signed up'
+    values={values}
+  />
+);
+
+export const NotificationAdminSignUp: React.FC<{
+  notification: NotificationGroupAdminSignUp;
+  unread: boolean;
+}> = ({ notification, unread }) => (
+  <NotificationGroupWithStatus
+    type='admin-sign-up'
+    icon={PersonAddIcon}
+    iconId='person-add'
+    accountIds={notification.sampleAccountIds}
+    timestamp={notification.latest_page_notification_at}
+    count={notification.notifications_count}
+    labelRenderer={labelRenderer}
+    unread={unread}
+  />
+);
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_favourite.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_favourite.tsx
new file mode 100644
index 000000000..22838fe69
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_favourite.tsx
@@ -0,0 +1,45 @@
+import { FormattedMessage } from 'react-intl';
+
+import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
+import type { NotificationGroupFavourite } from 'mastodon/models/notification_group';
+import { useAppSelector } from 'mastodon/store';
+
+import type { LabelRenderer } from './notification_group_with_status';
+import { NotificationGroupWithStatus } from './notification_group_with_status';
+
+const labelRenderer: LabelRenderer = (values) => (
+  <FormattedMessage
+    id='notification.favourite'
+    defaultMessage='{name} favorited your status'
+    values={values}
+  />
+);
+
+export const NotificationFavourite: React.FC<{
+  notification: NotificationGroupFavourite;
+  unread: boolean;
+}> = ({ notification, unread }) => {
+  const { statusId } = notification;
+  const statusAccount = useAppSelector(
+    (state) =>
+      state.accounts.get(state.statuses.getIn([statusId, 'account']) as string)
+        ?.acct,
+  );
+
+  return (
+    <NotificationGroupWithStatus
+      type='favourite'
+      icon={StarIcon}
+      iconId='star'
+      accountIds={notification.sampleAccountIds}
+      statusId={notification.statusId}
+      timestamp={notification.latest_page_notification_at}
+      count={notification.notifications_count}
+      labelRenderer={labelRenderer}
+      labelSeeMoreHref={
+        statusAccount ? `/@${statusAccount}/${statusId}/favourites` : undefined
+      }
+      unread={unread}
+    />
+  );
+};
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx
new file mode 100644
index 000000000..0ed96ae63
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_follow.tsx
@@ -0,0 +1,31 @@
+import { FormattedMessage } from 'react-intl';
+
+import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
+import type { NotificationGroupFollow } from 'mastodon/models/notification_group';
+
+import type { LabelRenderer } from './notification_group_with_status';
+import { NotificationGroupWithStatus } from './notification_group_with_status';
+
+const labelRenderer: LabelRenderer = (values) => (
+  <FormattedMessage
+    id='notification.follow'
+    defaultMessage='{name} followed you'
+    values={values}
+  />
+);
+
+export const NotificationFollow: React.FC<{
+  notification: NotificationGroupFollow;
+  unread: boolean;
+}> = ({ notification, unread }) => (
+  <NotificationGroupWithStatus
+    type='follow'
+    icon={PersonAddIcon}
+    iconId='person-add'
+    accountIds={notification.sampleAccountIds}
+    timestamp={notification.latest_page_notification_at}
+    count={notification.notifications_count}
+    labelRenderer={labelRenderer}
+    unread={unread}
+  />
+);
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx
new file mode 100644
index 000000000..8c9837efa
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx
@@ -0,0 +1,78 @@
+import { useCallback } from 'react';
+
+import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
+
+import CheckIcon from '@/material-icons/400-24px/check.svg?react';
+import CloseIcon from '@/material-icons/400-24px/close.svg?react';
+import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
+import {
+  authorizeFollowRequest,
+  rejectFollowRequest,
+} from 'mastodon/actions/accounts';
+import { IconButton } from 'mastodon/components/icon_button';
+import type { NotificationGroupFollowRequest } from 'mastodon/models/notification_group';
+import { useAppDispatch } from 'mastodon/store';
+
+import type { LabelRenderer } from './notification_group_with_status';
+import { NotificationGroupWithStatus } from './notification_group_with_status';
+
+const messages = defineMessages({
+  authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
+  reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
+});
+
+const labelRenderer: LabelRenderer = (values) => (
+  <FormattedMessage
+    id='notification.follow_request'
+    defaultMessage='{name} has requested to follow you'
+    values={values}
+  />
+);
+
+export const NotificationFollowRequest: React.FC<{
+  notification: NotificationGroupFollowRequest;
+  unread: boolean;
+}> = ({ notification, unread }) => {
+  const intl = useIntl();
+
+  const dispatch = useAppDispatch();
+
+  const onAuthorize = useCallback(() => {
+    dispatch(authorizeFollowRequest(notification.sampleAccountIds[0]));
+  }, [dispatch, notification.sampleAccountIds]);
+
+  const onReject = useCallback(() => {
+    dispatch(rejectFollowRequest(notification.sampleAccountIds[0]));
+  }, [dispatch, notification.sampleAccountIds]);
+
+  const actions = (
+    <div className='notification-group__actions'>
+      <IconButton
+        title={intl.formatMessage(messages.reject)}
+        icon='times'
+        iconComponent={CloseIcon}
+        onClick={onReject}
+      />
+      <IconButton
+        title={intl.formatMessage(messages.authorize)}
+        icon='check'
+        iconComponent={CheckIcon}
+        onClick={onAuthorize}
+      />
+    </div>
+  );
+
+  return (
+    <NotificationGroupWithStatus
+      type='follow-request'
+      icon={PersonAddIcon}
+      iconId='person-add'
+      accountIds={notification.sampleAccountIds}
+      timestamp={notification.latest_page_notification_at}
+      count={notification.notifications_count}
+      labelRenderer={labelRenderer}
+      actions={actions}
+      unread={unread}
+    />
+  );
+};
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 000000000..1cfb235b7
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx
@@ -0,0 +1,134 @@
+import { useMemo } from 'react';
+
+import { HotKeys } from 'react-hotkeys';
+
+import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group';
+import { useAppSelector } from 'mastodon/store';
+
+import { NotificationAdminReport } from './notification_admin_report';
+import { NotificationAdminSignUp } from './notification_admin_sign_up';
+import { NotificationFavourite } from './notification_favourite';
+import { NotificationFollow } from './notification_follow';
+import { NotificationFollowRequest } from './notification_follow_request';
+import { NotificationMention } from './notification_mention';
+import { NotificationModerationWarning } from './notification_moderation_warning';
+import { NotificationPoll } from './notification_poll';
+import { NotificationReblog } from './notification_reblog';
+import { NotificationSeveredRelationships } from './notification_severed_relationships';
+import { NotificationStatus } from './notification_status';
+import { NotificationUpdate } from './notification_update';
+
+export const NotificationGroup: React.FC<{
+  notificationGroupId: NotificationGroupModel['group_key'];
+  unread: boolean;
+  onMoveUp: (groupId: string) => void;
+  onMoveDown: (groupId: string) => void;
+}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => {
+  const notificationGroup = useAppSelector((state) =>
+    state.notificationGroups.groups.find(
+      (item) => item.type !== 'gap' && item.group_key === notificationGroupId,
+    ),
+  );
+
+  const handlers = useMemo(
+    () => ({
+      moveUp: () => {
+        onMoveUp(notificationGroupId);
+      },
+
+      moveDown: () => {
+        onMoveDown(notificationGroupId);
+      },
+    }),
+    [notificationGroupId, onMoveUp, onMoveDown],
+  );
+
+  if (!notificationGroup || notificationGroup.type === 'gap') return null;
+
+  let content;
+
+  switch (notificationGroup.type) {
+    case 'reblog':
+      content = (
+        <NotificationReblog unread={unread} notification={notificationGroup} />
+      );
+      break;
+    case 'favourite':
+      content = (
+        <NotificationFavourite
+          unread={unread}
+          notification={notificationGroup}
+        />
+      );
+      break;
+    case 'severed_relationships':
+      content = (
+        <NotificationSeveredRelationships
+          unread={unread}
+          notification={notificationGroup}
+        />
+      );
+      break;
+    case 'mention':
+      content = (
+        <NotificationMention unread={unread} notification={notificationGroup} />
+      );
+      break;
+    case 'follow':
+      content = (
+        <NotificationFollow unread={unread} notification={notificationGroup} />
+      );
+      break;
+    case 'follow_request':
+      content = (
+        <NotificationFollowRequest
+          unread={unread}
+          notification={notificationGroup}
+        />
+      );
+      break;
+    case 'poll':
+      content = (
+        <NotificationPoll unread={unread} notification={notificationGroup} />
+      );
+      break;
+    case 'status':
+      content = (
+        <NotificationStatus unread={unread} notification={notificationGroup} />
+      );
+      break;
+    case 'update':
+      content = (
+        <NotificationUpdate unread={unread} notification={notificationGroup} />
+      );
+      break;
+    case 'admin.sign_up':
+      content = (
+        <NotificationAdminSignUp
+          unread={unread}
+          notification={notificationGroup}
+        />
+      );
+      break;
+    case 'admin.report':
+      content = (
+        <NotificationAdminReport
+          unread={unread}
+          notification={notificationGroup}
+        />
+      );
+      break;
+    case 'moderation_warning':
+      content = (
+        <NotificationModerationWarning
+          unread={unread}
+          notification={notificationGroup}
+        />
+      );
+      break;
+    default:
+      return null;
+  }
+
+  return <HotKeys handlers={handlers}>{content}</HotKeys>;
+};
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx
new file mode 100644
index 000000000..23004f7ee
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx
@@ -0,0 +1,91 @@
+import { useMemo } from 'react';
+
+import classNames from 'classnames';
+
+import type { IconProp } from 'mastodon/components/icon';
+import { Icon } from 'mastodon/components/icon';
+import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
+
+import { AvatarGroup } from './avatar_group';
+import { EmbeddedStatus } from './embedded_status';
+import { NamesList } from './names_list';
+
+export type LabelRenderer = (
+  values: Record<string, React.ReactNode>,
+) => JSX.Element;
+
+export const NotificationGroupWithStatus: React.FC<{
+  icon: IconProp;
+  iconId: string;
+  statusId?: string;
+  actions?: JSX.Element;
+  count: number;
+  accountIds: string[];
+  timestamp: string;
+  labelRenderer: LabelRenderer;
+  labelSeeMoreHref?: string;
+  type: string;
+  unread: boolean;
+}> = ({
+  icon,
+  iconId,
+  timestamp,
+  accountIds,
+  actions,
+  count,
+  statusId,
+  labelRenderer,
+  labelSeeMoreHref,
+  type,
+  unread,
+}) => {
+  const label = useMemo(
+    () =>
+      labelRenderer({
+        name: (
+          <NamesList
+            accountIds={accountIds}
+            total={count}
+            seeMoreHref={labelSeeMoreHref}
+          />
+        ),
+      }),
+    [labelRenderer, accountIds, count, labelSeeMoreHref],
+  );
+
+  return (
+    <div
+      role='button'
+      className={classNames(
+        `notification-group focusable notification-group--${type}`,
+        { 'notification-group--unread': unread },
+      )}
+      tabIndex={0}
+    >
+      <div className='notification-group__icon'>
+        <Icon icon={icon} id={iconId} />
+      </div>
+
+      <div className='notification-group__main'>
+        <div className='notification-group__main__header'>
+          <div className='notification-group__main__header__wrapper'>
+            <AvatarGroup accountIds={accountIds} />
+
+            {actions}
+          </div>
+
+          <div className='notification-group__main__header__label'>
+            {label}
+            {timestamp && <RelativeTimestamp timestamp={timestamp} />}
+          </div>
+        </div>
+
+        {statusId && (
+          <div className='notification-group__main__status'>
+            <EmbeddedStatus statusId={statusId} />
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx
new file mode 100644
index 000000000..8c584f0ce
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx
@@ -0,0 +1,55 @@
+import { FormattedMessage } from 'react-intl';
+
+import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
+import type { StatusVisibility } from 'mastodon/api_types/statuses';
+import type { NotificationGroupMention } from 'mastodon/models/notification_group';
+import { useAppSelector } from 'mastodon/store';
+
+import type { LabelRenderer } from './notification_group_with_status';
+import { NotificationWithStatus } from './notification_with_status';
+
+const labelRenderer: LabelRenderer = (values) => (
+  <FormattedMessage
+    id='notification.mention'
+    defaultMessage='{name} mentioned you'
+    values={values}
+  />
+);
+
+const privateMentionLabelRenderer: LabelRenderer = (values) => (
+  <FormattedMessage
+    id='notification.private_mention'
+    defaultMessage='{name} privately mentioned you'
+    values={values}
+  />
+);
+
+export const NotificationMention: React.FC<{
+  notification: NotificationGroupMention;
+  unread: boolean;
+}> = ({ notification, unread }) => {
+  const statusVisibility = useAppSelector(
+    (state) =>
+      state.statuses.getIn([
+        notification.statusId,
+        'visibility',
+      ]) as StatusVisibility,
+  );
+
+  return (
+    <NotificationWithStatus
+      type='mention'
+      icon={ReplyIcon}
+      iconId='reply'
+      accountIds={notification.sampleAccountIds}
+      count={notification.notifications_count}
+      statusId={notification.statusId}
+      labelRenderer={
+        statusVisibility === 'direct'
+          ? privateMentionLabelRenderer
+          : labelRenderer
+      }
+      unread={unread}
+    />
+  );
+};
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_moderation_warning.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_moderation_warning.tsx
new file mode 100644
index 000000000..d653385fd
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_moderation_warning.tsx
@@ -0,0 +1,13 @@
+import { ModerationWarning } from 'mastodon/features/notifications/components/moderation_warning';
+import type { NotificationGroupModerationWarning } from 'mastodon/models/notification_group';
+
+export const NotificationModerationWarning: React.FC<{
+  notification: NotificationGroupModerationWarning;
+  unread: boolean;
+}> = ({ notification: { moderationWarning }, unread }) => (
+  <ModerationWarning
+    action={moderationWarning.action}
+    id={moderationWarning.id}
+    unread={unread}
+  />
+);
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_poll.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_poll.tsx
new file mode 100644
index 000000000..a7748c2c7
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_poll.tsx
@@ -0,0 +1,41 @@
+import { FormattedMessage } from 'react-intl';
+
+import BarChart4BarsIcon from '@/material-icons/400-20px/bar_chart_4_bars.svg?react';
+import { me } from 'mastodon/initial_state';
+import type { NotificationGroupPoll } from 'mastodon/models/notification_group';
+
+import { NotificationWithStatus } from './notification_with_status';
+
+const labelRendererOther = () => (
+  <FormattedMessage
+    id='notification.poll'
+    defaultMessage='A poll you voted in has ended'
+  />
+);
+
+const labelRendererOwn = () => (
+  <FormattedMessage
+    id='notification.own_poll'
+    defaultMessage='Your poll has ended'
+  />
+);
+
+export const NotificationPoll: React.FC<{
+  notification: NotificationGroupPoll;
+  unread: boolean;
+}> = ({ notification, unread }) => (
+  <NotificationWithStatus
+    type='poll'
+    icon={BarChart4BarsIcon}
+    iconId='bar-chart-4-bars'
+    accountIds={notification.sampleAccountIds}
+    count={notification.notifications_count}
+    statusId={notification.statusId}
+    labelRenderer={
+      notification.sampleAccountIds[0] === me
+        ? labelRendererOwn
+        : labelRendererOther
+    }
+    unread={unread}
+  />
+);
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 000000000..062556868
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_reblog.tsx
@@ -0,0 +1,45 @@
+import { FormattedMessage } from 'react-intl';
+
+import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
+import type { NotificationGroupReblog } from 'mastodon/models/notification_group';
+import { useAppSelector } from 'mastodon/store';
+
+import type { LabelRenderer } from './notification_group_with_status';
+import { NotificationGroupWithStatus } from './notification_group_with_status';
+
+const labelRenderer: LabelRenderer = (values) => (
+  <FormattedMessage
+    id='notification.reblog'
+    defaultMessage='{name} boosted your status'
+    values={values}
+  />
+);
+
+export const NotificationReblog: React.FC<{
+  notification: NotificationGroupReblog;
+  unread: boolean;
+}> = ({ notification, unread }) => {
+  const { statusId } = notification;
+  const statusAccount = useAppSelector(
+    (state) =>
+      state.accounts.get(state.statuses.getIn([statusId, 'account']) as string)
+        ?.acct,
+  );
+
+  return (
+    <NotificationGroupWithStatus
+      type='reblog'
+      icon={RepeatIcon}
+      iconId='repeat'
+      accountIds={notification.sampleAccountIds}
+      statusId={notification.statusId}
+      timestamp={notification.latest_page_notification_at}
+      count={notification.notifications_count}
+      labelRenderer={labelRenderer}
+      labelSeeMoreHref={
+        statusAccount ? `/@${statusAccount}/${statusId}/reblogs` : undefined
+      }
+      unread={unread}
+    />
+  );
+};
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_severed_relationships.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_severed_relationships.tsx
new file mode 100644
index 000000000..fd92498ea
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_severed_relationships.tsx
@@ -0,0 +1,15 @@
+import { RelationshipsSeveranceEvent } from 'mastodon/features/notifications/components/relationships_severance_event';
+import type { NotificationGroupSeveredRelationships } from 'mastodon/models/notification_group';
+
+export const NotificationSeveredRelationships: React.FC<{
+  notification: NotificationGroupSeveredRelationships;
+  unread: boolean;
+}> = ({ notification: { event }, unread }) => (
+  <RelationshipsSeveranceEvent
+    type={event.type}
+    target={event.target_name}
+    followersCount={event.followers_count}
+    followingCount={event.following_count}
+    unread={unread}
+  />
+);
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_status.tsx
new file mode 100644
index 000000000..9ade355a7
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_status.tsx
@@ -0,0 +1,31 @@
+import { FormattedMessage } from 'react-intl';
+
+import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
+import type { NotificationGroupStatus } from 'mastodon/models/notification_group';
+
+import type { LabelRenderer } from './notification_group_with_status';
+import { NotificationWithStatus } from './notification_with_status';
+
+const labelRenderer: LabelRenderer = (values) => (
+  <FormattedMessage
+    id='notification.status'
+    defaultMessage='{name} just posted'
+    values={values}
+  />
+);
+
+export const NotificationStatus: React.FC<{
+  notification: NotificationGroupStatus;
+  unread: boolean;
+}> = ({ notification, unread }) => (
+  <NotificationWithStatus
+    type='status'
+    icon={NotificationsActiveIcon}
+    iconId='notifications-active'
+    accountIds={notification.sampleAccountIds}
+    count={notification.notifications_count}
+    statusId={notification.statusId}
+    labelRenderer={labelRenderer}
+    unread={unread}
+  />
+);
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_update.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_update.tsx
new file mode 100644
index 000000000..c518367bf
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_update.tsx
@@ -0,0 +1,31 @@
+import { FormattedMessage } from 'react-intl';
+
+import EditIcon from '@/material-icons/400-24px/edit.svg?react';
+import type { NotificationGroupUpdate } from 'mastodon/models/notification_group';
+
+import type { LabelRenderer } from './notification_group_with_status';
+import { NotificationWithStatus } from './notification_with_status';
+
+const labelRenderer: LabelRenderer = (values) => (
+  <FormattedMessage
+    id='notification.update'
+    defaultMessage='{name} edited a post'
+    values={values}
+  />
+);
+
+export const NotificationUpdate: React.FC<{
+  notification: NotificationGroupUpdate;
+  unread: boolean;
+}> = ({ notification, unread }) => (
+  <NotificationWithStatus
+    type='update'
+    icon={EditIcon}
+    iconId='edit'
+    accountIds={notification.sampleAccountIds}
+    count={notification.notifications_count}
+    statusId={notification.statusId}
+    labelRenderer={labelRenderer}
+    unread={unread}
+  />
+);
diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx
new file mode 100644
index 000000000..27de76b48
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx
@@ -0,0 +1,73 @@
+import { useMemo } from 'react';
+
+import classNames from 'classnames';
+
+import type { IconProp } from 'mastodon/components/icon';
+import { Icon } from 'mastodon/components/icon';
+import Status from 'mastodon/containers/status_container';
+import { useAppSelector } from 'mastodon/store';
+
+import { NamesList } from './names_list';
+import type { LabelRenderer } from './notification_group_with_status';
+
+export const NotificationWithStatus: React.FC<{
+  type: string;
+  icon: IconProp;
+  iconId: string;
+  accountIds: string[];
+  statusId: string;
+  count: number;
+  labelRenderer: LabelRenderer;
+  unread: boolean;
+}> = ({
+  icon,
+  iconId,
+  accountIds,
+  statusId,
+  count,
+  labelRenderer,
+  type,
+  unread,
+}) => {
+  const label = useMemo(
+    () =>
+      labelRenderer({
+        name: <NamesList accountIds={accountIds} total={count} />,
+      }),
+    [labelRenderer, accountIds, count],
+  );
+
+  const isPrivateMention = useAppSelector(
+    (state) => state.statuses.getIn([statusId, 'visibility']) === 'direct',
+  );
+
+  return (
+    <div
+      role='button'
+      className={classNames(
+        `notification-ungrouped focusable notification-ungrouped--${type}`,
+        {
+          'notification-ungrouped--unread': unread,
+          'notification-ungrouped--direct': isPrivateMention,
+        },
+      )}
+      tabIndex={0}
+    >
+      <div className='notification-ungrouped__header'>
+        <div className='notification-ungrouped__header__icon'>
+          <Icon icon={icon} id={iconId} />
+        </div>
+        {label}
+      </div>
+
+      <Status
+        // @ts-expect-error -- <Status> is not yet typed
+        id={statusId}
+        contextType='notifications'
+        withDismiss
+        skipPrepend
+        avatarSize={40}
+      />
+    </div>
+  );
+};
diff --git a/app/javascript/mastodon/features/notifications_v2/filter_bar.tsx b/app/javascript/mastodon/features/notifications_v2/filter_bar.tsx
new file mode 100644
index 000000000..37d2d864b
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/filter_bar.tsx
@@ -0,0 +1,145 @@
+import type { PropsWithChildren } from 'react';
+import { useCallback } from 'react';
+
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+
+import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
+import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
+import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
+import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
+import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
+import StarIcon from '@/material-icons/400-24px/star.svg?react';
+import { setNotificationsFilter } from 'mastodon/actions/notification_groups';
+import { Icon } from 'mastodon/components/icon';
+import {
+  selectSettingsNotificationsQuickFilterActive,
+  selectSettingsNotificationsQuickFilterAdvanced,
+} from 'mastodon/selectors/settings';
+import { useAppDispatch, useAppSelector } from 'mastodon/store';
+
+const tooltips = defineMessages({
+  mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
+  favourites: {
+    id: 'notifications.filter.favourites',
+    defaultMessage: 'Favorites',
+  },
+  boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
+  polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
+  follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
+  statuses: {
+    id: 'notifications.filter.statuses',
+    defaultMessage: 'Updates from people you follow',
+  },
+});
+
+const BarButton: React.FC<
+  PropsWithChildren<{
+    selectedFilter: string;
+    type: string;
+    title?: string;
+  }>
+> = ({ selectedFilter, type, title, children }) => {
+  const dispatch = useAppDispatch();
+
+  const onClick = useCallback(() => {
+    void dispatch(setNotificationsFilter({ filterType: type }));
+  }, [dispatch, type]);
+
+  return (
+    <button
+      className={selectedFilter === type ? 'active' : ''}
+      onClick={onClick}
+      title={title}
+    >
+      {children}
+    </button>
+  );
+};
+
+export const FilterBar: React.FC = () => {
+  const intl = useIntl();
+
+  const selectedFilter = useAppSelector(
+    selectSettingsNotificationsQuickFilterActive,
+  );
+  const advancedMode = useAppSelector(
+    selectSettingsNotificationsQuickFilterAdvanced,
+  );
+
+  if (advancedMode)
+    return (
+      <div className='notification__filter-bar'>
+        <BarButton selectedFilter={selectedFilter} type='all' key='all'>
+          <FormattedMessage
+            id='notifications.filter.all'
+            defaultMessage='All'
+          />
+        </BarButton>
+        <BarButton
+          selectedFilter={selectedFilter}
+          type='mention'
+          key='mention'
+          title={intl.formatMessage(tooltips.mentions)}
+        >
+          <Icon id='reply-all' icon={ReplyAllIcon} />
+        </BarButton>
+        <BarButton
+          selectedFilter={selectedFilter}
+          type='favourite'
+          key='favourite'
+          title={intl.formatMessage(tooltips.favourites)}
+        >
+          <Icon id='star' icon={StarIcon} />
+        </BarButton>
+        <BarButton
+          selectedFilter={selectedFilter}
+          type='reblog'
+          key='reblog'
+          title={intl.formatMessage(tooltips.boosts)}
+        >
+          <Icon id='retweet' icon={RepeatIcon} />
+        </BarButton>
+        <BarButton
+          selectedFilter={selectedFilter}
+          type='poll'
+          key='poll'
+          title={intl.formatMessage(tooltips.polls)}
+        >
+          <Icon id='tasks' icon={InsertChartIcon} />
+        </BarButton>
+        <BarButton
+          selectedFilter={selectedFilter}
+          type='status'
+          key='status'
+          title={intl.formatMessage(tooltips.statuses)}
+        >
+          <Icon id='home' icon={HomeIcon} />
+        </BarButton>
+        <BarButton
+          selectedFilter={selectedFilter}
+          type='follow'
+          key='follow'
+          title={intl.formatMessage(tooltips.follows)}
+        >
+          <Icon id='user-plus' icon={PersonAddIcon} />
+        </BarButton>
+      </div>
+    );
+  else
+    return (
+      <div className='notification__filter-bar'>
+        <BarButton selectedFilter={selectedFilter} type='all' key='all'>
+          <FormattedMessage
+            id='notifications.filter.all'
+            defaultMessage='All'
+          />
+        </BarButton>
+        <BarButton selectedFilter={selectedFilter} type='mention' key='mention'>
+          <FormattedMessage
+            id='notifications.filter.mentions'
+            defaultMessage='Mentions'
+          />
+        </BarButton>
+      </div>
+    );
+};
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 000000000..fc20f0583
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_v2/index.tsx
@@ -0,0 +1,354 @@
+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 { 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 {
+  fetchNotificationsGap,
+  updateScrollPosition,
+  loadPending,
+  markNotificationsAsRead,
+  mountNotifications,
+  unmountNotifications,
+} 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 type { NotificationGap } from 'mastodon/reducers/notification_groups';
+import {
+  selectUnreadNotificationGroupsCount,
+  selectPendingNotificationGroupsCount,
+} from 'mastodon/selectors/notifications';
+import {
+  selectNeedsNotificationPermission,
+  selectSettingsNotificationsExcludedTypes,
+  selectSettingsNotificationsQuickFilterActive,
+  selectSettingsNotificationsQuickFilterShow,
+  selectSettingsNotificationsShowUnread,
+} from 'mastodon/selectors/settings';
+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 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 { NotificationGroup } from './components/notification_group';
+import { FilterBar } from './filter_bar';
+
+const messages = defineMessages({
+  title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  markAsRead: {
+    id: 'notifications.mark_as_read',
+    defaultMessage: 'Mark every notification as read',
+  },
+});
+
+const getNotifications = createSelector(
+  [
+    selectSettingsNotificationsQuickFilterShow,
+    selectSettingsNotificationsQuickFilterActive,
+    selectSettingsNotificationsExcludedTypes,
+    (state: RootState) => state.notificationGroups.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,
+    );
+  },
+);
+
+export const Notifications: React.FC<{
+  columnId?: string;
+  multiColumn?: boolean;
+}> = ({ columnId, multiColumn }) => {
+  const intl = useIntl();
+  const notifications = useAppSelector(getNotifications);
+  const dispatch = useAppDispatch();
+  const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
+  const hasMore = notifications.at(-1)?.type === 'gap';
+
+  const lastReadId = useAppSelector((s) =>
+    selectSettingsNotificationsShowUnread(s)
+      ? s.notificationGroups.lastReadId
+      : '0',
+  );
+
+  const numPending = useAppSelector(selectPendingNotificationGroupsCount);
+
+  const unreadNotificationsCount = useAppSelector(
+    selectUnreadNotificationGroupsCount,
+  );
+
+  const isUnread = unreadNotificationsCount > 0;
+
+  const canMarkAsRead =
+    useAppSelector(selectSettingsNotificationsShowUnread) &&
+    unreadNotificationsCount > 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();
+    }
+  }, []);
+
+  // Keep track of mounted components for unread notification handling
+  useEffect(() => {
+    dispatch(mountNotifications());
+
+    return () => {
+      dispatch(unmountNotifications());
+      dispatch(updateScrollPosition({ top: false }));
+    };
+  }, [dispatch]);
+
+  const handleLoadGap = useCallback(
+    (gap: NotificationGap) => {
+      void dispatch(fetchNotificationsGap({ gap }));
+    },
+    [dispatch],
+  );
+
+  const handleLoadOlder = useDebouncedCallback(
+    () => {
+      const gap = notifications.at(-1);
+      if (gap?.type === 'gap') void dispatch(fetchNotificationsGap({ gap }));
+    },
+    300,
+    { leading: true },
+  );
+
+  const handleLoadPending = useCallback(() => {
+    dispatch(loadPending());
+  }, [dispatch]);
+
+  const handleScrollToTop = useDebouncedCallback(() => {
+    dispatch(updateScrollPosition({ top: true }));
+  }, 100);
+
+  const handleScroll = useDebouncedCallback(() => {
+    dispatch(updateScrollPosition({ top: 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 filterBar = signedIn ? <FilterBar /> : null;
+
+  const scrollableContent = useMemo(() => {
+    if (notifications.length === 0 && !hasMore) return null;
+
+    return notifications.map((item) =>
+      item.type === 'gap' ? (
+        <LoadGap
+          key={`${item.maxId}-${item.sinceId}`}
+          disabled={isLoading}
+          param={item}
+          onClick={handleLoadGap}
+        />
+      ) : (
+        <NotificationGroup
+          key={item.group_key}
+          notificationGroupId={item.group_key}
+          onMoveUp={handleMoveUp}
+          onMoveDown={handleMoveDown}
+          unread={
+            lastReadId !== '0' &&
+            !!item.page_max_id &&
+            compareId(item.page_max_id, lastReadId) > 0
+          }
+        />
+      ),
+    );
+  }, [
+    notifications,
+    isLoading,
+    hasMore,
+    lastReadId,
+    handleLoadGap,
+    handleMoveUp,
+    handleMoveDown,
+  ]);
+
+  const prepend = (
+    <>
+      {needsNotificationPermission && <NotificationsPermissionBanner />}
+      <FilteredNotificationsBanner />
+    </>
+  );
+
+  const scrollContainer = signedIn ? (
+    <ScrollableList
+      scrollKey={`notifications-${columnId}`}
+      trackScroll={!pinned}
+      isLoading={isLoading}
+      showLoading={isLoading && notifications.length === 0}
+      hasMore={hasMore}
+      numPending={numPending}
+      prepend={prepend}
+      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)}
+    >
+      <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>
+
+      {filterBar}
+
+      {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;
diff --git a/app/javascript/mastodon/features/notifications_wrapper.jsx b/app/javascript/mastodon/features/notifications_wrapper.jsx
new file mode 100644
index 000000000..057ed1b39
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications_wrapper.jsx
@@ -0,0 +1,13 @@
+import Notifications from 'mastodon/features/notifications';
+import Notifications_v2 from 'mastodon/features/notifications_v2';
+import { useAppSelector } from 'mastodon/store';
+
+export const NotificationsWrapper = (props) => {
+  const optedInGroupedNotifications = useAppSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false));
+
+  return (
+    optedInGroupedNotifications ? <Notifications_v2 {...props} /> : <Notifications {...props} />
+  );
+};
+
+export default NotificationsWrapper;
\ No newline at end of file
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx
index 19c2f40ac..063ac28d9 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.jsx
+++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx
@@ -10,7 +10,7 @@ import { scrollRight } from '../../../scroll';
 import BundleContainer from '../containers/bundle_container';
 import {
   Compose,
-  Notifications,
+  NotificationsWrapper,
   HomeTimeline,
   CommunityTimeline,
   PublicTimeline,
@@ -32,7 +32,7 @@ import NavigationPanel from './navigation_panel';
 const componentMap = {
   'COMPOSE': Compose,
   'HOME': HomeTimeline,
-  'NOTIFICATIONS': Notifications,
+  'NOTIFICATIONS': NotificationsWrapper,
   'PUBLIC': PublicTimeline,
   'REMOTE': PublicTimeline,
   'COMMUNITY': CommunityTimeline,
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
index ff90eef35..2648923bf 100644
--- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
+++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
@@ -34,6 +34,7 @@ import { NavigationPortal } from 'mastodon/components/navigation_portal';
 import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
 import { timelinePreview, trendsEnabled } from 'mastodon/initial_state';
 import { transientSingleColumn } from 'mastodon/is_mobile';
+import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
 
 import ColumnLink from './column_link';
 import DisabledAccountBanner from './disabled_account_banner';
@@ -59,15 +60,19 @@ const messages = defineMessages({
 });
 
 const NotificationsLink = () => {
+  const optedInGroupedNotifications = useSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false));
   const count = useSelector(state => state.getIn(['notifications', 'unread']));
   const intl = useIntl();
 
+  const newCount = useSelector(selectUnreadNotificationGroupsCount);
+
   return (
     <ColumnLink
+      key='notifications'
       transparent
       to='/notifications'
-      icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={count} className='column-link__icon' />}
-      activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={count} className='column-link__icon' />}
+      icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={optedInGroupedNotifications ? newCount : count} className='column-link__icon' />}
+      activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={optedInGroupedNotifications ? newCount : count} className='column-link__icon' />}
       text={intl.formatMessage(messages.notifications)}
     />
   );
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx
index d9f609620..f36e0cf50 100644
--- a/app/javascript/mastodon/features/ui/index.jsx
+++ b/app/javascript/mastodon/features/ui/index.jsx
@@ -13,6 +13,7 @@ import { HotKeys } from 'react-hotkeys';
 
 import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
 import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
+import { initializeNotifications } from 'mastodon/actions/notifications_migration';
 import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
 import { HoverCardController } from 'mastodon/components/hover_card_controller';
 import { PictureInPicture } from 'mastodon/features/picture_in_picture';
@@ -22,7 +23,6 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
 
 import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
 import { clearHeight } from '../../actions/height_cache';
-import { expandNotifications } from '../../actions/notifications';
 import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
 import { expandHomeTimeline } from '../../actions/timelines';
 import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state';
@@ -49,7 +49,7 @@ import {
   Favourites,
   DirectTimeline,
   HashtagTimeline,
-  Notifications,
+  NotificationsWrapper,
   NotificationRequests,
   NotificationRequest,
   FollowRequests,
@@ -71,6 +71,7 @@ import {
 } from './util/async-components';
 import { ColumnsContextProvider } from './util/columns_context';
 import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
+
 // Dummy import, to make sure that <Status /> ends up in the application bundle.
 // Without this it ends up in ~8 very commonly used bundles.
 import '../../components/status';
@@ -205,7 +206,7 @@ class SwitchingColumnsArea extends PureComponent {
             <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
             <WrappedRoute path='/links/:url' component={LinkTimeline} content={children} />
             <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
-            <WrappedRoute path='/notifications' component={Notifications} content={children} exact />
+            <WrappedRoute path='/notifications' component={NotificationsWrapper} 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} />
@@ -405,7 +406,7 @@ class UI extends PureComponent {
     if (signedIn) {
       this.props.dispatch(fetchMarkers());
       this.props.dispatch(expandHomeTimeline());
-      this.props.dispatch(expandNotifications());
+      this.props.dispatch(initializeNotifications());
       this.props.dispatch(fetchServerTranslationLanguages());
 
       setTimeout(() => this.props.dispatch(fetchServer()), 3000);
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index b8a2359d9..7c4372d5a 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -7,7 +7,15 @@ export function Compose () {
 }
 
 export function Notifications () {
-  return import(/* webpackChunkName: "features/notifications" */'../../notifications');
+  return import(/* webpackChunkName: "features/notifications_v1" */'../../notifications');
+}
+
+export function Notifications_v2 () {
+  return import(/* webpackChunkName: "features/notifications_v2" */'../../notifications_v2');
+}
+
+export function NotificationsWrapper () {
+  return import(/* webpackChunkName: "features/notifications" */'../../notifications_wrapper');
 }
 
 export function HomeTimeline () {
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 13296e1d2..60bdfd1d4 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -443,6 +443,8 @@
   "mute_modal.title": "Mute user?",
   "mute_modal.you_wont_see_mentions": "You won't see posts that mention them.",
   "mute_modal.you_wont_see_posts": "They can still see your posts, but you won't see theirs.",
+  "name_and_others": "{name} and {count, plural, one {# other} other {# others}}",
+  "name_and_others_with_link": "{name} and <a>{count, plural, one {# other} other {# others}}</a>",
   "navigation_bar.about": "About",
   "navigation_bar.advanced_interface": "Open in advanced web interface",
   "navigation_bar.blocks": "Blocked users",
@@ -470,6 +472,10 @@
   "navigation_bar.security": "Security",
   "not_signed_in_indicator.not_signed_in": "You need to login to access this resource.",
   "notification.admin.report": "{name} reported {target}",
+  "notification.admin.report_account": "{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}",
+  "notification.admin.report_account_other": "{name} reported {count, plural, one {one post} other {# posts}} from {target}",
+  "notification.admin.report_statuses": "{name} reported {target} for {category}",
+  "notification.admin.report_statuses_other": "{name} reported {target}",
   "notification.admin.sign_up": "{name} signed up",
   "notification.favourite": "{name} favorited your post",
   "notification.follow": "{name} followed you",
@@ -485,7 +491,8 @@
   "notification.moderation_warning.action_silence": "Your account has been limited.",
   "notification.moderation_warning.action_suspend": "Your account has been suspended.",
   "notification.own_poll": "Your poll has ended",
-  "notification.poll": "A poll you have voted in has ended",
+  "notification.poll": "A poll you voted in has ended",
+  "notification.private_mention": "{name} privately mentioned you",
   "notification.reblog": "{name} boosted your post",
   "notification.relationships_severance_event": "Lost connections with {name}",
   "notification.relationships_severance_event.account_suspension": "An admin from {from} has suspended {target}, which means you can no longer receive updates from them or interact with them.",
@@ -503,6 +510,8 @@
   "notifications.column_settings.admin.report": "New reports:",
   "notifications.column_settings.admin.sign_up": "New sign-ups:",
   "notifications.column_settings.alert": "Desktop notifications",
+  "notifications.column_settings.beta.category": "Experimental features",
+  "notifications.column_settings.beta.grouping": "Group notifications",
   "notifications.column_settings.favourite": "Favorites:",
   "notifications.column_settings.filter_bar.advanced": "Display all categories",
   "notifications.column_settings.filter_bar.category": "Quick filter bar",
@@ -666,9 +675,13 @@
   "report.unfollow_explanation": "You are following this account. To not see their posts in your home feed anymore, unfollow them.",
   "report_notification.attached_statuses": "{count, plural, one {{count} post} other {{count} posts}} attached",
   "report_notification.categories.legal": "Legal",
+  "report_notification.categories.legal_sentence": "illegal content",
   "report_notification.categories.other": "Other",
+  "report_notification.categories.other_sentence": "other",
   "report_notification.categories.spam": "Spam",
+  "report_notification.categories.spam_sentence": "spam",
   "report_notification.categories.violation": "Rule violation",
+  "report_notification.categories.violation_sentence": "rule violation",
   "report_notification.open": "Open report",
   "search.no_recent_searches": "No recent searches",
   "search.placeholder": "Search",
diff --git a/app/javascript/mastodon/models/notification_group.ts b/app/javascript/mastodon/models/notification_group.ts
new file mode 100644
index 000000000..5fe1e6f2e
--- /dev/null
+++ b/app/javascript/mastodon/models/notification_group.ts
@@ -0,0 +1,207 @@
+import type {
+  ApiAccountRelationshipSeveranceEventJSON,
+  ApiAccountWarningJSON,
+  BaseNotificationGroupJSON,
+  ApiNotificationGroupJSON,
+  ApiNotificationJSON,
+  NotificationType,
+  NotificationWithStatusType,
+} from 'mastodon/api_types/notifications';
+import type { ApiReportJSON } from 'mastodon/api_types/reports';
+
+// Maximum number of avatars displayed in a notification group
+// This corresponds to the max lenght of `group.sampleAccountIds`
+export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
+
+interface BaseNotificationGroup
+  extends Omit<BaseNotificationGroupJSON, 'sample_accounts'> {
+  sampleAccountIds: string[];
+}
+
+interface BaseNotificationWithStatus<Type extends NotificationWithStatusType>
+  extends BaseNotificationGroup {
+  type: Type;
+  statusId: string;
+}
+
+interface BaseNotification<Type extends NotificationType>
+  extends BaseNotificationGroup {
+  type: Type;
+}
+
+export type NotificationGroupFavourite =
+  BaseNotificationWithStatus<'favourite'>;
+export type NotificationGroupReblog = BaseNotificationWithStatus<'reblog'>;
+export type NotificationGroupStatus = BaseNotificationWithStatus<'status'>;
+export type NotificationGroupMention = BaseNotificationWithStatus<'mention'>;
+export type NotificationGroupPoll = BaseNotificationWithStatus<'poll'>;
+export type NotificationGroupUpdate = BaseNotificationWithStatus<'update'>;
+export type NotificationGroupFollow = BaseNotification<'follow'>;
+export type NotificationGroupFollowRequest = BaseNotification<'follow_request'>;
+export type NotificationGroupAdminSignUp = BaseNotification<'admin.sign_up'>;
+
+export type AccountWarningAction =
+  | 'none'
+  | 'disable'
+  | 'mark_statuses_as_sensitive'
+  | 'delete_statuses'
+  | 'sensitive'
+  | 'silence'
+  | 'suspend';
+export interface AccountWarning
+  extends Omit<ApiAccountWarningJSON, 'target_account'> {
+  targetAccountId: string;
+}
+
+export interface NotificationGroupModerationWarning
+  extends BaseNotification<'moderation_warning'> {
+  moderationWarning: AccountWarning;
+}
+
+type AccountRelationshipSeveranceEvent =
+  ApiAccountRelationshipSeveranceEventJSON;
+export interface NotificationGroupSeveredRelationships
+  extends BaseNotification<'severed_relationships'> {
+  event: AccountRelationshipSeveranceEvent;
+}
+
+interface Report extends Omit<ApiReportJSON, 'target_account'> {
+  targetAccountId: string;
+}
+
+export interface NotificationGroupAdminReport
+  extends BaseNotification<'admin.report'> {
+  report: Report;
+}
+
+export type NotificationGroup =
+  | NotificationGroupFavourite
+  | NotificationGroupReblog
+  | NotificationGroupStatus
+  | NotificationGroupMention
+  | NotificationGroupPoll
+  | NotificationGroupUpdate
+  | NotificationGroupFollow
+  | NotificationGroupFollowRequest
+  | NotificationGroupModerationWarning
+  | NotificationGroupSeveredRelationships
+  | NotificationGroupAdminSignUp
+  | NotificationGroupAdminReport;
+
+function createReportFromJSON(reportJSON: ApiReportJSON): Report {
+  const { target_account, ...report } = reportJSON;
+  return {
+    targetAccountId: target_account.id,
+    ...report,
+  };
+}
+
+function createAccountWarningFromJSON(
+  warningJSON: ApiAccountWarningJSON,
+): AccountWarning {
+  const { target_account, ...warning } = warningJSON;
+  return {
+    targetAccountId: target_account.id,
+    ...warning,
+  };
+}
+
+function createAccountRelationshipSeveranceEventFromJSON(
+  eventJson: ApiAccountRelationshipSeveranceEventJSON,
+): AccountRelationshipSeveranceEvent {
+  return eventJson;
+}
+
+export function createNotificationGroupFromJSON(
+  groupJson: ApiNotificationGroupJSON,
+): NotificationGroup {
+  const { sample_accounts, ...group } = groupJson;
+  const sampleAccountIds = sample_accounts.map((account) => account.id);
+
+  switch (group.type) {
+    case 'favourite':
+    case 'reblog':
+    case 'status':
+    case 'mention':
+    case 'poll':
+    case 'update': {
+      const { status, ...groupWithoutStatus } = group;
+      return {
+        statusId: status.id,
+        sampleAccountIds,
+        ...groupWithoutStatus,
+      };
+    }
+    case 'admin.report': {
+      const { report, ...groupWithoutTargetAccount } = group;
+      return {
+        report: createReportFromJSON(report),
+        sampleAccountIds,
+        ...groupWithoutTargetAccount,
+      };
+    }
+    case 'severed_relationships':
+      return {
+        ...group,
+        event: createAccountRelationshipSeveranceEventFromJSON(group.event),
+        sampleAccountIds,
+      };
+
+    case 'moderation_warning': {
+      const { moderation_warning, ...groupWithoutModerationWarning } = group;
+      return {
+        ...groupWithoutModerationWarning,
+        moderationWarning: createAccountWarningFromJSON(moderation_warning),
+        sampleAccountIds,
+      };
+    }
+    default:
+      return {
+        sampleAccountIds,
+        ...group,
+      };
+  }
+}
+
+export function createNotificationGroupFromNotificationJSON(
+  notification: ApiNotificationJSON,
+) {
+  const group = {
+    sampleAccountIds: [notification.account.id],
+    group_key: notification.group_key,
+    notifications_count: 1,
+    type: notification.type,
+    most_recent_notification_id: notification.id,
+    page_min_id: notification.id,
+    page_max_id: notification.id,
+    latest_page_notification_at: notification.created_at,
+  } as NotificationGroup;
+
+  switch (notification.type) {
+    case 'favourite':
+    case 'reblog':
+    case 'status':
+    case 'mention':
+    case 'poll':
+    case 'update':
+      return { ...group, statusId: notification.status.id };
+    case 'admin.report':
+      return { ...group, report: createReportFromJSON(notification.report) };
+    case 'severed_relationships':
+      return {
+        ...group,
+        event: createAccountRelationshipSeveranceEventFromJSON(
+          notification.event,
+        ),
+      };
+    case 'moderation_warning':
+      return {
+        ...group,
+        moderationWarning: createAccountWarningFromJSON(
+          notification.moderation_warning,
+        ),
+      };
+    default:
+      return group;
+  }
+}
diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts
index 6296ef202..b92de0dbc 100644
--- a/app/javascript/mastodon/reducers/index.ts
+++ b/app/javascript/mastodon/reducers/index.ts
@@ -24,6 +24,7 @@ import { markersReducer } from './markers';
 import media_attachments from './media_attachments';
 import meta from './meta';
 import { modalReducer } from './modal';
+import { notificationGroupsReducer } from './notification_groups';
 import { notificationPolicyReducer } from './notification_policy';
 import { notificationRequestsReducer } from './notification_requests';
 import notifications from './notifications';
@@ -65,6 +66,7 @@ const reducers = {
   search,
   media_attachments,
   notifications,
+  notificationGroups: notificationGroupsReducer,
   height_cache,
   custom_emojis,
   lists,
diff --git a/app/javascript/mastodon/reducers/markers.ts b/app/javascript/mastodon/reducers/markers.ts
index ec85d0f17..b1f10b5fa 100644
--- a/app/javascript/mastodon/reducers/markers.ts
+++ b/app/javascript/mastodon/reducers/markers.ts
@@ -1,6 +1,7 @@
 import { createReducer } from '@reduxjs/toolkit';
 
-import { submitMarkersAction } from 'mastodon/actions/markers';
+import { submitMarkersAction, fetchMarkers } from 'mastodon/actions/markers';
+import { compareId } from 'mastodon/compare_id';
 
 const initialState = {
   home: '0',
@@ -15,4 +16,23 @@ export const markersReducer = createReducer(initialState, (builder) => {
       if (notifications) state.notifications = notifications;
     },
   );
+  builder.addCase(
+    fetchMarkers.fulfilled,
+    (
+      state,
+      {
+        payload: {
+          markers: { home, notifications },
+        },
+      },
+    ) => {
+      if (home && compareId(home.last_read_id, state.home) > 0)
+        state.home = home.last_read_id;
+      if (
+        notifications &&
+        compareId(notifications.last_read_id, state.notifications) > 0
+      )
+        state.notifications = notifications.last_read_id;
+    },
+  );
 });
diff --git a/app/javascript/mastodon/reducers/notification_groups.ts b/app/javascript/mastodon/reducers/notification_groups.ts
new file mode 100644
index 000000000..e59f3e7ca
--- /dev/null
+++ b/app/javascript/mastodon/reducers/notification_groups.ts
@@ -0,0 +1,508 @@
+import { createReducer, isAnyOf } from '@reduxjs/toolkit';
+
+import {
+  authorizeFollowRequestSuccess,
+  blockAccountSuccess,
+  muteAccountSuccess,
+  rejectFollowRequestSuccess,
+} from 'mastodon/actions/accounts_typed';
+import { focusApp, unfocusApp } from 'mastodon/actions/app';
+import { blockDomainSuccess } from 'mastodon/actions/domain_blocks_typed';
+import { fetchMarkers } from 'mastodon/actions/markers';
+import {
+  clearNotifications,
+  fetchNotifications,
+  fetchNotificationsGap,
+  processNewNotificationForGroups,
+  loadPending,
+  updateScrollPosition,
+  markNotificationsAsRead,
+  mountNotifications,
+  unmountNotifications,
+} from 'mastodon/actions/notification_groups';
+import {
+  disconnectTimeline,
+  timelineDelete,
+} from 'mastodon/actions/timelines_typed';
+import type { ApiNotificationJSON } from 'mastodon/api_types/notifications';
+import { compareId } from 'mastodon/compare_id';
+import { usePendingItems } from 'mastodon/initial_state';
+import {
+  NOTIFICATIONS_GROUP_MAX_AVATARS,
+  createNotificationGroupFromJSON,
+  createNotificationGroupFromNotificationJSON,
+} from 'mastodon/models/notification_group';
+import type { NotificationGroup } from 'mastodon/models/notification_group';
+
+const NOTIFICATIONS_TRIM_LIMIT = 50;
+
+export interface NotificationGap {
+  type: 'gap';
+  maxId?: string;
+  sinceId?: string;
+}
+
+interface NotificationGroupsState {
+  groups: (NotificationGroup | NotificationGap)[];
+  pendingGroups: (NotificationGroup | NotificationGap)[];
+  scrolledToTop: boolean;
+  isLoading: boolean;
+  lastReadId: string;
+  mounted: number;
+  isTabVisible: boolean;
+}
+
+const initialState: NotificationGroupsState = {
+  groups: [],
+  pendingGroups: [], // holds pending groups in slow mode
+  scrolledToTop: false,
+  isLoading: false,
+  // The following properties are used to track unread notifications
+  lastReadId: '0', // used for unread notifications
+  mounted: 0, // number of mounted notification list components, usually 0 or 1
+  isTabVisible: true,
+};
+
+function filterNotificationsForAccounts(
+  groups: NotificationGroupsState['groups'],
+  accountIds: string[],
+  onlyForType?: string,
+) {
+  groups = groups
+    .map((group) => {
+      if (
+        group.type !== 'gap' &&
+        (!onlyForType || group.type === onlyForType)
+      ) {
+        const previousLength = group.sampleAccountIds.length;
+
+        group.sampleAccountIds = group.sampleAccountIds.filter(
+          (id) => !accountIds.includes(id),
+        );
+
+        const newLength = group.sampleAccountIds.length;
+        const removed = previousLength - newLength;
+
+        group.notifications_count -= removed;
+      }
+
+      return group;
+    })
+    .filter(
+      (group) => group.type === 'gap' || group.sampleAccountIds.length > 0,
+    );
+  mergeGaps(groups);
+  return groups;
+}
+
+function filterNotificationsForStatus(
+  groups: NotificationGroupsState['groups'],
+  statusId: string,
+) {
+  groups = groups.filter(
+    (group) =>
+      group.type === 'gap' ||
+      !('statusId' in group) ||
+      group.statusId !== statusId,
+  );
+  mergeGaps(groups);
+  return groups;
+}
+
+function removeNotificationsForAccounts(
+  state: NotificationGroupsState,
+  accountIds: string[],
+  onlyForType?: string,
+) {
+  state.groups = filterNotificationsForAccounts(
+    state.groups,
+    accountIds,
+    onlyForType,
+  );
+  state.pendingGroups = filterNotificationsForAccounts(
+    state.pendingGroups,
+    accountIds,
+    onlyForType,
+  );
+}
+
+function removeNotificationsForStatus(
+  state: NotificationGroupsState,
+  statusId: string,
+) {
+  state.groups = filterNotificationsForStatus(state.groups, statusId);
+  state.pendingGroups = filterNotificationsForStatus(
+    state.pendingGroups,
+    statusId,
+  );
+}
+
+function isNotificationGroup(
+  groupOrGap: NotificationGroup | NotificationGap,
+): groupOrGap is NotificationGroup {
+  return groupOrGap.type !== 'gap';
+}
+
+// Merge adjacent gaps in `groups` in-place
+function mergeGaps(groups: NotificationGroupsState['groups']) {
+  for (let i = 0; i < groups.length; i++) {
+    const firstGroupOrGap = groups[i];
+
+    if (firstGroupOrGap?.type === 'gap') {
+      let lastGap = firstGroupOrGap;
+      let j = i + 1;
+
+      for (; j < groups.length; j++) {
+        const groupOrGap = groups[j];
+        if (groupOrGap?.type === 'gap') lastGap = groupOrGap;
+        else break;
+      }
+
+      if (j - i > 1) {
+        groups.splice(i, j - i, {
+          type: 'gap',
+          maxId: firstGroupOrGap.maxId,
+          sinceId: lastGap.sinceId,
+        });
+      }
+    }
+  }
+}
+
+// Checks if `groups[index-1]` and `groups[index]` are gaps, and merge them in-place if they are
+function mergeGapsAround(
+  groups: NotificationGroupsState['groups'],
+  index: number,
+) {
+  if (index > 0) {
+    const potentialFirstGap = groups[index - 1];
+    const potentialSecondGap = groups[index];
+
+    if (
+      potentialFirstGap?.type === 'gap' &&
+      potentialSecondGap?.type === 'gap'
+    ) {
+      groups.splice(index - 1, 2, {
+        type: 'gap',
+        maxId: potentialFirstGap.maxId,
+        sinceId: potentialSecondGap.sinceId,
+      });
+    }
+  }
+}
+
+function processNewNotification(
+  groups: NotificationGroupsState['groups'],
+  notification: ApiNotificationJSON,
+) {
+  const existingGroupIndex = groups.findIndex(
+    (group) =>
+      group.type !== 'gap' && group.group_key === notification.group_key,
+  );
+
+  // In any case, we are going to add a group at the top
+  // If there is currently a gap at the top, now is the time to update it
+  if (groups.length > 0 && groups[0]?.type === 'gap') {
+    groups[0].maxId = notification.id;
+  }
+
+  if (existingGroupIndex > -1) {
+    const existingGroup = groups[existingGroupIndex];
+
+    if (
+      existingGroup &&
+      existingGroup.type !== 'gap' &&
+      !existingGroup.sampleAccountIds.includes(notification.account.id) // This can happen for example if you like, then unlike, then like again the same post
+    ) {
+      // Update the existing group
+      if (
+        existingGroup.sampleAccountIds.unshift(notification.account.id) >
+        NOTIFICATIONS_GROUP_MAX_AVATARS
+      )
+        existingGroup.sampleAccountIds.pop();
+
+      existingGroup.most_recent_notification_id = notification.id;
+      existingGroup.page_max_id = notification.id;
+      existingGroup.latest_page_notification_at = notification.created_at;
+      existingGroup.notifications_count += 1;
+
+      groups.splice(existingGroupIndex, 1);
+      mergeGapsAround(groups, existingGroupIndex);
+
+      groups.unshift(existingGroup);
+    }
+  } else {
+    // Create a new group
+    groups.unshift(createNotificationGroupFromNotificationJSON(notification));
+  }
+}
+
+function trimNotifications(state: NotificationGroupsState) {
+  if (state.scrolledToTop) {
+    state.groups.splice(NOTIFICATIONS_TRIM_LIMIT);
+  }
+}
+
+function shouldMarkNewNotificationsAsRead(
+  {
+    isTabVisible,
+    scrolledToTop,
+    mounted,
+    lastReadId,
+    groups,
+  }: NotificationGroupsState,
+  ignoreScroll = false,
+) {
+  const isMounted = mounted > 0;
+  const oldestGroup = groups.findLast(isNotificationGroup);
+  const hasMore = groups.at(-1)?.type === 'gap';
+  const oldestGroupReached =
+    !hasMore ||
+    lastReadId === '0' ||
+    (oldestGroup?.page_min_id &&
+      compareId(oldestGroup.page_min_id, lastReadId) <= 0);
+
+  return (
+    isTabVisible &&
+    (ignoreScroll || scrolledToTop) &&
+    isMounted &&
+    oldestGroupReached
+  );
+}
+
+function updateLastReadId(
+  state: NotificationGroupsState,
+  group: NotificationGroup | undefined = undefined,
+) {
+  if (shouldMarkNewNotificationsAsRead(state)) {
+    group = group ?? state.groups.find(isNotificationGroup);
+    if (
+      group?.page_max_id &&
+      compareId(state.lastReadId, group.page_max_id) < 0
+    )
+      state.lastReadId = group.page_max_id;
+  }
+}
+
+export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
+  initialState,
+  (builder) => {
+    builder
+      .addCase(fetchNotifications.fulfilled, (state, action) => {
+        state.groups = action.payload.map((json) =>
+          json.type === 'gap' ? json : createNotificationGroupFromJSON(json),
+        );
+        state.isLoading = false;
+        updateLastReadId(state);
+      })
+      .addCase(fetchNotificationsGap.fulfilled, (state, action) => {
+        const { notifications } = action.payload;
+
+        // find the gap in the existing notifications
+        const gapIndex = state.groups.findIndex(
+          (groupOrGap) =>
+            groupOrGap.type === 'gap' &&
+            groupOrGap.sinceId === action.meta.arg.gap.sinceId &&
+            groupOrGap.maxId === action.meta.arg.gap.maxId,
+        );
+
+        if (gapIndex < 0)
+          // We do not know where to insert, let's return
+          return;
+
+        // Filling a disconnection gap means we're getting historical data
+        // about groups we may know or may not know about.
+
+        // The notifications timeline is split in two by the gap, with
+        // group information newer than the gap, and group information older
+        // than the gap.
+
+        // Filling a gap should not touch anything before the gap, so any
+        // information on groups already appearing before the gap should be
+        // discarded, while any information on groups appearing after the gap
+        // can be updated and re-ordered.
+
+        const oldestPageNotification = notifications.at(-1)?.page_min_id;
+
+        // replace the gap with the notifications + a new gap
+
+        const newerGroupKeys = state.groups
+          .slice(0, gapIndex)
+          .filter(isNotificationGroup)
+          .map((group) => group.group_key);
+
+        const toInsert: NotificationGroupsState['groups'] = notifications
+          .map((json) => createNotificationGroupFromJSON(json))
+          .filter(
+            (notification) => !newerGroupKeys.includes(notification.group_key),
+          );
+
+        const apiGroupKeys = (toInsert as NotificationGroup[]).map(
+          (group) => group.group_key,
+        );
+
+        const sinceId = action.meta.arg.gap.sinceId;
+        if (
+          notifications.length > 0 &&
+          !(
+            oldestPageNotification &&
+            sinceId &&
+            compareId(oldestPageNotification, sinceId) <= 0
+          )
+        ) {
+          // If we get an empty page, it means we reached the bottom, so we do not need to insert a new gap
+          // Similarly, if we've fetched more than the gap's, this means we have completely filled it
+          toInsert.push({
+            type: 'gap',
+            maxId: notifications.at(-1)?.page_max_id,
+            sinceId,
+          } as NotificationGap);
+        }
+
+        // Remove older groups covered by the API
+        state.groups = state.groups.filter(
+          (groupOrGap) =>
+            groupOrGap.type !== 'gap' &&
+            !apiGroupKeys.includes(groupOrGap.group_key),
+        );
+
+        // Replace the gap with API results (+ the new gap if needed)
+        state.groups.splice(gapIndex, 1, ...toInsert);
+
+        // Finally, merge any adjacent gaps that could have been created by filtering
+        // groups earlier
+        mergeGaps(state.groups);
+
+        state.isLoading = false;
+
+        updateLastReadId(state);
+      })
+      .addCase(processNewNotificationForGroups.fulfilled, (state, action) => {
+        const notification = action.payload;
+        processNewNotification(
+          usePendingItems ? state.pendingGroups : state.groups,
+          notification,
+        );
+        updateLastReadId(state);
+        trimNotifications(state);
+      })
+      .addCase(disconnectTimeline, (state, action) => {
+        if (action.payload.timeline === 'home') {
+          if (state.groups.length > 0 && state.groups[0]?.type !== 'gap') {
+            state.groups.unshift({
+              type: 'gap',
+              sinceId: state.groups[0]?.page_min_id,
+            });
+          }
+        }
+      })
+      .addCase(timelineDelete, (state, action) => {
+        removeNotificationsForStatus(state, action.payload.statusId);
+      })
+      .addCase(clearNotifications.pending, (state) => {
+        state.groups = [];
+        state.pendingGroups = [];
+      })
+      .addCase(blockAccountSuccess, (state, action) => {
+        removeNotificationsForAccounts(state, [action.payload.relationship.id]);
+      })
+      .addCase(muteAccountSuccess, (state, action) => {
+        if (action.payload.relationship.muting_notifications)
+          removeNotificationsForAccounts(state, [
+            action.payload.relationship.id,
+          ]);
+      })
+      .addCase(blockDomainSuccess, (state, action) => {
+        removeNotificationsForAccounts(
+          state,
+          action.payload.accounts.map((account) => account.id),
+        );
+      })
+      .addCase(loadPending, (state) => {
+        // First, remove any existing group and merge data
+        state.pendingGroups.forEach((group) => {
+          if (group.type !== 'gap') {
+            const existingGroupIndex = state.groups.findIndex(
+              (groupOrGap) =>
+                isNotificationGroup(groupOrGap) &&
+                groupOrGap.group_key === group.group_key,
+            );
+            if (existingGroupIndex > -1) {
+              const existingGroup = state.groups[existingGroupIndex];
+              if (existingGroup && existingGroup.type !== 'gap') {
+                group.notifications_count += existingGroup.notifications_count;
+                group.sampleAccountIds = group.sampleAccountIds
+                  .concat(existingGroup.sampleAccountIds)
+                  .slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS);
+                state.groups.splice(existingGroupIndex, 1);
+              }
+            }
+          }
+          trimNotifications(state);
+        });
+
+        // Then build the consolidated list and clear pending groups
+        state.groups = state.pendingGroups.concat(state.groups);
+        state.pendingGroups = [];
+      })
+      .addCase(updateScrollPosition, (state, action) => {
+        state.scrolledToTop = action.payload.top;
+        updateLastReadId(state);
+        trimNotifications(state);
+      })
+      .addCase(markNotificationsAsRead, (state) => {
+        const mostRecentGroup = state.groups.find(isNotificationGroup);
+        if (
+          mostRecentGroup?.page_max_id &&
+          compareId(state.lastReadId, mostRecentGroup.page_max_id) < 0
+        )
+          state.lastReadId = mostRecentGroup.page_max_id;
+      })
+      .addCase(fetchMarkers.fulfilled, (state, action) => {
+        if (
+          action.payload.markers.notifications &&
+          compareId(
+            state.lastReadId,
+            action.payload.markers.notifications.last_read_id,
+          ) < 0
+        )
+          state.lastReadId = action.payload.markers.notifications.last_read_id;
+      })
+      .addCase(mountNotifications, (state) => {
+        state.mounted += 1;
+        updateLastReadId(state);
+      })
+      .addCase(unmountNotifications, (state) => {
+        state.mounted -= 1;
+      })
+      .addCase(focusApp, (state) => {
+        state.isTabVisible = true;
+        updateLastReadId(state);
+      })
+      .addCase(unfocusApp, (state) => {
+        state.isTabVisible = false;
+      })
+      .addMatcher(
+        isAnyOf(authorizeFollowRequestSuccess, rejectFollowRequestSuccess),
+        (state, action) => {
+          removeNotificationsForAccounts(
+            state,
+            [action.payload.id],
+            'follow_request',
+          );
+        },
+      )
+      .addMatcher(
+        isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending),
+        (state) => {
+          state.isLoading = true;
+        },
+      )
+      .addMatcher(
+        isAnyOf(fetchNotifications.rejected, fetchNotificationsGap.rejected),
+        (state) => {
+          state.isLoading = false;
+        },
+      );
+  },
+);
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index 79aa5651f..622f5e8e8 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -16,13 +16,13 @@ import {
 import {
   fetchMarkers,
 } from '../actions/markers';
+import { clearNotifications } from '../actions/notification_groups';
 import {
   notificationsUpdate,
   NOTIFICATIONS_EXPAND_SUCCESS,
   NOTIFICATIONS_EXPAND_REQUEST,
   NOTIFICATIONS_EXPAND_FAIL,
   NOTIFICATIONS_FILTER_SET,
-  NOTIFICATIONS_CLEAR,
   NOTIFICATIONS_SCROLL_TOP,
   NOTIFICATIONS_LOAD_PENDING,
   NOTIFICATIONS_MOUNT,
@@ -290,7 +290,7 @@ export default function notifications(state = initialState, action) {
   case authorizeFollowRequestSuccess.type:
   case rejectFollowRequestSuccess.type:
     return filterNotifications(state, [action.payload.id], 'follow_request');
-  case NOTIFICATIONS_CLEAR:
+  case clearNotifications.pending.type:
     return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
   case timelineDelete.type:
     return deleteByStatus(state, action.payload.statusId);
diff --git a/app/javascript/mastodon/selectors/notifications.ts b/app/javascript/mastodon/selectors/notifications.ts
new file mode 100644
index 000000000..1b1ed2154
--- /dev/null
+++ b/app/javascript/mastodon/selectors/notifications.ts
@@ -0,0 +1,34 @@
+import { createSelector } from '@reduxjs/toolkit';
+
+import { compareId } from 'mastodon/compare_id';
+import type { RootState } from 'mastodon/store';
+
+export const selectUnreadNotificationGroupsCount = createSelector(
+  [
+    (s: RootState) => s.notificationGroups.lastReadId,
+    (s: RootState) => s.notificationGroups.pendingGroups,
+    (s: RootState) => s.notificationGroups.groups,
+  ],
+  (notificationMarker, pendingGroups, groups) => {
+    return (
+      groups.filter(
+        (group) =>
+          group.type !== 'gap' &&
+          group.page_max_id &&
+          compareId(group.page_max_id, notificationMarker) > 0,
+      ).length +
+      pendingGroups.filter(
+        (group) =>
+          group.type !== 'gap' &&
+          group.page_max_id &&
+          compareId(group.page_max_id, notificationMarker) > 0,
+      ).length
+    );
+  },
+);
+
+export const selectPendingNotificationGroupsCount = createSelector(
+  [(s: RootState) => s.notificationGroups.pendingGroups],
+  (pendingGroups) =>
+    pendingGroups.filter((group) => group.type !== 'gap').length,
+);
diff --git a/app/javascript/mastodon/selectors/settings.ts b/app/javascript/mastodon/selectors/settings.ts
new file mode 100644
index 000000000..64d9440bc
--- /dev/null
+++ b/app/javascript/mastodon/selectors/settings.ts
@@ -0,0 +1,40 @@
+import type { RootState } from 'mastodon/store';
+
+/* 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
+export const selectSettingsNotificationsShows = (state: RootState) =>
+  state.settings.getIn(['notifications', 'shows']).toJS() as Record<
+    string,
+    boolean
+  >;
+
+export const selectSettingsNotificationsExcludedTypes = (state: RootState) =>
+  Object.entries(selectSettingsNotificationsShows(state))
+    .filter(([_type, enabled]) => !enabled)
+    .map(([type, _enabled]) => type);
+
+export const selectSettingsNotificationsQuickFilterShow = (state: RootState) =>
+  state.settings.getIn(['notifications', 'quickFilter', 'show']) as boolean;
+
+export const selectSettingsNotificationsQuickFilterActive = (
+  state: RootState,
+) => state.settings.getIn(['notifications', 'quickFilter', 'active']) as string;
+
+export const selectSettingsNotificationsQuickFilterAdvanced = (
+  state: RootState,
+) =>
+  state.settings.getIn(['notifications', 'quickFilter', 'advanced']) as boolean;
+
+export const selectSettingsNotificationsShowUnread = (state: RootState) =>
+  state.settings.getIn(['notifications', 'showUnread']) as boolean;
+
+export 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 */
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index e94ce2d8f..5a0a41c97 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1611,14 +1611,19 @@ body > [data-popper-placement] {
   }
 }
 
-.status__wrapper-direct {
+.status__wrapper-direct,
+.notification-ungrouped--direct {
   background: rgba($ui-highlight-color, 0.05);
 
   &:focus {
-    background: rgba($ui-highlight-color, 0.05);
+    background: rgba($ui-highlight-color, 0.1);
   }
+}
 
-  .status__prepend {
+.status__wrapper-direct,
+.notification-ungrouped--direct {
+  .status__prepend,
+  .notification-ungrouped__header {
     color: $highlight-text-color;
   }
 }
@@ -2209,41 +2214,28 @@ a.account__display-name {
   }
 }
 
-.notification__relationships-severance-event,
-.notification__moderation-warning {
-  display: flex;
-  gap: 16px;
+.notification-group--link {
   color: $secondary-text-color;
   text-decoration: none;
-  align-items: flex-start;
-  padding: 16px 32px;
-  border-bottom: 1px solid var(--background-border-color);
 
-  &:hover {
-    color: $primary-text-color;
-  }
-
-  .icon {
-    padding: 2px;
-    color: $highlight-text-color;
-  }
-
-  &__content {
+  .notification-group__main {
     display: flex;
     flex-direction: column;
     align-items: flex-start;
     gap: 8px;
     flex-grow: 1;
-    font-size: 16px;
-    line-height: 24px;
+    font-size: 15px;
+    line-height: 22px;
 
-    strong {
+    strong,
+    bdi {
       font-weight: 700;
     }
 
     .link-button {
       font-size: inherit;
       line-height: inherit;
+      font-weight: inherit;
     }
   }
 }
@@ -10193,8 +10185,8 @@ noscript {
   display: flex;
   align-items: center;
   border-bottom: 1px solid var(--background-border-color);
-  padding: 24px 32px;
-  gap: 16px;
+  padding: 16px 24px;
+  gap: 8px;
   color: $darker-text-color;
   text-decoration: none;
 
@@ -10204,10 +10196,8 @@ noscript {
     color: $secondary-text-color;
   }
 
-  .icon {
-    width: 24px;
-    height: 24px;
-    padding: 2px;
+  .notification-group__icon {
+    color: inherit;
   }
 
   &__text {
@@ -10345,6 +10335,251 @@ noscript {
   }
 }
 
+.notification-group {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  padding: 16px 24px;
+  border-bottom: 1px solid var(--background-border-color);
+
+  &__icon {
+    width: 40px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex: 0 0 auto;
+    color: $dark-text-color;
+
+    .icon {
+      width: 28px;
+      height: 28px;
+    }
+  }
+
+  &--follow &__icon,
+  &--follow-request &__icon {
+    color: $highlight-text-color;
+  }
+
+  &--favourite &__icon {
+    color: $gold-star;
+  }
+
+  &--reblog &__icon {
+    color: $valid-value-color;
+  }
+
+  &--relationships-severance-event &__icon,
+  &--admin-report &__icon,
+  &--admin-sign-up &__icon {
+    color: $dark-text-color;
+  }
+
+  &--moderation-warning &__icon {
+    color: $red-bookmark;
+  }
+
+  &--follow-request &__actions {
+    align-items: center;
+    display: flex;
+    gap: 8px;
+
+    .icon-button {
+      border: 1px solid var(--background-border-color);
+      border-radius: 50%;
+      padding: 1px;
+    }
+  }
+
+  &__main {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    flex: 1 1 auto;
+    overflow: hidden;
+
+    &__header {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+
+      &__wrapper {
+        display: flex;
+        justify-content: space-between;
+      }
+
+      &__label {
+        display: flex;
+        gap: 8px;
+        font-size: 15px;
+        line-height: 22px;
+        color: $darker-text-color;
+
+        a {
+          color: inherit;
+          text-decoration: none;
+        }
+
+        bdi {
+          font-weight: 700;
+          color: $primary-text-color;
+        }
+
+        time {
+          color: $dark-text-color;
+        }
+      }
+    }
+
+    &__status {
+      border: 1px solid var(--background-border-color);
+      border-radius: 8px;
+      padding: 8px;
+    }
+  }
+
+  &__avatar-group {
+    display: flex;
+    gap: 8px;
+    height: 28px;
+    overflow-y: hidden;
+    flex-wrap: wrap;
+  }
+
+  .status {
+    padding: 0;
+    border: 0;
+  }
+
+  &__embedded-status {
+    &__account {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      margin-bottom: 8px;
+      color: $dark-text-color;
+
+      bdi {
+        color: inherit;
+      }
+    }
+
+    .account__avatar {
+      opacity: 0.5;
+    }
+
+    &__content {
+      display: -webkit-box;
+      font-size: 15px;
+      line-height: 22px;
+      color: $dark-text-color;
+      cursor: pointer;
+      -webkit-line-clamp: 4;
+      -webkit-box-orient: vertical;
+      max-height: 4 * 22px;
+      overflow: hidden;
+
+      p,
+      a {
+        color: inherit;
+      }
+    }
+  }
+}
+
+.notification-ungrouped {
+  padding: 16px 24px;
+  border-bottom: 1px solid var(--background-border-color);
+
+  &__header {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    color: $dark-text-color;
+    font-size: 15px;
+    line-height: 22px;
+    font-weight: 500;
+    padding-inline-start: 24px;
+    margin-bottom: 16px;
+
+    &__icon {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex: 0 0 auto;
+
+      .icon {
+        width: 16px;
+        height: 16px;
+      }
+    }
+
+    a {
+      color: inherit;
+      text-decoration: none;
+    }
+  }
+
+  .status {
+    border: 0;
+    padding: 0;
+
+    &__avatar {
+      width: 40px;
+      height: 40px;
+    }
+  }
+
+  .status__wrapper-direct {
+    background: transparent;
+  }
+
+  $icon-margin: 48px; // 40px avatar + 8px gap
+
+  .status__content,
+  .status__action-bar,
+  .media-gallery,
+  .video-player,
+  .audio-player,
+  .attachment-list,
+  .picture-in-picture-placeholder,
+  .more-from-author,
+  .status-card,
+  .hashtag-bar {
+    margin-inline-start: $icon-margin;
+    width: calc(100% - $icon-margin);
+  }
+
+  .more-from-author {
+    width: calc(100% - $icon-margin + 2px);
+  }
+
+  .status__content__read-more-button {
+    margin-inline-start: $icon-margin;
+  }
+
+  .notification__report {
+    border: 0;
+    padding: 0;
+  }
+}
+
+.notification-group--unread,
+.notification-ungrouped--unread {
+  position: relative;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    inset-inline-start: 0;
+    width: 100%;
+    height: 100%;
+    border-inline-start: 4px solid $highlight-text-color;
+    pointer-events: none;
+  }
+}
+
 .hover-card-controller[data-popper-reference-hidden='true'] {
   opacity: 0;
   pointer-events: none;
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 01abe74f5..6d4041147 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -30,6 +30,7 @@ class Notification < ApplicationRecord
     'Poll' => :poll,
   }.freeze
 
+  # Please update app/javascript/api_types/notification.ts if you change this
   PROPERTIES = {
     mention: {
       filterable: true,
diff --git a/app/models/notification_group.rb b/app/models/notification_group.rb
index b1cbd7c19..223945f07 100644
--- a/app/models/notification_group.rb
+++ b/app/models/notification_group.rb
@@ -3,13 +3,17 @@
 class NotificationGroup < ActiveModelSerializers::Model
   attributes :group_key, :sample_accounts, :notifications_count, :notification, :most_recent_notification_id
 
+  # Try to keep this consistent with `app/javascript/mastodon/models/notification_group.ts`
+  SAMPLE_ACCOUNTS_SIZE = 8
+
   def self.from_notification(notification, max_id: nil)
     if notification.group_key.present?
-      # TODO: caching and preloading
+      # TODO: caching, and, if caching, preloading
       scope = notification.account.notifications.where(group_key: notification.group_key)
       scope = scope.where(id: ..max_id) if max_id.present?
 
-      most_recent_notifications = scope.order(id: :desc).take(3)
+      # Ideally, we would not load accounts for each notification group
+      most_recent_notifications = scope.order(id: :desc).includes(:from_account).take(SAMPLE_ACCOUNTS_SIZE)
       most_recent_id = most_recent_notifications.first.id
       sample_accounts = most_recent_notifications.map(&:from_account)
       notifications_count = scope.count
diff --git a/app/serializers/rest/notification_group_serializer.rb b/app/serializers/rest/notification_group_serializer.rb
index 9aa5663f4..749f71775 100644
--- a/app/serializers/rest/notification_group_serializer.rb
+++ b/app/serializers/rest/notification_group_serializer.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class REST::NotificationGroupSerializer < ActiveModel::Serializer
+  # Please update app/javascript/api_types/notification.ts when making changes to the attributes
   attributes :group_key, :notifications_count, :type, :most_recent_notification_id
 
   attribute :page_min_id, if: :paginated?
diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb
index ee17af807..320bc8696 100644
--- a/app/serializers/rest/notification_serializer.rb
+++ b/app/serializers/rest/notification_serializer.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class REST::NotificationSerializer < ActiveModel::Serializer
+  # Please update app/javascript/api_types/notification.ts when making changes to the attributes
   attributes :id, :type, :created_at, :group_key
 
   attribute :filtered, if: :filtered?
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index d69b5af14..acbb3fc78 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -4,7 +4,6 @@ class NotifyService < BaseService
   include Redisable
 
   MAXIMUM_GROUP_SPAN_HOURS = 12
-  MAXIMUM_GROUP_GAP_TIME = 4.hours.to_i
 
   NON_EMAIL_TYPES = %i(
     admin.report
@@ -217,9 +216,8 @@ class NotifyService < BaseService
     previous_bucket = redis.get(redis_key).to_i
     hour_bucket = previous_bucket if hour_bucket < previous_bucket + MAXIMUM_GROUP_SPAN_HOURS
 
-    # Do not track groups past a given inactivity time
     # We do not concern ourselves with race conditions since we use hour buckets
-    redis.set(redis_key, hour_bucket, ex: MAXIMUM_GROUP_GAP_TIME)
+    redis.set(redis_key, hour_bucket, ex: MAXIMUM_GROUP_SPAN_HOURS)
 
     "#{type_prefix}-#{hour_bucket}"
   end
diff --git a/config/routes.rb b/config/routes.rb
index e4f091043..93bdb9596 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -29,6 +29,7 @@ Rails.application.routes.draw do
     /lists/(*any)
     /links/(*any)
     /notifications/(*any)
+    /notifications_v2/(*any)
     /favourites
     /bookmarks
     /pinned
diff --git a/package.json b/package.json
index 404c4f486..4571ca03a 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 c5d048006..86640faa0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2910,6 +2910,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"
@@ -17543,6 +17544,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"