diff --git a/packages/pl-fe/src/actions/conversations.ts b/packages/pl-fe/src/actions/conversations.ts deleted file mode 100644 index f79b47ff3..000000000 --- a/packages/pl-fe/src/actions/conversations.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { isLoggedIn } from '@/utils/auth'; - -import { getClient } from '../api'; - -import { importEntities } from './importer'; - -import type { AppDispatch, RootState } from '@/store'; -import type { Account, Conversation, PaginatedResponse } from 'pl-api'; - -const CONVERSATIONS_MOUNT = 'CONVERSATIONS_MOUNT' as const; -const CONVERSATIONS_UNMOUNT = 'CONVERSATIONS_UNMOUNT' as const; - -const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST' as const; -const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS' as const; -const CONVERSATIONS_FETCH_FAIL = 'CONVERSATIONS_FETCH_FAIL' as const; -const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE' as const; - -const CONVERSATIONS_READ = 'CONVERSATIONS_READ' as const; - -const mountConversations = () => ({ type: CONVERSATIONS_MOUNT }); - -const unmountConversations = () => ({ type: CONVERSATIONS_UNMOUNT }); - -interface ConversationsReadAction { - type: typeof CONVERSATIONS_READ; - conversationId: string; -} - -const markConversationRead = - (conversationId: string) => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - - dispatch({ - type: CONVERSATIONS_READ, - conversationId, - }); - - return getClient(getState).timelines.markConversationRead(conversationId); - }; - -const expandConversations = - (expand = true) => - (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - const state = getState(); - if (state.conversations.isLoading) return; - - const hasMore = state.conversations.hasMore; - if (expand && !hasMore) return; - - dispatch(expandConversationsRequest()); - - return (state.conversations.next?.() ?? getClient(state).timelines.getConversations()) - .then((response) => { - dispatch( - importEntities({ - accounts: response.items.reduce( - (aggr: Array, item) => aggr.concat(item.accounts), - [], - ), - statuses: response.items.map((item) => item.last_status), - }), - ); - dispatch(expandConversationsSuccess(response.items, response.next, expand)); - }) - .catch((err) => dispatch(expandConversationsFail(err))); - }; - -const expandConversationsRequest = () => ({ type: CONVERSATIONS_FETCH_REQUEST }); - -const expandConversationsSuccess = ( - conversations: Conversation[], - next: (() => Promise>) | null, - isLoadingRecent: boolean, -) => ({ - type: CONVERSATIONS_FETCH_SUCCESS, - conversations, - next, - isLoadingRecent, -}); - -const expandConversationsFail = (error: unknown) => ({ - type: CONVERSATIONS_FETCH_FAIL, - error, -}); - -interface ConversataionsUpdateAction { - type: typeof CONVERSATIONS_UPDATE; - conversation: Conversation; -} - -const updateConversations = (conversation: Conversation) => (dispatch: AppDispatch) => { - dispatch( - importEntities({ - accounts: conversation.accounts, - statuses: [conversation.last_status], - }), - ); - - return dispatch({ - type: CONVERSATIONS_UPDATE, - conversation, - }); -}; - -type ConversationsAction = - | ReturnType - | ReturnType - | ConversationsReadAction - | ReturnType - | ReturnType - | ReturnType - | ConversataionsUpdateAction; - -export { - CONVERSATIONS_MOUNT, - CONVERSATIONS_UNMOUNT, - CONVERSATIONS_FETCH_REQUEST, - CONVERSATIONS_FETCH_SUCCESS, - CONVERSATIONS_FETCH_FAIL, - CONVERSATIONS_UPDATE, - CONVERSATIONS_READ, - mountConversations, - unmountConversations, - markConversationRead, - expandConversations, - updateConversations, - type ConversationsAction, -}; diff --git a/packages/pl-fe/src/api/hooks/streaming/use-user-stream.ts b/packages/pl-fe/src/api/hooks/streaming/use-user-stream.ts index fb0d0d124..a8d120098 100644 --- a/packages/pl-fe/src/api/hooks/streaming/use-user-stream.ts +++ b/packages/pl-fe/src/api/hooks/streaming/use-user-stream.ts @@ -1,6 +1,5 @@ import { useCallback } from 'react'; -import { updateConversations } from '@/actions/conversations'; import { fetchFilters } from '@/actions/filters'; import { MARKER_FETCH_SUCCESS } from '@/actions/markers'; import { updateNotificationsQueue } from '@/actions/notifications'; @@ -12,6 +11,7 @@ import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useLoggedIn } from '@/hooks/use-logged-in'; import messages from '@/messages'; import { queryClient } from '@/queries/client'; +import { updateConversations } from '@/queries/conversations/use-conversations'; import { useSettings } from '@/stores/settings'; import { getUnreadChatsCount, updateChatListItem } from '@/utils/chats'; import { play, soundCache } from '@/utils/sounds'; @@ -131,7 +131,7 @@ const useUserStream = () => { }); break; case 'conversation': - dispatch(updateConversations(event.payload)); + updateConversations(event.payload); break; case 'filters_changed': dispatch(fetchFilters()); diff --git a/packages/pl-fe/src/features/conversations/components/conversation.tsx b/packages/pl-fe/src/features/conversations/components/conversation.tsx index b06847e25..52c1aa240 100644 --- a/packages/pl-fe/src/features/conversations/components/conversation.tsx +++ b/packages/pl-fe/src/features/conversations/components/conversation.tsx @@ -1,32 +1,29 @@ import { useNavigate } from '@tanstack/react-router'; import React from 'react'; -import { markConversationRead } from '@/actions/conversations'; import { useAccount } from '@/api/hooks/accounts/use-account'; import StatusContainer from '@/containers/status-container'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useAppSelector } from '@/hooks/use-app-selector'; +import { + useMarkConversationRead, + type MinifiedConversation, +} from '@/queries/conversations/use-conversations'; interface IConversation { - conversationId: string; + conversation: MinifiedConversation; onMoveUp: (id: string) => void; onMoveDown: (id: string) => void; } -const Conversation: React.FC = ({ conversationId, onMoveUp, onMoveDown }) => { - const dispatch = useAppDispatch(); +const Conversation: React.FC = ({ conversation, onMoveUp, onMoveDown }) => { const navigate = useNavigate(); - const { - account_ids, - unread, - last_status: lastStatusId, - } = useAppSelector((state) => state.conversations.items.find((x) => x.id === conversationId)!); + const { id: conversationId, account_ids, unread, last_status: lastStatusId } = conversation; + const { mutate: markConversationRead } = useMarkConversationRead(conversationId); const { account: lastStatusAccount } = useAccount(account_ids[0]); const handleClick = () => { if (unread) { - dispatch(markConversationRead(conversationId)); + markConversationRead(); } if (lastStatusId) diff --git a/packages/pl-fe/src/features/conversations/components/conversations-list.tsx b/packages/pl-fe/src/features/conversations/components/conversations-list.tsx index 6f5ec4584..d7a8cdb12 100644 --- a/packages/pl-fe/src/features/conversations/components/conversations-list.tsx +++ b/packages/pl-fe/src/features/conversations/components/conversations-list.tsx @@ -2,10 +2,9 @@ import { debounce } from '@tanstack/react-pacer/debouncer'; import React, { useCallback, useRef } from 'react'; import { FormattedMessage } from 'react-intl'; -import { expandConversations } from '@/actions/conversations'; import ScrollableList from '@/components/scrollable-list'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useAppSelector } from '@/hooks/use-app-selector'; +import PlaceholderStatus from '@/features/placeholder/components/placeholder-status'; +import { useConversations } from '@/queries/conversations/use-conversations'; import { selectChild } from '@/utils/scroll-utils'; import Conversation from './conversation'; @@ -13,12 +12,9 @@ import Conversation from './conversation'; import type { VirtuosoHandle } from 'react-virtuoso'; const ConversationsList: React.FC = () => { - const dispatch = useAppDispatch(); const ref = useRef(null); - const conversations = useAppSelector((state) => state.conversations.items); - const isLoading = useAppSelector((state) => state.conversations.isLoading); - const hasMore = useAppSelector((state) => state.conversations.hasMore); + const { conversations, isLoading, hasNextPage, isFetching, fetchNextPage } = useConversations(); const getCurrentIndex = (id: string) => conversations.findIndex((x) => x.id === id); @@ -40,22 +36,22 @@ const ConversationsList: React.FC = () => { const handleLoadOlder = useCallback( debounce( () => { - if (hasMore) dispatch(expandConversations()); + if (hasNextPage) fetchNextPage(); }, { wait: 300, leading: true }, ), - [hasMore], + [hasNextPage, fetchNextPage], ); return ( { /> } listClassName='⁂-status-list' + placeholderComponent={PlaceholderStatus} + placeholderCount={20} > - {conversations.map((item: any) => ( + {conversations.map((item) => ( diff --git a/packages/pl-fe/src/pages/status-lists/conversations.tsx b/packages/pl-fe/src/pages/status-lists/conversations.tsx index ebc173310..3a181fc4e 100644 --- a/packages/pl-fe/src/pages/status-lists/conversations.tsx +++ b/packages/pl-fe/src/pages/status-lists/conversations.tsx @@ -1,15 +1,9 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { - mountConversations, - unmountConversations, - expandConversations, -} from '@/actions/conversations'; import { useDirectStream } from '@/api/hooks/streaming/use-direct-stream'; import Column from '@/components/ui/column'; import ConversationsList from '@/features/conversations/components/conversations-list'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; const messages = defineMessages({ title: { id: 'column.direct', defaultMessage: 'Direct messages' }, @@ -18,19 +12,9 @@ const messages = defineMessages({ const ConversationsTimeline = () => { const intl = useIntl(); - const dispatch = useAppDispatch(); useDirectStream(); - useEffect(() => { - dispatch(mountConversations()); - dispatch(expandConversations(false)); - - return () => { - dispatch(unmountConversations()); - }; - }, []); - return ( diff --git a/packages/pl-fe/src/queries/conversations/use-conversations.ts b/packages/pl-fe/src/queries/conversations/use-conversations.ts new file mode 100644 index 000000000..11a4c85e1 --- /dev/null +++ b/packages/pl-fe/src/queries/conversations/use-conversations.ts @@ -0,0 +1,166 @@ +import { + type InfiniteData, + useInfiniteQuery, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; +import { create } from 'mutative'; +import { useMemo } from 'react'; + +import { importEntities } from '@/actions/importer'; +import { useClient } from '@/hooks/use-client'; +import { useLoggedIn } from '@/hooks/use-logged-in'; +import { store } from '@/store'; +import { compareDate } from '@/utils/comparators'; + +import { queryClient } from '../client'; +import { updatePaginatedResponse } from '../utils/update-paginated-response'; + +import type { Conversation, PaginatedResponse } from 'pl-api'; + +type MinifiedConversation = { + id: string; + unread: boolean; + account_ids: string[]; + last_status: string | null; + last_status_created_at: string | null; +}; + +type MinifiedConversationPage = PaginatedResponse; + +const minifyConversation = (conversation: Conversation): MinifiedConversation => ({ + id: conversation.id, + unread: conversation.unread, + account_ids: conversation.accounts.map((account) => account.id), + last_status: conversation.last_status?.id ?? null, + last_status_created_at: conversation.last_status?.created_at ?? null, +}); + +const sortConversations = (items: MinifiedConversation[]) => + items.toSorted((a, b) => { + if (a.last_status_created_at === null || b.last_status_created_at === null) { + return -1; + } + + return compareDate(a.last_status_created_at, b.last_status_created_at); + }); + +const importConversationEntities = (conversations: Conversation[]) => { + store.dispatch( + importEntities({ + accounts: conversations.flatMap((conversation) => conversation.accounts), + statuses: conversations.map((conversation) => conversation.last_status), + }) as any, + ); +}; + +const minifyConversationPage = ( + response: PaginatedResponse, +): MinifiedConversationPage => { + importConversationEntities(response.items); + + return { + ...response, + previous: response.previous + ? () => response.previous!().then((page) => minifyConversationPage(page)) + : null, + next: response.next + ? () => response.next!().then((page) => minifyConversationPage(page)) + : null, + items: response.items.map(minifyConversation), + }; +}; + +const updateConversations = (conversation: Conversation) => { + importConversationEntities([conversation]); + + queryClient.setQueryData>(['conversations'], (data) => { + if (!data || !data.pages.length) return data; + + return create(data, (draft) => { + const updatedConversation = minifyConversation(conversation); + + let found = false; + + for (const page of draft.pages) { + const index = page.items.findIndex((item) => item.id === updatedConversation.id); + if (index !== -1) { + page.items[index] = updatedConversation; + found = true; + break; + } + } + + if (!found) { + draft.pages[0].items.unshift(updatedConversation); + } + }); + }); +}; + +const useConversations = () => { + const client = useClient(); + const { isLoggedIn } = useLoggedIn(); + + const query = useInfiniteQuery({ + queryKey: ['conversations'], + queryFn: async ({ pageParam }) => { + if (pageParam.next) { + return pageParam.next(); + } + + const response = await client.timelines.getConversations(); + return minifyConversationPage(response); + }, + initialPageParam: { + previous: null, + next: null, + items: [], + partial: false, + } as MinifiedConversationPage, + getNextPageParam: (page) => (page.next ? page : undefined), + enabled: isLoggedIn, + }); + + const conversations = useMemo( + () => sortConversations(query.data?.pages.flatMap((page) => page.items) ?? []), + [query.data], + ); + + return { ...query, conversations }; +}; + +const useMarkConversationRead = (conversationId: string) => { + const client = useClient(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['conversations', conversationId, 'read'], + mutationFn: () => client.timelines.markConversationRead(conversationId), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: ['conversations'] }); + + const previous = queryClient.getQueryData>([ + 'conversations', + ]); + + updatePaginatedResponse(['conversations'], (items) => + items.map((item) => (item.id === conversationId ? { ...item, unread: false } : item)), + ); + + return { previous }; + }, + onError: (_, __, context) => { + if (context?.previous) { + queryClient.setQueryData(['conversations'], context.previous); + } + }, + }); +}; + +export { + useConversations, + useMarkConversationRead, + updateConversations, + type MinifiedConversation, +}; diff --git a/packages/pl-fe/src/queries/events/use-event-participation-requests.ts b/packages/pl-fe/src/queries/events/use-event-participation-requests.ts index 4a4a76639..c089c7147 100644 --- a/packages/pl-fe/src/queries/events/use-event-participation-requests.ts +++ b/packages/pl-fe/src/queries/events/use-event-participation-requests.ts @@ -1,10 +1,10 @@ -import { type InfiniteData, useMutation } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { importEntities } from '@/actions/importer'; import { useClient } from '@/hooks/use-client'; -import { queryClient } from '@/queries/client'; import { makePaginatedResponseQuery } from '@/queries/utils/make-paginated-response-query'; import { minifyList } from '@/queries/utils/minify-list'; +import { updatePaginatedResponse } from '@/queries/utils/update-paginated-response'; import { store } from '@/store'; import type { PlApiClient } from 'pl-api'; @@ -24,20 +24,12 @@ const minifyRequestList = ( ); type MinifiedRequestList = ReturnType; +type MinifiedRequest = MinifiedRequestList['items'][0]; const removeRequest = (statusId: string, accountId: string) => - queryClient.setQueryData>( + updatePaginatedResponse( ['accountsLists', 'eventParticipationRequests', statusId], - (data) => - data - ? { - ...data, - pages: data.pages.map(({ items, ...page }) => ({ - ...page, - items: items.filter(({ account_id }) => account_id !== accountId), - })), - } - : undefined, + (items) => items.filter(({ account_id }) => account_id !== accountId), ); const useEventParticipationRequests = makePaginatedResponseQuery( diff --git a/packages/pl-fe/src/queries/utils/update-paginated-response.ts b/packages/pl-fe/src/queries/utils/update-paginated-response.ts new file mode 100644 index 000000000..61ef3aabf --- /dev/null +++ b/packages/pl-fe/src/queries/utils/update-paginated-response.ts @@ -0,0 +1,21 @@ +import { type InfiniteData, type QueryKey } from '@tanstack/react-query'; +import { PaginatedResponse } from 'pl-api'; + +import { queryClient } from '@/queries/client'; + +const updatePaginatedResponse = ( + queryKey: QueryKey, + updater: (items: PaginatedResponse['items']) => PaginatedResponse['items'], +) => + queryClient.setQueryData>>(queryKey, (data) => { + if (!data) return undefined; + return { + ...data, + pages: data.pages.map((page) => ({ + ...page, + items: updater(page.items), + })), + }; + }); + +export { updatePaginatedResponse }; diff --git a/packages/pl-fe/src/reducers/conversations.ts b/packages/pl-fe/src/reducers/conversations.ts deleted file mode 100644 index da4e20791..000000000 --- a/packages/pl-fe/src/reducers/conversations.ts +++ /dev/null @@ -1,138 +0,0 @@ -import pick from 'lodash/pick'; -import { create } from 'mutative'; - -import { - CONVERSATIONS_MOUNT, - CONVERSATIONS_UNMOUNT, - CONVERSATIONS_FETCH_REQUEST, - CONVERSATIONS_FETCH_SUCCESS, - CONVERSATIONS_FETCH_FAIL, - CONVERSATIONS_UPDATE, - CONVERSATIONS_READ, - type ConversationsAction, -} from '../actions/conversations'; -import { compareDate } from '../utils/comparators'; - -import type { Conversation, PaginatedResponse } from 'pl-api'; - -interface State { - items: Array; - isLoading: boolean; - hasMore: boolean; - next: (() => Promise>) | null; - mounted: number; -} - -const initialState: State = { - items: [], - isLoading: false, - hasMore: true, - next: null, - mounted: 0, -}; - -const minifyConversation = (conversation: Conversation) => ({ - ...pick(conversation, ['id', 'unread']), - account_ids: conversation.accounts.map((a) => a.id), - last_status: conversation.last_status?.id ?? null, - last_status_created_at: conversation.last_status?.created_at ?? null, -}); - -type MinifiedConversation = ReturnType; - -const updateConversation = (state: State, item: Conversation) => { - const index = state.items.findIndex((x) => x.id === item.id); - const newItem = minifyConversation(item); - - if (index === -1) { - state.items = [newItem, ...state.items]; - } else { - state.items[index] = newItem; - } -}; - -const expandNormalizedConversations = ( - state: State, - conversations: Conversation[], - next: (() => Promise>) | null, - isLoadingRecent?: boolean, -) => { - let items = conversations.map(minifyConversation); - - if (items.length) { - let list = state.items.map((oldItem) => { - const newItemIndex = items.findIndex((x) => x.id === oldItem.id); - - if (newItemIndex === -1) { - return oldItem; - } - - const newItem = items[newItemIndex]; - items = items.filter((_, index) => index !== newItemIndex); - - return newItem; - }); - - list = list.concat(items); - - state.items = list.toSorted((a, b) => { - if (a.last_status_created_at === null || b.last_status_created_at === null) { - return -1; - } - - return compareDate(a.last_status_created_at, b.last_status_created_at); - }); - } - - state.hasMore = !next; - state.next = next; - state.isLoading = false; -}; - -const conversations = (state = initialState, action: ConversationsAction): State => { - switch (action.type) { - case CONVERSATIONS_FETCH_REQUEST: - return create(state, (draft) => { - draft.isLoading = true; - }); - case CONVERSATIONS_FETCH_FAIL: - return create(state, (draft) => { - draft.isLoading = false; - }); - case CONVERSATIONS_FETCH_SUCCESS: - return create(state, (draft) => { - expandNormalizedConversations( - draft, - action.conversations, - action.next, - action.isLoadingRecent, - ); - }); - case CONVERSATIONS_UPDATE: - return create(state, (draft) => { - updateConversation(state, action.conversation); - }); - case CONVERSATIONS_MOUNT: - return create(state, (draft) => { - draft.mounted += 1; - }); - case CONVERSATIONS_UNMOUNT: - return create(state, (draft) => { - draft.mounted -= 1; - }); - case CONVERSATIONS_READ: - return create(state, (draft) => { - state.items = state.items.map((item) => { - if (item.id === action.conversationId) { - return { ...item, unread: false }; - } - - return item; - }); - }); - default: - return state; - } -}; - -export { conversations as default }; diff --git a/packages/pl-fe/src/reducers/index.ts b/packages/pl-fe/src/reducers/index.ts index 789bfdc92..9dd303b14 100644 --- a/packages/pl-fe/src/reducers/index.ts +++ b/packages/pl-fe/src/reducers/index.ts @@ -7,7 +7,6 @@ import entities from '@/entity-store/reducer'; import admin from './admin'; import auth from './auth'; import compose from './compose'; -import conversations from './conversations'; import filters from './filters'; import frontendConfig from './frontend-config'; import instance from './instance'; @@ -22,7 +21,6 @@ const reducers = { admin, auth, compose, - conversations, entities, filters, frontendConfig,