import haptics from '@components/haptics' import { MutationOptions, QueryFunctionContext, useInfiniteQuery, UseInfiniteQueryOptions, useMutation } from '@tanstack/react-query' import apiGeneral from '@utils/api/general' import { PagedResponse } from '@utils/api/helpers' import apiInstance from '@utils/api/instance' import { appendRemote } from '@utils/helpers/appendRemote' import { featureCheck } from '@utils/helpers/featureCheck' import { useNavState } from '@utils/navigation/navigators' import { queryClient } from '@utils/queryHooks' import { getAccountStorage, setAccountStorage } from '@utils/storage/actions' import { AxiosError } from 'axios' import { uniqBy } from 'lodash' import { searchLocalStatus } from './search' import deleteItem from './timeline/deleteItem' import editItem from './timeline/editItem' import updateStatusProperty from './timeline/updateStatusProperty' import { infinitePageParams } from './utils' export type QueryKeyTimeline = [ 'Timeline', ( | { page: Exclude } | { page: 'Following' showBoosts: boolean showReplies: boolean } | { page: 'Hashtag' tag_name: Mastodon.Tag['name'] } | { page: 'List' list: Mastodon.List['id'] } | { page: 'Account' id?: Mastodon.Account['id'] exclude_reblogs: boolean only_media: boolean } | { page: 'Toot' toot: Mastodon.Status['id'] remote: boolean } | { page: 'Explore'; domain?: string } ) ] export const queryFunctionTimeline = async ({ queryKey, pageParam }: QueryFunctionContext) => { const page = queryKey[1] let marker: string | undefined if (page.page === 'Following' && !pageParam?.offset && !pageParam?.min_id && !pageParam?.max_id) { marker = getAccountStorage.string('read_marker_following') } const params: { [key: string]: string } = marker ? { limit: 20, max_id: marker } : { limit: 20, ...pageParam } switch (page.page) { case 'Following': return apiInstance({ method: 'get', url: 'timelines/home', params }).then(res => { if (marker && !res.body.length) { setAccountStorage([{ key: 'read_marker_following', value: undefined }]) return Promise.reject() } if (!page.showBoosts || !page.showReplies) { return { ...res, body: res.body .filter(status => { if (!page.showBoosts && status.reblog) { return null } if (!page.showReplies && status.in_reply_to_id?.length) { return null } return status }) .filter(s => s) } } else { return res } }) case 'Local': return apiInstance({ method: 'get', url: 'timelines/public', params: { ...params, local: 'true' } }) case 'LocalPublic': return apiInstance({ method: 'get', url: 'timelines/public', params }) case 'Explore': if (page.domain) { return apiGeneral({ method: 'get', domain: page.domain, url: 'api/v1/timelines/public', params: { ...params, local: 'true' } }).then(res => ({ ...res, body: res.body.map(status => appendRemote.status(status)) })) } else { return apiInstance({ method: 'get', url: 'trends/statuses', params }) } case 'Notifications': const notificationsFilter = getAccountStorage.object('notifications') const usePositiveFilter = featureCheck('notification_types_positive_filter') return apiInstance({ method: 'get', url: 'notifications', params: { ...params, ...(notificationsFilter && (usePositiveFilter ? { types: Object.keys(notificationsFilter) // @ts-ignore .filter(filter => notificationsFilter[filter] === true) } : { exclude_types: Object.keys(notificationsFilter) // @ts-ignore .filter(filter => notificationsFilter[filter] === false) })) } }) case 'Account': if (!page.id) return Promise.reject('Timeline query account id not provided') if (page.only_media) { return apiInstance({ method: 'get', url: `accounts/${page.id}/statuses`, params: { only_media: 'true', ...params } }) } else if (page.exclude_reblogs) { if (pageParam && pageParam.hasOwnProperty('max_id')) { return apiInstance({ method: 'get', url: `accounts/${page.id}/statuses`, params: { exclude_replies: 'true', ...params } }) } else { const res1 = await apiInstance<(Mastodon.Status & { _pinned: boolean })[]>({ method: 'get', url: `accounts/${page.id}/statuses`, params: { pinned: 'true' } }) res1.body = res1.body.map(status => { status._pinned = true return status }) const res2 = await apiInstance({ method: 'get', url: `accounts/${page.id}/statuses`, params: { exclude_replies: 'true' } }) return { body: uniqBy([...res1.body, ...res2.body], 'id'), ...(res2.links?.next && { links: { next: res2.links.next } }) } } } else { return apiInstance({ method: 'get', url: `accounts/${page.id}/statuses`, params: { ...params, exclude_replies: false, only_media: false } }) } case 'Hashtag': return apiInstance({ method: 'get', url: `timelines/tag/${page.tag_name}`, params }) case 'Conversations': return apiInstance({ method: 'get', url: `conversations`, params }) case 'Bookmarks': return apiInstance({ method: 'get', url: `bookmarks`, params }) case 'Favourites': return apiInstance({ method: 'get', url: `favourites`, params }) case 'List': return apiInstance({ method: 'get', url: `timelines/list/${page.list}`, params }) default: return Promise.reject('Timeline query no page matched') } } type Unpromise> = T extends Promise ? U : never export type TimelineData = Unpromise> const useTimelineQuery = ({ options, ...queryKeyParams }: QueryKeyTimeline[1] & { options?: Omit< UseInfiniteQueryOptions, AxiosError>, 'getPreviousPageParam' | 'getNextPageParam' > }) => { const queryKey: QueryKeyTimeline = ['Timeline', { ...queryKeyParams }] return useInfiniteQuery(queryKey, queryFunctionTimeline, { refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false, ...options, ...infinitePageParams }) } // --- Separator --- enum MapPropertyToUrl { bookmarked = 'bookmark', favourited = 'favourite', muted = 'mute', pinned = 'pin', reblogged = 'reblog' } export type MutationVarsTimelineUpdateStatusProperty = { // This is status in general, including "status" inside conversation and notification type: 'updateStatusProperty' status: Mastodon.Status payload: | { type: 'bookmarked' | 'muted' | 'pinned' to: boolean } | { type: 'favourited' to: boolean } | { type: 'reblogged' visibility: 'public' | 'unlisted' to: boolean } | { type: 'poll' action: 'vote' options: boolean[] } | { type: 'poll' action: 'refresh' } } export type MutationVarsTimelineUpdateAccountProperty = { // This is status in general, including "status" inside conversation and notification type: 'updateAccountProperty' id: Mastodon.Account['id'] payload: { property: 'mute' | 'block' | 'reports' currentValue?: boolean } } export type MutationVarsTimelineEditItem = { type: 'editItem' status: Mastodon.Status navigationState: (QueryKeyTimeline | undefined)[] } export type MutationVarsTimelineDeleteItem = { type: 'deleteItem' source: 'statuses' | 'conversations' id: Mastodon.Status['id'] } export type MutationVarsTimelineDomainBlock = { // This is for deleting status and conversation type: 'domainBlock' domain: string } export type MutationVarsTimeline = | MutationVarsTimelineUpdateStatusProperty | MutationVarsTimelineUpdateAccountProperty | MutationVarsTimelineEditItem | MutationVarsTimelineDeleteItem | MutationVarsTimelineDomainBlock const mutationFunction = async (params: MutationVarsTimeline) => { switch (params.type) { case 'updateStatusProperty': let tootId = params.status.id let pollId = params.status.poll?.id if (params.status._remote) { const fetched = await searchLocalStatus(params.status.uri) if (fetched) { tootId = fetched.id pollId = fetched.poll?.id } else { return Promise.reject('Fetching for remote toot failed') } } switch (params.payload.type) { case 'poll': return apiInstance({ method: params.payload.action === 'vote' ? 'post' : 'get', url: params.payload.action === 'vote' ? `polls/${pollId}/votes` : `polls/${pollId}`, ...(params.payload.action === 'vote' && { body: { choices: params.payload.options .map((option, index) => (option ? index.toString() : undefined)) .filter(o => o) } }) }) default: return apiInstance({ method: 'post', url: `statuses/${tootId}/${params.payload.to ? '' : 'un'}${ MapPropertyToUrl[params.payload.type] }`, ...(params.payload.type === 'reblogged' && { body: { visibility: params.payload.visibility } }) }) } case 'updateAccountProperty': switch (params.payload.property) { case 'block': case 'mute': return apiInstance({ method: 'post', url: `accounts/${params.id}/${params.payload.currentValue ? 'un' : ''}${ params.payload.property }` }) case 'reports': return apiInstance({ method: 'post', url: `reports`, params: { account_id: params.id } }) } case 'editItem': return { body: params.status } case 'deleteItem': return apiInstance({ method: 'delete', url: `${params.source}/${params.id}` }) case 'domainBlock': return apiInstance({ method: 'post', url: `domain_blocks`, params: { domain: params.domain } }) } } type MutationOptionsTimeline = MutationOptions< { body: Mastodon.Conversation | Mastodon.Notification | Mastodon.Status }, AxiosError, MutationVarsTimeline > const useTimelineMutation = ({ onError, onMutate, onSettled, onSuccess }: { onError?: MutationOptionsTimeline['onError'] onMutate?: boolean onSettled?: MutationOptionsTimeline['onSettled'] onSuccess?: MutationOptionsTimeline['onSuccess'] }) => { const navigationState = useNavState() return useMutation< { body: Mastodon.Conversation | Mastodon.Notification | Mastodon.Status }, AxiosError, MutationVarsTimeline >(mutationFunction, { onError, onSettled, onSuccess, ...(onMutate && { onMutate: params => { queryClient.cancelQueries(navigationState[0]) const oldData = navigationState[0] && queryClient.getQueryData(navigationState[0]) haptics('Light') switch (params.type) { case 'updateStatusProperty': updateStatusProperty(params, navigationState) break case 'editItem': editItem(params) break case 'deleteItem': deleteItem(params, navigationState) break } return oldData } }) }) } export { useTimelineQuery, useTimelineMutation }