diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index e2aa429b9..1cb17bfb9 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -9,6 +9,7 @@ import { AppContextProvider } from './common/context/AppContext'; import { ContextMenuProvider } from './common/context/ContextMenuContext'; import useElectronEvent from './common/hooks/useElectronEvent'; import { ontimeQueryClient } from './common/queryClient'; +import { socketClientName } from './common/stores/connectionName'; import { connectSocket } from './common/utils/socket'; import theme from './theme/theme'; import { TranslationProvider } from './translation/TranslationProvider'; @@ -18,7 +19,8 @@ import AppRouter from './AppRouter'; // @ts-expect-error no types from font import import('typeface-open-sans'); -connectSocket(); +const preferredClientName = socketClientName.getState().name; +connectSocket(preferredClientName); function App() { const { isElectron, sendToElectron } = useElectronEvent(); diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx index 7195e668e..461cc62ce 100644 --- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx @@ -1,6 +1,7 @@ -import { KeyboardEvent, useEffect, useRef, useState } from 'react'; +import { KeyboardEvent, memo, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Link, useLocation, useSearchParams } from 'react-router-dom'; +import { useDisclosure } from '@chakra-ui/react'; import { IoApps } from '@react-icons/all-files/io5/IoApps'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import { IoContract } from '@react-icons/all-files/io5/IoContract'; @@ -14,9 +15,11 @@ import useFullscreen from '../../hooks/useFullscreen'; import { useKeyDown } from '../../hooks/useKeyDown'; import { useViewOptionsStore } from '../../stores/viewOptions'; +import RenameClientModal from './rename-client-modal/RenameClientModal'; + import style from './NavigationMenu.module.scss'; -export default function NavigationMenu() { +function NavigationMenu() { const location = useLocation(); const { isFullScreen, toggleFullScreen } = useFullscreen(); @@ -27,8 +30,10 @@ export default function NavigationMenu() { const menuRef = useRef(null); useClickOutside(menuRef, () => setShowMenu(false)); + const { isOpen, onOpen, onClose } = useDisclosure(); + const toggleMenu = () => setShowMenu((prev) => !prev); - useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' }); + useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen }); useEffect(() => { let fadeOut: NodeJS.Timeout | null = null; @@ -51,6 +56,7 @@ export default function NavigationMenu() { const isKeyEnter = (event: KeyboardEvent) => event.key === 'Enter'; const handleFullscreen = () => toggleFullScreen(); const handleMirror = () => toggleMirror(); + const showEditFormDrawer = () => { searchParams.append('edit', 'true'); setSearchParams(searchParams); @@ -58,6 +64,7 @@ export default function NavigationMenu() { return createPortal(
{navigatorConstants.map((route) => ( @@ -116,3 +131,5 @@ export default function NavigationMenu() { document.body, ); } + +export default memo(NavigationMenu); diff --git a/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.module.scss b/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.module.scss new file mode 100644 index 000000000..6d48a1ba4 --- /dev/null +++ b/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.module.scss @@ -0,0 +1,5 @@ +.modalBody { + display: flex; + flex-direction: column; + gap: 1rem; +} diff --git a/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.tsx b/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.tsx new file mode 100644 index 000000000..10af3d969 --- /dev/null +++ b/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, +} from '@chakra-ui/react'; + +import { setClientName } from '../../../hooks/useSocket'; +import { useSocketClientName } from '../../../stores/connectionName'; + +import style from './RenameClientModal.module.scss'; + +interface RenameClientModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) { + const { name: clientName, persistName } = useSocketClientName(); + const [newName, setNewName] = useState(clientName); + + useEffect(() => { + setNewName(clientName); + }, [isOpen, clientName]); + + const handleRename = async () => { + if (newName) { + await setClientName(newName); + persistName(newName); + onClose(); + } + }; + + return ( + + + + Rename client + + + setNewName(e.target.value)} + variant='ontime-filled-on-light' + /> + + + + + ); +} diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 606b5e9a3..95be26d8f 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -105,3 +105,5 @@ export const useTimer = () => { return useRuntimeStore(featureSelector, deepCompare); }; + +export const setClientName = (newName: string) => socketSendJson('set-client-name', newName); diff --git a/apps/client/src/common/stores/connectionName.ts b/apps/client/src/common/stores/connectionName.ts new file mode 100644 index 000000000..00aaf9eb6 --- /dev/null +++ b/apps/client/src/common/stores/connectionName.ts @@ -0,0 +1,26 @@ +import { useStore } from 'zustand'; +import { createStore } from 'zustand/vanilla'; + +interface SocketClientNameState { + name?: string; + setName: (newValue: string) => void; + persistName: (newValue: string) => void; +} + +const clientNameKey = 'ontime-client-name'; + +function persistKeyToStorage(newValue: string) { + localStorage.setItem(clientNameKey, newValue); +} + +export const socketClientName = createStore((set) => ({ + name: localStorage.getItem(clientNameKey) ?? undefined, + setName: (newValue: string) => set(() => ({ name: newValue })), + persistName: (newValue: string) => + set(() => { + persistKeyToStorage(newValue); + return { name: newValue }; + }), +})); + +export const useSocketClientName = () => useStore(socketClientName); diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index ddeeb21cf..2a54fa16a 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -2,6 +2,7 @@ import { Log, RuntimeStore } from 'ontime-types'; import { RUNTIME, websocketUrl } from '../api/apiConstants'; import { ontimeQueryClient } from '../queryClient'; +import { socketClientName } from '../stores/connectionName'; import { addLog } from '../stores/logger'; import { runtime } from '../stores/runtime'; @@ -11,13 +12,17 @@ const reconnectInterval = 1000; export let shouldReconnect = true; export let hasConnected = false; export let reconnectAttempts = 0; -export const connectSocket = () => { +export const connectSocket = (preferredClientName?: string) => { websocket = new WebSocket(websocketUrl); websocket.onopen = () => { clearTimeout(reconnectTimeout as NodeJS.Timeout); hasConnected = true; reconnectAttempts = 0; + + if (preferredClientName) { + socketSendJson('set-client-name', preferredClientName); + } }; websocket.onclose = () => { @@ -49,6 +54,10 @@ export const connectSocket = () => { // TODO: implement partial store updates switch (type) { + case 'client-name': { + socketClientName.getState().setName(payload); + break; + } case 'ontime-log': { addLog(payload as Log); break; diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts index d5fe7d000..d092c7cca 100644 --- a/apps/server/src/adapters/WebsocketAdapter.ts +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -45,9 +45,9 @@ export class SocketServer implements IAdapter { this.wss = new WebSocketServer({ path: '/ws', server }); this.wss.on('connection', (ws) => { - const clientId = getRandomName(); + let clientId = getRandomName(); this.clientIds.add(clientId); - logger.info('RX', `${this.wss.clients.size} Connections with new: ${clientId}`); + logger.info('CLIENT', `${this.wss.clients.size} Connections with new: ${clientId}`); // send store payload on connect ws.send( @@ -57,10 +57,17 @@ export class SocketServer implements IAdapter { }), ); + ws.send( + JSON.stringify({ + type: 'client-name', + payload: clientId, + }), + ); + ws.on('error', console.error); ws.on('close', () => { - logger.info('RX', `${this.wss.clients.size} Connections with disconnected: ${clientId}`); + logger.info('CLIENT', `${this.wss.clients.size} Connections with disconnected: ${clientId}`); this.clientIds.delete(clientId); }); @@ -69,20 +76,37 @@ export class SocketServer implements IAdapter { ws.close(); } - // TODO: protocol specific stuff should be handled here - // eg: rename-client - // socket.on('rename-client', (newName) => { - // if (newName) { - // const previousName = this._clientNames[socket.id]; - // this._clientNames[socket.id] = newName; - // this.info('CLIENT', `Client ${previousName} renamed to ${newName}`); - // } - // }); - try { const message = JSON.parse(data); const { type, payload } = message; + if (type === 'get-client-name') { + ws.send( + JSON.stringify({ + type: 'client-name', + payload: clientId, + }), + ); + return; + } + + if (type === 'set-client-name') { + if (payload) { + const previousName = clientId; + clientId = payload; + this.clientIds.delete(previousName); + this.clientIds.add(clientId); + logger.info('CLIENT', `Client ${previousName} renamed to ${clientId}`); + } + ws.send( + JSON.stringify({ + type: 'client-name', + payload: clientId, + }), + ); + return; + } + if (type === 'hello') { ws.send('hi'); return; @@ -95,6 +119,7 @@ export class SocketServer implements IAdapter { return; } + // Protocol specific stuff handled above try { const reply = dispatchFromAdapter(type, payload, 'ws'); if (reply) {