2022-08-31 02:35:06 -07:00
|
|
|
import classNames from 'clsx';
|
2022-06-17 13:37:54 -07:00
|
|
|
import {
|
|
|
|
Map as ImmutableMap,
|
|
|
|
List as ImmutableList,
|
|
|
|
OrderedSet as ImmutableOrderedSet,
|
|
|
|
} from 'immutable';
|
|
|
|
import escape from 'lodash/escape';
|
|
|
|
import throttle from 'lodash/throttle';
|
2022-06-17 14:50:18 -07:00
|
|
|
import React, { useState, useEffect, useRef, useLayoutEffect, useMemo } from 'react';
|
2022-06-17 13:37:54 -07:00
|
|
|
import { useIntl, defineMessages } from 'react-intl';
|
|
|
|
import { createSelector } from 'reselect';
|
|
|
|
|
|
|
|
import { fetchChatMessages, deleteChatMessage } from 'soapbox/actions/chats';
|
|
|
|
import { openModal } from 'soapbox/actions/modals';
|
|
|
|
import { initReportById } from 'soapbox/actions/reports';
|
2022-08-10 05:38:49 -07:00
|
|
|
import { Avatar, HStack, IconButton, Spinner, Stack, Text } from 'soapbox/components/ui';
|
2022-06-17 13:37:54 -07:00
|
|
|
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
|
|
|
|
import emojify from 'soapbox/features/emoji/emoji';
|
|
|
|
import Bundle from 'soapbox/features/ui/components/bundle';
|
|
|
|
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
2022-08-10 05:38:49 -07:00
|
|
|
import { useAppSelector, useAppDispatch, useRefEventHandler, useOwnAccount } from 'soapbox/hooks';
|
|
|
|
import { IChat, IChatMessage, useChatMessages } from 'soapbox/queries/chats';
|
2022-06-17 13:37:54 -07:00
|
|
|
import { onlyEmoji } from 'soapbox/utils/rich_content';
|
|
|
|
|
|
|
|
import type { Menu } from 'soapbox/components/dropdown_menu';
|
|
|
|
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
|
|
|
|
|
|
|
|
const BIG_EMOJI_LIMIT = 1;
|
|
|
|
|
|
|
|
const messages = defineMessages({
|
|
|
|
today: { id: 'chats.dividers.today', defaultMessage: 'Today' },
|
|
|
|
more: { id: 'chats.actions.more', defaultMessage: 'More' },
|
|
|
|
delete: { id: 'chats.actions.delete', defaultMessage: 'Delete message' },
|
|
|
|
report: { id: 'chats.actions.report', defaultMessage: 'Report user' },
|
|
|
|
});
|
|
|
|
|
|
|
|
type TimeFormat = 'today' | 'date';
|
|
|
|
|
2022-08-10 05:38:49 -07:00
|
|
|
const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | null => {
|
2022-06-17 13:37:54 -07:00
|
|
|
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();
|
2022-06-17 13:37:54 -07:00
|
|
|
|
|
|
|
if (prevDate !== currDate) {
|
|
|
|
return currDate === nowDate ? 'today' : 'date';
|
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
};
|
|
|
|
|
2022-08-10 05:38:49 -07:00
|
|
|
// const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap<string, any>, emoji: ImmutableMap<string, any>) => {
|
|
|
|
// return map.set(`:${emoji.get('shortcode')}:`, emoji);
|
|
|
|
// }, ImmutableMap());
|
2022-06-17 13:37:54 -07:00
|
|
|
|
|
|
|
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 {
|
2022-06-17 15:37:09 -07:00
|
|
|
/** Chat the messages are being rendered from. */
|
2022-08-10 05:38:49 -07:00
|
|
|
chat: IChat,
|
2022-06-17 15:37:09 -07:00
|
|
|
/** Message IDs to render. */
|
2022-06-17 13:37:54 -07:00
|
|
|
chatMessageIds: ImmutableOrderedSet<string>,
|
2022-06-21 13:58:03 -07:00
|
|
|
/** Whether to make the chatbox fill the height of the screen. */
|
|
|
|
autosize?: boolean,
|
2022-06-17 13:37:54 -07:00
|
|
|
}
|
|
|
|
|
2022-06-17 15:37:09 -07:00
|
|
|
/** Scrollable list of chat messages. */
|
2022-08-10 05:38:49 -07:00
|
|
|
const ChatMessageList: React.FC<IChatMessageList> = ({ chat, chatMessageIds, autosize }) => {
|
2022-06-17 13:37:54 -07:00
|
|
|
const intl = useIntl();
|
|
|
|
const dispatch = useAppDispatch();
|
2022-08-10 05:38:49 -07:00
|
|
|
const account = useOwnAccount();
|
|
|
|
|
|
|
|
const [initialLoad, setInitialLoad] = useState(true);
|
|
|
|
const [scrollPosition, setScrollPosition] = useState(0);
|
|
|
|
|
|
|
|
const { data: chatMessages, isFetching, isFetched, fetchNextPage, isFetchingNextPage, isPlaceholderData } = useChatMessages(chat.id);
|
|
|
|
const formattedChatMessages = chatMessages || [];
|
|
|
|
|
2022-06-17 13:37:54 -07:00
|
|
|
|
|
|
|
const me = useAppSelector(state => state.me);
|
|
|
|
|
|
|
|
|
|
|
|
const node = useRef<HTMLDivElement>(null);
|
|
|
|
const messagesEnd = useRef<HTMLDivElement>(null);
|
2022-06-17 14:50:18 -07:00
|
|
|
const lastComputedScroll = useRef<number | undefined>(undefined);
|
|
|
|
const scrollBottom = useRef<number | undefined>(undefined);
|
|
|
|
|
2022-08-10 05:38:49 -07:00
|
|
|
const initialCount = useMemo(() => formattedChatMessages.length, []);
|
2022-06-17 13:37:54 -07:00
|
|
|
|
|
|
|
const scrollToBottom = () => {
|
|
|
|
messagesEnd.current?.scrollIntoView(false);
|
|
|
|
};
|
|
|
|
|
|
|
|
const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => {
|
|
|
|
return intl.formatDate(
|
|
|
|
new Date(chatMessage.created_at), {
|
2022-08-10 05:38:49 -07:00
|
|
|
hour12: false,
|
|
|
|
year: 'numeric',
|
|
|
|
month: 'short',
|
|
|
|
day: '2-digit',
|
|
|
|
hour: '2-digit',
|
|
|
|
minute: '2-digit',
|
|
|
|
},
|
2022-06-17 13:37:54 -07:00
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
const setBubbleRef = (c: HTMLDivElement) => {
|
|
|
|
if (!c) return;
|
|
|
|
const links = c.querySelectorAll('a[rel="ugc"]');
|
|
|
|
|
|
|
|
links.forEach(link => {
|
|
|
|
link.classList.add('chat-link');
|
|
|
|
link.setAttribute('rel', 'ugc nofollow noopener');
|
|
|
|
link.setAttribute('target', '_blank');
|
|
|
|
});
|
|
|
|
|
|
|
|
if (onlyEmoji(c, BIG_EMOJI_LIMIT, false)) {
|
|
|
|
c.classList.add('chat-message__bubble--onlyEmoji');
|
|
|
|
} else {
|
|
|
|
c.classList.remove('chat-message__bubble--onlyEmoji');
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const isNearBottom = (): boolean => {
|
|
|
|
const elem = node.current;
|
|
|
|
if (!elem) return false;
|
|
|
|
|
|
|
|
const scrollBottom = elem.scrollHeight - elem.offsetHeight - elem.scrollTop;
|
|
|
|
return scrollBottom < elem.offsetHeight * 1.5;
|
|
|
|
};
|
|
|
|
|
2022-06-17 14:50:18 -07:00
|
|
|
const restoreScrollPosition = () => {
|
|
|
|
if (node.current && scrollBottom.current) {
|
2022-08-10 05:38:49 -07:00
|
|
|
console.log('bottom', scrollBottom.current);
|
|
|
|
|
2022-06-17 14:50:18 -07:00
|
|
|
lastComputedScroll.current = node.current.scrollHeight - scrollBottom.current;
|
|
|
|
node.current.scrollTop = lastComputedScroll.current;
|
|
|
|
}
|
|
|
|
};
|
2022-06-17 13:37:54 -07:00
|
|
|
|
|
|
|
const handleLoadMore = () => {
|
2022-08-10 05:38:49 -07:00
|
|
|
// const maxId = chatMessages.getIn([0, 'id']) as string;
|
|
|
|
// dispatch(fetchChatMessages(chat.id, maxId as any));
|
|
|
|
// setIsLoading(true);
|
|
|
|
if (!isFetching) {
|
|
|
|
// setMaxId(formattedChatMessages[0].id);
|
|
|
|
fetchNextPage()
|
|
|
|
.then(() => {
|
|
|
|
if (node.current) {
|
|
|
|
setScrollPosition(node.current.scrollHeight - node.current.scrollTop);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
.catch(() => null);
|
|
|
|
}
|
2022-06-17 13:37:54 -07:00
|
|
|
};
|
|
|
|
|
2022-08-10 05:38:49 -07:00
|
|
|
const handleScroll = throttle(() => {
|
2022-06-17 13:37:54 -07:00
|
|
|
if (node.current) {
|
|
|
|
const { scrollTop, offsetHeight } = node.current;
|
|
|
|
const computedScroll = lastComputedScroll.current === scrollTop;
|
2022-08-10 05:38:49 -07:00
|
|
|
const nearTop = scrollTop < offsetHeight;
|
2022-06-17 13:37:54 -07:00
|
|
|
|
2022-08-10 05:38:49 -07:00
|
|
|
setScrollPosition(node.current.scrollHeight - node.current.scrollTop);
|
|
|
|
|
|
|
|
if (nearTop && !isFetching && !initialLoad && !computedScroll) {
|
2022-06-17 13:37:54 -07:00
|
|
|
handleLoadMore();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, 150, {
|
|
|
|
trailing: true,
|
2022-08-10 05:38:49 -07:00
|
|
|
});
|
2022-06-17 13:37:54 -07:00
|
|
|
|
|
|
|
const onOpenMedia = (media: any, index: number) => {
|
|
|
|
dispatch(openModal('MEDIA', { media, index }));
|
|
|
|
};
|
|
|
|
|
|
|
|
const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
|
|
|
|
const { attachment } = chatMessage;
|
|
|
|
if (!attachment) return null;
|
|
|
|
return (
|
|
|
|
<div className='chat-message__media'>
|
|
|
|
<Bundle fetchComponent={MediaGallery}>
|
|
|
|
{(Component: any) => (
|
|
|
|
<Component
|
|
|
|
media={ImmutableList([attachment])}
|
|
|
|
height={120}
|
|
|
|
onOpenMedia={onOpenMedia}
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
</Bundle>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
const parsePendingContent = (content: string) => {
|
|
|
|
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
|
|
|
|
};
|
|
|
|
|
|
|
|
const parseContent = (chatMessage: ChatMessageEntity) => {
|
|
|
|
const content = chatMessage.content || '';
|
|
|
|
const pending = chatMessage.pending;
|
|
|
|
const deleting = chatMessage.deleting;
|
|
|
|
const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
|
2022-08-10 05:38:49 -07:00
|
|
|
return formatted;
|
|
|
|
// const emojiMap = makeEmojiMap(chatMessage);
|
|
|
|
// return emojify(formatted, emojiMap.toJS());
|
2022-06-17 13:37:54 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
const renderDivider = (key: React.Key, text: string) => (
|
2022-08-10 05:38:49 -07:00
|
|
|
<div className='relative' key={key}>
|
|
|
|
<div className='absolute inset-0 flex items-center' aria-hidden='true'>
|
|
|
|
<div className='w-full border-solid border-t border-gray-300' />
|
|
|
|
</div>
|
|
|
|
<div className='relative flex justify-center'>
|
|
|
|
<Text theme='muted' size='xs' className='px-2 bg-white' tag='span'>{text}</Text>
|
|
|
|
</div>
|
|
|
|
</div>
|
2022-06-17 13:37:54 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
const handleDeleteMessage = (chatId: string, messageId: string) => {
|
|
|
|
return () => {
|
|
|
|
dispatch(deleteChatMessage(chatId, messageId));
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
const handleReportUser = (userId: string) => {
|
|
|
|
return () => {
|
|
|
|
dispatch(initReportById(userId));
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
2022-08-10 05:38:49 -07:00
|
|
|
const renderMessage = (chatMessage: any) => {
|
|
|
|
const isMyMessage = chatMessage.account_id === me;
|
|
|
|
|
2022-06-17 13:37:54 -07:00
|
|
|
const menu: Menu = [
|
|
|
|
{
|
|
|
|
text: intl.formatMessage(messages.delete),
|
|
|
|
action: handleDeleteMessage(chatMessage.chat_id, chatMessage.id),
|
2022-07-09 09:20:02 -07:00
|
|
|
icon: require('@tabler/icons/trash.svg'),
|
2022-06-17 13:37:54 -07:00
|
|
|
destructive: true,
|
|
|
|
},
|
|
|
|
];
|
|
|
|
|
|
|
|
if (chatMessage.account_id !== me) {
|
|
|
|
menu.push({
|
|
|
|
text: intl.formatMessage(messages.report),
|
|
|
|
action: handleReportUser(chatMessage.account_id),
|
2022-07-09 09:20:02 -07:00
|
|
|
icon: require('@tabler/icons/flag.svg'),
|
2022-06-17 13:37:54 -07:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
2022-08-10 05:38:49 -07:00
|
|
|
<Stack
|
2022-06-17 13:37:54 -07:00
|
|
|
key={chatMessage.id}
|
2022-08-10 05:38:49 -07:00
|
|
|
space={1}
|
|
|
|
className={classNames({
|
|
|
|
'group max-w-[85%]': true,
|
|
|
|
'ml-auto': isMyMessage,
|
|
|
|
})}
|
2022-06-17 13:37:54 -07:00
|
|
|
>
|
2022-08-10 05:38:49 -07:00
|
|
|
<HStack
|
|
|
|
alignItems='center'
|
|
|
|
justifyContent={isMyMessage ? 'end' : 'start'}
|
|
|
|
className={classNames({
|
|
|
|
'opacity-50': chatMessage.pending,
|
|
|
|
})}
|
2022-06-17 13:37:54 -07:00
|
|
|
>
|
2022-08-10 05:38:49 -07:00
|
|
|
{isMyMessage ? (
|
|
|
|
// <IconButton
|
|
|
|
// src={require('@tabler/icons/dots.svg')}
|
|
|
|
// className='hidden group-hover:block text-gray-600 mr-2'
|
|
|
|
// iconClassName='w-5 h-5'
|
|
|
|
// />
|
|
|
|
<div className='hidden group-hover:block mr-2 text-gray-500'>
|
|
|
|
<DropdownMenuContainer
|
|
|
|
items={menu}
|
|
|
|
src={require('@tabler/icons/dots.svg')}
|
|
|
|
title={intl.formatMessage(messages.more)}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
<div
|
|
|
|
title={getFormattedTimestamp(chatMessage)}
|
|
|
|
className={
|
|
|
|
classNames({
|
|
|
|
'text-ellipsis break-words relative rounded-md p-2': true,
|
|
|
|
'bg-primary-500 text-white mr-2': isMyMessage,
|
|
|
|
'bg-gray-200 text-gray-900 order-2 ml-2': !isMyMessage,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
ref={setBubbleRef}
|
|
|
|
tabIndex={0}
|
|
|
|
>
|
|
|
|
{maybeRenderMedia(chatMessage)}
|
|
|
|
<Text size='sm' theme='inherit' dangerouslySetInnerHTML={{ __html: parseContent(chatMessage) }} />
|
|
|
|
<div className='chat-message__menu'>
|
|
|
|
<DropdownMenuContainer
|
|
|
|
items={menu}
|
|
|
|
src={require('@tabler/icons/dots.svg')}
|
|
|
|
title={intl.formatMessage(messages.more)}
|
|
|
|
/>
|
|
|
|
</div>
|
2022-06-17 13:37:54 -07:00
|
|
|
</div>
|
2022-08-10 05:38:49 -07:00
|
|
|
|
|
|
|
<div className={classNames({ 'order-1': !isMyMessage })}>
|
|
|
|
<Avatar src={isMyMessage ? account?.avatar : chat.account.avatar} size={34} />
|
|
|
|
</div>
|
|
|
|
</HStack>
|
|
|
|
|
|
|
|
<HStack
|
|
|
|
alignItems='center'
|
|
|
|
space={2}
|
|
|
|
className={classNames({
|
|
|
|
'ml-auto': isMyMessage,
|
|
|
|
})}
|
|
|
|
>
|
|
|
|
<Text
|
|
|
|
theme='muted'
|
|
|
|
size='xs'
|
|
|
|
className={classNames({
|
|
|
|
'text-right': isMyMessage,
|
|
|
|
'order-2': !isMyMessage,
|
|
|
|
})}
|
|
|
|
>
|
|
|
|
{intl.formatTime(chatMessage.created_at)}
|
|
|
|
</Text>
|
|
|
|
|
|
|
|
<div className={classNames({ 'order-1': !isMyMessage })}>
|
|
|
|
<div className='w-[34px]' />
|
|
|
|
</div>
|
|
|
|
</HStack>
|
|
|
|
</Stack>
|
2022-06-17 13:37:54 -07:00
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2022-06-17 15:37:09 -07:00
|
|
|
useEffect(() => {
|
2022-08-10 05:38:49 -07:00
|
|
|
if (isFetched) {
|
|
|
|
setInitialLoad(false);
|
|
|
|
scrollToBottom();
|
|
|
|
}
|
|
|
|
}, [isFetched]);
|
2022-06-17 15:37:09 -07:00
|
|
|
|
|
|
|
// Store the scroll position.
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
if (node.current) {
|
|
|
|
const { scrollHeight, scrollTop } = node.current;
|
|
|
|
scrollBottom.current = scrollHeight - scrollTop;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Stick scrollbar to bottom.
|
2022-08-10 05:38:49 -07:00
|
|
|
// useEffect(() => {
|
|
|
|
// if (isNearBottom()) {
|
|
|
|
// scrollToBottom();
|
|
|
|
// }
|
|
|
|
|
|
|
|
// // First load.
|
|
|
|
// // if (chatMessages.count() !== initialCount) {
|
|
|
|
// // setInitialLoad(false);
|
|
|
|
// // setIsLoading(false);
|
|
|
|
// // scrollToBottom();
|
|
|
|
// // }
|
|
|
|
// }, [formattedChatMessages.length]);
|
|
|
|
|
|
|
|
// useEffect(() => {
|
|
|
|
// scrollToBottom();
|
|
|
|
// }, [messagesEnd.current]);
|
2022-06-17 15:37:09 -07:00
|
|
|
|
2022-08-10 05:38:49 -07:00
|
|
|
// History added.
|
|
|
|
const lastChatId = Number(chatMessages && chatMessages[0]?.id);
|
2022-06-17 15:37:09 -07:00
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
// Restore scroll bar position when loading old messages.
|
2022-08-10 05:38:49 -07:00
|
|
|
console.log('hii');
|
2022-06-17 15:37:09 -07:00
|
|
|
if (!initialLoad) {
|
2022-08-10 05:38:49 -07:00
|
|
|
|
2022-06-17 15:37:09 -07:00
|
|
|
restoreScrollPosition();
|
|
|
|
}
|
2022-08-10 05:38:49 -07:00
|
|
|
}, [formattedChatMessages.length, initialLoad]);
|
|
|
|
|
|
|
|
|
|
|
|
if (isPlaceholderData) {
|
|
|
|
return (
|
|
|
|
<Stack alignItems='center' justifyContent='center' className='h-full flex-grow'>
|
|
|
|
<Spinner withText={false} />
|
|
|
|
</Stack>
|
|
|
|
);
|
|
|
|
}
|
2022-06-17 15:37:09 -07:00
|
|
|
|
2022-06-17 13:37:54 -07:00
|
|
|
return (
|
2022-08-10 05:38:49 -07:00
|
|
|
<div className='h-full flex flex-col space-y-4 px-4 flex-grow overflow-y-scroll' onScroll={handleScroll} ref={node}> {/* style={{ height: autosize ? 'calc(100vh - 16rem)' : undefined }} */}
|
|
|
|
{formattedChatMessages.reduce((acc: any, curr: any, idx: number) => {
|
|
|
|
const lastMessage = formattedChatMessages[idx - 1];
|
2022-06-17 13:37:54 -07:00
|
|
|
|
|
|
|
if (lastMessage) {
|
|
|
|
const key = `${curr.id}_divider`;
|
|
|
|
switch (timeChange(lastMessage, curr)) {
|
|
|
|
case 'today':
|
|
|
|
acc.push(renderDivider(key, intl.formatMessage(messages.today)));
|
|
|
|
break;
|
|
|
|
case 'date':
|
2022-08-10 05:38:49 -07:00
|
|
|
acc.push(renderDivider(key, intl.formatDate(new Date(curr.created_at), { weekday: 'short', hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' })));
|
2022-06-17 13:37:54 -07:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
acc.push(renderMessage(curr));
|
|
|
|
return acc;
|
|
|
|
}, [] as React.ReactNode[])}
|
|
|
|
<div style={{ float: 'left', clear: 'both' }} ref={messagesEnd} />
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
export default ChatMessageList;
|