nicolium: migrate notifications to tanstack/react-query
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
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,
|
||||
|
||||
Reference in New Issue
Block a user