From 63d7671fed02e7e7555ac7b0e319768023f36aee Mon Sep 17 00:00:00 2001 From: Ginger Wong Date: Fri, 14 Aug 2020 04:19:19 -0700 Subject: [PATCH] progress wip. separated out chat input component and its respective methods. --- webroot/js/app.js | 12 +- webroot/js/chat/chat-input.js | 211 ++++++++++++++++++++++++++++++ webroot/js/chat/chat.js | 236 ++++++++++++++++++++-------------- webroot/js/chat/message.js | 11 +- webroot/js/chat/standalone.js | 13 +- webroot/js/message.js | 2 +- webroot/js/utils/chat.js | 18 ++- webroot/js/websocket.js | 2 +- webroot/standalone-chat.html | 2 +- 9 files changed, 392 insertions(+), 115 deletions(-) create mode 100644 webroot/js/chat/chat-input.js diff --git a/webroot/js/app.js b/webroot/js/app.js index 1c5a8d9b5..6141b3e8c 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -1,6 +1,6 @@ import Websocket from './websocket.js'; import { MessagingInterface, Message } from './message.js'; -import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js'; +import SOCKET_MESSAGE_TYPES from './utils/socket-message-types.js'; import { OwncastPlayer } from './player.js'; const MESSAGE_OFFLINE = 'Stream is offline.'; @@ -18,7 +18,7 @@ const TIMER_STREAM_DURATION_COUNTER = 1000; class Owncast { constructor() { - this.player; + this.player; this.configData; this.vueApp; @@ -67,7 +67,7 @@ class Owncast { streamStatus: MESSAGE_OFFLINE, // Default state. viewerCount: 0, isOnline: false, - + // from config appVersion: '', extraUserContent: '', @@ -260,7 +260,7 @@ class Owncast { } } }; - + // update vueApp.streamStatus text when online setCurrentStreamDuration() { // Default to something @@ -272,7 +272,7 @@ class Owncast { } this.vueApp.streamStatus = `${MESSAGE_ONLINE} ${streamDurationString}.` } - + handleNetworkingError(error) { console.log(`>>> App Error: ${error}`) }; @@ -329,4 +329,4 @@ class Owncast { }; }; -export default Owncast; \ No newline at end of file +export default Owncast; diff --git a/webroot/js/chat/chat-input.js b/webroot/js/chat/chat-input.js new file mode 100644 index 000000000..421b6a2aa --- /dev/null +++ b/webroot/js/chat/chat-input.js @@ -0,0 +1,211 @@ +import { h, Component, createRef } from 'https://unpkg.com/preact?module'; +import htm from 'https://unpkg.com/htm?module'; +const html = htm.bind(h); + +import { EmojiButton } from 'https://cdn.skypack.dev/@joeattardi/emoji-button'; + +import { URL_CUSTOM_EMOJIS } from '../utils.js'; +import { generatePlaceholderText } from '../utils/chat.js'; + +export default class ChatInput extends Component { + constructor(props, context) { + super(props, context); + this.formMessageInput = createRef(); + + this.messageCharCount = 0; + this.maxMessageLength = 500; + this.maxMessageBuffer = 20; + + this.emojiPicker = null; + + this.prepNewLine = false; + + this.handleEmojiButtonClick = this.handleEmojiButtonClick.bind(this); + this.handleEmojiSelected = this.handleEmojiSelected.bind(this); + this.getCustomEmojis = this.getCustomEmojis.bind(this); + + this.handleMessageInputKeydown = this.handleMessageInputKeydown.bind(this); + this.handleMessageInputKeyup = this.handleMessageInputKeyup.bind(this); + this.handleMessageInputBlur = this.handleMessageInputBlur.bind(this); + this.handleMessageInput = this.handleMessageInput.bind(this); + } + + componentDidMount() { + this.getCustomEmojis(); + } + + getCustomEmojis() { + fetch(URL_CUSTOM_EMOJIS) + .then(response => { + if (!response.ok) { + throw new Error(`Network response was not ok ${response.ok}`); + } + return response.json(); + }) + .then(json => { + this.emojiPicker = new EmojiButton({ + zIndex: 100, + theme: 'dark', + custom: json, + initialCategory: 'custom', + showPreview: false, + position: { + top: '50%', + right: '100' + }, + }); + this.emojiPicker.on('emoji', emoji => { + this.handleEmojiSelected(emoji); + }); + }) + .catch(error => { + // this.handleNetworkingError(`Emoji Fetch: ${error}`); + }); + } + + handleEmojiButtonClick() { + if (this.emojiPicker) { + this.emojiPicker.togglePicker(this.emojiPicker); + } + } + + handleEmojiSelected(emoji) { + if (emoji.url) { + const url = location.protocol + "//" + location.host + "/" + emoji.url; + const name = url.split('\\').pop().split('/').pop(); + document.querySelector('#message-body-form').innerHTML += "\"""; + } else { + document.querySelector('#message-body-form').innerHTML += emoji.emoji; + } + } + + // autocomplete user names + autoCompleteNames() { + const { chatUserNames } = this.props; + const rawValue = this.formMessageInput.innerHTML; + const position = getCaretPosition(this.formMessageInput); + const at = rawValue.lastIndexOf('@', position - 1); + + if (at === -1) { + return false; + } + + var partial = rawValue.substring(at + 1, position).trim(); + + if (partial === this.suggestion) { + partial = this.partial; + } else { + this.partial = partial; + } + + const possibilities = chatUsernames.filter(function (username) { + return username.toLowerCase().startsWith(partial.toLowerCase()); + }); + + if (this.completionIndex === undefined || ++this.completionIndex >= possibilities.length) { + this.completionIndex = 0; + } + + if (possibilities.length > 0) { + this.suggestion = possibilities[this.completionIndex]; + + // TODO: Fix the space not working. I'm guessing because the DOM ignores spaces and it requires a nbsp or something? + this.formMessageInput.innerHTML = rawValue.substring(0, at + 1) + this.suggestion + ' ' + rawValue.substring(position); + setCaretPosition(this.formMessageInput, at + this.suggestion.length + 2); + } + + return true; + } + + handleMessageInputKeydown(event) { + console.log("========this.formMessageInput", this.formMessageInput) + const okCodes = [37,38,39,40,16,91,18,46,8]; + const value = this.formMessageInput.innerHTML.trim(); + const numCharsLeft = this.maxMessageLength - value.length; + if (event.keyCode === 13) { // enter + if (!this.prepNewLine) { + this.submitChat(value); + event.preventDefault(); + this.prepNewLine = false; + + return; + } + } + if (event.keyCode === 16 || event.keyCode === 17) { // ctrl, shift + this.prepNewLine = true; + } + if (event.keyCode === 9) { // tab + if (this.autoCompleteNames()) { + event.preventDefault(); + + // value could have been changed, update variables + value = this.formMessageInput.innerHTML.trim(); + numCharsLeft = this.maxMessageLength - value.length; + } + } + + // if (numCharsLeft <= this.maxMessageBuffer) { + // this.tagMessageFormWarning.innerText = `${numCharsLeft} chars left`; + // if (numCharsLeft <= 0 && !okCodes.includes(event.keyCode)) { + // event.preventDefault(); + // return; + // } + // } else { + // this.tagMessageFormWarning.innerText = ''; + // } + } + + handleMessageInputKeyup(event) { + if (event.keyCode === 16 || event.keyCode === 17) { // ctrl, shift + this.prepNewLine = false; + } + } + + handleMessageInputBlur(event) { + this.prepNewLine = false; + } + + handleMessageInput(event) { + // event.target.value + } + + // setChatPlaceholderText() { + // // NOTE: This is a fake placeholder that is being styled via CSS. + // // You can't just set the .placeholder property because it's not a form element. + // const hasSentFirstChatMessage = getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT); + // const placeholderText = hasSentFirstChatMessage ? CHAT_PLACEHOLDER_TEXT : CHAT_INITIAL_PLACEHOLDER_TEXT; + // this.formMessageInput.setAttribute("placeholder", placeholderText); + // } + + render(props, state) { + const { contenteditable, hasSentFirstChatMessage } = props; + const emojiButtonStyle = { + display: this.emojiPicker ? 'block' : 'none', + }; + + const placeholderText = generatePlaceholderText(contenteditable, hasSentFirstChatMessage); + + return ( + html` +
+
+ +
+ `); + } + +} diff --git a/webroot/js/chat/chat.js b/webroot/js/chat/chat.js index f5f246aa2..c3c1fc484 100644 --- a/webroot/js/chat/chat.js +++ b/webroot/js/chat/chat.js @@ -3,52 +3,49 @@ import htm from 'https://unpkg.com/htm?module'; // Initialize htm with Preact const html = htm.bind(h); -import { EmojiButton } from 'https://cdn.skypack.dev/@joeattardi/emoji-button'; - - +import { getLocalStorage, setLocalStorage } from '../utils.js'; +import { KEY_CHAT_FIRST_MESSAGE_SENT } from '../utils/chat.js'; import SOCKET_MESSAGE_TYPES from '../utils/socket-message-types.js'; import Message from './message.js'; -import Websocket, { CALLBACKS } from '../websocket.js'; +import ChatInput from './chat-input.js'; +import { CALLBACKS } from '../websocket.js'; + -import { URL_CHAT_HISTORY, URL_CUSTOM_EMOJIS } from '../utils.js'; +import { URL_CHAT_HISTORY, setVHvar, hasTouchScreen } from '../utils.js'; export default class Chat extends Component { constructor(props, context) { super(props, context); - this.messageCharCount = 0; - this.maxMessageLength = 500; - this.maxMessageBuffer = 20; - this.state = { - inputEnabled: false, + inputEnabled: true, + hasSentFirstChatMessage: getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT), + inputValue: '', + inputWarning: '', messages: [], chatUserNames: [], + } - this.emojiPicker = null; this.websocket = null; - - this.handleEmojiButtonClick = this.handleEmojiButtonClick.bind(this); - this.handleEmojiSelected = this.handleEmojiSelected.bind(this); - this.getCustomEmojis = this.getCustomEmojis.bind(this); this.getChatHistory = this.getChatHistory.bind(this); this.receivedWebsocketMessage = this.receivedWebsocketMessage.bind(this); this.websocketDisconnected = this.websocketDisconnected.bind(this); + + this.handleSubmitChatButton = this.handleSubmitChatButton.bind(this); } componentDidMount() { - /* - - set up websocket - - get emojis - - get chat history - */ - this.setupWebSocket(); + this.setupWebSocketCallbacks(); this.getChatHistory(); - this.getCustomEmojis(); + if (hasTouchScreen()) { + setVHvar(); + window.addEventListener("orientationchange", setVHvar); + // this.tagAppContainer.classList.add('touch-screen'); + } } componentDidUpdate(prevProps) { @@ -58,13 +55,15 @@ export default class Chat extends Component { if (prevName !== username) { this.sendUsernameChange(prevName, username, userAvatarImage); } - } - setupWebSocket() { - this.websocket = new Websocket(); - this.websocket.addListener(CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED, this.receivedWebsocketMessage); - this.websocket.addListener(CALLBACKS.WEBSOCKET_DISCONNECTED, this.websocketDisconnected); + setupWebSocketCallbacks() { + this.websocket = this.props.websocket; + if (this.websocket) { + this.websocket.addListener(CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED, this.receivedWebsocketMessage); + this.websocket.addListener(CALLBACKS.WEBSOCKET_DISCONNECTED, this.websocketDisconnected); + } + } // fetch chat history @@ -77,6 +76,7 @@ export default class Chat extends Component { return response.json(); }) .then(data => { + console.log("=====chat history data",data) this.setState({ messages: data, }); @@ -86,38 +86,11 @@ export default class Chat extends Component { // this.vueApp.messages = formattedMessages.concat(this.vueApp.messages); }) .catch(error => { - this.handleNetworkingError(`Fetch getChatHistory: ${error}`); + // this.handleNetworkingError(`Fetch getChatHistory: ${error}`); }); } - getCustomEmojis() { - fetch(URL_CUSTOM_EMOJIS) - .then(response => { - if (!response.ok) { - throw new Error(`Network response was not ok ${response.ok}`); - } - return response.json(); - }) - .then(json => { - this.emojiPicker = new EmojiButton({ - zIndex: 100, - theme: 'dark', - custom: json, - initialCategory: 'custom', - showPreview: false, - position: { - top: '50%', - right: '100' - }, - }); - this.emojiPicker.on('emoji', emoji => { - this.handleEmojiSelected(emoji); - }); - }) - .catch(error => { - this.handleNetworkingError(`Emoji Fetch: ${error}`); - }); - } + sendUsernameChange(oldName, newName, image) { const nameChange = { @@ -126,24 +99,10 @@ export default class Chat extends Component { newName: newName, image: image, }; - this.send(nameChange); + this.websocket.send(nameChange); } - handleEmojiButtonClick() { - if (this.emojiPicker) { - this.emojiPicker.togglePicker(this.picker); - } - } - handleEmojiSelected(emoji) { - if (emoji.url) { - const url = location.protocol + "//" + location.host + "/" + emoji.url; - const name = url.split('\\').pop().split('/').pop(); - document.querySelector('#message-body-form').innerHTML += "\"""; - } else { - document.querySelector('#message-body-form').innerHTML += emoji.emoji; - } - } receivedWebsocketMessage(message) { this.addMessage(message); @@ -155,24 +114,117 @@ export default class Chat extends Component { // } } + // if incoming message has same id as existing message, don't add it addMessage(message) { const { messages: curMessages } = this.state; const existing = curMessages.filter(function (item) { return item.id === message.id; }) if (existing.length === 0 || !existing) { - this.setState({ + const newState = { messages: [...curMessages, message], - }); + }; + const updatedChatUserNames = this.updateAuthorList(message); + if (updatedChatUserNames.length) { + newState.chatUserNames = [...updatedChatUserNames]; + } + this.setState(newState); } + + // todo - jump to bottom + // jumpToBottom(this.scrollableMessagesContainer); } websocketDisconnected() { - this.websocket = null; + // this.websocket = null; + this.disableChat() + } + + handleSubmitChatButton(event) { + const { inputValue } = this.state; + var value = inputValue.trim(); + if (value) { + this.submitChat(value); + event.preventDefault(); + return false; + } + event.preventDefault(); + return false; + } + + submitChat(content) { + if (!content) { + return; + } + const { username, userAvatarImage } = this.props; + var message = new Message({ + body: content, + author: username, + image: userAvatarImage, + type: SOCKET_MESSAGE_TYPES.CHAT, + }); + this.websocket.send(message); + + // clear out things. + const newStates = { + inputValue: '', + inputWarning: '', + }; + // this.formMessageInput.innerHTML = ''; + // this.tagMessageFormWarning.innerText = ''; + + // const hasSentFirstChatMessage = getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT); + if (!this.state.hasSentFirstChatMessage) { + newStates.hasSentFirstChatMessage = true; + setLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT, true); + // this.setChatPlaceholderText(); + } + this.setState(newStates); + } + + disableChat() { + this.setState({ + inputEnabled: false, + }); + // if (this.formMessageInput) { + // this.formMessageInput.contentEditable = false; + // this.formMessageInput.innerHTML = ''; + // this.formMessageInput.setAttribute("placeholder", CHAT_PLACEHOLDER_OFFLINE); + // } + } + + enableChat() { + this.setState({ + inputEnabled: true, + }); + // if (this.formMessageInput) { + // this.formMessageInput.contentEditable = true; + // this.setChatPlaceholderText(); + // } + } + + updateAuthorList(message) { + const { type } = message; + const username = ''; + const nameList = this.state.chatUserNames; + + if ( + type === SOCKET_MESSAGE_TYPES.CHAT && + !nameList.includes(message.author) + ) { + return nameList.push(message.author); + } else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) { + const { oldName, newName } = message; + const oldNameIndex = nameList.indexOf(oldName); + return nameList.splice(oldNameIndex, 1, newName); + } + return []; } + + render(props, state) { const { username } = props; - const { messages } = state; + const { messages, inputEnabled, hasSentFirstChatMessage, chatUserNames, inputWarning } = state; return ( html` @@ -180,37 +232,35 @@ export default class Chat extends Component {
${ - messages.map(message => (html`<${Message} message=${message} />`)) + messages.map(message => (html`<${Message} message=${message} username=${username} />`)) } messages..
-
+ - + - - + <${ChatInput} + contenteditable=${inputEnabled} + hasSentFirstChatMessage=${hasSentFirstChatMessage} + chatUserNames=${chatUserNames} + handleSubmitForm=${this.handleSubmitChatButton} + />
- + ${inputWarning}
-
+
@@ -218,9 +268,3 @@ export default class Chat extends Component { } } - - - - - - diff --git a/webroot/js/chat/message.js b/webroot/js/chat/message.js index f6084c9dd..6de5afa82 100644 --- a/webroot/js/chat/message.js +++ b/webroot/js/chat/message.js @@ -7,12 +7,12 @@ import SOCKET_MESSAGE_TYPES from '../utils/socket-message-types.js'; export default class Message extends Component { render(props) { - const { message } = props; + const { message, username } = props; const { type } = message; if (type === SOCKET_MESSAGE_TYPES.CHAT) { const { image, author, body } = message; - const formattedMessage = formatMessageText(body); + const formattedMessage = formatMessageText(body, username); const avatar = image || generateAvatar(author); const avatarBgColor = { backgroundColor: messageBubbleColorForString(author) }; return ( @@ -26,7 +26,12 @@ export default class Message extends Component {

${author}

-

${formattedMessage}

+
`); diff --git a/webroot/js/chat/standalone.js b/webroot/js/chat/standalone.js index e36025044..88c710ef7 100644 --- a/webroot/js/chat/standalone.js +++ b/webroot/js/chat/standalone.js @@ -1,20 +1,23 @@ import { html, Component } from "https://unpkg.com/htm/preact/index.mjs?module"; import UserInfo from './user-info.js'; import Chat from './chat.js'; +import Websocket from '../websocket.js'; import { getLocalStorage, generateAvatar, generateUsername } from '../utils.js'; import { KEY_USERNAME, KEY_AVATAR } from '../utils/chat.js'; -export class StandaloneChat extends Component { +export default class StandaloneChat extends Component { constructor(props, context) { super(props, context); this.state = { + websocket: new Websocket(), chatEnabled: true, // always true for standalone chat username: getLocalStorage(KEY_USERNAME) || generateUsername(), userAvatarImage: getLocalStorage(KEY_AVATAR) || generateAvatar(`${this.username}${Date.now()}`), }; + this.websocket = null; this.handleUsernameChange = this.handleUsernameChange.bind(this); } @@ -30,7 +33,7 @@ export class StandaloneChat extends Component { } render(props, state) { - const { username, userAvatarImage } = state; + const { username, userAvatarImage, websocket } = state; return ( html`
@@ -40,7 +43,11 @@ export class StandaloneChat extends Component { handleUsernameChange=${this.handleUsernameChange} handleChatToggle=${this.handleChatToggle} /> - <${Chat} username=${username} userAvatarImage=${userAvatarImage} chatEnabled /> + <${Chat} + websocket=${websocket} + username=${username} + userAvatarImage=${userAvatarImage} + chatEnabled />
`); } diff --git a/webroot/js/message.js b/webroot/js/message.js index 7d14cba2c..2422fdfd8 100644 --- a/webroot/js/message.js +++ b/webroot/js/message.js @@ -1,4 +1,4 @@ -import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js'; +import SOCKET_MESSAGE_TYPES from './utils/socket-message-types.js'; const KEY_USERNAME = 'owncast_username'; const KEY_AVATAR = 'owncast_avatar'; diff --git a/webroot/js/utils/chat.js b/webroot/js/utils/chat.js index dc26d23ca..37d75a7b8 100644 --- a/webroot/js/utils/chat.js +++ b/webroot/js/utils/chat.js @@ -1,3 +1,5 @@ +import { addNewlines } from '../utils.js'; + export const KEY_USERNAME = 'owncast_username'; export const KEY_AVATAR = 'owncast_avatar'; export const KEY_CHAT_DISPLAYED = 'owncast_chat'; @@ -6,7 +8,7 @@ export const CHAT_INITIAL_PLACEHOLDER_TEXT = 'Type here to chat, no account nece export const CHAT_PLACEHOLDER_TEXT = 'Message'; export const CHAT_PLACEHOLDER_OFFLINE = 'Chat is offline.'; -export function formatMessageText(message) { +export function formatMessageText(message, username) { showdown.setFlavor('github'); let formattedText = new showdown.Converter({ emoji: true, @@ -19,13 +21,13 @@ export function formatMessageText(message) { }).makeHtml(message); formattedText = linkify(formattedText, message); - formattedText = highlightUsername(formattedText); + formattedText = highlightUsername(formattedText, username); return addNewlines(formattedText); } -function highlightUsername(message) { - const username = document.getElementById('self-message-author').value; +function highlightUsername(message, username) { + // const username = document.getElementById('self-message-author').value; const pattern = new RegExp('@?' + username.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'gi'); return message.replace(pattern, '$&'); } @@ -171,3 +173,11 @@ export function setCaretPosition(editableDiv, position) { sel.removeAllRanges(); sel.addRange(range); } + + +export function generatePlaceholderText(isEnabled, hasSentFirstChatMessage) { + if (isEnabled) { + return hasSentFirstChatMessage ? CHAT_PLACEHOLDER_TEXT : CHAT_INITIAL_PLACEHOLDER_TEXT; + } + return CHAT_PLACEHOLDER_OFFLINE; +} diff --git a/webroot/js/websocket.js b/webroot/js/websocket.js index b1ba7dae1..3b558f99f 100644 --- a/webroot/js/websocket.js +++ b/webroot/js/websocket.js @@ -1,4 +1,4 @@ -import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js'; +import SOCKET_MESSAGE_TYPES from './utils/socket-message-types.js'; const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`; diff --git a/webroot/standalone-chat.html b/webroot/standalone-chat.html index 22b871829..5cde0a98c 100644 --- a/webroot/standalone-chat.html +++ b/webroot/standalone-chat.html @@ -14,7 +14,7 @@