diff --git a/app/soapbox/features/chats/components/chat_message_list.js b/app/soapbox/features/chats/components/chat_message_list.js
deleted file mode 100644
index 7f02219b4..000000000
--- a/app/soapbox/features/chats/components/chat_message_list.js
+++ /dev/null
@@ -1,340 +0,0 @@
-import classNames from 'classnames';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
-import escape from 'lodash/escape';
-import throttle from 'lodash/throttle';
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { injectIntl, defineMessages } from 'react-intl';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-
-import { fetchChatMessages, deleteChatMessage } from 'soapbox/actions/chats';
-import { openModal } from 'soapbox/actions/modals';
-import { initReportById } from 'soapbox/actions/reports';
-import { Text } from 'soapbox/components/ui';
-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';
-import { onlyEmoji } from 'soapbox/utils/rich_content';
-
-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' },
-});
-
-const timeChange = (prev, curr) => {
- const prevDate = new Date(prev.get('created_at')).getDate();
- const currDate = new Date(curr.get('created_at')).getDate();
- const nowDate = new Date().getDate();
-
- if (prevDate !== currDate) {
- return currDate === nowDate ? 'today' : 'date';
- }
-
- return null;
-};
-
-const makeEmojiMap = record => record.get('emojis', ImmutableList()).reduce((map, emoji) => {
- return map.set(`:${emoji.get('shortcode')}:`, emoji);
-}, ImmutableMap());
-
-const makeGetChatMessages = () => {
- return createSelector(
- [(chatMessages, chatMessageIds) => (
- chatMessageIds.reduce((acc, curr) => {
- const chatMessage = chatMessages.get(curr);
- return chatMessage ? acc.push(chatMessage) : acc;
- }, ImmutableList())
- )],
- chatMessages => chatMessages,
- );
-};
-
-const makeMapStateToProps = () => {
- const getChatMessages = makeGetChatMessages();
-
- const mapStateToProps = (state, { chatMessageIds }) => {
- const chatMessages = state.get('chat_messages');
-
- return {
- me: state.get('me'),
- chatMessages: getChatMessages(chatMessages, chatMessageIds),
- };
- };
-
- return mapStateToProps;
-};
-
-export default @connect(makeMapStateToProps)
-@injectIntl
-class ChatMessageList extends ImmutablePureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- chatId: PropTypes.string,
- chatMessages: ImmutablePropTypes.list,
- chatMessageIds: ImmutablePropTypes.orderedSet,
- me: PropTypes.node,
- }
-
- static defaultProps = {
- chatMessages: ImmutableList(),
- }
-
- state = {
- initialLoad: true,
- isLoading: false,
- }
-
- scrollToBottom = () => {
- if (!this.messagesEnd) return;
- this.messagesEnd.scrollIntoView(false);
- }
-
- setMessageEndRef = (el) => {
- this.messagesEnd = el;
- };
-
- getFormattedTimestamp = (chatMessage) => {
- const { intl } = this.props;
- return intl.formatDate(
- new Date(chatMessage.get('created_at')), {
- hour12: false,
- year: 'numeric',
- month: 'short',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- },
- );
- };
-
- setBubbleRef = (c) => {
- 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');
- }
- }
-
- isNearBottom = () => {
- const elem = this.node;
- if (!elem) return false;
-
- const scrollBottom = elem.scrollHeight - elem.offsetHeight - elem.scrollTop;
- return scrollBottom < elem.offsetHeight * 1.5;
- }
-
- handleResize = throttle((e) => {
- if (this.isNearBottom()) this.scrollToBottom();
- }, 150);
-
- componentDidMount() {
- const { dispatch, chatId } = this.props;
- dispatch(fetchChatMessages(chatId));
-
- this.node.addEventListener('scroll', this.handleScroll);
- window.addEventListener('resize', this.handleResize);
- this.scrollToBottom();
- }
-
- getSnapshotBeforeUpdate(prevProps, prevState) {
- const { scrollHeight, scrollTop } = this.node;
- return scrollHeight - scrollTop;
- }
-
- restoreScrollPosition = (scrollBottom) => {
- this.lastComputedScroll = this.node.scrollHeight - scrollBottom;
- this.node.scrollTop = this.lastComputedScroll;
- }
-
- componentDidUpdate(prevProps, prevState, scrollBottom) {
- const { initialLoad } = this.state;
- const oldCount = prevProps.chatMessages.count();
- const newCount = this.props.chatMessages.count();
- const isNearBottom = this.isNearBottom();
- const historyAdded = prevProps.chatMessages.getIn([0, 'id']) !== this.props.chatMessages.getIn([0, 'id']);
-
- // Retain scroll bar position when loading old messages
- this.restoreScrollPosition(scrollBottom);
-
- if (oldCount !== newCount) {
- if (isNearBottom || initialLoad) this.scrollToBottom();
- if (historyAdded) this.setState({ isLoading: false, initialLoad: false });
- }
- }
-
- componentWillUnmount() {
- this.node.removeEventListener('scroll', this.handleScroll);
- window.removeEventListener('resize', this.handleResize);
- }
-
- handleLoadMore = () => {
- const { dispatch, chatId, chatMessages } = this.props;
- const maxId = chatMessages.getIn([0, 'id']);
- dispatch(fetchChatMessages(chatId, maxId));
- this.setState({ isLoading: true });
- }
-
- handleScroll = throttle(() => {
- const { lastComputedScroll } = this;
- const { isLoading, initialLoad } = this.state;
- const { scrollTop, offsetHeight } = this.node;
- const computedScroll = lastComputedScroll === scrollTop;
- const nearTop = scrollTop < offsetHeight * 2;
-
- if (nearTop && !isLoading && !initialLoad && !computedScroll)
- this.handleLoadMore();
- }, 150, {
- trailing: true,
- });
-
- onOpenMedia = (media, index) => {
- this.props.dispatch(openModal('MEDIA', { media, index }));
- };
-
- maybeRenderMedia = chatMessage => {
- const attachment = chatMessage.get('attachment');
- if (!attachment) return null;
- return (
-
-
- {Component => (
-
- )}
-
-
- );
- }
-
- parsePendingContent = content => {
- return escape(content).replace(/(?:\r\n|\r|\n)/g, '
');
- }
-
- parseContent = chatMessage => {
- const content = chatMessage.get('content') || '';
- const pending = chatMessage.get('pending', false);
- const deleting = chatMessage.get('deleting', false);
- const formatted = (pending && !deleting) ? this.parsePendingContent(content) : content;
- const emojiMap = makeEmojiMap(chatMessage);
- return emojify(formatted, emojiMap.toJS());
- }
-
- setRef = (c) => {
- this.node = c;
- }
-
- renderDivider = (key, text) => (
- {text}
- )
-
- handleDeleteMessage = (chatId, messageId) => {
- return () => {
- this.props.dispatch(deleteChatMessage(chatId, messageId));
- };
- }
-
- handleReportUser = (userId) => {
- return () => {
- this.props.dispatch(initReportById(userId));
- };
- }
-
- renderMessage = (chatMessage) => {
- const { me, intl } = this.props;
- const menu = [
- {
- text: intl.formatMessage(messages.delete),
- action: this.handleDeleteMessage(chatMessage.get('chat_id'), chatMessage.get('id')),
- icon: require('@tabler/icons/icons/trash.svg'),
- destructive: true,
- },
- ];
-
- if (chatMessage.get('account_id') !== me) {
- menu.push({
- text: intl.formatMessage(messages.report),
- action: this.handleReportUser(chatMessage.get('account_id')),
- icon: require('@tabler/icons/icons/flag.svg'),
- });
- }
-
- return (
-
-
- {this.maybeRenderMedia(chatMessage)}
-
-
-
-
-
-
- );
- }
-
- render() {
- const { chatMessages, intl } = this.props;
-
- return (
-
- {chatMessages.reduce((acc, curr, idx) => {
- const lastMessage = chatMessages.get(idx - 1);
-
- if (lastMessage) {
- const key = `${curr.get('id')}_divider`;
- switch (timeChange(lastMessage, curr)) {
- case 'today':
- acc.push(this.renderDivider(key, intl.formatMessage(messages.today)));
- break;
- case 'date':
- acc.push(this.renderDivider(key, new Date(curr.get('created_at')).toDateString()));
- break;
- }
- }
-
- acc.push(this.renderMessage(curr));
- return acc;
- }, [])}
-
-
- );
- }
-
-}
diff --git a/app/soapbox/features/chats/components/chat_message_list.tsx b/app/soapbox/features/chats/components/chat_message_list.tsx
new file mode 100644
index 000000000..41f0c1f12
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat_message_list.tsx
@@ -0,0 +1,318 @@
+import classNames from 'classnames';
+import {
+ Map as ImmutableMap,
+ List as ImmutableList,
+ OrderedSet as ImmutableOrderedSet,
+} from 'immutable';
+import escape from 'lodash/escape';
+import throttle from 'lodash/throttle';
+import React, { useState, useEffect, useRef } 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 { initReportById } from 'soapbox/actions/reports';
+import { Text } from 'soapbox/components/ui';
+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';
+import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
+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';
+
+const timeChange = (prev: ChatMessageEntity, curr: ChatMessageEntity): TimeFormat | null => {
+ const prevDate = new Date(prev.created_at).getDate();
+ const currDate = new Date(curr.created_at).getDate();
+ const nowDate = new Date().getDate();
+
+ if (prevDate !== currDate) {
+ return currDate === nowDate ? 'today' : 'date';
+ }
+
+ return null;
+};
+
+const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap, emoji: ImmutableMap) => {
+ return map.set(`:${emoji.get('shortcode')}:`, emoji);
+}, ImmutableMap());
+
+const getChatMessages = createSelector(
+ [(chatMessages: ImmutableMap, chatMessageIds: ImmutableOrderedSet) => (
+ chatMessageIds.reduce((acc, curr) => {
+ const chatMessage = chatMessages.get(curr);
+ return chatMessage ? acc.push(chatMessage) : acc;
+ }, ImmutableList())
+ )],
+ chatMessages => chatMessages,
+);
+
+interface IChatMessageList {
+ chatId: string,
+ chatMessageIds: ImmutableOrderedSet,
+}
+
+const ChatMessageList: React.FC = ({ chatId, chatMessageIds }) => {
+ const intl = useIntl();
+ const dispatch = useAppDispatch();
+
+ const me = useAppSelector(state => state.me);
+ const chatMessages = useAppSelector(state => getChatMessages(state.chat_messages, chatMessageIds));
+
+ const [initialLoad, setInitialLoad] = useState(true);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const node = useRef(null);
+ const messagesEnd = useRef(null);
+ const lastComputedScroll = useRef(0);
+
+ const scrollToBottom = () => {
+ messagesEnd.current?.scrollIntoView(false);
+ };
+
+ const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => {
+ return intl.formatDate(
+ new Date(chatMessage.created_at), {
+ hour12: false,
+ year: 'numeric',
+ month: 'short',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ },
+ );
+ };
+
+ 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;
+ };
+
+ const handleResize = throttle(() => {
+ if (isNearBottom()) {
+ scrollToBottom();
+ }
+ }, 150);
+
+ useEffect(() => {
+ dispatch(fetchChatMessages(chatId));
+
+ node.current?.addEventListener('scroll', handleScroll);
+ window.addEventListener('resize', handleResize);
+ scrollToBottom();
+
+ return () => {
+ node.current?.removeEventListener('scroll', handleScroll);
+ window.removeEventListener('resize', handleResize);
+ };
+ }, []);
+
+ // const getScrollBottom = (): number | undefined => {
+ // if (node.current) {
+ // const { scrollHeight, scrollTop } = node.current;
+ // return scrollHeight - scrollTop;
+ // }
+ //
+ // return undefined;
+ // };
+
+ // const restoreScrollPosition = (scrollBottom: number) => {
+ // if (node.current) {
+ // lastComputedScroll.current = node.current.scrollHeight - scrollBottom;
+ // node.current.scrollTop = lastComputedScroll.current;
+ // }
+ // };
+
+ // Stick scrollbar to bottom.
+ useEffect(() => {
+ if (isNearBottom() || initialLoad) {
+ scrollToBottom();
+ }
+ }, [chatMessages.count()]);
+
+ // History added.
+ useEffect(() => {
+ // Retain scroll bar position when loading old messages
+ // restoreScrollPosition(scrollBottom);
+
+ setIsLoading(false);
+ setInitialLoad(false);
+ }, [chatMessages.getIn([0, 'id'])]);
+
+ const handleLoadMore = () => {
+ const maxId = chatMessages.getIn([0, 'id']) as string;
+ dispatch(fetchChatMessages(chatId, maxId as any));
+ setIsLoading(true);
+ };
+
+ const handleScroll = throttle(() => {
+ if (node.current) {
+ const { scrollTop, offsetHeight } = node.current;
+ const computedScroll = lastComputedScroll.current === scrollTop;
+ const nearTop = scrollTop < offsetHeight * 2;
+
+ if (nearTop && !isLoading && !initialLoad && !computedScroll) {
+ handleLoadMore();
+ }
+ }
+ }, 150, {
+ trailing: true,
+ });
+
+ const onOpenMedia = (media: any, index: number) => {
+ dispatch(openModal('MEDIA', { media, index }));
+ };
+
+ const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
+ const { attachment } = chatMessage;
+ if (!attachment) return null;
+ return (
+
+
+ {(Component: any) => (
+
+ )}
+
+
+ );
+ };
+
+ const parsePendingContent = (content: string) => {
+ return escape(content).replace(/(?:\r\n|\r|\n)/g, '
');
+ };
+
+ const parseContent = (chatMessage: ChatMessageEntity) => {
+ const content = chatMessage.content || '';
+ const pending = chatMessage.pending;
+ const deleting = chatMessage.deleting;
+ const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
+ const emojiMap = makeEmojiMap(chatMessage);
+ return emojify(formatted, emojiMap.toJS());
+ };
+
+ const renderDivider = (key: React.Key, text: string) => (
+ {text}
+ );
+
+ const handleDeleteMessage = (chatId: string, messageId: string) => {
+ return () => {
+ dispatch(deleteChatMessage(chatId, messageId));
+ };
+ };
+
+ const handleReportUser = (userId: string) => {
+ return () => {
+ dispatch(initReportById(userId));
+ };
+ };
+
+ const renderMessage = (chatMessage: ChatMessageEntity) => {
+ const menu: Menu = [
+ {
+ text: intl.formatMessage(messages.delete),
+ action: handleDeleteMessage(chatMessage.chat_id, chatMessage.id),
+ icon: require('@tabler/icons/icons/trash.svg'),
+ destructive: true,
+ },
+ ];
+
+ if (chatMessage.account_id !== me) {
+ menu.push({
+ text: intl.formatMessage(messages.report),
+ action: handleReportUser(chatMessage.account_id),
+ icon: require('@tabler/icons/icons/flag.svg'),
+ });
+ }
+
+ return (
+
+
+ {maybeRenderMedia(chatMessage)}
+
+
+
+
+
+
+ );
+ };
+
+ return (
+
+ {chatMessages.reduce((acc, curr, idx) => {
+ const lastMessage = chatMessages.get(idx - 1);
+
+ if (lastMessage) {
+ const key = `${curr.id}_divider`;
+ switch (timeChange(lastMessage, curr)) {
+ case 'today':
+ acc.push(renderDivider(key, intl.formatMessage(messages.today)));
+ break;
+ case 'date':
+ acc.push(renderDivider(key, new Date(curr.created_at).toDateString()));
+ break;
+ }
+ }
+
+ acc.push(renderMessage(curr));
+ return acc;
+ }, [] as React.ReactNode[])}
+
+
+ );
+};
+
+export default ChatMessageList;