import { Virtuoso } from 'react-virtuoso'; import { useState, useMemo, useRef, CSSProperties, FC, useEffect } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { ConnectedClientInfoEvent, FediverseEvent, MessageType, NameChangeEvent, } from '../../../interfaces/socket-events'; import styles from './ChatContainer.module.scss'; import { ChatMessage } from '../../../interfaces/chat-message.model'; import { ChatUserMessage } from '../ChatUserMessage/ChatUserMessage'; import { ChatTextField } from '../ChatTextField/ChatTextField'; import { ChatModeratorNotification } from '../ChatModeratorNotification/ChatModeratorNotification'; import { ChatSystemMessage } from '../ChatSystemMessage/ChatSystemMessage'; import { ChatJoinMessage } from '../ChatJoinMessage/ChatJoinMessage'; import { ScrollToBotBtn } from './ScrollToBotBtn'; import { ChatActionMessage } from '../ChatActionMessage/ChatActionMessage'; import { ChatSocialMessage } from '../ChatSocialMessage/ChatSocialMessage'; import { ChatNameChangeMessage } from '../ChatNameChangeMessage/ChatNameChangeMessage'; import { User } from '../../../interfaces/user.model'; import { ComponentError } from '../../ui/ComponentError/ComponentError'; export type ChatContainerProps = { messages: ChatMessage[]; usernameToHighlight: string; chatUserId: string; isModerator: boolean; showInput?: boolean; height?: string; chatAvailable: boolean; focusInput?: boolean; desktop?: boolean; }; let resizeWindowCallback: () => void; function shouldCollapseMessages(message: ChatMessage, previous: ChatMessage): boolean { if (!message || !message.user) { return false; } if (previous.type !== MessageType.CHAT) { return false; } const { user: { id }, } = message; if (id !== previous.user.id) { return false; } if (!previous.timestamp || !message.timestamp) { return false; } const maxTimestampDelta = 1000 * 40; // 40 seconds const lastTimestamp = new Date(previous.timestamp).getTime(); const thisTimestamp = new Date(message.timestamp).getTime(); if (thisTimestamp - lastTimestamp > maxTimestampDelta) { return false; } return true; } function checkIsModerator(message: ChatMessage | ConnectedClientInfoEvent) { const { user } = message; const u = new User(user); return u.isModerator; } export const ChatContainer: FC = ({ messages, usernameToHighlight, chatUserId, isModerator, showInput, height, chatAvailable: chatEnabled, desktop, focusInput = true, }) => { const [showScrollToBottomButton, setShowScrollToBottomButton] = useState(false); const [isAtBottom, setIsAtBottom] = useState(false); const chatContainerRef = useRef(null); const scrollToBottomDelay = useRef(null); const collapsedIndexes: boolean[] = []; let consecutiveTally: number = 1; function calculateCollapsedMessages() { // Limits the number of messages that can be collapsed in a row. const maxCollapsedMessageCount = 5; for (let i = collapsedIndexes.length; i < messages.length; i += 1) { const collapse: boolean = i > 0 && consecutiveTally < maxCollapsedMessageCount && shouldCollapseMessages(messages[i], messages[i - 1]); collapsedIndexes.push(collapse); consecutiveTally = 1 + (collapse ? consecutiveTally : 0); } } function shouldCollapse(index: number): boolean { if (collapsedIndexes.length <= index) { calculateCollapsedMessages(); } return collapsedIndexes[index]; } useEffect( () => // Clear the timer when the component unmounts () => { clearTimeout(scrollToBottomDelay.current); }, [], ); const getFediverseMessage = (message: FediverseEvent) => ; const getUserJoinedMessage = (message: ChatMessage) => { const { user: { displayName, displayColor }, } = message; const isAuthorModerator = checkIsModerator(message); return ( ); }; const getActionMessage = (message: ChatMessage) => { const { body } = message; return ; }; const getConnectedInfoMessage = (message: ConnectedClientInfoEvent) => { const modStatusUpdate = checkIsModerator(message); if (!modStatusUpdate) { // Important note: We can't return null or an element with zero width // or zero height. So to work around this we return a very small 1x1 div. const st: CSSProperties = { width: '1px', height: '1px' }; return
; } // Alert the user that they are a moderator. return ; }; const getUserChatMessageView = (index: number, message: ChatMessage) => { const isAuthorModerator = checkIsModerator(message); return ( ); }; const getViewForMessage = ( index: number, message: ChatMessage | NameChangeEvent | ConnectedClientInfoEvent | FediverseEvent, ) => { switch (message.type) { case MessageType.CHAT: return getUserChatMessageView(index, message as ChatMessage); case MessageType.NAME_CHANGE: return ; case MessageType.CONNECTED_USER_INFO: return getConnectedInfoMessage(message as ConnectedClientInfoEvent); case MessageType.USER_JOINED: return getUserJoinedMessage(message as ChatMessage); case MessageType.CHAT_ACTION: return getActionMessage(message as ChatMessage); case MessageType.SYSTEM: return ( ); case MessageType.FEDIVERSE_ENGAGEMENT_FOLLOW: return getFediverseMessage(message as FediverseEvent); case MessageType.FEDIVERSE_ENGAGEMENT_LIKE: return getFediverseMessage(message as FediverseEvent); case MessageType.FEDIVERSE_ENGAGEMENT_REPOST: return getFediverseMessage(message as FediverseEvent); default: return null; } }; const scrollChatToBottom = ref => { clearTimeout(scrollToBottomDelay.current); scrollToBottomDelay.current = setTimeout(() => { ref.current?.scrollTo({ top: Infinity, left: 0, behavior: 'auto' }); setIsAtBottom(true); }, 150); setShowScrollToBottomButton(false); }; // This is a hack to force a scroll to the very bottom of the chat messages // on initial mount of the component. // For https://github.com/owncast/owncast/issues/2500 useEffect(() => { setTimeout(() => { scrollChatToBottom(chatContainerRef); }, 500); }, []); const MessagesTable = useMemo( () => ( <> getViewForMessage(index, message)} initialTopMostItemIndex={messages.length - 1} followOutput={() => { if (isAtBottom) { setShowScrollToBottomButton(false); scrollChatToBottom(chatContainerRef); return 'smooth'; } return false; }} alignToBottom atBottomThreshold={70} atBottomStateChange={bottom => { setIsAtBottom(bottom); if (bottom) { setShowScrollToBottomButton(false); } else { setShowScrollToBottomButton(true); } }} /> {showScrollToBottomButton && ( { scrollChatToBottom(chatContainerRef); }} /> )} ), [messages, usernameToHighlight, chatUserId, isModerator, showScrollToBottomButton, isAtBottom], ); const defaultChatWidth: number = 320; function clampChatWidth(desired) { return Math.max(200, Math.min(window.innerWidth * 0.666, desired)); } function startDrag(dragEvent) { const container = document.getElementById('chat-container'); function move(event) { container.style.width = `${clampChatWidth(window.innerWidth - event.x)}px`; } function endDrag() { window.document.removeEventListener('mousemove', move); window.document.removeEventListener('mouseup', endDrag); window.document.removeEventListener('focusout', endDrag); } window.document.addEventListener('mousemove', move); window.document.addEventListener('mouseup', endDrag); window.document.addEventListener('focusout', endDrag); dragEvent.preventDefault(); // Prevent selecting the page as you resize it } // Re-clamp the chat size whenever the window resizes function resize() { const container = desktop && document.getElementById('chat-container'); if (container) { const currentWidth = parseFloat(container.style.width) || defaultChatWidth; container.style.width = `${clampChatWidth(currentWidth)}px`; } } if (resizeWindowCallback) window.removeEventListener('resize', resizeWindowCallback); if (desktop) { window.addEventListener('resize', resize); resizeWindowCallback = resize; } else { resizeWindowCallback = null; } return ( ( )} >
{MessagesTable} {showInput && (
)} {desktop && (
)}
); }; ChatContainer.defaultProps = { showInput: true, height: 'auto', };