mirror of
https://github.com/owncast/owncast.git
synced 2024-11-24 13:50:06 +03:00
Support changing your own name and handling name change events
This commit is contained in:
parent
5a51b2d779
commit
1d213b71d4
12 changed files with 147 additions and 100 deletions
|
@ -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) {
|
||||
|
|
|
@ -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 <div>Component goes here</div>;
|
||||
export default function ChatActionMessage(props: Props) {
|
||||
const { body } = props;
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: body }} />;
|
||||
}
|
||||
|
|
|
@ -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 = <LoadingOutlined style={{ fontSize: '32px' }} spin />;
|
||||
|
||||
const getNameChangeViewForMessage = (message: NameChangeEvent) => {
|
||||
const { oldName } = message;
|
||||
const { user } = message;
|
||||
const { displayName } = user;
|
||||
const body = `<strong>${oldName}</strong> is now known as <strong>${displayName}</strong>`;
|
||||
return <ChatActionMessage body={body} />;
|
||||
};
|
||||
|
||||
const getViewForMessage = message => {
|
||||
switch (message.type) {
|
||||
case MessageType.CHAT:
|
||||
return <ChatUserMessage message={message} showModeratorMenu={false} />;
|
||||
case MessageType.NAME_CHANGE:
|
||||
return getNameChangeViewForMessage(message);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -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<WebsocketService>(websocketServiceAtom);
|
||||
const chatDisplayName = useRecoilValue<string>(chatDisplayNameAtom);
|
||||
const [newName, setNewName] = useState<any>(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 <div>Name change modal component goes here</div>;
|
||||
return (
|
||||
<div>
|
||||
Your chat display name is what people see when you send chat messages. Other information can
|
||||
go here to mention auth, and stuff.
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="Your chat display name"
|
||||
maxLength={10}
|
||||
showCount
|
||||
defaultValue={chatDisplayName}
|
||||
/>
|
||||
<Button disabled={!saveEnabled} onClick={handleNameChange}>
|
||||
Change name
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -31,3 +31,8 @@ export interface ChatEvent extends SocketEvent {
|
|||
user: User;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface NameChangeEvent extends SocketEvent {
|
||||
user: User;
|
||||
oldName: string;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Outbound: Other components can pass an object to `send`.
|
||||
send(message: 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.`);
|
||||
isConnected(): boolean {
|
||||
return this.websocket?.readyState === this.websocket?.OPEN;
|
||||
}
|
||||
|
||||
const messageJSON = JSON.stringify(message);
|
||||
// Outbound: Other components can pass an object to `send`.
|
||||
send(socketEvent: any) {
|
||||
// Sanity check that what we're sending is a valid type.
|
||||
if (!socketEvent.type || !MessageType[socketEvent.type]) {
|
||||
console.warn(`Outbound message: Unknown socket message type: "${socketEvent.type}" sent.`);
|
||||
}
|
||||
|
||||
const messageJSON = JSON.stringify(socketEvent);
|
||||
this.websocket.send(messageJSON);
|
||||
}
|
||||
|
||||
|
|
|
@ -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<typeof NameChangeModal>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const Template: ComponentStory<typeof NameChangeModal> = args => <NameChangeModal />;
|
||||
const Template: ComponentStory<typeof NameChangeModal> = args => (
|
||||
<RecoilRoot>
|
||||
<NameChangeModal />
|
||||
</RecoilRoot>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const Basic = Template.bind({});
|
||||
|
|
|
@ -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 <br>s
|
||||
export function addNewlines(str) {
|
||||
return str.replace(/(?:\r\n|\r|\n)/g, '<br />');
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
47
web/utils/localStorage.ts
Normal file
47
web/utils/localStorage.ts
Normal file
|
@ -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,
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue