From c563742856a4229bb43be8e38629279317d0edb3 Mon Sep 17 00:00:00 2001 From: janWilejan <119548498+janWilejan@users.noreply.github.com> Date: Mon, 26 Jun 2023 16:00:27 +0000 Subject: [PATCH] Chat popup (#3098) * add pop out chat button * add button to close chat popup * chat is hidden on main interface when a popup chat is open * NameChangeEvent renames clients with the given id if you have two or more owncast windows (or pop-out chats) open, changing your name in 1 client is reflected in all clients. * replace isChatVisible booleans with chatState enum * update stories to use ChatState * fix build tests --------- Co-authored-by: janWilejan <> --- .../common/UserDropdown/UserDropdown.tsx | 62 ++++++++++++++++--- web/components/layouts/Main/Main.stories.tsx | 5 +- web/components/layouts/Main/Main.tsx | 7 ++- web/components/stores/ClientConfigStore.tsx | 34 +++++----- .../eventhandlers/handleNameChangeEvent.tsx | 17 ++++- web/components/ui/Content/Content.tsx | 9 +-- 6 files changed, 101 insertions(+), 33 deletions(-) diff --git a/web/components/common/UserDropdown/UserDropdown.tsx b/web/components/common/UserDropdown/UserDropdown.tsx index a439194fa..75be8b641 100644 --- a/web/components/common/UserDropdown/UserDropdown.tsx +++ b/web/components/common/UserDropdown/UserDropdown.tsx @@ -7,7 +7,8 @@ import { useHotkeys } from 'react-hotkeys-hook'; import dynamic from 'next/dynamic'; import { ErrorBoundary } from 'react-error-boundary'; import { - chatVisibleToggleAtom, + ChatState, + chatStateAtom, currentUserAtom, appStateAtom, } from '../../stores/ClientConfigStore'; @@ -29,6 +30,14 @@ const LockOutlined = dynamic(() => import('@ant-design/icons/LockOutlined'), { ssr: false, }); +const ShrinkOutlined = dynamic(() => import('@ant-design/icons/ShrinkOutlined'), { + ssr: false, +}); + +const ExpandAltOutlined = dynamic(() => import('@ant-design/icons/ExpandAltOutlined'), { + ssr: false, +}); + const MessageOutlined = dynamic(() => import('@ant-design/icons/MessageOutlined'), { ssr: false, }); @@ -70,7 +79,8 @@ export const UserDropdown: FC = ({ }) => { const [showNameChangeModal, setShowNameChangeModal] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false); - const [chatToggleVisible, setChatToggleVisible] = useRecoilState(chatVisibleToggleAtom); + const [chatState, setChatState] = useRecoilState(chatStateAtom); + const [popupWindow, setPopupWindow] = useState(null); const appState = useRecoilValue(appStateAtom); const toggleChatVisibility = () => { @@ -79,7 +89,7 @@ export const UserDropdown: FC = ({ return; } - setChatToggleVisible(!chatToggleVisible); + setChatState(chatState === ChatState.VISIBLE ? ChatState.HIDDEN : ChatState.VISIBLE); }; const handleChangeName = () => { @@ -90,6 +100,34 @@ export const UserDropdown: FC = ({ setShowNameChangeModal(false); }; + const closeChatPopup = () => { + if (popupWindow) { + popupWindow.close(); + } + setPopupWindow(null); + setChatState(ChatState.VISIBLE); + }; + + const openChatPopup = () => { + // close popup (if any) to prevent multiple popup windows. + closeChatPopup(); + const w = window.open('/embed/chat/readwrite', '_blank', 'popup'); + w.addEventListener('beforeunload', closeChatPopup); + setPopupWindow(w); + setChatState(ChatState.POPPED_OUT); + }; + + const canShowHideChat = + showHideChatOption && + appState.chatAvailable && + (chatState === ChatState.HIDDEN || chatState === ChatState.VISIBLE); + const canShowChatPopup = + showHideChatOption && + appState.chatAvailable && + (chatState === ChatState.HIDDEN || + chatState === ChatState.VISIBLE || + chatState === ChatState.POPPED_OUT); + // Register keyboard shortcut for the space bar to toggle playback useHotkeys( 'c', @@ -97,7 +135,7 @@ export const UserDropdown: FC = ({ { enableOnContentEditable: false, }, - [chatToggleVisible], + [chatState === ChatState.VISIBLE], ); const currentUser = useRecoilValue(currentUserAtom); @@ -115,17 +153,27 @@ export const UserDropdown: FC = ({ } onClick={() => setShowAuthModal(true)}> Authenticate - {showHideChatOption && appState.chatAvailable && ( + {canShowHideChat && ( } onClick={() => toggleChatVisibility()} - aria-expanded={chatToggleVisible} + aria-expanded={chatState === ChatState.VISIBLE} className={styles.chatToggle} > - {chatToggleVisible ? 'Hide Chat' : 'Show Chat'} + {chatState === ChatState.VISIBLE ? 'Hide Chat' : 'Show Chat'} )} + {canShowChatPopup && + (popupWindow ? ( + } onClick={closeChatPopup}> + Put chat back + + ) : ( + } onClick={openChatPopup}> + Pop out chat + + ))} ); diff --git a/web/components/layouts/Main/Main.stories.tsx b/web/components/layouts/Main/Main.stories.tsx index b17c16e05..417dcae6c 100644 --- a/web/components/layouts/Main/Main.stories.tsx +++ b/web/components/layouts/Main/Main.stories.tsx @@ -6,7 +6,8 @@ import { accessTokenAtom, appStateAtom, chatMessagesAtom, - chatVisibleToggleAtom, + ChatState, + chatStateAtom, clientConfigStateAtom, currentUserAtom, fatalErrorStateAtom, @@ -68,7 +69,7 @@ const initializeDefaultState = (mutableState: MutableSnapshot) => { chatAvailable: false, }); mutableState.set(clientConfigStateAtom, defaultClientConfig); - mutableState.set(chatVisibleToggleAtom, true); + mutableState.set(chatStateAtom, ChatState.VISIBLE); mutableState.set(accessTokenAtom, 'token'); mutableState.set(currentUserAtom, { ...spidermanUser, diff --git a/web/components/layouts/Main/Main.tsx b/web/components/layouts/Main/Main.tsx index e8e296baf..60855ff33 100644 --- a/web/components/layouts/Main/Main.tsx +++ b/web/components/layouts/Main/Main.tsx @@ -17,7 +17,8 @@ import { appStateAtom, serverStatusState, isMobileAtom, - isChatVisibleSelector, + ChatState, + chatStateAtom, } from '../../stores/ClientConfigStore'; import { Content } from '../../ui/Content/Content'; import { Header } from '../../ui/Header/Header'; @@ -54,14 +55,14 @@ export const Main: FC = () => { const fatalError = useRecoilValue(fatalErrorStateAtom); const appState = useRecoilValue(appStateAtom); const isMobile = useRecoilValue(isMobileAtom); - const isChatVisible = useRecoilValue(isChatVisibleSelector); + const chatState = useRecoilValue(chatStateAtom); const layoutRef = useRef(null); const { chatDisabled } = clientConfig; const { videoAvailable } = appState; const { online, streamTitle } = clientStatus; // accounts for sidebar width when online in desktop - const showChat = online && !chatDisabled && isChatVisible; + const showChat = online && !chatDisabled && chatState === ChatState.VISIBLE; const dynamicFooterPadding = showChat && !isMobile ? DYNAMIC_PADDING_VALUE : ''; useEffect(() => { diff --git a/web/components/stores/ClientConfigStore.tsx b/web/components/stores/ClientConfigStore.tsx index f093dfcf9..45e4c3290 100644 --- a/web/components/stores/ClientConfigStore.tsx +++ b/web/components/stores/ClientConfigStore.tsx @@ -18,6 +18,7 @@ import { ConnectedClientInfoEvent, MessageType, ChatEvent, + NameChangeEvent, MessageVisibilityEvent, SocketEvent, FediverseEvent, @@ -88,11 +89,6 @@ export const isMobileAtom = atom({ default: undefined, }); -export const chatVisibleToggleAtom = atom({ - key: 'chatVisibilityToggleAtom', - default: true, -}); - export const isVideoPlayingAtom = atom({ key: 'isVideoPlayingAtom', default: false, @@ -122,15 +118,23 @@ export const isChatAvailableSelector = selector({ }, }); -// Chat is visible if the user wishes it to be visible AND the required -// chat state is set. -export const isChatVisibleSelector = selector({ - key: 'isChatVisibleSelector', - get: ({ get }) => { - const state: AppStateOptions = get(appStateAtom); - const userVisibleToggle: boolean = get(chatVisibleToggleAtom); - return state.chatAvailable && userVisibleToggle && !hasWebsocketDisconnected; - }, +// The requested state of chat in the UI +export enum ChatState { + VISIBLE, // Chat is open (the default state when the stream is online) + HIDDEN, // Chat is hidden + POPPED_OUT, // Chat is playing in a popout window + EMBEDDED, // This window is opened at /embed/chat/readwrite/ +} + +export const chatStateAtom = atom({ + key: 'chatState', + default: (() => { + // XXX Somehow, `window` is undefined here, even though this runs in client + const window = globalThis; + return window?.location?.pathname === '/embed/chat/readwrite/' + ? ChatState.EMBEDDED + : ChatState.VISIBLE; + })(), }); // We display in an "online/live" state as long as video is actively playing. @@ -315,7 +319,7 @@ export const ClientConfigStore: FC = () => { setChatMessages(currentState => [...currentState, message as ChatEvent]); break; case MessageType.NAME_CHANGE: - handleNameChangeEvent(message as ChatEvent, setChatMessages); + handleNameChangeEvent(message as NameChangeEvent, setChatMessages, setCurrentUser); break; case MessageType.USER_JOINED: setChatMessages(currentState => [...currentState, message as ChatEvent]); diff --git a/web/components/stores/eventhandlers/handleNameChangeEvent.tsx b/web/components/stores/eventhandlers/handleNameChangeEvent.tsx index 78c0b75cb..a762b2720 100644 --- a/web/components/stores/eventhandlers/handleNameChangeEvent.tsx +++ b/web/components/stores/eventhandlers/handleNameChangeEvent.tsx @@ -1,5 +1,18 @@ -import { ChatEvent } from '../../../interfaces/socket-events'; +import { NameChangeEvent } from '../../../interfaces/socket-events'; +import { CurrentUser } from '../../../interfaces/current-user'; -export function handleNameChangeEvent(message: ChatEvent, setChatMessages) { +export function handleNameChangeEvent( + message: NameChangeEvent, + setChatMessages, + setCurrentUser: (_: (_: CurrentUser) => CurrentUser) => void, +) { + setCurrentUser(currentUser => + currentUser.id === message.user.id + ? { + ...currentUser, + displayName: message.user.displayName, + } + : currentUser, + ); setChatMessages(currentState => [...currentState, message]); } diff --git a/web/components/ui/Content/Content.tsx b/web/components/ui/Content/Content.tsx index b1a303619..d64eb9754 100644 --- a/web/components/ui/Content/Content.tsx +++ b/web/components/ui/Content/Content.tsx @@ -12,7 +12,8 @@ import { clientConfigStateAtom, chatMessagesAtom, currentUserAtom, - isChatVisibleSelector, + ChatState, + chatStateAtom, appStateAtom, isOnlineSelector, isMobileAtom, @@ -92,7 +93,7 @@ const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay }) export const Content: FC = () => { const appState = useRecoilValue(appStateAtom); const clientConfig = useRecoilValue(clientConfigStateAtom); - const isChatVisible = useRecoilValue(isChatVisibleSelector); + const chatState = useRecoilValue(chatStateAtom); const currentUser = useRecoilValue(currentUserAtom); const serverStatus = useRecoilValue(serverStatusState); const [isMobile, setIsMobile] = useRecoilState(isMobileAtom); @@ -184,7 +185,7 @@ export const Content: FC = () => { ); }, [browserNotificationsEnabled]); - const showChat = isChatAvailable && !chatDisabled && isChatVisible; + const showChat = isChatAvailable && !chatDisabled && chatState === ChatState.VISIBLE; // accounts for sidebar width when online in desktop const dynamicPadding = showChat && !isMobile ? '320px' : '0px'; @@ -309,7 +310,7 @@ export const Content: FC = () => { handleClose={() => setShowFollowModal(false)} /> - {isMobile && showChatModal && isChatVisible && ( + {isMobile && showChatModal && chatState === ChatState.VISIBLE && (