mirror of
https://github.com/mastodon/mastodon.git
synced 2024-12-29 14:35:06 +01:00
Add a new experimental notifications route
This commit is contained in:
parent
bb516ba1e7
commit
62088f51bd
@ -7,7 +7,7 @@ import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
|||||||
|
|
||||||
export const fetchNotifications = createDataLoadingThunk(
|
export const fetchNotifications = createDataLoadingThunk(
|
||||||
'notificationGroups/fetch',
|
'notificationGroups/fetch',
|
||||||
apiFetchNotifications,
|
() => apiFetchNotifications(),
|
||||||
(notifications, { dispatch }) => {
|
(notifications, { dispatch }) => {
|
||||||
const fetchedAccounts: ApiAccountJSON[] = [];
|
const fetchedAccounts: ApiAccountJSON[] = [];
|
||||||
const fetchedStatuses: ApiStatusJSON[] = [];
|
const fetchedStatuses: ApiStatusJSON[] = [];
|
||||||
@ -21,8 +21,8 @@ export const fetchNotifications = createDataLoadingThunk(
|
|||||||
// fetchedAccounts.push(...notification.report.target_account);
|
// fetchedAccounts.push(...notification.report.target_account);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
if ('target_status' in notification) {
|
if ('status' in notification) {
|
||||||
fetchedStatuses.push(notification.target_status);
|
fetchedStatuses.push(notification.status);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -178,7 +178,7 @@ const noOp = () => {};
|
|||||||
|
|
||||||
let expandNotificationsController = new AbortController();
|
let expandNotificationsController = new AbortController();
|
||||||
|
|
||||||
export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) {
|
export function expandNotifications({ maxId, forceLoad = false } = {}, done = noOp) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
|
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
|
||||||
const notifications = getState().get('notifications');
|
const notifications = getState().get('notifications');
|
||||||
|
@ -33,7 +33,7 @@ export interface BaseNotificationGroupJSON {
|
|||||||
|
|
||||||
interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {
|
interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {
|
||||||
type: NotificationWithStatusType;
|
type: NotificationWithStatusType;
|
||||||
target_status: ApiStatusJSON;
|
status: ApiStatusJSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON {
|
interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON {
|
||||||
|
@ -0,0 +1,42 @@
|
|||||||
|
import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group';
|
||||||
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { NotificationReblog } from './notification_reblog';
|
||||||
|
|
||||||
|
export const NotificationGroup: React.FC<{
|
||||||
|
notificationGroupId: NotificationGroupModel['group_key'];
|
||||||
|
unread: boolean;
|
||||||
|
onMoveUp: unknown;
|
||||||
|
onMoveDown: unknown;
|
||||||
|
}> = ({ notificationGroupId }) => {
|
||||||
|
const notificationGroup = useAppSelector((state) =>
|
||||||
|
state.notificationsGroups.groups.find(
|
||||||
|
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!notificationGroup || notificationGroup.type === 'gap') return null;
|
||||||
|
|
||||||
|
switch (notificationGroup.type) {
|
||||||
|
case 'reblog':
|
||||||
|
return <NotificationReblog notification={notificationGroup} />;
|
||||||
|
case 'follow':
|
||||||
|
case 'follow_request':
|
||||||
|
case 'favourite':
|
||||||
|
case 'mention':
|
||||||
|
case 'poll':
|
||||||
|
case 'status':
|
||||||
|
case 'update':
|
||||||
|
case 'admin.sign_up':
|
||||||
|
case 'admin.report':
|
||||||
|
case 'moderation_warning':
|
||||||
|
case 'severed_relationships':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<pre>{JSON.stringify(notificationGroup, undefined, 2)}</pre>
|
||||||
|
<hr />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,7 @@
|
|||||||
|
import type { NotificationGroupReblog } from 'mastodon/models/notification_group';
|
||||||
|
|
||||||
|
export const NotificationReblog: React.FC<{
|
||||||
|
notification: NotificationGroupReblog;
|
||||||
|
}> = ({ notification }) => {
|
||||||
|
return <div>reblog {notification.group_key}</div>;
|
||||||
|
};
|
397
app/javascript/mastodon/features/notifications_v2/index.tsx
Normal file
397
app/javascript/mastodon/features/notifications_v2/index.tsx
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
|
||||||
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
|
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
|
||||||
|
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||||
|
import { fetchNotifications } from 'mastodon/actions/notification_groups';
|
||||||
|
import { compareId } from 'mastodon/compare_id';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||||
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
import type { RootState } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
|
import { submitMarkers } from '../../actions/markers';
|
||||||
|
import {
|
||||||
|
expandNotifications,
|
||||||
|
scrollTopNotifications,
|
||||||
|
loadPending,
|
||||||
|
mountNotifications,
|
||||||
|
unmountNotifications,
|
||||||
|
markNotificationsAsRead,
|
||||||
|
} from '../../actions/notifications';
|
||||||
|
import Column from '../../components/column';
|
||||||
|
import ColumnHeader from '../../components/column_header';
|
||||||
|
import { LoadGap } from '../../components/load_gap';
|
||||||
|
import ScrollableList from '../../components/scrollable_list';
|
||||||
|
import { FilteredNotificationsBanner } from '../notifications/components/filtered_notifications_banner';
|
||||||
|
import NotificationsPermissionBanner from '../notifications/components/notifications_permission_banner';
|
||||||
|
import ColumnSettingsContainer from '../notifications/containers/column_settings_container';
|
||||||
|
import FilterBarContainer from '../notifications/containers/filter_bar_container';
|
||||||
|
|
||||||
|
import { NotificationGroup } from './components/notification_group';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
||||||
|
markAsRead: {
|
||||||
|
id: 'notifications.mark_as_read',
|
||||||
|
defaultMessage: 'Mark every notification as read',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||||
|
// state.settings is not yet typed, so we disable some ESLint checks for those selectors
|
||||||
|
const selectSettingsNotificationsShow = (state: RootState) =>
|
||||||
|
state.settings.getIn(['notifications', 'shows']) as ImmutableMap<
|
||||||
|
string,
|
||||||
|
boolean
|
||||||
|
>;
|
||||||
|
|
||||||
|
const selectSettingsNotificationsQuickFilterShow = (state: RootState) =>
|
||||||
|
state.settings.getIn(['notifications', 'quickFilter', 'show']) as boolean;
|
||||||
|
|
||||||
|
const selectSettingsNotificationsQuickFilterActive = (state: RootState) =>
|
||||||
|
state.settings.getIn(['notifications', 'quickFilter', 'active']) as string;
|
||||||
|
|
||||||
|
const selectSettingsNotificationsShowUnread = (state: RootState) =>
|
||||||
|
state.settings.getIn(['notifications', 'showUnread']) as boolean;
|
||||||
|
|
||||||
|
const selectNeedsNotificationPermission = (state: RootState) =>
|
||||||
|
(state.settings.getIn(['notifications', 'alerts']).includes(true) &&
|
||||||
|
state.notifications.get('browserSupport') &&
|
||||||
|
state.notifications.get('browserPermission') === 'default' &&
|
||||||
|
!state.settings.getIn([
|
||||||
|
'notifications',
|
||||||
|
'dismissPermissionBanner',
|
||||||
|
])) as boolean;
|
||||||
|
|
||||||
|
/* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||||
|
|
||||||
|
const getExcludedTypes = createSelector(
|
||||||
|
[selectSettingsNotificationsShow],
|
||||||
|
(shows) => {
|
||||||
|
return ImmutableList(shows.filter((item) => !item).keys());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const getNotifications = createSelector(
|
||||||
|
[
|
||||||
|
selectSettingsNotificationsQuickFilterShow,
|
||||||
|
selectSettingsNotificationsQuickFilterActive,
|
||||||
|
getExcludedTypes,
|
||||||
|
(state: RootState) => state.notificationsGroups.groups,
|
||||||
|
],
|
||||||
|
(showFilterBar, allowedType, excludedTypes, notifications) => {
|
||||||
|
if (!showFilterBar || allowedType === 'all') {
|
||||||
|
// used if user changed the notification settings after loading the notifications from the server
|
||||||
|
// otherwise a list of notifications will come pre-filtered from the backend
|
||||||
|
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
|
||||||
|
return notifications.filter(
|
||||||
|
(item) => item.type !== 'gap' || !excludedTypes.includes(item.type),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return notifications.filter(
|
||||||
|
(item) => item.type !== 'gap' || allowedType === item.type,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// const mapStateToProps = (state) => ({
|
||||||
|
// isUnread:
|
||||||
|
// state.getIn(['notifications', 'unread']) > 0 ||
|
||||||
|
// state.getIn(['notifications', 'pendingItems']).size > 0,
|
||||||
|
// numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList())
|
||||||
|
// .size,
|
||||||
|
// canMarkAsRead:
|
||||||
|
// state.getIn(['settings', 'notifications', 'showUnread']) &&
|
||||||
|
// state.getIn(['notifications', 'readMarkerId']) !== '0' &&
|
||||||
|
// getNotifications(state).some(
|
||||||
|
// (item) =>
|
||||||
|
// item !== null &&
|
||||||
|
// compareId(
|
||||||
|
// item.get('id'),
|
||||||
|
// state.getIn(['notifications', 'readMarkerId']),
|
||||||
|
// ) > 0,
|
||||||
|
// ),
|
||||||
|
// });
|
||||||
|
|
||||||
|
export const Notifications: React.FC<{
|
||||||
|
columnId?: string;
|
||||||
|
isUnread?: boolean;
|
||||||
|
multiColumn?: boolean;
|
||||||
|
numPending: number;
|
||||||
|
}> = ({ isUnread, columnId, multiColumn, numPending }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const notifications = useAppSelector(getNotifications);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const isLoading = useAppSelector((s) => s.notificationsGroups.isLoading);
|
||||||
|
const hasMore = useAppSelector((s) => s.notificationsGroups.hasMore);
|
||||||
|
const readMarkerId = useAppSelector(
|
||||||
|
(s) => s.notificationsGroups.readMarkerId,
|
||||||
|
);
|
||||||
|
const lastReadId = useAppSelector((s) =>
|
||||||
|
selectSettingsNotificationsShowUnread(s)
|
||||||
|
? s.notificationsGroups.readMarkerId
|
||||||
|
: '0',
|
||||||
|
);
|
||||||
|
const canMarkAsRead = useAppSelector(
|
||||||
|
(s) =>
|
||||||
|
selectSettingsNotificationsShowUnread(s) &&
|
||||||
|
s.notificationsGroups.readMarkerId !== '0' &&
|
||||||
|
notifications.some(
|
||||||
|
(item) =>
|
||||||
|
item.type !== 'gap' && compareId(item.group_key, readMarkerId) > 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const needsNotificationPermission = useAppSelector(
|
||||||
|
selectNeedsNotificationPermission,
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnRef = useRef<Column>(null);
|
||||||
|
|
||||||
|
const selectChild = useCallback((index: number, alignTop: boolean) => {
|
||||||
|
const container = columnRef.current?.node as HTMLElement | undefined;
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const element = container.querySelector<HTMLElement>(
|
||||||
|
`article:nth-of-type(${index + 1}) .focusable`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
if (alignTop && container.scrollTop > element.offsetTop) {
|
||||||
|
element.scrollIntoView(true);
|
||||||
|
} else if (
|
||||||
|
!alignTop &&
|
||||||
|
container.scrollTop + container.clientHeight <
|
||||||
|
element.offsetTop + element.offsetHeight
|
||||||
|
) {
|
||||||
|
element.scrollIntoView(false);
|
||||||
|
}
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(mountNotifications());
|
||||||
|
|
||||||
|
// FIXME: remove once this becomes the main implementation
|
||||||
|
void dispatch(fetchNotifications());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dispatch(unmountNotifications());
|
||||||
|
dispatch(scrollTopNotifications(false));
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleLoadGap = useCallback(
|
||||||
|
(maxId: string) => {
|
||||||
|
dispatch(expandNotifications({ maxId }));
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: fix this, probably incorrect
|
||||||
|
const handleLoadOlder = useDebouncedCallback(
|
||||||
|
() => {
|
||||||
|
const last = notifications[notifications.length - 1];
|
||||||
|
if (last && last.type !== 'gap')
|
||||||
|
dispatch(expandNotifications({ maxId: last.group_key }));
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
{ leading: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLoadPending = useCallback(() => {
|
||||||
|
dispatch(loadPending());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleScrollToTop = useDebouncedCallback(() => {
|
||||||
|
dispatch(scrollTopNotifications(true));
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const handleScroll = useDebouncedCallback(() => {
|
||||||
|
dispatch(scrollTopNotifications(false));
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
handleLoadOlder.cancel();
|
||||||
|
handleScrollToTop.cancel();
|
||||||
|
handleScroll.cancel();
|
||||||
|
};
|
||||||
|
}, [handleLoadOlder, handleScrollToTop, handleScroll]);
|
||||||
|
|
||||||
|
const handlePin = useCallback(() => {
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(removeColumn(columnId));
|
||||||
|
} else {
|
||||||
|
dispatch(addColumn('NOTIFICATIONS', {}));
|
||||||
|
}
|
||||||
|
}, [columnId, dispatch]);
|
||||||
|
|
||||||
|
const handleMove = useCallback(
|
||||||
|
(dir: unknown) => {
|
||||||
|
dispatch(moveColumn(columnId, dir));
|
||||||
|
},
|
||||||
|
[dispatch, columnId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHeaderClick = useCallback(() => {
|
||||||
|
columnRef.current?.scrollTop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMoveUp = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const elementIndex =
|
||||||
|
notifications.findIndex(
|
||||||
|
(item) => item.type !== 'gap' && item.group_key === id,
|
||||||
|
) - 1;
|
||||||
|
selectChild(elementIndex, true);
|
||||||
|
},
|
||||||
|
[notifications, selectChild],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMoveDown = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const elementIndex =
|
||||||
|
notifications.findIndex(
|
||||||
|
(item) => item.type !== 'gap' && item.group_key === id,
|
||||||
|
) + 1;
|
||||||
|
selectChild(elementIndex, false);
|
||||||
|
},
|
||||||
|
[notifications, selectChild],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMarkAsRead = useCallback(() => {
|
||||||
|
dispatch(markNotificationsAsRead());
|
||||||
|
void dispatch(submitMarkers({ immediate: true }));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const pinned = !!columnId;
|
||||||
|
const emptyMessage = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.notifications'
|
||||||
|
defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { signedIn } = useIdentity();
|
||||||
|
|
||||||
|
const filterBarContainer = signedIn ? <FilterBarContainer /> : null;
|
||||||
|
|
||||||
|
const scrollableContent = useMemo(() => {
|
||||||
|
if (notifications.length === 0 && !hasMore) return null;
|
||||||
|
|
||||||
|
return notifications.map((item) =>
|
||||||
|
item.type === 'gap' ? (
|
||||||
|
<LoadGap
|
||||||
|
key={item.id}
|
||||||
|
disabled={isLoading}
|
||||||
|
maxId={item.maxId}
|
||||||
|
onClick={handleLoadGap}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<NotificationGroup
|
||||||
|
key={item.group_key}
|
||||||
|
notificationGroupId={item.group_key}
|
||||||
|
onMoveUp={handleMoveUp}
|
||||||
|
onMoveDown={handleMoveDown}
|
||||||
|
unread={
|
||||||
|
lastReadId !== '0' && compareId(item.group_key, lastReadId) > 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
notifications,
|
||||||
|
isLoading,
|
||||||
|
hasMore,
|
||||||
|
lastReadId,
|
||||||
|
handleLoadGap,
|
||||||
|
handleMoveUp,
|
||||||
|
handleMoveDown,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const scrollContainer = signedIn ? (
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey={`notifications-${columnId}`}
|
||||||
|
trackScroll={!pinned}
|
||||||
|
isLoading={isLoading}
|
||||||
|
showLoading={isLoading && notifications.length === 0}
|
||||||
|
hasMore={hasMore}
|
||||||
|
numPending={numPending}
|
||||||
|
prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
|
||||||
|
alwaysPrepend
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
onLoadMore={handleLoadOlder}
|
||||||
|
onLoadPending={handleLoadPending}
|
||||||
|
onScrollToTop={handleScrollToTop}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
>
|
||||||
|
{scrollableContent}
|
||||||
|
</ScrollableList>
|
||||||
|
) : (
|
||||||
|
<NotSignedInIndicator />
|
||||||
|
);
|
||||||
|
|
||||||
|
const extraButton = canMarkAsRead ? (
|
||||||
|
<button
|
||||||
|
aria-label={intl.formatMessage(messages.markAsRead)}
|
||||||
|
title={intl.formatMessage(messages.markAsRead)}
|
||||||
|
onClick={handleMarkAsRead}
|
||||||
|
className='column-header__button'
|
||||||
|
>
|
||||||
|
<Icon id='done-all' icon={DoneAllIcon} />
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
ref={columnRef}
|
||||||
|
label={intl.formatMessage(messages.title)}
|
||||||
|
>
|
||||||
|
{/* @ts-expect-error This component is not yet Typescript */}
|
||||||
|
<ColumnHeader
|
||||||
|
icon='bell'
|
||||||
|
iconComponent={NotificationsIcon}
|
||||||
|
active={isUnread}
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
|
onPin={handlePin}
|
||||||
|
onMove={handleMove}
|
||||||
|
onClick={handleHeaderClick}
|
||||||
|
pinned={pinned}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
extraButton={extraButton}
|
||||||
|
>
|
||||||
|
<ColumnSettingsContainer />
|
||||||
|
</ColumnHeader>
|
||||||
|
|
||||||
|
{filterBarContainer}
|
||||||
|
|
||||||
|
<FilteredNotificationsBanner />
|
||||||
|
|
||||||
|
{scrollContainer}
|
||||||
|
|
||||||
|
<Helmet>
|
||||||
|
<title>{intl.formatMessage(messages.title)}</title>
|
||||||
|
<meta name='robots' content='noindex' />
|
||||||
|
</Helmet>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default Notifications;
|
@ -49,6 +49,7 @@ import {
|
|||||||
DirectTimeline,
|
DirectTimeline,
|
||||||
HashtagTimeline,
|
HashtagTimeline,
|
||||||
Notifications,
|
Notifications,
|
||||||
|
Notifications_v2,
|
||||||
NotificationRequests,
|
NotificationRequests,
|
||||||
NotificationRequest,
|
NotificationRequest,
|
||||||
FollowRequests,
|
FollowRequests,
|
||||||
@ -203,6 +204,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
|
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
|
||||||
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
|
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
|
||||||
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
|
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
|
||||||
|
<WrappedRoute path='/notifications_v2' component={Notifications_v2} content={children} exact />
|
||||||
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
||||||
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
|
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
|
||||||
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
||||||
|
@ -10,6 +10,10 @@ export function Notifications () {
|
|||||||
return import(/* webpackChunkName: "features/notifications" */'../../notifications');
|
return import(/* webpackChunkName: "features/notifications" */'../../notifications');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Notifications_v2 () {
|
||||||
|
return import(/* webpackChunkName: "features/notifications_v2" */'../../notifications_v2');
|
||||||
|
}
|
||||||
|
|
||||||
export function HomeTimeline () {
|
export function HomeTimeline () {
|
||||||
return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline');
|
return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline');
|
||||||
}
|
}
|
||||||
|
@ -4,14 +4,16 @@ import type {
|
|||||||
NotificationType,
|
NotificationType,
|
||||||
NotificationWithStatusType,
|
NotificationWithStatusType,
|
||||||
} from 'mastodon/api_types/notifications';
|
} from 'mastodon/api_types/notifications';
|
||||||
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
|
|
||||||
|
|
||||||
type BaseNotificationGroup = BaseNotificationGroupJSON;
|
interface BaseNotificationGroup
|
||||||
|
extends Omit<BaseNotificationGroupJSON, 'sample_accounts'> {
|
||||||
|
sampleAccountsIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface BaseNotificationWithStatus<Type extends NotificationWithStatusType>
|
interface BaseNotificationWithStatus<Type extends NotificationWithStatusType>
|
||||||
extends BaseNotificationGroup {
|
extends BaseNotificationGroup {
|
||||||
type: Type;
|
type: Type;
|
||||||
status: ApiStatusJSON;
|
statusId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseNotification<Type extends NotificationType>
|
interface BaseNotification<Type extends NotificationType>
|
||||||
@ -54,6 +56,20 @@ export type NotificationGroup =
|
|||||||
export function createNotificationGroupFromJSON(
|
export function createNotificationGroupFromJSON(
|
||||||
groupJson: NotificationGroupJSON,
|
groupJson: NotificationGroupJSON,
|
||||||
): NotificationGroup {
|
): NotificationGroup {
|
||||||
// @ts-expect-error -- FIXME: properly convert the special notifications here
|
const { sample_accounts, ...group } = groupJson;
|
||||||
return groupJson;
|
const sampleAccountsIds = sample_accounts.map((account) => account.id);
|
||||||
|
|
||||||
|
if ('status' in group) {
|
||||||
|
const { status, ...groupWithoutStatus } = group;
|
||||||
|
return {
|
||||||
|
statusId: status.id,
|
||||||
|
sampleAccountsIds,
|
||||||
|
...groupWithoutStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sampleAccountsIds,
|
||||||
|
...group,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -27,6 +27,7 @@ import { modalReducer } from './modal';
|
|||||||
import { notificationPolicyReducer } from './notification_policy';
|
import { notificationPolicyReducer } from './notification_policy';
|
||||||
import { notificationRequestsReducer } from './notification_requests';
|
import { notificationRequestsReducer } from './notification_requests';
|
||||||
import notifications from './notifications';
|
import notifications from './notifications';
|
||||||
|
import { notificationGroupsReducer } from './notifications_groups';
|
||||||
import { pictureInPictureReducer } from './picture_in_picture';
|
import { pictureInPictureReducer } from './picture_in_picture';
|
||||||
import polls from './polls';
|
import polls from './polls';
|
||||||
import push_notifications from './push_notifications';
|
import push_notifications from './push_notifications';
|
||||||
@ -65,6 +66,7 @@ const reducers = {
|
|||||||
search,
|
search,
|
||||||
media_attachments,
|
media_attachments,
|
||||||
notifications,
|
notifications,
|
||||||
|
notificationsGroups: notificationGroupsReducer,
|
||||||
height_cache,
|
height_cache,
|
||||||
custom_emojis,
|
custom_emojis,
|
||||||
lists,
|
lists,
|
||||||
|
@ -4,23 +4,44 @@ import { fetchNotifications } from 'mastodon/actions/notification_groups';
|
|||||||
import { createNotificationGroupFromJSON } from 'mastodon/models/notification_group';
|
import { createNotificationGroupFromJSON } from 'mastodon/models/notification_group';
|
||||||
import type { NotificationGroup } from 'mastodon/models/notification_group';
|
import type { NotificationGroup } from 'mastodon/models/notification_group';
|
||||||
|
|
||||||
|
interface Gap {
|
||||||
|
type: 'gap';
|
||||||
|
id: string;
|
||||||
|
maxId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface NotificationGroupsState {
|
interface NotificationGroupsState {
|
||||||
groups: NotificationGroup[];
|
groups: (NotificationGroup | Gap)[];
|
||||||
unread: number;
|
unread: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
readMarkerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: NotificationGroupsState = {
|
const initialState: NotificationGroupsState = {
|
||||||
groups: [],
|
groups: [],
|
||||||
unread: 0,
|
unread: 0,
|
||||||
|
isLoading: false,
|
||||||
|
hasMore: false,
|
||||||
|
readMarkerId: '0',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const notificationGroupsReducer = createReducer(
|
export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
||||||
initialState,
|
initialState,
|
||||||
(builder) => {
|
(builder) => {
|
||||||
|
builder.addCase(fetchNotifications.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
|
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
|
||||||
state.groups = action.payload.map((json) =>
|
state.groups = action.payload.map((json) =>
|
||||||
createNotificationGroupFromJSON(json),
|
createNotificationGroupFromJSON(json),
|
||||||
);
|
);
|
||||||
|
state.isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addCase(fetchNotifications.rejected, (state) => {
|
||||||
|
state.isLoading = false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -28,6 +28,7 @@ Rails.application.routes.draw do
|
|||||||
/conversations
|
/conversations
|
||||||
/lists/(*any)
|
/lists/(*any)
|
||||||
/notifications/(*any)
|
/notifications/(*any)
|
||||||
|
/notifications_v2/(*any)
|
||||||
/favourites
|
/favourites
|
||||||
/bookmarks
|
/bookmarks
|
||||||
/pinned
|
/pinned
|
||||||
|
@ -123,6 +123,7 @@
|
|||||||
"tesseract.js": "^2.1.5",
|
"tesseract.js": "^2.1.5",
|
||||||
"tiny-queue": "^0.2.1",
|
"tiny-queue": "^0.2.1",
|
||||||
"twitter-text": "3.1.0",
|
"twitter-text": "3.1.0",
|
||||||
|
"use-debounce": "^10.0.0",
|
||||||
"webpack": "^4.47.0",
|
"webpack": "^4.47.0",
|
||||||
"webpack-assets-manifest": "^4.0.6",
|
"webpack-assets-manifest": "^4.0.6",
|
||||||
"webpack-bundle-analyzer": "^4.8.0",
|
"webpack-bundle-analyzer": "^4.8.0",
|
||||||
|
10
yarn.lock
10
yarn.lock
@ -2887,6 +2887,7 @@ __metadata:
|
|||||||
tiny-queue: "npm:^0.2.1"
|
tiny-queue: "npm:^0.2.1"
|
||||||
twitter-text: "npm:3.1.0"
|
twitter-text: "npm:3.1.0"
|
||||||
typescript: "npm:^5.0.4"
|
typescript: "npm:^5.0.4"
|
||||||
|
use-debounce: "npm:^10.0.0"
|
||||||
webpack: "npm:^4.47.0"
|
webpack: "npm:^4.47.0"
|
||||||
webpack-assets-manifest: "npm:^4.0.6"
|
webpack-assets-manifest: "npm:^4.0.6"
|
||||||
webpack-bundle-analyzer: "npm:^4.8.0"
|
webpack-bundle-analyzer: "npm:^4.8.0"
|
||||||
@ -17385,6 +17386,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"use-isomorphic-layout-effect@npm:^1.1.1, use-isomorphic-layout-effect@npm:^1.1.2":
|
||||||
version: 1.1.2
|
version: 1.1.2
|
||||||
resolution: "use-isomorphic-layout-effect@npm:1.1.2"
|
resolution: "use-isomorphic-layout-effect@npm:1.1.2"
|
||||||
|
Loading…
Reference in New Issue
Block a user