nicolium: migrate notifications to tanstack/react-query

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-02-23 22:51:10 +01:00
parent e3aaa580b5
commit 6ceee73b60
16 changed files with 581 additions and 685 deletions

View File

@ -1,349 +0,0 @@
import IntlMessageFormat from 'intl-messageformat';
import 'intl-pluralrules';
import { defineMessages } from 'react-intl';
import { getClient } from '@/api';
import { getNotificationStatus } from '@/features/notifications/components/notification';
import { normalizeNotification } from '@/normalizers/notification';
import { appendFollowRequest } from '@/queries/accounts/use-follow-requests';
import { getFilters, regexFromFilters } from '@/selectors';
import { useSettingsStore } from '@/stores/settings';
import { isLoggedIn } from '@/utils/auth';
import { compareId } from '@/utils/comparators';
import { unescapeHTML } from '@/utils/html';
import { EXCLUDE_TYPES, NOTIFICATION_TYPES } from '@/utils/notification';
import { joinPublicPath } from '@/utils/static';
import { fetchRelationships } from './accounts';
import { importEntities } from './importer';
import { saveMarker } from './markers';
import { saveSettings } from './settings';
import type { AppDispatch, RootState } from '@/store';
import type {
Notification as BaseNotification,
GetGroupedNotificationsParams,
GroupedNotificationsResults,
NotificationGroup,
PaginatedResponse,
} from 'pl-api';
const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE' as const;
const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP' as const;
const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST' as const;
const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS' as const;
const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL' as const;
const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET' as const;
const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP' as const;
const FILTER_TYPES = {
all: undefined,
mention: ['mention', 'quote'],
favourite: ['favourite', 'emoji_reaction', 'reaction'],
reblog: ['reblog'],
poll: ['poll'],
status: ['status'],
follow: ['follow', 'follow_request'],
events: ['event_reminder', 'participation_request', 'participation_accepted'],
};
type FilterType = keyof typeof FILTER_TYPES;
defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
});
const fetchRelatedRelationships = (
dispatch: AppDispatch,
notifications: Array<NotificationGroup>,
) => {
const accountIds = notifications
.filter((item) => item.type === 'follow')
.map((item) => item.sample_account_ids)
.flat();
if (accountIds.length > 0) {
dispatch(fetchRelationships(accountIds));
}
};
interface NotificationsUpdateAction {
type: typeof NOTIFICATIONS_UPDATE;
notification: NotificationGroup;
}
const updateNotifications = (notification: BaseNotification) => (dispatch: AppDispatch) => {
const selectedFilter = useSettingsStore.getState().settings.notifications.quickFilter.active;
const showInColumn =
selectedFilter === 'all'
? true
: (FILTER_TYPES[selectedFilter as FilterType] ?? [notification.type]).includes(
notification.type,
);
dispatch(
importEntities({
accounts: [
notification.account,
notification.type === 'move' ? notification.target : undefined,
],
statuses: [getNotificationStatus(notification) as any],
}),
);
if (showInColumn) {
const normalizedNotification = normalizeNotification(notification);
if (normalizedNotification.type === 'follow_request') {
normalizedNotification.sample_account_ids.forEach(appendFollowRequest);
}
dispatch<NotificationsUpdateAction>({
type: NOTIFICATIONS_UPDATE,
notification: normalizedNotification,
});
fetchRelatedRelationships(dispatch, [normalizedNotification]);
}
};
interface NotificationsUpdateNoopAction {
type: typeof NOTIFICATIONS_UPDATE_NOOP;
meta: { sound: 'boop' };
}
const updateNotificationsQueue =
(notification: BaseNotification, intlMessages: Record<string, string>, intlLocale: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!notification.type) return; // drop invalid notifications
if (notification.type === 'chat_mention') return; // Drop chat notifications, handle them per-chat
const filters = getFilters(getState(), { contextType: 'notifications' });
const playSound = useSettingsStore.getState().settings.notifications.sounds[notification.type];
const status = getNotificationStatus(notification);
let filtered: boolean | null = false;
if (notification.type === 'mention' || notification.type === 'status') {
const regex = regexFromFilters(filters);
const searchIndex =
notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
filtered = regex && regex.test(searchIndex);
}
// Desktop notifications
try {
const isNotificationsEnabled = window.Notification?.permission === 'granted';
if (!filtered && isNotificationsEnabled) {
const title = new IntlMessageFormat(
intlMessages[`notification.${notification.type}`],
intlLocale,
).format({
name:
notification.account.display_name.length > 0
? notification.account.display_name
: notification.account.username,
}) as string;
const body =
status && status.spoiler_text.length > 0
? status.spoiler_text
: unescapeHTML(status ? status.content : '');
navigator.serviceWorker.ready
.then((serviceWorkerRegistration) => {
serviceWorkerRegistration
.showNotification(title, {
body,
icon: notification.account.avatar,
tag: notification.id,
data: {
url: joinPublicPath('/notifications'),
},
})
.catch(console.error);
})
.catch(console.error);
}
} catch (e) {
console.warn(e);
}
if (playSound && !filtered) {
dispatch<NotificationsUpdateNoopAction>({
type: NOTIFICATIONS_UPDATE_NOOP,
meta: { sound: 'boop' },
});
}
dispatch(updateNotifications(notification));
};
const excludeTypesFromFilter = (filters: string[]) =>
NOTIFICATION_TYPES.filter((item) => !filters.includes(item));
const noOp = () =>
new Promise((f) => {
f(undefined);
});
let abortExpandNotifications = new AbortController();
const expandNotifications =
({ maxId }: Record<string, any> = {}, done: () => any = noOp, abort?: boolean) =>
async (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return dispatch(noOp);
const state = getState();
const features = state.auth.client.features;
const activeFilter = useSettingsStore.getState().settings.notifications.quickFilter
.active as FilterType;
const notifications = state.notifications;
if (notifications.isLoading) {
if (abort) {
abortExpandNotifications.abort();
abortExpandNotifications = new AbortController();
} else {
done();
return dispatch(noOp);
}
}
const params: GetGroupedNotificationsParams = {
max_id: maxId,
};
if (activeFilter === 'all') {
if (features.notificationsIncludeTypes) {
params.types = NOTIFICATION_TYPES.filter((type) => !EXCLUDE_TYPES.includes(type as any));
} else {
params.exclude_types = [...EXCLUDE_TYPES];
}
} else {
const filtered = FILTER_TYPES[activeFilter] || [activeFilter];
if (features.notificationsIncludeTypes) {
params.types = filtered;
} else {
params.exclude_types = excludeTypesFromFilter(filtered);
}
}
dispatch(expandNotificationsRequest());
try {
const {
items: { accounts, statuses, notification_groups },
next,
} = await getClient(state).groupedNotifications.getGroupedNotifications(params, {
signal: abortExpandNotifications.signal,
});
dispatch(
importEntities({
accounts,
statuses,
}),
);
dispatch(expandNotificationsSuccess(notification_groups, next));
fetchRelatedRelationships(dispatch, notification_groups);
done();
} catch (error) {
dispatch(expandNotificationsFail(error));
done();
}
};
const expandNotificationsRequest = () => ({ type: NOTIFICATIONS_EXPAND_REQUEST });
const expandNotificationsSuccess = (
notifications: Array<NotificationGroup>,
next: (() => Promise<PaginatedResponse<GroupedNotificationsResults, false>>) | null,
) => ({
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications,
next,
});
const expandNotificationsFail = (error: unknown) => ({
type: NOTIFICATIONS_EXPAND_FAIL,
error,
});
interface NotificationsScrollTopAction {
type: typeof NOTIFICATIONS_SCROLL_TOP;
top: boolean;
}
const scrollTopNotifications = (top: boolean) => (dispatch: AppDispatch) => {
dispatch(markReadNotifications());
return dispatch<NotificationsScrollTopAction>({
type: NOTIFICATIONS_SCROLL_TOP,
top,
});
};
interface SetFilterAction {
type: typeof NOTIFICATIONS_FILTER_SET;
}
const setFilter = (filterType: FilterType, abort?: boolean) => (dispatch: AppDispatch) => {
const settingsStore = useSettingsStore.getState();
const activeFilter = settingsStore.settings.notifications.quickFilter.active as FilterType;
settingsStore.actions.changeSetting(['notifications', 'quickFilter', 'active'], filterType);
dispatch(expandNotifications(undefined, undefined, abort));
if (activeFilter !== filterType) dispatch(saveSettings());
return dispatch<SetFilterAction>({ type: NOTIFICATIONS_FILTER_SET });
};
const markReadNotifications = () => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const topNotificationId = state.notifications.items[0]?.page_max_id;
const lastReadId = state.notifications.lastRead;
if (topNotificationId && (lastReadId === -1 || compareId(topNotificationId, lastReadId) > 0)) {
const marker = {
notifications: {
last_read_id: topNotificationId,
},
};
dispatch(saveMarker(marker));
}
};
type NotificationsAction =
| NotificationsUpdateAction
| NotificationsUpdateNoopAction
| ReturnType<typeof expandNotificationsRequest>
| ReturnType<typeof expandNotificationsSuccess>
| ReturnType<typeof expandNotificationsFail>
| NotificationsScrollTopAction
| SetFilterAction;
export {
NOTIFICATIONS_UPDATE,
NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_EXPAND_FAIL,
NOTIFICATIONS_FILTER_SET,
NOTIFICATIONS_SCROLL_TOP,
type FilterType,
updateNotifications,
updateNotificationsQueue,
expandNotifications,
scrollTopNotifications,
setFilter,
markReadNotifications,
type NotificationsAction,
};

