nicolium: migrate notifications to tanstack/react-query
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@ -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,
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -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!}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
356
packages/pl-fe/src/queries/notifications/use-notifications.ts
Normal file
356
packages/pl-fe/src/queries/notifications/use-notifications.ts
Normal 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,
|
||||
};
|
||||
@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
@ -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),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user