pleroma/app/soapbox/features/chats/components/chat-message-list.tsx

282 lines
8.6 KiB
TypeScript
Raw Normal View History

2022-10-04 07:48:37 -07:00
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl';
2022-11-09 10:17:10 -08:00
import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Avatar, Button, Divider, Spinner, Stack, Text } from 'soapbox/components/ui';
2022-08-18 09:52:04 -07:00
import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message';
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { IChat, useChatActions, useChatMessages } from 'soapbox/queries/chats';
import ChatMessage from './chat-message';
2022-08-17 12:48:04 -07:00
import ChatMessageListIntro from './chat-message-list-intro';
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
const messages = defineMessages({
today: { id: 'chats.dividers.today', defaultMessage: 'Today' },
more: { id: 'chats.actions.more', defaultMessage: 'More' },
2022-09-22 08:51:12 -07:00
delete: { id: 'chats.actions.delete', defaultMessage: 'Delete for both' },
2022-08-30 07:42:55 -07:00
copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' },
report: { id: 'chats.actions.report', defaultMessage: 'Report' },
deleteForMe: { id: 'chats.actions.deleteForMe', defaultMessage: 'Delete for me' },
2022-10-25 10:07:25 -07:00
blockedBy: { id: 'chat_message_list.blockedBy', defaultMessage: 'You are blocked by' },
2022-09-14 07:35:32 -07:00
networkFailureTitle: { id: 'chat_message_list.network_failure.title', defaultMessage: 'Whoops!' },
networkFailureSubtitle: { id: 'chat_message_list.network_failure.subtitle', defaultMessage: 'We encountered a network failure.' },
networkFailureAction: { id: 'chat_message_list.network_failure.action', defaultMessage: 'Try again' },
});
type TimeFormat = 'today' | 'date';
const timeChange = (prev: ChatMessageEntity, curr: ChatMessageEntity): TimeFormat | null => {
const prevDate = new Date(prev.created_at).getDate();
const currDate = new Date(curr.created_at).getDate();
2022-08-10 05:38:49 -07:00
const nowDate = new Date().getDate();
if (prevDate !== currDate) {
return currDate === nowDate ? 'today' : 'date';
}
return null;
};
2022-11-09 10:17:10 -08:00
const START_INDEX = 10000;
const List: Components['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
return <div ref={ref} {...rest} className='mb-2' />;
});
const Scroller: Components['Scroller'] = React.forwardRef((props, ref) => {
const { style, context, ...rest } = props;
return (
<div
{...rest}
ref={ref}
style={{
...style,
scrollbarGutter: 'stable',
}}
/>
);
});
interface IChatMessageList {
2022-06-17 15:37:09 -07:00
/** Chat the messages are being rendered from. */
chat: IChat
}
2022-06-17 15:37:09 -07:00
/** Scrollable list of chat messages. */
2022-11-03 10:55:33 -07:00
const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
const intl = useIntl();
2023-06-25 10:35:09 -07:00
const { account } = useOwnAccount();
2022-08-10 05:38:49 -07:00
const myLastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === account?.id)?.date;
const myLastReadMessageTimestamp = myLastReadMessageDateString ? new Date(myLastReadMessageDateString) : null;
2022-08-10 05:38:49 -07:00
2022-10-04 07:48:37 -07:00
const node = useRef<VirtuosoHandle>(null);
const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
2022-08-10 05:38:49 -07:00
const { markChatAsRead } = useChatActions(chat.id);
2022-09-08 09:47:19 -07:00
const {
data: chatMessages,
fetchNextPage,
2022-10-04 07:48:37 -07:00
hasNextPage,
2022-09-08 09:47:19 -07:00
isError,
isFetching,
isFetchingNextPage,
isLoading,
refetch,
2022-10-25 10:10:53 -07:00
} = useChatMessages(chat);
2022-10-04 07:48:37 -07:00
2022-08-10 05:38:49 -07:00
const formattedChatMessages = chatMessages || [];
2022-10-25 10:07:25 -07:00
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by']));
2022-10-04 07:48:37 -07:00
const lastChatMessage = chatMessages ? chatMessages[chatMessages.length - 1] : null;
useEffect(() => {
2022-10-04 07:48:37 -07:00
if (!chatMessages) {
return;
2022-10-04 07:48:37 -07:00
}
const nextFirstItemIndex = START_INDEX - chatMessages.length;
setFirstItemIndex(nextFirstItemIndex);
}, [lastChatMessage]);
const buildCachedMessages = () => {
if (!chatMessages) {
return [];
}
const currentYear = new Date().getFullYear();
2022-10-04 07:48:37 -07:00
return chatMessages.reduce((acc: any, curr: any, idx: number) => {
const lastMessage = formattedChatMessages[idx - 1];
const messageDate = new Date(curr.created_at);
2022-10-04 07:48:37 -07:00
if (lastMessage) {
switch (timeChange(lastMessage, curr)) {
case 'today':
acc.push({
type: 'divider',
text: intl.formatMessage(messages.today),
});
break;
case 'date':
acc.push({
type: 'divider',
text: intl.formatDate(messageDate, {
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
month: 'short',
day: 'numeric',
year: messageDate.getFullYear() !== currentYear ? '2-digit' : undefined,
}),
2022-10-04 07:48:37 -07:00
});
break;
}
}
acc.push(curr);
return acc;
}, []);
};
const cachedChatMessages = buildCachedMessages();
const initialScrollPositionProps = useMemo(() => {
if (process.env.NODE_ENV === 'test') {
return {};
}
return {
initialTopMostItemIndex: cachedChatMessages.length - 1,
firstItemIndex: Math.max(0, firstItemIndex),
};
}, [cachedChatMessages.length, firstItemIndex]);
2022-10-04 07:48:37 -07:00
const handleStartReached = useCallback(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
2022-08-10 05:38:49 -07:00
}
2022-10-04 07:48:37 -07:00
return false;
}, [firstItemIndex, hasNextPage, isFetching]);
2022-12-13 08:05:11 -08:00
const renderDivider = (key: React.Key, text: string) => <Divider key={key} text={text} textSize='xs' />;
2022-08-26 09:41:25 -07:00
useEffect(() => {
const lastMessage = formattedChatMessages[formattedChatMessages.length - 1];
if (!lastMessage) {
return;
}
const lastMessageId = lastMessage.id;
const isMessagePending = lastMessage.pending;
const isAlreadyRead = myLastReadMessageTimestamp ? myLastReadMessageTimestamp >= new Date(lastMessage.created_at) : false;
2022-08-30 07:10:31 -07:00
/**
* Only "mark the message as read" if..
* 1) it is not pending and
* 2) it has not already been read
*/
if (!isMessagePending && !isAlreadyRead) {
2022-08-30 07:10:31 -07:00
markChatAsRead(lastMessageId);
}
2022-08-26 09:41:25 -07:00
}, [formattedChatMessages.length]);
2022-10-25 10:07:25 -07:00
if (isBlocked) {
return (
2023-02-01 14:13:42 -08:00
<Stack alignItems='center' justifyContent='center' className='h-full grow'>
2022-10-25 10:07:25 -07:00
<Stack alignItems='center' space={2}>
<Avatar src={chat.account.avatar} size={75} />
<Text align='center'>
<>
<Text tag='span'>{intl.formatMessage(messages.blockedBy)}</Text>
{' '}
<Text tag='span' theme='primary'>@{chat.account.acct}</Text>
</>
</Text>
</Stack>
</Stack>
);
}
2022-09-08 09:47:19 -07:00
if (isError) {
return (
2023-02-01 14:13:42 -08:00
<Stack alignItems='center' justifyContent='center' className='h-full grow'>
2022-09-08 09:47:19 -07:00
<Stack space={4}>
<Stack space={1}>
2022-09-14 07:35:32 -07:00
<Text size='lg' weight='bold' align='center'>
{intl.formatMessage(messages.networkFailureTitle)}
</Text>
2022-09-08 09:47:19 -07:00
<Text theme='muted' align='center'>
2022-09-14 07:35:32 -07:00
{intl.formatMessage(messages.networkFailureSubtitle)}
2022-09-08 09:47:19 -07:00
</Text>
</Stack>
<div className='mx-auto'>
<Button theme='primary' onClick={() => refetch()}>
2022-09-14 07:35:32 -07:00
{intl.formatMessage(messages.networkFailureAction)}
2022-09-08 09:47:19 -07:00
</Button>
</div>
</Stack>
</Stack>
);
}
2022-10-05 13:13:29 -07:00
if (isLoading) {
return (
2023-02-01 14:13:42 -08:00
<div className='flex grow flex-col justify-end pb-4'>
2022-10-05 13:13:29 -07:00
<div className='px-4'>
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage />
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage />
</div>
</div>
);
}
return (
2023-02-01 14:13:42 -08:00
<div className='flex h-full grow flex-col space-y-6'>
<div className='flex grow flex-col justify-end'>
2022-10-04 07:48:37 -07:00
<Virtuoso
ref={node}
2022-10-05 12:25:56 -07:00
alignToBottom
{...initialScrollPositionProps}
2022-10-04 07:48:37 -07:00
data={cachedChatMessages}
startReached={handleStartReached}
2022-10-17 09:08:46 -07:00
followOutput='auto'
2022-11-09 06:16:22 -08:00
itemContent={(index, chatMessage) => {
2022-10-04 07:48:37 -07:00
if (chatMessage.type === 'divider') {
2022-11-09 06:16:22 -08:00
return renderDivider(index, chatMessage.text);
2022-10-04 07:48:37 -07:00
} else {
return <ChatMessage chat={chat} chatMessage={chatMessage} />;
2022-08-16 05:39:58 -07:00
}
2022-10-04 07:48:37 -07:00
}}
components={{
2022-11-09 10:17:10 -08:00
List,
Scroller,
2022-10-04 07:48:37 -07:00
Header: () => {
if (hasNextPage || isFetchingNextPage) {
return <Spinner withText={false} />;
2022-10-04 07:48:37 -07:00
}
2022-08-16 05:39:58 -07:00
2022-10-04 07:48:37 -07:00
if (!hasNextPage && !isLoading) {
2022-10-05 12:25:56 -07:00
return <ChatMessageListIntro />;
2022-10-04 07:48:37 -07:00
}
return null;
},
2022-10-04 07:48:37 -07:00
}}
/>
</div>
</div>
);
};
export default ChatMessageList;