View File

@ -1,7 +1,5 @@
import { useCallback } from 'react';
import { MARKER_FETCH_SUCCESS } from '@/actions/markers';
import { updateNotificationsQueue } from '@/actions/notifications';
import { getLocale } from '@/actions/settings';
import { updateStatus } from '@/actions/statuses';
import { deleteFromTimelines, processTimelineUpdate } from '@/actions/timelines';
@ -11,6 +9,7 @@ import { useLoggedIn } from '@/hooks/use-logged-in';
import messages from '@/messages';
import { queryClient } from '@/queries/client';
import { updateConversations } from '@/queries/conversations/use-conversations';
import { useProcessStreamNotification } from '@/queries/notifications/use-notifications';
import { useSettings } from '@/stores/settings';
import { getUnreadChatsCount, updateChatListItem } from '@/utils/chats';
import { play, soundCache } from '@/utils/sounds';
@ -108,6 +107,7 @@ const useUserStream = () => {
const dispatch = useAppDispatch();
const statContext = useStatContext();
const settings = useSettings();
const processStreamNotification = useProcessStreamNotification();
const listener = useCallback((event: StreamingEvent) => {
switch (event.event) {
@ -123,7 +123,7 @@ const useUserStream = () => {
case 'notification':
messages[getLocale()]()
.then((messages) => {
dispatch(updateNotificationsQueue(event.payload, messages, getLocale()));
processStreamNotification(event.payload, messages, getLocale());
})
.catch((error) => {
console.error(error);
@ -167,7 +167,7 @@ const useUserStream = () => {
deleteAnnouncement(event.payload);
break;
case 'marker':
dispatch({ type: MARKER_FETCH_SUCCESS, marker: event.payload });
queryClient.setQueryData(['markers', 'notifications'], event.payload ?? null);
break;
}
}, []);

View File

@ -1,17 +1,12 @@
import { InfiniteData, useQueryClient } from '@tanstack/react-query';
import clsx from 'clsx';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { createSelector } from 'reselect';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import '@/styles/new/notifications.scss';
import {
type FilterType,
expandNotifications,
markReadNotifications,
scrollTopNotifications,
setFilter,
} from '@/actions/notifications';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { saveSettings } from '@/actions/settings';
import PullToRefresh from '@/components/pull-to-refresh';
import ScrollTopButton from '@/components/scroll-top-button';
import ScrollableList from '@/components/scrollable-list';
@ -21,13 +16,16 @@ import Tabs from '@/components/ui/tabs';
import Notification from '@/features/notifications/components/notification';
import PlaceholderNotification from '@/features/placeholder/components/placeholder-notification';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useFeatures } from '@/hooks/use-features';
import { useSettings } from '@/stores/settings';
import {
type FilterType,
useMarkNotificationsReadMutation,
useNotifications,
} from '@/queries/notifications/use-notifications';
import { useSettings, useSettingsStoreActions } from '@/stores/settings';
import { selectChild } from '@/utils/scroll-utils';
import type { Item } from '@/components/ui/tabs';
import type { RootState } from '@/store';
import type { VirtuosoHandle } from 'react-virtuoso';
const messages = defineMessages({
@ -58,17 +56,15 @@ const FilterBar = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const settings = useSettings();
const { changeSetting } = useSettingsStoreActions();
const features = useFeatures();
const selectedFilter = settings.notifications.quickFilter.active;
const advancedMode = settings.notifications.quickFilter.advanced;
const onClick = (notificationType: FilterType) => () => {
try {
dispatch(setFilter(notificationType, true));
} catch (e) {
console.error(e);
}
const onClick = (filterType: FilterType) => () => {
changeSetting(['notifications', 'quickFilter', 'active'], filterType);
dispatch(saveSettings());
};
const items: Item[] = [
@ -174,21 +170,47 @@ const FilterBar = () => {
return <Tabs items={items} activeItem={selectedFilter} />;
};
const getNotifications = createSelector(
[
(state: RootState) => state.notifications.items,
(_, topNotification?: string) => topNotification,
],
(notifications, topNotificationId) => {
if (topNotificationId) {
const queuedNotificationCount = notifications.findIndex(
(notification) => notification.most_recent_notification_id <= topNotificationId,
interface INotificationsColumn {
multiColumn?: boolean;
}
const NotificationsColumn: React.FC<INotificationsColumn> = ({ multiColumn }) => {
const features = useFeatures();
const settings = useSettings();
const { mutate: markNotificationsRead } = useMarkNotificationsReadMutation();
const queryClient = useQueryClient();
const showFilterBar =
(features.notificationsExcludeTypes || features.notificationsIncludeTypes) &&
settings.notifications.quickFilter.show;
const activeFilter = settings.notifications.quickFilter.active;
const {
data: notifications = [],
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
refetch,
} = useNotifications(activeFilter);
const [topNotification, setTopNotification] = useState<string>();
const { queuedNotificationCount, displayedNotifications } = useMemo(() => {
if (topNotification) {
const cutoffIndex = notifications.findIndex(
(notification) => notification.most_recent_notification_id <= topNotification,
);
const displayedNotifications = notifications.slice(queuedNotificationCount);
if (cutoffIndex === -1) {
return {
queuedNotificationCount: 0,
displayedNotifications: notifications,
};
}
return {
queuedNotificationCount,
displayedNotifications,
queuedNotificationCount: cutoffIndex,
displayedNotifications: notifications.slice(cutoffIndex),
};
}
@ -196,67 +218,35 @@ const getNotifications = createSelector(
queuedNotificationCount: 0,
displayedNotifications: notifications,
};
},
);
interface INotificationsColumn {
multiColumn?: boolean;
}
const NotificationsColumn: React.FC<INotificationsColumn> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const features = useFeatures();
const settings = useSettings();
const showFilterBar =
(features.notificationsExcludeTypes || features.notificationsIncludeTypes) &&
settings.notifications.quickFilter.show;
const activeFilter = settings.notifications.quickFilter.active;
const [topNotification, setTopNotification] = useState<string>();
const { queuedNotificationCount, displayedNotifications } = useAppSelector((state) =>
getNotifications(state, topNotification),
);
const isLoading = useAppSelector((state) => state.notifications.isLoading);
// const isUnread = useAppSelector(state => state.notifications.unread > 0);
const hasMore = useAppSelector((state) => state.notifications.hasMore);
}, [notifications, topNotification]);
const hasMore = hasNextPage ?? false;
const node = useRef<VirtuosoHandle>(null);
const scrollableContentRef = useRef<Array<JSX.Element> | null>(null);
// const handleLoadGap = (maxId) => {
// dispatch(expandNotifications({ maxId }));
// };
const handleLoadOlder = useCallback(
debounce(
() => {
const minId = displayedNotifications.reduce<string | undefined>(
(minId, notification) =>
minId && notification.page_min_id && notification.page_min_id > minId
? minId
: notification.page_min_id,
undefined,
);
dispatch(expandNotifications({ maxId: minId }));
if (!hasMore || isFetchingNextPage) return;
fetchNextPage().catch((error) => {
console.error(error);
});
},
300,
{ leading: true },
),
[displayedNotifications],
[fetchNextPage, hasMore, isFetchingNextPage],
);
const handleScrollToTop = useCallback(
debounce(() => {
dispatch(scrollTopNotifications(true));
const topNotificationId =
displayedNotifications[0]?.page_max_id ??
displayedNotifications[0]?.most_recent_notification_id;
markNotificationsRead(topNotificationId);
}, 100),
[],
);
const handleScroll = useCallback(
debounce(() => {
dispatch(scrollTopNotifications(false));
}, 100),
[],
[fetchNextPage, hasMore, isFetchingNextPage, displayedNotifications],
);
const handleMoveUp = (id: string) => {
@ -273,27 +263,36 @@ const NotificationsColumn: React.FC<INotificationsColumn> = ({ multiColumn }) =>
const handleDequeueNotifications = useCallback(() => {
setTopNotification(undefined);
dispatch(markReadNotifications());
}, []);
const handleRefresh = useCallback(() => dispatch(expandNotifications()), []);
markNotificationsRead(notifications[0]?.most_recent_notification_id);
}, [notifications, markNotificationsRead]);
const handleRefresh = useCallback(() => {
queryClient.setQueryData<InfiniteData<any>>(['notifications', activeFilter], (data) => {
if (!data) return data;
return {
...data,
pages: data.pages.slice(0, 1),
pageParams: data.pageParams.slice(0, 1),
};
});
refetch().catch(console.error);
}, [refetch]);
useEffect(() => {
handleDequeueNotifications();
dispatch(scrollTopNotifications(true));
return () => {
handleLoadOlder.cancel?.();
handleScrollToTop.cancel();
handleScroll.cancel?.();
dispatch(scrollTopNotifications(false));
handleScrollToTop.cancel?.();
};
}, []);
useEffect(() => {
if (topNotification || displayedNotifications.length === 0) return;
setTopNotification(displayedNotifications[0].most_recent_notification_id);
}, [displayedNotifications.length]);
}, [displayedNotifications, topNotification]);
const emptyMessage =
activeFilter === 'all' ? (
@ -333,18 +332,15 @@ const NotificationsColumn: React.FC<INotificationsColumn> = ({ multiColumn }) =>
<ScrollableList
ref={node}
scrollKey='notifications'
isLoading={isLoading}
showLoading={isLoading && displayedNotifications.length === 0}
isLoading={isFetching}
showLoading={isLoading}
hasMore={hasMore}
emptyMessageText={emptyMessage}
placeholderComponent={PlaceholderNotification}
placeholderCount={20}
onLoadMore={handleLoadOlder}
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
listClassName={clsx('⁂-status-list', {
'animate-pulse': displayedNotifications.length === 0,
})}
listClassName={clsx('⁂-status-list', { 'animate-pulse': isLoading })}
useWindowScroll={!multiColumn}
>
{scrollableContent!}

View File

@ -2,10 +2,10 @@ import React from 'react';
import { Helmet as ReactHelmet } from 'react-helmet-async';
import { useStatContext } from '@/contexts/stat-context';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useInstance } from '@/hooks/use-instance';
import { usePendingUsersCount } from '@/queries/admin/use-accounts';
import { usePendingReportsCount } from '@/queries/admin/use-reports';
import { useNotificationsUnreadCount } from '@/queries/notifications/use-notifications';
import { useSettings } from '@/stores/settings';
import FaviconService from '@/utils/favicon-service';
@ -20,13 +20,9 @@ const Helmet: React.FC<IHelmet> = ({ children }) => {
const { unreadChatsCount } = useStatContext();
const { data: awaitingApprovalCount = 0 } = usePendingUsersCount();
const { data: pendingReportsCount = 0 } = usePendingReportsCount();
const unreadCount = useAppSelector(
(state) =>
(state.notifications.unread || 0) +
unreadChatsCount +
awaitingApprovalCount +
pendingReportsCount,
);
const notificationCount = useNotificationsUnreadCount();
const unreadCount =
notificationCount + unreadChatsCount + awaitingApprovalCount + pendingReportsCount;
const { demetricator } = useSettings();
const hasUnreadNotifications = React.useMemo(

View File

@ -38,7 +38,7 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
// Whether we are scrolled above the `autoloadThreshold`.
const [scrolledTop, setScrolledTop] = useState<boolean>(false);
const visible = count > 0 && (autoloadThreshold ? scrolled : scrolledTop);
const visible = count > 0 && (!autoloadTimelines || scrolled);
const buttonMessage = intl.formatMessage(message, { count });
/** Number of pixels scrolled down from the top of the page. */

View File

@ -8,7 +8,6 @@ import Stack from '@/components/ui/stack';
import { useStatContext } from '@/contexts/stat-context';
import ComposeButton from '@/features/ui/components/compose-button';
import ProfileDropdown from '@/features/ui/components/profile-dropdown';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useFeatures } from '@/hooks/use-features';
import { useInstance } from '@/hooks/use-instance';
import { useOwnAccount } from '@/hooks/use-own-account';
@ -16,6 +15,7 @@ import { useRegistrationStatus } from '@/hooks/use-registration-status';
import { useFollowRequestsCount } from '@/queries/accounts/use-follow-requests';
import { usePendingUsersCount } from '@/queries/admin/use-accounts';
import { usePendingReportsCount } from '@/queries/admin/use-reports';
import { useNotificationsUnreadCount } from '@/queries/notifications/use-notifications';
import { scheduledStatusesCountQueryOptions } from '@/queries/statuses/scheduled-statuses';
import { useDraftStatusesCountQuery } from '@/queries/statuses/use-draft-statuses';
import { useInteractionRequestsCount } from '@/queries/statuses/use-interaction-requests';
@ -78,7 +78,7 @@ const SidebarNavigation: React.FC<ISidebarNavigation> = React.memo(({ shrink })
[!!account, features],
);
const notificationCount = useAppSelector((state) => state.notifications.unread);
const notificationCount = useNotificationsUnreadCount();
const followRequestsCount = useFollowRequestsCount().data ?? 0;
const interactionRequestsCount = useInteractionRequestsCount().data ?? 0;
const { data: awaitingApprovalCount = 0 } = usePendingUsersCount();

View File

@ -12,6 +12,7 @@ import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useFeatures } from '@/hooks/use-features';
import { useOwnAccount } from '@/hooks/use-own-account';
import { useNotificationsUnreadCount } from '@/queries/notifications/use-notifications';
import { useModalsActions } from '@/stores/modals';
import { useIsSidebarOpen, useUiStoreActions } from '@/stores/ui';
import { isStandalone } from '@/utils/state';
@ -43,7 +44,7 @@ const ThumbNavigation: React.FC = React.memo((): JSX.Element => {
const { unreadChatsCount } = useStatContext();
const standalone = useAppSelector(isStandalone);
const notificationCount = useAppSelector((state) => state.notifications.unread);
const notificationCount = useNotificationsUnreadCount();
const handleOpenComposeModal = () => {
if (match?.params.groupId) {

View File

@ -580,4 +580,9 @@ const Notification: React.FC<INotification> = (props) => {
);
};
export { Notification as default, buildLink, getNotificationStatus };
export {
Notification as default,
buildLink,
getNotificationStatus,
messages as notificationMessages,
};

View File

@ -4,8 +4,6 @@ import React, { Suspense, useEffect, useRef } from 'react';
import { Toaster } from 'react-hot-toast';
import { fetchConfig } from '@/actions/admin';
import { fetchMarker } from '@/actions/markers';
import { expandNotifications } from '@/actions/notifications';
import { register as registerPushNotifications } from '@/actions/push-notifications/registerer';
import { fetchHomeTimeline } from '@/actions/timelines';
import { useUserStream } from '@/api/hooks/streaming/use-user-stream';
@ -22,6 +20,10 @@ import { useOwnAccount } from '@/hooks/use-own-account';
import { prefetchFollowRequests } from '@/queries/accounts/use-follow-requests';
import { queryClient } from '@/queries/client';
import { prefetchCustomEmojis } from '@/queries/instance/use-custom-emojis';
import {
usePrefetchNotifications,
usePrefetchNotificationsMarker,
} from '@/queries/notifications/use-notifications';
import { useFilters } from '@/queries/settings/use-filters';
import { scheduledStatusesQueryOptions } from '@/queries/statuses/scheduled-statuses';
import { useSettings } from '@/stores/settings';
@ -61,6 +63,8 @@ const UI: React.FC = React.memo(() => {
useShoutboxSubscription();
useFilters();
usePrefetchNotifications();
usePrefetchNotificationsMarker();
const { isDragging } = useDraggedFiles(node);
@ -93,10 +97,6 @@ const UI: React.FC = React.memo(() => {
dispatch(fetchHomeTimeline());
dispatch(expandNotifications())
.then(() => dispatch(fetchMarker(['notifications'])))
.catch(console.error);
if (account.is_admin && features.pleromaAdminAccounts) {
dispatch(fetchConfig());
}

View File

@ -0,0 +1,356 @@
import {
type InfiniteData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import 'intl-pluralrules';
import { useCallback, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { importEntities } from '@/actions/importer';
import {
getNotificationStatus,
notificationMessages,
} from '@/features/notifications/components/notification';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useClient } from '@/hooks/use-client';
import { useLoggedIn } from '@/hooks/use-logged-in';
import { normalizeNotification } from '@/normalizers/notification';
import { appendFollowRequest } from '@/queries/accounts/use-follow-requests';
import { queryClient } from '@/queries/client';
import { makePaginatedResponseQueryOptions } from '@/queries/utils/make-paginated-response-query-options';
import { getFilters, regexFromFilters } from '@/selectors';
import { useSettingsStore } from '@/stores/settings';
import { compareId } from '@/utils/comparators';
import { unescapeHTML } from '@/utils/html';
import { EXCLUDE_TYPES, NOTIFICATION_TYPES } from '@/utils/notification';
import { play, soundCache } from '@/utils/sounds';
import { joinPublicPath } from '@/utils/static';
import { minifyGroupedNotifications } from '../utils/minify-list';
import type {
GetGroupedNotificationsParams,
Notification,
NotificationGroup,
PaginatedResponse,
} from 'pl-api';
const FILTER_TYPES = {
all: undefined,
mention: ['mention', 'quote'],
favourite: ['favourite', 'emoji_reaction', 'reaction'],
reblog: ['reblog'],
poll: ['poll'],
status: ['status'],
follow: ['follow', 'follow_request'],
events: ['event_reminder', 'participation_request', 'participation_accepted'],
} as const;
type FilterType = keyof typeof FILTER_TYPES;
const useActiveFilter = () =>
useSettingsStore((state) => state.settings.notifications.quickFilter.active);
const excludeTypesFromFilter = (filters: string[]) =>
NOTIFICATION_TYPES.filter((item) => !filters.includes(item)) as string[];
const buildNotificationsParams = (
activeFilter: FilterType,
notificationsIncludeTypes: boolean,
): GetGroupedNotificationsParams => {
const params: GetGroupedNotificationsParams = {};
if (activeFilter === 'all') {
if (notificationsIncludeTypes) {
const excludedTypes = new Set<string>(EXCLUDE_TYPES);
params.types = NOTIFICATION_TYPES.filter((type) => !excludedTypes.has(type));
} else {
params.exclude_types = [...EXCLUDE_TYPES];
}
return params;
}
const filtered = [...(FILTER_TYPES[activeFilter] || [activeFilter])];
if (notificationsIncludeTypes) {
params.types = filtered;
} else {
params.exclude_types = excludeTypesFromFilter(filtered);
}
return params;
};
const shouldDisplayNotification = (
notificationType: Notification['type'],
activeFilter: FilterType,
) => {
if (activeFilter === 'all') return true;
const allowedTypes = [...(FILTER_TYPES[activeFilter] ?? [notificationType])] as string[];
return allowedTypes.includes(notificationType);
};
const notificationsQueryOptions = makePaginatedResponseQueryOptions(
(activeFilter: FilterType) => ['notifications', activeFilter],
(client, [activeFilter]) =>
client.groupedNotifications
.getGroupedNotifications(
buildNotificationsParams(activeFilter, client.features.notificationsIncludeTypes),
)
.then(minifyGroupedNotifications),
);
const useNotifications = (activeFilter: FilterType) => {
const { me } = useLoggedIn();
return useInfiniteQuery({
...notificationsQueryOptions(activeFilter),
enabled: !!me,
});
};
const useNotificationsMarker = () => {
const client = useClient();
const { me } = useLoggedIn();
return useQuery({
queryKey: ['markers', 'notifications'],
queryFn: async () =>
(await client.timelines.getMarkers(['notifications'])).notifications ?? null,
enabled: !!me,
});
};
const usePrefetchNotificationsMarker = () => {
const client = useClient();
const queryClient = useQueryClient();
const { me } = useLoggedIn();
useEffect(() => {
if (!me) return;
queryClient.prefetchQuery({
queryKey: ['markers', 'notifications'],
queryFn: async () =>
(await client.timelines.getMarkers(['notifications'])).notifications ?? null,
});
}, [me]);
};
const useProcessStreamNotification = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const filters = useAppSelector((state) => getFilters(state, { contextType: 'notifications' }));
const activeFilter = useActiveFilter();
const { sounds } = useSettingsStore((state) => state.settings.notifications);
const processStreamNotification = useCallback(
(notification: Notification, intlMessages: Record<string, string>, intlLocale: string) => {
if (!notification.type) return;
if (notification.type === 'chat_mention') return;
const playSound = sounds[notification.type];
const status = getNotificationStatus(notification);
let filtered: boolean | null = false;
if (notification.type === 'mention' || notification.type === 'status') {
const regex = regexFromFilters(filters);
const searchIndex =
notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
filtered = regex && regex.test(searchIndex);
}
try {
const isNotificationsEnabled = window.Notification?.permission === 'granted';
if (!filtered && isNotificationsEnabled) {
const targetName = notification.type === 'move' ? notification.target.acct : '';
const isReblog = status?.reblog_id ? 1 : 0;
const title = intl.formatMessage(notificationMessages[notification.type], {
name: notification.account.display_name,
targetName,
isReblog,
});
const body =
status && status.spoiler_text.length > 0
? status.spoiler_text
: unescapeHTML(status ? status.content : '');
navigator.serviceWorker.ready
.then((serviceWorkerRegistration) => {
serviceWorkerRegistration
.showNotification(title, {
body,
icon: notification.account.avatar,
tag: notification.id,
data: {
url: joinPublicPath('/notifications'),
},
})
.catch(console.error);
})
.catch(console.error);
}
} catch (error) {
console.warn(error);
}
if (playSound && !filtered) {
play(soundCache.boop);
}
dispatch(
importEntities({
accounts: [
notification.account,
notification.type === 'move' ? notification.target : undefined,
],
statuses: [status],
}),
);
const normalizedNotification = normalizeNotification(notification);
prependNotification(normalizedNotification, 'all');
if (shouldDisplayNotification(notification.type, activeFilter)) {
prependNotification(normalizedNotification, activeFilter);
}
if (normalizedNotification.type === 'follow_request') {
normalizedNotification.sample_account_ids.forEach(appendFollowRequest);
}
},
[filters, sounds, activeFilter],
);
return processStreamNotification;
};
const useMarkNotificationsReadMutation = () => {
const client = useClient();
return useMutation({
mutationKey: ['markers', 'notifications', 'save'],
mutationFn: async (lastReadId?: string | null) => {
if (!lastReadId) return;
return await client.timelines.saveMarkers({
notifications: {
last_read_id: lastReadId,
},
});
},
onSuccess: (markers, lastReadId) => {
if (markers?.notifications) {
queryClient.setQueryData(['markers', 'notifications'], markers.notifications);
return;
}
if (!lastReadId) return;
queryClient.setQueryData(['markers', 'notifications'], (marker) => {
if (!marker) return undefined;
return {
...marker,
last_read_id: lastReadId,
};
});
},
});
};
const countUnreadNotifications = (
notifications: NotificationGroup[],
lastReadId?: string | null,
) => {
if (!lastReadId) return notifications.length;
return notifications.reduce((count, notification) => {
if (compareId(notification.most_recent_notification_id, lastReadId) > 0) {
return count + 1;
}
return count;
}, 0);
};
const useNotificationsUnreadCount = () => {
const { data: marker } = useNotificationsMarker();
const { data: notifications = [] } = useNotifications('all');
return countUnreadNotifications(notifications, marker?.last_read_id);
};
const usePrefetchNotifications = () => {
const queryClient = useQueryClient();
const { me } = useLoggedIn();
const activeFilter = useActiveFilter();
useEffect(() => {
if (!me) return;
queryClient.prefetchInfiniteQuery(notificationsQueryOptions(activeFilter));
}, [me]);
};
const filterUnique = (
notification: NotificationGroup,
index: number,
notifications: Array<NotificationGroup>,
) => notifications.findIndex(({ group_key }) => group_key === notification.group_key) === index;
// For sorting the notifications
const comparator = (
a: Pick<NotificationGroup, 'most_recent_notification_id'>,
b: Pick<NotificationGroup, 'most_recent_notification_id'>,
) => {
const length = Math.max(
a.most_recent_notification_id.length,
b.most_recent_notification_id.length,
);
return b.most_recent_notification_id
.padStart(length, '0')
.localeCompare(a.most_recent_notification_id.padStart(length, '0'));
};
const prependNotification = (notification: NotificationGroup, filter: FilterType) => {
queryClient.setQueryData<InfiniteData<PaginatedResponse<NotificationGroup>>>(
['notifications', filter],
(data) => {
if (!data || !data.pages.length) return data;
const [firstPage, ...restPages] = data.pages;
return {
...data,
pages: [
{
...firstPage,
items: [notification, ...firstPage.items].toSorted(comparator).filter(filterUnique),
},
...restPages,
],
};
},
);
};
export {
FILTER_TYPES,
type FilterType,
useMarkNotificationsReadMutation,
useNotifications,
useNotificationsMarker,
useNotificationsUnreadCount,
usePrefetchNotifications,
usePrefetchNotificationsMarker,
useProcessStreamNotification,
};

View File

@ -2,37 +2,59 @@ import { type InfiniteData, infiniteQueryOptions, type QueryKey } from '@tanstac
import { store } from '@/store';
import { PaginatedResponseArray } from './make-paginated-response-query';
import {
PaginatedResponseArray,
type PaginatedResponseQueryResult,
} from './make-paginated-response-query';
import type { PaginatedResponse, PlApiClient } from 'pl-api';
const makePaginatedResponseQueryOptions =
<T1 extends Array<any>, T2, T3 = PaginatedResponseArray<T2>>(
<
T1 extends Array<any>,
T2,
IsArray extends boolean = true,
T3 = PaginatedResponseQueryResult<T2, IsArray>,
>(
queryKey: QueryKey | ((...params: T1) => QueryKey),
queryFn: (client: PlApiClient, params: T1) => Promise<PaginatedResponse<T2>>,
select?: (data: InfiniteData<PaginatedResponse<T2>>) => T3,
queryFn: (client: PlApiClient, params: T1) => Promise<PaginatedResponse<T2, IsArray>>,
select?: (data: InfiniteData<PaginatedResponse<T2, IsArray>>) => T3,
) =>
(...params: T1) =>
infiniteQueryOptions({
queryKey: typeof queryKey === 'object' ? queryKey : queryKey(...params),
queryFn: ({ pageParam }) =>
pageParam.next?.() ?? queryFn(store.getState().auth.client, params),
initialPageParam: { previous: null, next: null, items: [], partial: false } as Awaited<
ReturnType<typeof queryFn>
>,
initialPageParam: {
previous: null,
next: null,
items: [] as unknown as PaginatedResponse<T2, IsArray>['items'],
partial: false,
} as Awaited<ReturnType<typeof queryFn>>,
getNextPageParam: (page) => (page.next ? page : undefined),
select:
select ??
((data) => {
const items = new PaginatedResponseArray(...data.pages.map((page) => page.items).flat());
const lastPage = data.pages.at(-1);
if (lastPage) {
items.total = lastPage.total;
items.partial = lastPage.partial;
if (!lastPage) {
return new PaginatedResponseArray() as T3;
}
return items as T3;
if (Array.isArray(lastPage.items)) {
const items = new PaginatedResponseArray(
...data.pages.flatMap((page) =>
Array.isArray(page.items) ? page.items : [page.items],
),
);
items.total = lastPage.total;
items.partial = lastPage.partial;
return items as T3;
}
return lastPage.items as T3;
}),
});

View File

@ -10,11 +10,22 @@ class PaginatedResponseArray<T> extends Array<T> {
partial?: boolean;
}
type PaginatedResponseQueryResult<T, IsArray extends boolean> = IsArray extends true
? PaginatedResponseArray<T>
: T extends Array<infer TItem>
? PaginatedResponseArray<TItem>
: T;
const makePaginatedResponseQuery =
<T1 extends Array<any>, T2, T3 = PaginatedResponseArray<T2>>(
<
T1 extends Array<any>,
T2,
IsArray extends boolean = true,
T3 = PaginatedResponseQueryResult<T2, IsArray>,
>(
queryKey: QueryKey | ((...params: T1) => QueryKey),
queryFn: (client: PlApiClient, params: T1) => Promise<PaginatedResponse<T2>>,
select?: (data: InfiniteData<PaginatedResponse<T2>>) => T3,
queryFn: (client: PlApiClient, params: T1) => Promise<PaginatedResponse<T2, IsArray>>,
select?: (data: InfiniteData<PaginatedResponse<T2, IsArray>>) => T3,
enabled?: ((...params: T1) => boolean) | 'isLoggedIn' | 'isAdmin',
) =>
(...params: T1) => {
@ -24,22 +35,36 @@ const makePaginatedResponseQuery =
return useInfiniteQuery({
queryKey: typeof queryKey === 'object' ? queryKey : queryKey(...params),
queryFn: ({ pageParam }) => pageParam.next?.() ?? queryFn(client, params),
initialPageParam: { previous: null, next: null, items: [], partial: false } as Awaited<
ReturnType<typeof queryFn>
>,
initialPageParam: {
previous: null,
next: null,
items: [] as unknown as PaginatedResponse<T2, IsArray>['items'],
partial: false,
} as Awaited<ReturnType<typeof queryFn>>,
getNextPageParam: (page) => (page.next ? page : undefined),
select:
select ??
((data) => {
const items = new PaginatedResponseArray(...data.pages.map((page) => page.items).flat());
const lastPage = data.pages.at(-1);
if (lastPage) {
items.total = lastPage.total;
items.partial = lastPage.partial;
if (!lastPage) {
return new PaginatedResponseArray() as T3;
}
return items as T3;
if (Array.isArray(lastPage.items)) {
const items = new PaginatedResponseArray(
...data.pages.flatMap((page) =>
Array.isArray(page.items) ? page.items : [page.items],
),
);
items.total = lastPage.total;
items.partial = lastPage.partial;
return items as T3;
}
return lastPage.items as T3;
}),
enabled:
enabled === 'isLoggedIn'
@ -50,4 +75,4 @@ const makePaginatedResponseQuery =
});
};
export { makePaginatedResponseQuery, PaginatedResponseArray };
export { makePaginatedResponseQuery, PaginatedResponseArray, type PaginatedResponseQueryResult };

View File

@ -10,25 +10,35 @@ import type {
BlockedAccount,
Conversation,
Group,
GroupedNotificationsResults,
MutedAccount,
NotificationGroup,
PaginatedResponse,
Status,
} from 'pl-api';
const minifyList = <T1, T2>(
{ previous, next, items, ...response }: PaginatedResponse<T1>,
const minifyList = <T1, T2, IsArray extends boolean = true>(
{ previous, next, items, ...response }: PaginatedResponse<T1, IsArray>,
minifier: (value: T1) => T2,
importer?: (items: Array<T1>) => void,
): PaginatedResponse<T2> => {
importer?: (items: PaginatedResponse<T1, IsArray>['items']) => void,
isArray: IsArray = true as IsArray,
): PaginatedResponse<T2, IsArray> => {
importer?.(items);
const minifiedItems = (
isArray ? (items as T1[]).map(minifier) : minifier(items as T1)
) as PaginatedResponse<T2, IsArray>['items'];
return {
...response,
previous: previous
? () => previous().then((list) => minifyList(list, minifier, importer))
? () =>
previous().then((list) => minifyList<T1, T2, IsArray>(list, minifier, importer, isArray))
: null,
next: next ? () => next().then((list) => minifyList(list, minifier, importer)) : null,
items: items.map(minifier),
next: next
? () => next().then((list) => minifyList<T1, T2, IsArray>(list, minifier, importer, isArray))
: null,
items: minifiedItems,
};
};
@ -103,6 +113,25 @@ const minifyConversationList = (response: PaginatedResponse<Conversation>) =>
);
});
const minifyGroupedNotifications = (
response: PaginatedResponse<GroupedNotificationsResults, false>,
): PaginatedResponse<NotificationGroup[], false> =>
minifyList(
response,
(results) => results.notification_groups,
(results) => {
const { accounts, statuses } = results;
store.dispatch(
importEntities({
accounts,
statuses,
}) as any,
);
},
false,
);
const minifyAdminAccount = ({ account, ...adminAccount }: AdminAccount) => {
store.dispatch(importEntities({ accounts: [account] }) as any);
queryClient.setQueryData(['admin', 'accounts', adminAccount.id], adminAccount);
@ -182,6 +211,7 @@ export {
minifyGroupList,
minifyConversation,
minifyConversationList,
minifyGroupedNotifications,
minifyAdminAccount,
minifyAdminAccountList,
minifyAdminReport,

View File

@ -12,7 +12,6 @@ import frontendConfig from './frontend-config';
import instance from './instance';
import me from './me';
import meta from './meta';
import notifications from './notifications';
import push_notifications from './push-notifications';
import statuses from './statuses';
import timelines from './timelines';
@ -27,7 +26,6 @@ const reducers = {
instance,
me,
meta,
notifications,
push_notifications,
statuses,
timelines,

View File

@ -1,187 +0,0 @@
import { create } from 'mutative';
import {
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
type AccountsAction,
} from '../actions/accounts';
import { MARKER_FETCH_SUCCESS, MARKER_SAVE_SUCCESS, type MarkersAction } from '../actions/markers';
import {
NOTIFICATIONS_UPDATE,
NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_EXPAND_FAIL,
NOTIFICATIONS_FILTER_SET,
NOTIFICATIONS_SCROLL_TOP,
type NotificationsAction,
} from '../actions/notifications';
import { TIMELINE_DELETE, type TimelineAction } from '../actions/timelines';
import type {
GroupedNotificationsResults,
Markers,
NotificationGroup,
PaginatedResponse,
Relationship,
} from 'pl-api';
interface State {
items: Array<NotificationGroup>;
hasMore: boolean;
top: boolean;
unread: number;
isLoading: boolean;
lastRead: string | -1;
}
const initialState: State = {
items: [],
hasMore: true,
top: false,
unread: 0,
isLoading: false,
lastRead: -1,
};
const filterUnique = (
notification: NotificationGroup,
index: number,
notifications: Array<NotificationGroup>,
) => notifications.findIndex(({ group_key }) => group_key === notification.group_key) === index;
// For sorting the notifications
const comparator = (
a: Pick<NotificationGroup, 'most_recent_notification_id'>,
b: Pick<NotificationGroup, 'most_recent_notification_id'>,
) => {
const length = Math.max(
a.most_recent_notification_id.length,
b.most_recent_notification_id.length,
);
return b.most_recent_notification_id
.padStart(length, '0')
.localeCompare(a.most_recent_notification_id.padStart(length, '0'));
};
// Count how many notifications appear after the given ID (for unread count)
const countFuture = (notifications: Array<NotificationGroup>, lastId: string | number) =>
notifications.reduce((acc, notification) => {
const length = Math.max(
notification.most_recent_notification_id.length,
lastId.toString().length,
);
if (
notification.most_recent_notification_id
.padStart(length, '0')
.localeCompare(lastId.toString().padStart(length, '0')) === 1
) {
return acc + 1;
} else {
return acc;
}
}, 0);
const importNotification = (state: State, notification: NotificationGroup) =>
create(state, (draft) => {
const top = draft.top;
if (!top) draft.unread += 1;
draft.items = [notification, ...draft.items].toSorted(comparator).filter(filterUnique);
});
const expandNormalizedNotifications = (
state: State,
notifications: NotificationGroup[],
next: (() => Promise<PaginatedResponse<GroupedNotificationsResults, false>>) | null,
) =>
create(state, (draft) => {
draft.items = [...notifications, ...draft.items].toSorted(comparator).filter(filterUnique);
if (!next) draft.hasMore = false;
draft.isLoading = false;
});
const filterNotifications = (state: State, relationship: Relationship) =>
create(state, (draft) => {
draft.items = draft.items.filter((item) => !item.sample_account_ids.includes(relationship.id));
});
// const filterNotificationIds = (state: State, accountIds: Array<string>, type?: string) =>
// create(state, (draft) => {
// const helper = (list: Array<NotificationGroup>) => list.filter(item => !(accountIds.includes(item.sample_account_ids[0]) && (type === undefined || type === item.type)));
// draft.items = helper(draft.items);
// });
const updateTop = (state: State, top: boolean) =>
create(state, (draft) => {
if (top) draft.unread = 0;
draft.top = top;
});
const deleteByStatus = (state: State, statusId: string) =>
create(state, (draft) => {
// @ts-ignore
draft.items = draft.items.filterNot((item) => item !== null && item.status_id === statusId);
});
const importMarker = (state: State, marker: Markers) => {
const lastReadId = marker.notifications?.last_read_id || (-1 as string | -1);
if (!lastReadId) {
return state;
}
return create(state, (draft) => {
const notifications = draft.items;
const unread = countFuture(notifications, lastReadId);
draft.unread = unread;
draft.lastRead = lastReadId;
});
};
const notifications = (
state: State = initialState,
action: AccountsAction | MarkersAction | NotificationsAction | TimelineAction,
): State => {
switch (action.type) {
case NOTIFICATIONS_EXPAND_REQUEST:
return create(state, (draft) => {
draft.isLoading = true;
});
case NOTIFICATIONS_EXPAND_FAIL:
if ((action.error as any)?.message === 'canceled') return state;
return create(state, (draft) => {
draft.isLoading = false;
});
case NOTIFICATIONS_FILTER_SET:
return create(state, (draft) => {
draft.items = [];
draft.hasMore = true;
});
case NOTIFICATIONS_SCROLL_TOP:
return updateTop(state, action.top);
case NOTIFICATIONS_UPDATE:
return importNotification(state, action.notification);
case NOTIFICATIONS_EXPAND_SUCCESS:
return expandNormalizedNotifications(state, action.notifications, action.next);
case ACCOUNT_BLOCK_SUCCESS:
return filterNotifications(state, action.relationship);
case ACCOUNT_MUTE_SUCCESS:
return action.relationship.muting_notifications
? filterNotifications(state, action.relationship)
: state;
// case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
// case FOLLOW_REQUEST_REJECT_SUCCESS:
// return filterNotificationIds(state, [action.accountId], 'follow_request');
case MARKER_FETCH_SUCCESS:
case MARKER_SAVE_SUCCESS:
return importMarker(state, action.marker);
case TIMELINE_DELETE:
return deleteByStatus(state, action.statusId);
default:
return state;
}
};
export { notifications as default };

View File

@ -107,7 +107,10 @@ const settingsSchema = v.object({
notifications: coerceObject({
quickFilter: coerceObject({
active: v.optional(v.string(), 'all'),
active: v.optional(
v.picklist(['all', 'mention', 'favourite', 'reblog', 'poll', 'status', 'follow', 'events']),
'all',
),
advanced: v.optional(v.boolean(), false),
show: v.optional(v.boolean(), true),
}),