nicolium: migrate conversations

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-02-23 18:06:26 +01:00
parent e9e90875db
commit 1b6c941ca7
10 changed files with 216 additions and 327 deletions

View File

@ -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<ConversationsReadAction>({
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<Account>, 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<PaginatedResponse<Conversation>>) | 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<ConversataionsUpdateAction>({
type: CONVERSATIONS_UPDATE,
conversation,
});
};
type ConversationsAction =
| ReturnType<typeof mountConversations>
| ReturnType<typeof unmountConversations>
| ConversationsReadAction
| ReturnType<typeof expandConversationsRequest>
| ReturnType<typeof expandConversationsSuccess>
| ReturnType<typeof expandConversationsFail>
| 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,
};

View File

@ -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());

View File

@ -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<IConversation> = ({ conversationId, onMoveUp, onMoveDown }) => {
const dispatch = useAppDispatch();
const Conversation: React.FC<IConversation> = ({ 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)

View File

@ -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<VirtuosoHandle>(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 (
<ScrollableList
scrollKey='direct'
ref={ref}
hasMore={hasMore}
hasMore={hasNextPage}
onLoadMore={handleLoadOlder}
id='direct-list'
isLoading={isLoading}
showLoading={isLoading && conversations.length === 0}
isLoading={isFetching}
showLoading={isLoading}
emptyMessageText={
<FormattedMessage
id='empty_column.direct'
@ -63,11 +59,13 @@ const ConversationsList: React.FC = () => {
/>
}
listClassName='⁂-status-list'
placeholderComponent={PlaceholderStatus}
placeholderCount={20}
>
{conversations.map((item: any) => (
{conversations.map((item) => (
<Conversation
key={item.id}
conversationId={item.id}
conversation={item}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>

View File

@ -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 (
<Column label={intl.formatMessage(messages.title)}>
<ConversationsList />

View File

@ -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<MinifiedConversation>;
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<Conversation>,
): 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<InfiniteData<MinifiedConversationPage>>(['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<InfiniteData<MinifiedConversationPage>>([
'conversations',
]);
updatePaginatedResponse<MinifiedConversation>(['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,
};

View File

@ -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<typeof minifyRequestList>;
type MinifiedRequest = MinifiedRequestList['items'][0];
const removeRequest = (statusId: string, accountId: string) =>
queryClient.setQueryData<InfiniteData<MinifiedRequestList>>(
updatePaginatedResponse<MinifiedRequest>(
['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(

View File

@ -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 = <T>(
queryKey: QueryKey,
updater: (items: PaginatedResponse<T>['items']) => PaginatedResponse<T>['items'],
) =>
queryClient.setQueryData<InfiniteData<PaginatedResponse<T>>>(queryKey, (data) => {
if (!data) return undefined;
return {
...data,
pages: data.pages.map((page) => ({
...page,
items: updater(page.items),
})),
};
});
export { updatePaginatedResponse };

View File

@ -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<MinifiedConversation>;
isLoading: boolean;
hasMore: boolean;
next: (() => Promise<PaginatedResponse<Conversation>>) | 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<typeof minifyConversation>;
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<PaginatedResponse<Conversation>>) | 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 };

View File

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