From 1d213b71d42b51d7ed9807d7bc7c73dcfb02c07a Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Thu, 26 May 2022 13:52:04 -0700 Subject: [PATCH] Support changing your own name and handling name change events --- core/chat/events.go | 3 + web/components/chat/ChatActionMessage.tsx | 12 ++-- .../chat/ChatContainer/ChatContainer.tsx | 13 +++- web/components/modals/NameChangeModal.tsx | 45 ++++++++---- web/components/stores/ClientConfigStore.tsx | 6 +- .../eventhandlers/handleNameChangeEvent.tsx | 11 +++ web/components/video/OwncastPlayer.tsx | 2 +- web/interfaces/socket-events.ts | 5 ++ web/services/websocket-service.ts | 25 ++++--- web/stories/NameChangeModal.stories.tsx | 7 +- web/utils/helpers.js | 71 ++----------------- web/utils/localStorage.ts | 47 ++++++++++++ 12 files changed, 147 insertions(+), 100 deletions(-) create mode 100644 web/components/stores/eventhandlers/handleNameChangeEvent.tsx create mode 100644 web/utils/localStorage.ts diff --git a/core/chat/events.go b/core/chat/events.go index 36467264c..da7c19cc9 100644 --- a/core/chat/events.go +++ b/core/chat/events.go @@ -86,6 +86,9 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { receivedEvent.User = savedUser receivedEvent.ClientID = eventData.client.id webhooks.SendChatEventUsernameChanged(receivedEvent) + + // Resend the client's user so their username is in sync. + eventData.client.sendConnectedClientInfo() } func (s *Server) userMessageSent(eventData chatClientEvent) { diff --git a/web/components/chat/ChatActionMessage.tsx b/web/components/chat/ChatActionMessage.tsx index 120e2aaf6..c1c0b9197 100644 --- a/web/components/chat/ChatActionMessage.tsx +++ b/web/components/chat/ChatActionMessage.tsx @@ -1,11 +1,11 @@ -import { ChatMessage } from '../../interfaces/chat-message.model'; - +/* eslint-disable react/no-danger */ interface Props { - // eslint-disable-next-line react/no-unused-prop-types - message: ChatMessage; + body: string; } // eslint-disable-next-line @typescript-eslint/no-unused-vars -export default function ChatSystemMessage(props: Props) { - return
Component goes here
; +export default function ChatActionMessage(props: Props) { + const { body } = props; + + return
; } diff --git a/web/components/chat/ChatContainer/ChatContainer.tsx b/web/components/chat/ChatContainer/ChatContainer.tsx index e87b2a6b8..3b3ac565e 100644 --- a/web/components/chat/ChatContainer/ChatContainer.tsx +++ b/web/components/chat/ChatContainer/ChatContainer.tsx @@ -3,10 +3,11 @@ import { Virtuoso } from 'react-virtuoso'; import { useRef } from 'react'; import { LoadingOutlined } from '@ant-design/icons'; -import { MessageType } from '../../../interfaces/socket-events'; +import { MessageType, NameChangeEvent } from '../../../interfaces/socket-events'; import s from './ChatContainer.module.scss'; import { ChatMessage } from '../../../interfaces/chat-message.model'; import { ChatUserMessage } from '..'; +import ChatActionMessage from '../ChatActionMessage'; interface Props { messages: ChatMessage[]; @@ -19,10 +20,20 @@ export default function ChatContainer(props: Props) { const chatContainerRef = useRef(null); const spinIcon = ; + const getNameChangeViewForMessage = (message: NameChangeEvent) => { + const { oldName } = message; + const { user } = message; + const { displayName } = user; + const body = `${oldName} is now known as ${displayName}`; + return ; + }; + const getViewForMessage = message => { switch (message.type) { case MessageType.CHAT: return ; + case MessageType.NAME_CHANGE: + return getNameChangeViewForMessage(message); default: return null; } diff --git a/web/components/modals/NameChangeModal.tsx b/web/components/modals/NameChangeModal.tsx index 5d4d3aeb2..e851bfa68 100644 --- a/web/components/modals/NameChangeModal.tsx +++ b/web/components/modals/NameChangeModal.tsx @@ -1,25 +1,44 @@ +import { useState } from 'react'; import { useRecoilValue } from 'recoil'; +import { Input, Button } from 'antd'; +import { MessageType } from '../../interfaces/socket-events'; import WebsocketService from '../../services/websocket-service'; -// import { setLocalStorage } from '../../utils/helpers'; -import { websocketServiceAtom } from '../stores/ClientConfigStore'; +import { websocketServiceAtom, chatDisplayNameAtom } from '../stores/ClientConfigStore'; /* eslint-disable @typescript-eslint/no-unused-vars */ interface Props {} export default function NameChangeModal(props: Props) { const websocketService = useRecoilValue(websocketServiceAtom); + const chatDisplayName = useRecoilValue(chatDisplayNameAtom); + const [newName, setNewName] = useState(chatDisplayName); - // const handleNameChange = () => { - // // Send name change - // const nameChange = { - // type: SOCKET_MESSAGE_TYPES.NAME_CHANGE, - // newName, - // }; - // websocketService.send(nameChange); + const handleNameChange = () => { + const nameChange = { + type: MessageType.NAME_CHANGE, + newName, + }; + websocketService.send(nameChange); + }; - // // Store it locally - // setLocalStorage(KEY_USERNAME, newName); - // }; + const saveEnabled = + newName !== chatDisplayName && newName !== '' && websocketService?.isConnected(); - return
Name change modal component goes here
; + return ( +
+ Your chat display name is what people see when you send chat messages. Other information can + go here to mention auth, and stuff. + setNewName(e.target.value)} + placeholder="Your chat display name" + maxLength={10} + showCount + defaultValue={chatDisplayName} + /> + +
+ ); } diff --git a/web/components/stores/ClientConfigStore.tsx b/web/components/stores/ClientConfigStore.tsx index 2c65c9fbd..c3c825917 100644 --- a/web/components/stores/ClientConfigStore.tsx +++ b/web/components/stores/ClientConfigStore.tsx @@ -12,7 +12,7 @@ import appStateModel, { AppStateOptions, makeEmptyAppState, } from './application-state'; -import { setLocalStorage, getLocalStorage } from '../../utils/helpers'; +import { setLocalStorage, getLocalStorage } from '../../utils/localStorage'; import { ConnectedClientInfoEvent, MessageType, @@ -23,6 +23,7 @@ import { import handleChatMessage from './eventhandlers/handleChatMessage'; import handleConnectedClientInfoMessage from './eventhandlers/connected-client-info-handler'; import ServerStatusService from '../../services/status-service'; +import handleNameChangeEvent from './eventhandlers/handleNameChangeEvent'; const SERVER_STATUS_POLL_DURATION = 5000; const ACCESS_TOKEN_KEY = 'accessToken'; @@ -207,6 +208,9 @@ export function ClientConfigStore() { case MessageType.CHAT: handleChatMessage(message as ChatEvent, chatMessages, setChatMessages); break; + case MessageType.NAME_CHANGE: + handleNameChangeEvent(message as ChatEvent, chatMessages, setChatMessages); + break; default: console.error('Unknown socket message type: ', message.type); } diff --git a/web/components/stores/eventhandlers/handleNameChangeEvent.tsx b/web/components/stores/eventhandlers/handleNameChangeEvent.tsx new file mode 100644 index 000000000..f02c11828 --- /dev/null +++ b/web/components/stores/eventhandlers/handleNameChangeEvent.tsx @@ -0,0 +1,11 @@ +import { ChatMessage } from '../../../interfaces/chat-message.model'; +import { ChatEvent } from '../../../interfaces/socket-events'; + +export default function handleNameChangeEvent( + message: ChatEvent, + messages: ChatMessage[], + setChatMessages, +) { + const updatedMessages = [...messages, message]; + setChatMessages(updatedMessages); +} diff --git a/web/components/video/OwncastPlayer.tsx b/web/components/video/OwncastPlayer.tsx index b7cb7333a..d0aebbc62 100644 --- a/web/components/video/OwncastPlayer.tsx +++ b/web/components/video/OwncastPlayer.tsx @@ -3,7 +3,7 @@ import { useRecoilState } from 'recoil'; import VideoJS from './player'; import ViewerPing from './viewer-ping'; import VideoPoster from './VideoPoster'; -import { getLocalStorage, setLocalStorage } from '../../utils/helpers'; +import { getLocalStorage, setLocalStorage } from '../../utils/localStorage'; import { isVideoPlayingAtom } from '../stores/ClientConfigStore'; const PLAYER_VOLUME = 'owncast_volume'; diff --git a/web/interfaces/socket-events.ts b/web/interfaces/socket-events.ts index 1f3066346..46beb88de 100644 --- a/web/interfaces/socket-events.ts +++ b/web/interfaces/socket-events.ts @@ -31,3 +31,8 @@ export interface ChatEvent extends SocketEvent { user: User; body: string; } + +export interface NameChangeEvent extends SocketEvent { + user: User; + oldName: string; +} diff --git a/web/services/websocket-service.ts b/web/services/websocket-service.ts index 2ba819d01..211de9bbf 100644 --- a/web/services/websocket-service.ts +++ b/web/services/websocket-service.ts @@ -1,4 +1,3 @@ -import { message } from 'antd'; import { MessageType, SocketEvent } from '../interfaces/socket-events'; export interface SocketMessage { @@ -76,41 +75,45 @@ export default class WebsocketService { // Optimization where multiple events can be sent within a // single websocket message. So split them if needed. const messages = e.data.split('\n'); - let message: SocketEvent; + let socketEvent: SocketEvent; // eslint-disable-next-line no-plusplus for (let i = 0; i < messages.length; i++) { try { - message = JSON.parse(messages[i]); + socketEvent = JSON.parse(messages[i]); if (this.handleMessage) { - this.handleMessage(message); + this.handleMessage(socketEvent); } } catch (e) { console.error(e, e.data); return; } - if (!message.type) { - console.error('No type provided', message); + if (!socketEvent.type) { + console.error('No type provided', socketEvent); return; } // Send PONGs - if (message.type === MessageType.PING) { + if (socketEvent.type === MessageType.PING) { this.sendPong(); return; } } } + isConnected(): boolean { + return this.websocket?.readyState === this.websocket?.OPEN; + } + // Outbound: Other components can pass an object to `send`. - send(message: any) { + send(socketEvent: any) { // Sanity check that what we're sending is a valid type. - if (!message.type || !MessageType[message.type]) { - console.warn(`Outbound message: Unknown socket message type: "${message.type}" sent.`); + if (!socketEvent.type || !MessageType[socketEvent.type]) { + console.warn(`Outbound message: Unknown socket message type: "${socketEvent.type}" sent.`); } - const messageJSON = JSON.stringify(message); + const messageJSON = JSON.stringify(socketEvent); this.websocket.send(messageJSON); } diff --git a/web/stories/NameChangeModal.stories.tsx b/web/stories/NameChangeModal.stories.tsx index a8033183d..a3557b1a1 100644 --- a/web/stories/NameChangeModal.stories.tsx +++ b/web/stories/NameChangeModal.stories.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { RecoilRoot } from 'recoil'; import NameChangeModal from '../components/modals/NameChangeModal'; export default { @@ -9,7 +10,11 @@ export default { } as ComponentMeta; // eslint-disable-next-line @typescript-eslint/no-unused-vars -const Template: ComponentStory = args => ; +const Template: ComponentStory = args => ( + + + +); // eslint-disable-next-line @typescript-eslint/no-unused-vars export const Basic = Template.bind({}); diff --git a/web/utils/helpers.js b/web/utils/helpers.js index fc2919644..fc675d14b 100644 --- a/web/utils/helpers.js +++ b/web/utils/helpers.js @@ -1,49 +1,3 @@ -import { ORIENTATION_LANDSCAPE, ORIENTATION_PORTRAIT } from './constants.js'; - -export function getLocalStorage(key) { - try { - return localStorage.getItem(key); - } catch (e) {} - return null; -} - -export function setLocalStorage(key, value) { - try { - if (value !== '' && value !== null) { - localStorage.setItem(key, value); - } else { - localStorage.removeItem(key); - } - return true; - } catch (e) {} - return false; -} - -export function clearLocalStorage(key) { - localStorage.removeItem(key); -} - -// jump down to the max height of a div, with a slight delay -export function jumpToBottom(element, behavior) { - if (!element) return; - - if (!behavior) { - behavior = document.visibilityState === 'visible' ? 'smooth' : 'instant'; - } - - setTimeout( - () => { - element.scrollTo({ - top: element.scrollHeight, - left: 0, - behavior: behavior, - }); - }, - 50, - element, - ); -} - // convert newlines to
s export function addNewlines(str) { return str.replace(/(?:\r\n|\r|\n)/g, '
'); @@ -52,9 +6,8 @@ export function addNewlines(str) { export function pluralize(string, count) { if (count === 1) { return string; - } else { - return string + 's'; } + return `${string}s`; } // Trying to determine if browser is mobile/tablet. @@ -66,7 +19,7 @@ export function hasTouchScreen() { } else if ('msMaxTouchPoints' in navigator) { hasTouch = navigator.msMaxTouchPoints > 0; } else { - var mQ = window.matchMedia && matchMedia('(pointer:coarse)'); + const mQ = window.matchMedia && matchMedia('(pointer:coarse)'); if (mQ && mQ.media === '(pointer:coarse)') { hasTouch = !!mQ.matches; } else if ('orientation' in window) { @@ -79,20 +32,6 @@ export function hasTouchScreen() { return hasTouch; } -export function getOrientation(forTouch = false) { - // chrome mobile gives misleading matchMedia result when keyboard is up - if (forTouch && window.screen && window.screen.orientation) { - return window.screen.orientation.type.match('portrait') - ? ORIENTATION_PORTRAIT - : ORIENTATION_LANDSCAPE; - } else { - // all other cases - return window.matchMedia('(orientation: portrait)').matches - ? ORIENTATION_PORTRAIT - : ORIENTATION_LANDSCAPE; - } -} - export function padLeft(text, pad, size) { return String(pad.repeat(size) + text).slice(-size); } @@ -116,7 +55,7 @@ export function parseSecondsToDurationString(seconds = 0) { } export function setVHvar() { - var vh = window.innerHeight * 0.01; + const vh = window.innerHeight * 0.01; // Then we set the value in the --vh custom property to the root of the document document.documentElement.style.setProperty('--vh', `${vh}px`); } @@ -129,7 +68,7 @@ export function doesObjectSupportFunction(object, functionName) { export function classNames(json) { const classes = []; - Object.entries(json).map(function (item) { + Object.entries(json).map(item => { const [key, value] = item; if (value) { classes.push(key); @@ -208,7 +147,7 @@ export function paginateArray(items, page, perPage) { previousPage: page - 1 ? page - 1 : null, nextPage: totalPages > page ? page + 1 : null, total: items.length, - totalPages: totalPages, + totalPages, items: paginatedItems, }; } diff --git a/web/utils/localStorage.ts b/web/utils/localStorage.ts new file mode 100644 index 000000000..5d4c6baa0 --- /dev/null +++ b/web/utils/localStorage.ts @@ -0,0 +1,47 @@ +export const LOCAL_STORAGE_KEYS = { + username: 'username', +}; + +export function getLocalStorage(key) { + try { + return localStorage.getItem(key); + } catch (e) {} + return null; +} + +export function setLocalStorage(key, value) { + try { + if (value !== '' && value !== null) { + localStorage.setItem(key, value); + } else { + localStorage.removeItem(key); + } + return true; + } catch (e) {} + return false; +} + +export function clearLocalStorage(key) { + localStorage.removeItem(key); +} + +// jump down to the max height of a div, with a slight delay +export function jumpToBottom(element, behavior) { + if (!element) return; + + if (!behavior) { + behavior = document.visibilityState === 'visible' ? 'smooth' : 'instant'; + } + + setTimeout( + () => { + element.scrollTo({ + top: element.scrollHeight, + left: 0, + behavior, + }); + }, + 50, + element, + ); +}