ChatMessageList: convert to TSX (sorta)
This commit is contained in:
parent
6459096b58
commit
a7e5281098
2 changed files with 318 additions and 0 deletions
Binary file not shown.
318
app/soapbox/features/chats/components/chat_message_list.tsx
Normal file
318
app/soapbox/features/chats/components/chat_message_list.tsx
Normal file
|
@ -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<string, any>, emoji: ImmutableMap<string, any>) => {
|
||||
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 {
|
||||
chatId: string,
|
||||
chatMessageIds: ImmutableOrderedSet<string>,
|
||||
}
|
||||
|
||||
const ChatMessageList: React.FC<IChatMessageList> = ({ 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<HTMLDivElement>(null);
|
||||
const messagesEnd = useRef<HTMLDivElement>(null);
|
||||
const lastComputedScroll = useRef<number>(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 (
|
||||
<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;
|
||||
const emojiMap = makeEmojiMap(chatMessage);
|
||||
return emojify(formatted, emojiMap.toJS());
|
||||
};
|
||||
|
||||
const renderDivider = (key: React.Key, text: string) => (
|
||||
<div className='chat-messages__divider' key={key}>{text}</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={classNames('chat-message', {
|
||||
'chat-message--me': chatMessage.account_id === me,
|
||||
'chat-message--pending': chatMessage.pending,
|
||||
})}
|
||||
key={chatMessage.id}
|
||||
>
|
||||
<div
|
||||
title={getFormattedTimestamp(chatMessage)}
|
||||
className='chat-message__bubble'
|
||||
ref={setBubbleRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
{maybeRenderMedia(chatMessage)}
|
||||
<Text size='sm' dangerouslySetInnerHTML={{ __html: parseContent(chatMessage) }} />
|
||||
<div className='chat-message__menu'>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
src={require('@tabler/icons/icons/dots.svg')}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='chat-messages' ref={node}>
|
||||
{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[])}
|
||||
<div style={{ float: 'left', clear: 'both' }} ref={messagesEnd} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageList;
|
Loading…
Reference in a new issue