mirror of
https://github.com/owncast/owncast.git
synced 2024-11-24 21:59:43 +03:00
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:
parent
fca85a4a42
commit
c563742856
6 changed files with 101 additions and 33 deletions
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Reference in a new issue