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 { 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<UserDropdownProps> = ({
}) => {
const [showNameChangeModal, setShowNameChangeModal] = 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 toggleChatVisibility = () => {
@ -79,7 +89,7 @@ export const UserDropdown: FC<UserDropdownProps> = ({
return;
}
setChatToggleVisible(!chatToggleVisible);
setChatState(chatState === ChatState.VISIBLE ? ChatState.HIDDEN : ChatState.VISIBLE);
};
const handleChangeName = () => {
@ -90,6 +100,34 @@ export const UserDropdown: FC<UserDropdownProps> = ({
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<UserDropdownProps> = ({
{
enableOnContentEditable: false,
},
[chatToggleVisible],
[chatState === ChatState.VISIBLE],
);
const currentUser = useRecoilValue(currentUserAtom);
@ -115,17 +153,27 @@ export const UserDropdown: FC<UserDropdownProps> = ({
<Menu.Item key="1" icon={<LockOutlined />} onClick={() => setShowAuthModal(true)}>
Authenticate
</Menu.Item>
{showHideChatOption && appState.chatAvailable && (
{canShowHideChat && (
<Menu.Item
key="3"
icon={<MessageOutlined />}
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'}
</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>
);

View file

@ -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,

View file

@ -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<DisplayableError>(fatalErrorStateAtom);
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const isMobile = useRecoilValue<boolean | undefined>(isMobileAtom);
const isChatVisible = useRecoilValue<boolean>(isChatVisibleSelector);
const chatState = useRecoilValue<ChatState>(chatStateAtom);
const layoutRef = useRef<HTMLDivElement>(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(() => {

View file

@ -18,6 +18,7 @@ import {
ConnectedClientInfoEvent,
MessageType,
ChatEvent,
NameChangeEvent,
MessageVisibilityEvent,
SocketEvent,
FediverseEvent,
@ -88,11 +89,6 @@ export const isMobileAtom = atom<boolean | undefined>({
default: undefined,
});
export const chatVisibleToggleAtom = atom<boolean>({
key: 'chatVisibilityToggleAtom',
default: true,
});
export const isVideoPlayingAtom = atom<boolean>({
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<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.
@ -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]);

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]);
}

View file

@ -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<AppStateOptions>(appStateAtom);
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
const isChatVisible = useRecoilValue<boolean>(isChatVisibleSelector);
const chatState = useRecoilValue<ChatState>(chatStateAtom);
const currentUser = useRecoilValue(currentUserAtom);
const serverStatus = useRecoilValue<ServerStatus>(serverStatusState);
const [isMobile, setIsMobile] = useRecoilState<boolean | undefined>(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)}
/>
</Modal>
{isMobile && showChatModal && isChatVisible && (
{isMobile && showChatModal && chatState === ChatState.VISIBLE && (
<ChatModal
messages={messages}
currentUser={currentUser}