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 <>
This commit is contained in:
janWilejan 2023-06-26 16:00:27 +00:00 committed by GitHub
parent fca85a4a42
commit c563742856
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 101 additions and 33 deletions

View file

@ -7,7 +7,8 @@ import { useHotkeys } from 'react-hotkeys-hook';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { import {
chatVisibleToggleAtom, ChatState,
chatStateAtom,
currentUserAtom, currentUserAtom,
appStateAtom, appStateAtom,
} from '../../stores/ClientConfigStore'; } from '../../stores/ClientConfigStore';
@ -29,6 +30,14 @@ const LockOutlined = dynamic(() => import('@ant-design/icons/LockOutlined'), {
ssr: false, 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'), { const MessageOutlined = dynamic(() => import('@ant-design/icons/MessageOutlined'), {
ssr: false, ssr: false,
}); });
@ -70,7 +79,8 @@ export const UserDropdown: FC<UserDropdownProps> = ({
}) => { }) => {
const [showNameChangeModal, setShowNameChangeModal] = useState<boolean>(false); const [showNameChangeModal, setShowNameChangeModal] = useState<boolean>(false);
const [showAuthModal, setShowAuthModal] = useState<boolean>(false); const [showAuthModal, setShowAuthModal] = useState<boolean>(false);
const [chatToggleVisible, setChatToggleVisible] = useRecoilState(chatVisibleToggleAtom); const [chatState, setChatState] = useRecoilState(chatStateAtom);
const [popupWindow, setPopupWindow] = useState<Window>(null);
const appState = useRecoilValue<AppStateOptions>(appStateAtom); const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const toggleChatVisibility = () => { const toggleChatVisibility = () => {
@ -79,7 +89,7 @@ export const UserDropdown: FC<UserDropdownProps> = ({
return; return;
} }
setChatToggleVisible(!chatToggleVisible); setChatState(chatState === ChatState.VISIBLE ? ChatState.HIDDEN : ChatState.VISIBLE);
}; };
const handleChangeName = () => { const handleChangeName = () => {
@ -90,6 +100,34 @@ export const UserDropdown: FC<UserDropdownProps> = ({
setShowNameChangeModal(false); 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 // Register keyboard shortcut for the space bar to toggle playback
useHotkeys( useHotkeys(
'c', 'c',
@ -97,7 +135,7 @@ export const UserDropdown: FC<UserDropdownProps> = ({
{ {
enableOnContentEditable: false, enableOnContentEditable: false,
}, },
[chatToggleVisible], [chatState === ChatState.VISIBLE],
); );
const currentUser = useRecoilValue(currentUserAtom); const currentUser = useRecoilValue(currentUserAtom);
@ -115,17 +153,27 @@ export const UserDropdown: FC<UserDropdownProps> = ({
<Menu.Item key="1" icon={<LockOutlined />} onClick={() => setShowAuthModal(true)}> <Menu.Item key="1" icon={<LockOutlined />} onClick={() => setShowAuthModal(true)}>
Authenticate Authenticate
</Menu.Item> </Menu.Item>
{showHideChatOption && appState.chatAvailable && ( {canShowHideChat && (
<Menu.Item <Menu.Item
key="3" key="3"
icon={<MessageOutlined />} icon={<MessageOutlined />}
onClick={() => toggleChatVisibility()} onClick={() => toggleChatVisibility()}
aria-expanded={chatToggleVisible} aria-expanded={chatState === ChatState.VISIBLE}
className={styles.chatToggle} className={styles.chatToggle}
> >
{chatToggleVisible ? 'Hide Chat' : 'Show Chat'} {chatState === ChatState.VISIBLE ? 'Hide Chat' : 'Show Chat'}
</Menu.Item> </Menu.Item>
)} )}
{canShowChatPopup &&
(popupWindow ? (
<Menu.Item key="4" icon={<ShrinkOutlined />} onClick={closeChatPopup}>
Put chat back
</Menu.Item>
) : (
<Menu.Item key="4" icon={<ExpandAltOutlined />} onClick={openChatPopup}>
Pop out chat
</Menu.Item>
))}
</Menu> </Menu>
); );

View file

@ -6,7 +6,8 @@ import {
accessTokenAtom, accessTokenAtom,
appStateAtom, appStateAtom,
chatMessagesAtom, chatMessagesAtom,
chatVisibleToggleAtom, ChatState,
chatStateAtom,
clientConfigStateAtom, clientConfigStateAtom,
currentUserAtom, currentUserAtom,
fatalErrorStateAtom, fatalErrorStateAtom,
@ -68,7 +69,7 @@ const initializeDefaultState = (mutableState: MutableSnapshot) => {
chatAvailable: false, chatAvailable: false,
}); });
mutableState.set(clientConfigStateAtom, defaultClientConfig); mutableState.set(clientConfigStateAtom, defaultClientConfig);
mutableState.set(chatVisibleToggleAtom, true); mutableState.set(chatStateAtom, ChatState.VISIBLE);
mutableState.set(accessTokenAtom, 'token'); mutableState.set(accessTokenAtom, 'token');
mutableState.set(currentUserAtom, { mutableState.set(currentUserAtom, {
...spidermanUser, ...spidermanUser,

View file

@ -17,7 +17,8 @@ import {
appStateAtom, appStateAtom,
serverStatusState, serverStatusState,
isMobileAtom, isMobileAtom,
isChatVisibleSelector, ChatState,
chatStateAtom,
} from '../../stores/ClientConfigStore'; } from '../../stores/ClientConfigStore';
import { Content } from '../../ui/Content/Content'; import { Content } from '../../ui/Content/Content';
import { Header } from '../../ui/Header/Header'; import { Header } from '../../ui/Header/Header';
@ -54,14 +55,14 @@ export const Main: FC = () => {
const fatalError = useRecoilValue<DisplayableError>(fatalErrorStateAtom); const fatalError = useRecoilValue<DisplayableError>(fatalErrorStateAtom);
const appState = useRecoilValue<AppStateOptions>(appStateAtom); const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const isMobile = useRecoilValue<boolean | undefined>(isMobileAtom); const isMobile = useRecoilValue<boolean | undefined>(isMobileAtom);
const isChatVisible = useRecoilValue<boolean>(isChatVisibleSelector); const chatState = useRecoilValue<ChatState>(chatStateAtom);
const layoutRef = useRef<HTMLDivElement>(null); const layoutRef = useRef<HTMLDivElement>(null);
const { chatDisabled } = clientConfig; const { chatDisabled } = clientConfig;
const { videoAvailable } = appState; const { videoAvailable } = appState;
const { online, streamTitle } = clientStatus; const { online, streamTitle } = clientStatus;
// accounts for sidebar width when online in desktop // 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 : ''; const dynamicFooterPadding = showChat && !isMobile ? DYNAMIC_PADDING_VALUE : '';
useEffect(() => { useEffect(() => {

View file

@ -18,6 +18,7 @@ import {
ConnectedClientInfoEvent, ConnectedClientInfoEvent,
MessageType, MessageType,
ChatEvent, ChatEvent,
NameChangeEvent,
MessageVisibilityEvent, MessageVisibilityEvent,
SocketEvent, SocketEvent,
FediverseEvent, FediverseEvent,
@ -88,11 +89,6 @@ export const isMobileAtom = atom<boolean | undefined>({
default: undefined, default: undefined,
}); });
export const chatVisibleToggleAtom = atom<boolean>({
key: 'chatVisibilityToggleAtom',
default: true,
});
export const isVideoPlayingAtom = atom<boolean>({ export const isVideoPlayingAtom = atom<boolean>({
key: 'isVideoPlayingAtom', key: 'isVideoPlayingAtom',
default: false, default: false,
@ -122,15 +118,23 @@ export const isChatAvailableSelector = selector({
}, },
}); });
// Chat is visible if the user wishes it to be visible AND the required // The requested state of chat in the UI
// chat state is set. export enum ChatState {
export const isChatVisibleSelector = selector({ VISIBLE, // Chat is open (the default state when the stream is online)
key: 'isChatVisibleSelector', HIDDEN, // Chat is hidden
get: ({ get }) => { POPPED_OUT, // Chat is playing in a popout window
const state: AppStateOptions = get(appStateAtom); EMBEDDED, // This window is opened at /embed/chat/readwrite/
const userVisibleToggle: boolean = get(chatVisibleToggleAtom); }
return state.chatAvailable && userVisibleToggle && !hasWebsocketDisconnected;
}, export const chatStateAtom = atom<ChatState>({
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. // 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]); setChatMessages(currentState => [...currentState, message as ChatEvent]);
break; break;
case MessageType.NAME_CHANGE: case MessageType.NAME_CHANGE:
handleNameChangeEvent(message as ChatEvent, setChatMessages); handleNameChangeEvent(message as NameChangeEvent, setChatMessages, setCurrentUser);
break; break;
case MessageType.USER_JOINED: case MessageType.USER_JOINED:
setChatMessages(currentState => [...currentState, message as ChatEvent]); setChatMessages(currentState => [...currentState, message as ChatEvent]);

View file

@ -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]); setChatMessages(currentState => [...currentState, message]);
} }

View file

@ -12,7 +12,8 @@ import {
clientConfigStateAtom, clientConfigStateAtom,
chatMessagesAtom, chatMessagesAtom,
currentUserAtom, currentUserAtom,
isChatVisibleSelector, ChatState,
chatStateAtom,
appStateAtom, appStateAtom,
isOnlineSelector, isOnlineSelector,
isMobileAtom, isMobileAtom,
@ -92,7 +93,7 @@ const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay })
export const Content: FC = () => { export const Content: FC = () => {
const appState = useRecoilValue<AppStateOptions>(appStateAtom); const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom); const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
const isChatVisible = useRecoilValue<boolean>(isChatVisibleSelector); const chatState = useRecoilValue<ChatState>(chatStateAtom);
const currentUser = useRecoilValue(currentUserAtom); const currentUser = useRecoilValue(currentUserAtom);
const serverStatus = useRecoilValue<ServerStatus>(serverStatusState); const serverStatus = useRecoilValue<ServerStatus>(serverStatusState);
const [isMobile, setIsMobile] = useRecoilState<boolean | undefined>(isMobileAtom); const [isMobile, setIsMobile] = useRecoilState<boolean | undefined>(isMobileAtom);
@ -184,7 +185,7 @@ export const Content: FC = () => {
); );
}, [browserNotificationsEnabled]); }, [browserNotificationsEnabled]);
const showChat = isChatAvailable && !chatDisabled && isChatVisible; const showChat = isChatAvailable && !chatDisabled && chatState === ChatState.VISIBLE;
// accounts for sidebar width when online in desktop // accounts for sidebar width when online in desktop
const dynamicPadding = showChat && !isMobile ? '320px' : '0px'; const dynamicPadding = showChat && !isMobile ? '320px' : '0px';
@ -309,7 +310,7 @@ export const Content: FC = () => {
handleClose={() => setShowFollowModal(false)} handleClose={() => setShowFollowModal(false)}
/> />
</Modal> </Modal>
{isMobile && showChatModal && isChatVisible && ( {isMobile && showChatModal && chatState === ChatState.VISIBLE && (
<ChatModal <ChatModal
messages={messages} messages={messages}
currentUser={currentUser} currentUser={currentUser}