apps/chat/src/components/Chat/Playback/PlaybackControls.tsx (250 lines of code) (raw):

import { IconPlayerPlay } from '@tabler/icons-react'; import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useTranslation } from 'next-i18next'; import classNames from 'classnames'; import { hasParentWithFloatingOverlay } from '@/src/utils/app/modals'; import { ConversationsActions, ConversationsSelectors, } from '@/src/store/conversations/conversations.reducers'; import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; import { UISelectors } from '@/src/store/ui/ui.reducers'; import { ScrollDownButton } from '@/src/components/Common/ScrollDownButton'; import { ChatInputFooter } from '../ChatInput/ChatInputFooter'; import { PlaybackAttachments } from './PlaybackAttachments'; interface Props { showScrollDownButton: boolean; onScrollDownClick: () => void; onResize: (height: number) => void; nextMessageBoxRef: MutableRefObject<HTMLDivElement | null>; } enum PlaybackPhases { EMPTY = 'EMPTY', MESSAGE = 'MESSAGE', } export const PlaybackControls = ({ onScrollDownClick, onResize, nextMessageBoxRef, showScrollDownButton, }: Props) => { const { t } = useTranslation('playback'); const dispatch = useAppDispatch(); const isPlayback = useAppSelector( ConversationsSelectors.selectIsPlaybackSelectedConversations, ); const selectedConversations = useAppSelector( ConversationsSelectors.selectSelectedConversations, ); const isMessageStreaming = useAppSelector( ConversationsSelectors.selectIsConversationsStreaming, ); const activeIndex = useAppSelector( ConversationsSelectors.selectPlaybackActiveIndex, ); const isChatFullWidth = useAppSelector(UISelectors.selectIsChatFullWidth); const controlsContainerRef = useRef<HTMLDivElement | null>(null); const [phase, setPhase] = useState<PlaybackPhases>(PlaybackPhases.EMPTY); const isActiveIndex = typeof activeIndex === 'number'; const isNextMessageInStack = useMemo(() => { return ( selectedConversations.length && !!selectedConversations[0].playback && isActiveIndex && activeIndex >= 0 && selectedConversations[0].playback.messagesStack.length - 1 >= activeIndex ); }, [activeIndex, isActiveIndex, selectedConversations]); const isPrevMessageInStack = useMemo(() => { const prevIndex = isMessageStreaming ? 1 : 2; return ( selectedConversations.length && !!selectedConversations[0].playback && isActiveIndex && activeIndex >= 0 && selectedConversations[0].playback.messagesStack.length && selectedConversations[0].playback.messagesStack[activeIndex - prevIndex] ); }, [activeIndex, isActiveIndex, selectedConversations, isMessageStreaming]); const activeMessage = useMemo(() => { if (!isActiveIndex) { return; } const currentPlayback = selectedConversations[0]?.playback; const currentMessage = currentPlayback?.messagesStack[activeIndex]; const content = isNextMessageInStack && currentMessage && currentMessage?.content; const attachments = currentMessage && currentMessage?.custom_content?.attachments?.length ? currentMessage.custom_content.attachments : []; const message = attachments.length ? { content, custom_content: { attachments } } : { content }; return message; }, [activeIndex, isActiveIndex, isNextMessageInStack, selectedConversations]); const hasAttachments = activeMessage && activeMessage.custom_content && activeMessage.custom_content.attachments && activeMessage.custom_content.attachments.length; const handlePlayNextMessage = useCallback(() => { if (isMessageStreaming || !isNextMessageInStack) { return; } if (phase === PlaybackPhases.EMPTY) { setPhase(PlaybackPhases.MESSAGE); return; } setPhase(PlaybackPhases.EMPTY); dispatch(ConversationsActions.playbackNextMessageStart()); }, [dispatch, isMessageStreaming, isNextMessageInStack, phase]); const handlePrevMessage = useCallback(() => { if (activeIndex === 0 && phase !== PlaybackPhases.MESSAGE) { return; } if (phase === PlaybackPhases.EMPTY) { setPhase(PlaybackPhases.MESSAGE); } else { setPhase(PlaybackPhases.EMPTY); if (isPrevMessageInStack) { return; } } if (!isPrevMessageInStack) { return; } dispatch(ConversationsActions.playbackPrevMessage()); }, [dispatch, isPrevMessageInStack, phase, activeIndex]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (!isPlayback || hasParentWithFloatingOverlay(e.target as Element)) { return; } if ( isNextMessageInStack && (e.key === 'Enter' || e.key === 'ArrowDown' || e.key === 'ArrowRight' || e.key == ' ') ) { e.preventDefault(); handlePlayNextMessage(); } else if ( isActiveIndex && activeIndex >= 0 && (e.key === 'ArrowUp' || e.key === 'ArrowLeft') ) { e.preventDefault(); handlePrevMessage(); } }, [ isActiveIndex, activeIndex, handlePlayNextMessage, handlePrevMessage, isPlayback, isNextMessageInStack, ], ); useEffect(() => { if (isPlayback) { window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); }; } }, [handleKeyDown, isPlayback]); useEffect(() => { if (!controlsContainerRef) { return; } const resizeObserver = new ResizeObserver(() => { controlsContainerRef.current?.clientHeight && onResize(controlsContainerRef.current.clientHeight); }); controlsContainerRef.current && resizeObserver.observe(controlsContainerRef.current); return () => { resizeObserver.disconnect(); }; }, [controlsContainerRef, onResize]); return ( <div ref={controlsContainerRef} className="w-full pt-3 md:pt-5"> <div className={classNames( 'relative mx-2 mb-2 flex flex-row gap-3 md:mx-4 md:mb-0 md:last:mb-6', isChatFullWidth ? 'lg:ml-20 lg:mr-[84px]' : 'lg:mx-auto lg:max-w-3xl', )} data-qa="playback-control" > <button data-qa="playback-prev" onClick={handlePrevMessage} disabled={activeIndex === 0 && phase !== PlaybackPhases.MESSAGE} className="absolute bottom-3 left-4 rounded outline-none hover:text-accent-primary disabled:cursor-not-allowed disabled:text-controls-disable" > <IconPlayerPlay size={20} className="rotate-180" /> </button> <div ref={nextMessageBoxRef} className="m-0 max-h-[150px] min-h-[46px] w-full overflow-y-auto whitespace-pre-wrap rounded border border-transparent bg-layer-3 px-12 py-3 text-left outline-none focus-visible:border-accent-primary" data-qa="playback-message" > {isMessageStreaming ? ( <div className="absolute bottom-3 right-4 size-5 animate-spin rounded-full border-t-2 border-primary" data-qa="message-input-spinner" ></div> ) : ( <> {activeMessage && ( <> <span className={classNames( 'break-words', phase === PlaybackPhases.EMPTY && 'text-secondary', )} data-qa="playback-message-content" > {phase === PlaybackPhases.EMPTY ? t('Type a message') : (activeMessage.content ?? '')} </span> {phase === PlaybackPhases.MESSAGE && hasAttachments && ( <PlaybackAttachments attachments={activeMessage.custom_content.attachments} /> )} <button data-qa="playback-next" onClick={handlePlayNextMessage} className="absolute bottom-3 right-4 rounded outline-none hover:text-accent-primary disabled:cursor-not-allowed disabled:text-controls-disable" disabled={isMessageStreaming || !isNextMessageInStack} > <IconPlayerPlay size={20} className="shrink-0" /> </button> </> )} </> )} </div> {showScrollDownButton && ( <ScrollDownButton className="-top-16 right-0 md:-top-20" onScrollDownClick={onScrollDownClick} /> )} </div> <ChatInputFooter /> </div> ); };