Add infinite scroll to ChatList

alex-chats
Justin 2022-08-26 12:41:25 -04:00
rodzic e384d1f40d
commit 01167af69e
8 zmienionych plików z 114 dodań i 78 usunięć

Wyświetl plik

@ -1,6 +1,6 @@
import { useMutation } from '@tanstack/react-query';
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import React, { MutableRefObject, useRef, useState } from 'react';
import React, { MutableRefObject, useEffect, useRef, useState } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import {
@ -56,7 +56,6 @@ const ChatBox: React.FC<IChatBox> = ({ chat, onSetInputRef, autosize, inputRef }
const isSubmitDisabled = content.length === 0 && !attachment;
// TODO: needs last_read_id param
const markAsRead = useMutation(() => markChatAsRead(), {
onSuccess: () => {
queryClient.invalidateQueries(['chats']);
@ -216,12 +215,10 @@ const ChatBox: React.FC<IChatBox> = ({ chat, onSetInputRef, autosize, inputRef }
// );
};
if (!chatMessageIds) return null;
return (
<Stack className='overflow-hidden flex flex-grow' onMouseOver={handleMouseOver}>
<div className='flex-grow h-full overflow-hidden flex justify-center'>
<ChatMessageList chatMessageIds={chatMessageIds} chat={chat} autosize />
<ChatMessageList chat={chat} autosize />
</div >
<div className='mt-auto p-4 shadow-3xl'>

Wyświetl plik

@ -19,18 +19,15 @@ interface IChatList {
const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false }) => {
const dispatch = useDispatch();
const { chatsQuery: { data: chats, isFetching } } = useChats();
const { chatsQuery: { data: chats, isFetching, hasNextPage, fetchNextPage } } = useChats();
const isEmpty = chats?.length === 0;
// const handleLoadMore = useCallback(() => {
// if (hasMore && !isLoading) {
// dispatch(expandChats());
// }
// }, [dispatch, hasMore, isLoading]);
const handleLoadMore = () => console.log('load more');
const handleLoadMore = () => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
};
const handleRefresh = () => {
return dispatch(fetchChats()) as any;
@ -62,7 +59,7 @@ const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false })
)}
components={{
ScrollSeekPlaceholder: () => <PlaceholderChat />,
// Footer: () => hasMore ? <PlaceholderChat /> : null,
// Footer: () => hasNextPage ? <Spinner withText={false} /> : null,
EmptyPlaceholder: renderEmpty,
}}
/>

Wyświetl plik

@ -1,27 +1,20 @@
import { useMutation } from '@tanstack/react-query';
import classNames from 'clsx';
import {
Map as ImmutableMap,
List as ImmutableList,
OrderedSet as ImmutableOrderedSet,
} from 'immutable';
import { List as ImmutableList } from 'immutable';
import escape from 'lodash/escape';
import throttle from 'lodash/throttle';
import React, { useState, useEffect, useRef, useLayoutEffect, useMemo } from 'react';
import React, { useState, useEffect, useRef, useLayoutEffect } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { createSelector } from 'reselect';
import { fetchChatMessages, deleteChatMessage } from 'soapbox/actions/chats';
import { openModal } from 'soapbox/actions/modals';
import { initReport, initReportById } from 'soapbox/actions/reports';
import { Avatar, Button, Divider, HStack, IconButton, Spinner, Stack, Text } from 'soapbox/components/ui';
import { initReportById } from 'soapbox/actions/reports';
import { Avatar, Divider, HStack, Spinner, Stack, Text } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
import { useChatContext } from 'soapbox/contexts/chat-context';
import emojify from 'soapbox/features/emoji/emoji';
// import emojify from 'soapbox/features/emoji/emoji';
import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message';
import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useAppDispatch, useRefEventHandler, useOwnAccount } from 'soapbox/hooks';
import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { IChat, IChatMessage, useChat, useChatMessages } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import { onlyEmoji } from 'soapbox/utils/rich_content';
@ -58,27 +51,15 @@ const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | null =
// return map.set(`:${emoji.get('shortcode')}:`, emoji);
// }, ImmutableMap());
const getChatMessages = createSelector(
[(chatMessages: ImmutableMap<string, ChatMessageEntity>, chatMessageIds: ImmutableOrderedSet<string>) => (
chatMessageIds.reduce((acc, curr) => {
const chatMessage = chatMessages.get(curr);
return chatMessage ? acc.push(chatMessage) : acc;
}, ImmutableList<ChatMessageEntity>())
)],
chatMessages => chatMessages,
);
interface IChatMessageList {
/** Chat the messages are being rendered from. */
chat: IChat,
/** Message IDs to render. */
chatMessageIds: ImmutableOrderedSet<string>,
/** Whether to make the chatbox fill the height of the screen. */
autosize?: boolean,
}
/** Scrollable list of chat messages. */
const ChatMessageList: React.FC<IChatMessageList> = ({ chat, chatMessageIds, autosize }) => {
const ChatMessageList: React.FC<IChatMessageList> = ({ chat, autosize }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useOwnAccount();
@ -86,9 +67,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat, chatMessageIds, aut
const [initialLoad, setInitialLoad] = useState(true);
const [scrollPosition, setScrollPosition] = useState(0);
const { needsAcceptance } = useChatContext();
const { deleteChatMessage, acceptChat, deleteChat } = useChat(chat.id);
const { deleteChatMessage, markChatAsRead } = useChat(chat.id);
const { data: chatMessages, isLoading, isFetching, isFetched, fetchNextPage, isFetchingNextPage, isPlaceholderData } = useChatMessages(chat.id);
const formattedChatMessages = chatMessages || [];
@ -99,9 +78,6 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat, chatMessageIds, aut
const lastComputedScroll = useRef<number | undefined>(undefined);
const scrollBottom = useRef<number | undefined>(undefined);
const initialCount = useMemo(() => formattedChatMessages.length, []);
const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), {
onSettled: () => {
queryClient.invalidateQueries(['chats', 'messages', chat.id]);
@ -152,8 +128,6 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat, chatMessageIds, aut
const restoreScrollPosition = () => {
if (node.current && scrollBottom.current) {
console.log('bottom', scrollBottom.current);
lastComputedScroll.current = node.current.scrollHeight - scrollBottom.current;
node.current.scrollTop = lastComputedScroll.current;
}
@ -227,7 +201,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat, chatMessageIds, aut
// return emojify(formatted, emojiMap.toJS());
};
const renderDivider = (key: React.Key, text: string) => <Divider text={text} textSize='sm' />;
const renderDivider = (key: React.Key, text: string) => <Divider key={key} text={text} textSize='sm' />;
const handleReportUser = (userId: string) => {
return () => {
@ -370,14 +344,14 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat, chatMessageIds, aut
// }
}, [formattedChatMessages.length]);
useEffect(() => {
markChatAsRead();
}, [formattedChatMessages.length]);
// useEffect(() => {
// scrollToBottom();
// }, [messagesEnd.current]);
// History added.
const lastChatId = Number(chatMessages && chatMessages[0]?.id);
useEffect(() => {
// Restore scroll bar position when loading old messages.
if (!initialLoad) {
@ -400,6 +374,12 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat, chatMessageIds, aut
<ChatMessageListIntro />
) : null}
{isFetchingNextPage ? (
<div className='flex items-center justify-center'>
<Spinner size={30} withText={false} />
</div>
) : null}
<div className='flex-grow flex flex-col justify-end space-y-4'>
{isLoading ? (
<>

Wyświetl plik

@ -9,7 +9,8 @@ import { Avatar, HStack, Icon, Input, Stack, Text } from 'soapbox/components/ui'
import VerificationBadge from 'soapbox/components/verification_badge';
import { useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useDebounce } from 'soapbox/hooks';
import { useChats } from 'soapbox/queries/chats';
import { IChat, useChats } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import useAccountSearch from 'soapbox/queries/search';
import ChatList from '../chat-list';
@ -48,9 +49,11 @@ const ChatPane = () => {
},
onSuccess: (response) => {
setChat(response.data);
queryClient.invalidateQueries(['chats']);
},
});
const handleClickChat = (chat: IChat) => setChat(chat);
const clearValue = () => {
if (hasSearchValue) {
@ -66,7 +69,7 @@ const ChatPane = () => {
<button
key={account.id}
type='button'
className='px-4 py-2 w-full flex flex-col hover:bg-gray-100'
className='px-4 py-2 w-full flex flex-col hover:bg-gray-100 dark:hover:bg-gray-800'
onClick={() => {
handleClickOnSearchResult.mutate(account.id);
clearValue();
@ -88,7 +91,7 @@ const ChatPane = () => {
</Stack>
);
} else {
return <ChatList onClickChat={(chat) => setChat(chat)} useWindowScroll={false} />;
return <ChatList onClickChat={handleClickChat} useWindowScroll={false} />;
}
};

Wyświetl plik

@ -1,5 +1,6 @@
import React from 'react';
import RelativeTimestamp from 'soapbox/components/relative_timestamp';
import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification_badge';
@ -11,6 +12,11 @@ interface IChatInterface {
}
const Chat: React.FC<IChatInterface> = ({ chat, onClick }) => {
// Temporary: remove once bad Staging data is removed.
if (!chat.account) {
return null;
}
return (
<button
key={chat.id}
@ -18,16 +24,33 @@ const Chat: React.FC<IChatInterface> = ({ chat, onClick }) => {
onClick={() => onClick(chat)}
className='px-4 py-2 w-full flex flex-col hover:bg-gray-100 dark:hover:bg-gray-800'
>
<HStack alignItems='center' space={2}>
<Avatar src={chat.account?.avatar} size={40} />
<HStack alignItems='center' justifyContent='between' space={2} className='w-full'>
<HStack alignItems='center' space={2}>
<Avatar src={chat.account?.avatar} size={40} />
<Stack alignItems='start'>
<div className='flex items-center space-x-1 flex-grow'>
<Text weight='bold' size='sm' truncate>{chat.account?.display_name}</Text>
{chat.account?.verified && <VerificationBadge />}
</div>
<Text size='sm' weight='medium' theme='muted' truncate>@{chat.account?.acct}</Text>
</Stack>
<Stack alignItems='start'>
<div className='flex items-center space-x-1 flex-grow'>
<Text weight='bold' size='sm' truncate>{chat.account?.display_name || `@${chat.account.username}`}</Text>
{chat.account?.verified && <VerificationBadge />}
</div>
{chat.last_message?.content && (
<Text size='sm' weight='medium' theme='muted' truncate className='max-w-[200px]'>
{chat.last_message?.content}
</Text>
)}
</Stack>
</HStack>
{chat.last_message && (
<HStack alignItems='center' space={2}>
{chat.last_message.unread && (
<div className='w-2 h-2 rounded-full bg-secondary-500' />
)}
<RelativeTimestamp timestamp={chat.last_message.created_at} size='sm' />
</HStack>
)}
</HStack>
</button>
);

Wyświetl plik

@ -8,7 +8,7 @@ import { randomIntFromInterval } from '../utils';
import PlaceholderAvatar from './placeholder_avatar';
/** Fake chat to display while data is loading. */
const PlaceholderChat = ({ isMyMessage = false }: { isMyMessage?: boolean }) => {
const PlaceholderChatMessage = ({ isMyMessage = false }: { isMyMessage?: boolean }) => {
const messageLength = randomIntFromInterval(160, 220);
return (
@ -55,7 +55,7 @@ const PlaceholderChat = ({ isMyMessage = false }: { isMyMessage?: boolean }) =>
'order-2': !isMyMessage,
})}
>
<div style={{ width: 50, height: 12 }} className='rounded-full bg-primary-50 dark:bg-primary-800' />
<span style={{ width: 50, height: 12 }} className='rounded-full bg-primary-50 dark:bg-primary-800 block' />
</Text>
<div className={classNames({ 'order-1': !isMyMessage })}>
@ -66,4 +66,4 @@ const PlaceholderChat = ({ isMyMessage = false }: { isMyMessage?: boolean }) =>
);
};
export default PlaceholderChat;
export default PlaceholderChatMessage;

Wyświetl plik

@ -1,4 +1,4 @@
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import snackbar from 'soapbox/actions/snackbar';
@ -11,7 +11,15 @@ export interface IChat {
id: string
unread: number
created_by_account: string
last_message: null | string
last_message: null | {
account_id: string
chat_id: string
content: string
created_at: string
discarded_at: string | null
id: string
unread: boolean
}
created_at: Date
updated_at: Date
accepted: boolean
@ -76,18 +84,48 @@ const useChatMessages = (chatId: string) => {
data,
};
};
const useChats = () => {
const api = useApi();
const getChats = async() => {
const { data } = await api.get('/api/v1/pleroma/chats');
return data;
const getChats = async(pageParam?: any): Promise<{ result: IChat[], maxId: string, hasMore: boolean }> => {
const { data, headers } = await api.get('/api/v1/pleroma/chats', {
params: {
max_id: pageParam?.maxId,
},
});
const hasMore = !!headers.link;
const nextMaxId = data[data.length - 1]?.id;
return {
result: data,
maxId: nextMaxId,
hasMore,
};
};
const chatsQuery = useQuery<IChat[]>(['chats'], getChats, {
placeholderData: [],
const queryInfo = useInfiniteQuery(['chats'], ({ pageParam }) => getChats(pageParam), {
keepPreviousData: true,
getNextPageParam: (config) => {
if (config.hasMore) {
return { maxId: config.maxId };
}
return undefined;
},
});
const data = queryInfo.data?.pages.reduce<IChat[]>(
(prev: IChat[], curr) => [...prev, ...curr.result],
[],
);
const chatsQuery = {
...queryInfo,
data,
};
const getOrCreateChatByAccountId = (accountId: string) => api.post<IChat>(`/api/v1/pleroma/chats/by-account-id/${accountId}`);
return { chatsQuery, getOrCreateChatByAccountId };

Wyświetl plik

@ -29,8 +29,6 @@ const play = (audio: HTMLAudioElement): void => {
}
}
console.log('playing');
audio.play();
};