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

@@ -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,