From dad802f19a53d296508f01394bc1df264af12a06 Mon Sep 17 00:00:00 2001 From: Ginger Wong Date: Thu, 13 Aug 2020 01:28:25 -0700 Subject: [PATCH] Initial setup for standalone chat with Preact. - set up standalone static page and message related components - start separating out css into smaller more manageable files - start separating out utils into smaller modular files - renaming some files for consistency --- webroot/js/chat/chat.js | 117 ++++++++++++++++++ webroot/js/chat/message.js | 52 ++++++++ webroot/js/chat/standalone.js | 54 ++++++++ webroot/js/chat/user-info.js | 108 ++++++++++++++++ webroot/js/utils.js | 44 +++---- webroot/js/utils/chat.js | 7 ++ .../js/{chat => utils}/socketMessageTypes.js | 2 +- .../{usercolors.js => utils/user-colors.js} | 10 +- webroot/standalone-chat.html | 22 ++++ webroot/styles/message.css | 87 +++++++++++++ webroot/styles/user-content.css | 102 +++++++++++++++ 11 files changed, 578 insertions(+), 27 deletions(-) create mode 100644 webroot/js/chat/chat.js create mode 100644 webroot/js/chat/message.js create mode 100644 webroot/js/chat/standalone.js create mode 100644 webroot/js/chat/user-info.js create mode 100644 webroot/js/utils/chat.js rename webroot/js/{chat => utils}/socketMessageTypes.js (99%) rename webroot/js/{usercolors.js => utils/user-colors.js} (92%) create mode 100644 webroot/standalone-chat.html create mode 100644 webroot/styles/message.css create mode 100644 webroot/styles/user-content.css diff --git a/webroot/js/chat/chat.js b/webroot/js/chat/chat.js new file mode 100644 index 000000000..280abafef --- /dev/null +++ b/webroot/js/chat/chat.js @@ -0,0 +1,117 @@ +import { h, Component, render } from 'https://unpkg.com/preact?module'; +import htm from 'https://unpkg.com/htm?module'; +// Initialize htm with Preact +const html = htm.bind(h); + +import SOCKET_MESSAGE_TYPES from '../utils/socketMessageTypes.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, + messages: [], + + chatUserNames: [], + } + + } + + componentDidMount() { + + } + + componentDidUpdate(prevProps) { + const { username: prevName } = prevProps; + const { username, userAvatarImage } = this.props; + // if username updated, send a message + if (prevName !== username) { + this.sendUsernameChange(prevName, username, userAvatarImage); + } + + } + + sendUsernameChange(oldName, newName, image) { + const nameChange = { + type: SOCKET_MESSAGE_TYPES.NAME_CHANGE, + oldName: oldName, + newName: newName, + image: image, + }; + this.send(nameChange); + } + + render() { + const { username, userAvatarImage } = this.state; + return ( + html` +
+
+
+ messages... + +
+ + +
+
+ + + + + +
+ + +
+
+
+
+
+ `); + } + +} + + + + + + diff --git a/webroot/js/chat/message.js b/webroot/js/chat/message.js new file mode 100644 index 000000000..321c66b47 --- /dev/null +++ b/webroot/js/chat/message.js @@ -0,0 +1,52 @@ +import { h, Component, createRef } from 'https://unpkg.com/preact?module'; +import htm from 'https://unpkg.com/htm?module'; +// Initialize htm with Preact +const html = htm.bind(h); + +import {messageBubbleColorForString } from '../utils/user-colors.js'; + +export default class Message extends Component { + constructor(props, context) { + super(props, context); + + this.state = { + displayForm: false, + }; + + this.handleKeydown = this.handleKeydown.bind(this); + this.handleDisplayForm = this.handleDisplayForm.bind(this); + this.handleHideForm = this.handleHideForm.bind(this); + this.handleUpdateUsername = this.handleUpdateUsername.bind(this); + } + + + + render(props) { + const { message, type } = props; + const { image, author, text } = message; + + const styles = { + info: { + display: displayForm || narrowSpace ? 'none' : 'flex', + }, + form: { + display: displayForm ? 'flex' : 'none', + }, + }; + + return ( + html` +
+
+ +
+
+

{{ message.author }}

+

+
+
+ `); + } +} diff --git a/webroot/js/chat/standalone.js b/webroot/js/chat/standalone.js new file mode 100644 index 000000000..3489f44ff --- /dev/null +++ b/webroot/js/chat/standalone.js @@ -0,0 +1,54 @@ +import { html, Component } from "https://unpkg.com/htm/preact/index.mjs?module"; + +// import { h, Component, render } from 'https://unpkg.com/preact?module'; +// import htm from 'https://unpkg.com/htm?module'; +// Initialize htm with Preact +// const html = htm.bind(h); + +import UserInfo from './user-info.js'; +import Chat from './chat.js'; + +import { getLocalStorage, generateAvatar, generateUsername } from '../utils.js'; +import { KEY_USERNAME, KEY_AVATAR } from '../utils/chat.js'; + +export class StandaloneChat extends Component { + constructor(props, context) { + super(props, context); + + this.state = { + chatEnabled: true, // always true for standalone chat + username: getLocalStorage(KEY_USERNAME) || generateUsername(), + userAvatarImage: getLocalStorage(KEY_AVATAR) || generateAvatar(`${this.username}${Date.now()}`), + }; + + this.handleUsernameChange = this.handleUsernameChange.bind(this); + } + + handleUsernameChange(newName, newAvatar) { + this.setState({ + username: newName, + userAvatarImage: newAvatar, + }); + } + + handleChatToggle() { + return; + } + + render(props, state) { + const { username, userAvatarImage } = state; + return ( + html` +
+ <${UserInfo} + username=${username} + userAvatarImage=${userAvatarImage} + handleUsernameChange=${this.handleUsernameChange} + handleChatToggle=${this.handleChatToggle} + /> + <${Chat} username=${username} userAvatarImage=${userAvatarImage} chatEnabled /> +
+ `); + } + +} diff --git a/webroot/js/chat/user-info.js b/webroot/js/chat/user-info.js new file mode 100644 index 000000000..6cd54958f --- /dev/null +++ b/webroot/js/chat/user-info.js @@ -0,0 +1,108 @@ +import { h, Component, createRef } from 'https://unpkg.com/preact?module'; +import htm from 'https://unpkg.com/htm?module'; +// Initialize htm with Preact +const html = htm.bind(h); + +import { generateAvatar, setLocalStorage } from '../utils.js'; +import { KEY_USERNAME, KEY_AVATAR } from '../utils/chat.js'; + + +export default class UserInfo extends Component { + constructor(props, context) { + super(props, context); + + this.state = { + displayForm: false, + }; + + this.textInput = createRef(); + + this.handleKeydown = this.handleKeydown.bind(this); + this.handleDisplayForm = this.handleDisplayForm.bind(this); + this.handleHideForm = this.handleHideForm.bind(this); + this.handleUpdateUsername = this.handleUpdateUsername.bind(this); + } + + handleDisplayForm() { + this.setState({ + displayForm: true, + }); + } + + handleHideForm() { + this.setState({ + displayForm: false, + }); + } + + handleKeydown(event) { + if (event.keyCode === 13) { // enter + this.handleUpdateUsername(); + } else if (event.keyCode === 27) { // esc + this.handleHideForm(); + } + } + + handleUpdateUsername() { + const { username: curName, handleUsernameChange } = this.props; + let newName = this.textInput.current.value; + newName = newName.trim(); + if (newName !== '' && newName !== curName) { + const newAvatar = generateAvatar(`${newName}${Date.now()}`); + setLocalStorage(KEY_USERNAME, newName); + setLocalStorage(KEY_AVATAR, newAvatar); + if (handleUsernameChange) { + handleUsernameChange(newName, newAvatar); + } + this.handleHideForm(); + } + + } + + render(props, state) { + const { username, userAvatarImage, handleChatToggle } = props; + const { displayForm } = state; + + const narrowSpace = document.body.clientWidth < 640; + const styles = { + info: { + display: displayForm || narrowSpace ? 'none' : 'flex', + }, + form: { + display: displayForm ? 'flex' : 'none', + }, + }; + if (narrowSpace) { + styles.form.display = 'inline-block'; + } + return ( + html` +
+
+
+ + ${username} +
+ +
+ + + +
+
+ +
+ `); + } +} diff --git a/webroot/js/utils.js b/webroot/js/utils.js index ba09ca479..632beb573 100644 --- a/webroot/js/utils.js +++ b/webroot/js/utils.js @@ -1,13 +1,13 @@ -const LOCAL_TEST = window.location.href.indexOf('localhost:') >= 0; -const URL_PREFIX = LOCAL_TEST ? 'http://localhost:8080' : ''; +export const LOCAL_TEST = window.location.href.indexOf('localhost:') >= 0; +export const URL_PREFIX = LOCAL_TEST ? 'http://localhost:8080' : ''; -const POSTER_DEFAULT = `${URL_PREFIX}/img/logo.png`; -const POSTER_THUMB = `${URL_PREFIX}/thumbnail.jpg`; +export const POSTER_DEFAULT = `${URL_PREFIX}/img/logo.png`; +export const POSTER_THUMB = `${URL_PREFIX}/thumbnail.jpg`; -const URL_OWNCAST = 'https://github.com/gabek/owncast'; // used in footer +export const URL_OWNCAST = 'https://github.com/gabek/owncast'; // used in footer -function getLocalStorage(key) { +export function getLocalStorage(key) { try { return localStorage.getItem(key); } catch (e) { @@ -15,7 +15,7 @@ function getLocalStorage(key) { return null; } -function setLocalStorage(key, value) { +export function setLocalStorage(key, value) { try { if (value !== "" && value !== null) { localStorage.setItem(key, value); @@ -27,12 +27,12 @@ function setLocalStorage(key, value) { return false; } -function clearLocalStorage(key) { +export function clearLocalStorage(key) { localStorage.removeItem(key); } // jump down to the max height of a div, with a slight delay -function jumpToBottom(element) { +export function jumpToBottom(element) { if (!element) return; setTimeout(() => { @@ -45,11 +45,11 @@ function jumpToBottom(element) { } // convert newlines to
s -function addNewlines(str) { +export function addNewlines(str) { return str.replace(/(?:\r\n|\r|\n)/g, '
'); } -function pluralize(string, count) { +export function pluralize(string, count) { if (count === 1) { return string; } else { @@ -60,12 +60,12 @@ function pluralize(string, count) { // Trying to determine if browser is mobile/tablet. // Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent -function hasTouchScreen() { +export function hasTouchScreen() { var hasTouchScreen = false; - if ("maxTouchPoints" in navigator) { + if ("maxTouchPoints" in navigator) { hasTouchScreen = navigator.maxTouchPoints > 0; } else if ("msMaxTouchPoints" in navigator) { - hasTouchScreen = navigator.msMaxTouchPoints > 0; + hasTouchScreen = navigator.msMaxTouchPoints > 0; } else { var mQ = window.matchMedia && matchMedia("(pointer:coarse)"); if (mQ && mQ.media === "(pointer:coarse)") { @@ -85,20 +85,20 @@ function hasTouchScreen() { } // generate random avatar from https://robohash.org -function generateAvatar(hash) { +export function generateAvatar(hash) { const avatarSource = 'https://robohash.org/'; const optionSize = '?size=80x80'; - const optionSet = '&set=set3'; + const optionSet = '&set=set3'; const optionBg = ''; // or &bgset=bg1 or bg2 return avatarSource + hash + optionSize + optionSet + optionBg; } -function generateUsername() { +export function generateUsername() { return `User ${(Math.floor(Math.random() * 42) + 1)}`; } -function secondsToHMMSS(seconds = 0) { +export function secondsToHMMSS(seconds = 0) { const finiteSeconds = Number.isFinite(+seconds) ? Math.abs(seconds) : 0; const hours = Math.floor(finiteSeconds / 3600); @@ -113,13 +113,15 @@ function secondsToHMMSS(seconds = 0) { return hoursString + minString + secsString; } -function setVHvar() { +export function setVHvar() { var 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`); console.log("== new vh", vh) } -function doesObjectSupportFunction(object, functionName) { +export function doesObjectSupportFunction(object, functionName) { return typeof object[functionName] === "function"; -} \ No newline at end of file +} + +const DEFAULT_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; diff --git a/webroot/js/utils/chat.js b/webroot/js/utils/chat.js new file mode 100644 index 000000000..2bf744ee9 --- /dev/null +++ b/webroot/js/utils/chat.js @@ -0,0 +1,7 @@ +export const KEY_USERNAME = 'owncast_username'; +export const KEY_AVATAR = 'owncast_avatar'; +export const KEY_CHAT_DISPLAYED = 'owncast_chat'; +export const KEY_CHAT_FIRST_MESSAGE_SENT = 'owncast_first_message_sent'; +export const CHAT_INITIAL_PLACEHOLDER_TEXT = 'Type here to chat, no account necessary.'; +export const CHAT_PLACEHOLDER_TEXT = 'Message'; +export const CHAT_PLACEHOLDER_OFFLINE = 'Chat is offline.'; diff --git a/webroot/js/chat/socketMessageTypes.js b/webroot/js/utils/socketMessageTypes.js similarity index 99% rename from webroot/js/chat/socketMessageTypes.js rename to webroot/js/utils/socketMessageTypes.js index 54116e1b0..f52b57a3e 100644 --- a/webroot/js/chat/socketMessageTypes.js +++ b/webroot/js/utils/socketMessageTypes.js @@ -8,4 +8,4 @@ export default { PING: 'PING', NAME_CHANGE: 'NAME_CHANGE', PONG: 'PONG' -} \ No newline at end of file +}; diff --git a/webroot/js/usercolors.js b/webroot/js/utils/user-colors.js similarity index 92% rename from webroot/js/usercolors.js rename to webroot/js/utils/user-colors.js index bad61fa39..c1b161f5b 100644 --- a/webroot/js/usercolors.js +++ b/webroot/js/utils/user-colors.js @@ -1,4 +1,4 @@ -function getHashFromString(string) { +export function getHashFromString(string) { let hash = 1; for (let i = 0; i < string.length; i++) { const codepoint = string.charCodeAt(i); @@ -8,7 +8,7 @@ function getHashFromString(string) { return Math.abs(hash); } -function digitsFromNumber(number) { +export function digitsFromNumber(number) { const numberString = number.toString(); let digits = []; @@ -50,7 +50,7 @@ function digitsFromNumber(number) { // return filename + '.svg'; // } -function colorForString(str) { +export function colorForString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { // eslint-disable-next-line @@ -65,7 +65,7 @@ function colorForString(str) { return colour; } -function messageBubbleColorForString(str) { +export function messageBubbleColorForString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { // eslint-disable-next-line @@ -85,4 +85,4 @@ function messageBubbleColorForString(str) { b: parseInt(result[3], 16), } : null; return 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ', 0.4)'; -} \ No newline at end of file +} diff --git a/webroot/standalone-chat.html b/webroot/standalone-chat.html new file mode 100644 index 000000000..4eb58c1e4 --- /dev/null +++ b/webroot/standalone-chat.html @@ -0,0 +1,22 @@ + + + + + + + + + + + +
+ + + + diff --git a/webroot/styles/message.css b/webroot/styles/message.css new file mode 100644 index 000000000..67b7b77d6 --- /dev/null +++ b/webroot/styles/message.css @@ -0,0 +1,87 @@ + +#messages-container { + overflow: auto; + padding: 1em 0; +} +#message-input-container { + width: 100%; + padding: 1em; +} + +#message-form { + flex-direction: column; + align-items: flex-end; + margin-bottom: 0; +} +#message-body-form { + font-size: 1em; + height: 60px; +} +#message-body-form:disabled{ + opacity: .5; +} +#message-body-form img { + display: inline; + padding-left: 5px; + padding-right: 5px; +} + +#message-body-form .emoji { + width: 40px; +} + +#message-form-actions { + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.message-text img { + display: inline; + padding-left: 5px; + padding-right: 5px; +} + +.message-text .emoji { + width: 60px; +} + + +.message { + padding: .85em; + align-items: flex-start; +} +.message-avatar { + margin-right: .75em; +} +.message-avatar img { + max-width: unset; + height: 3.0em; + width: 3.0em; + padding: 5px; +} + +.message-content { + font-size: .85em; + max-width: 85%; + word-wrap: break-word; +} +.message-content a { + color: #7F9CF5; /* indigo-400 */ +} +.message-content a:hover { + text-decoration: underline; +} + +.message-text iframe { + width: 100%; + height: 170px; + border-radius: 15px; +} + +/* Emoji picker */ +#emoji-button { + margin: 0 .5em; + font-size: 1.5em +} \ No newline at end of file diff --git a/webroot/styles/user-content.css b/webroot/styles/user-content.css new file mode 100644 index 000000000..7a04ee539 --- /dev/null +++ b/webroot/styles/user-content.css @@ -0,0 +1,102 @@ +.extra-user-content { + padding: 1em 3em 3em 3em; +} + + + +.extra-user-content ol { + list-style: decimal; +} + +.extra-user-content ul { + list-style: unset; +} + +.extra-user-content h1, .extra-user-content h2, .extra-user-content h3, .extra-user-content h4 { + color: #111111; + font-weight: 400; } + +.extra-user-content h1, .extra-user-content h2, .extra-user-content h3, .extra-user-content h4, .extra-user-content h5, .extra-user-content p { + margin-bottom: 24px; + padding: 0; } + +.extra-user-content h1 { + font-size: 48px; } + +.extra-user-content h2 { + font-size: 36px; + margin: 24px 0 6px; } + +.extra-user-content h3 { + font-size: 24px; } + +.extra-user-content h4 { + font-size: 21px; } + +.extra-user-content h5 { + font-size: 18px; } + +.extra-user-content a { + color: #0099ff; + margin: 0; + padding: 0; + vertical-align: baseline; } + +.extra-user-content ul, .extra-user-content ol { + padding: 0; + margin: 0; } + +.extra-user-content li { + line-height: 24px; } + +.extra-user-content li ul, .extra-user-content li ul { + margin-left: 24px; } + +.extra-user-content p, .extra-user-content ul, .extra-user-content ol { + font-size: 16px; + line-height: 24px; + } + +.extra-user-content pre { + padding: 0px 24px; + max-width: 800px; + white-space: pre-wrap; } + +.extra-user-content code { + font-family: Consolas, Monaco, Andale Mono, monospace; + line-height: 1.5; + font-size: 13px; } + +.extra-user-content aside { + display: block; + float: right; + width: 390px; } + +.extra-user-content blockquote { + margin: 1em 2em; + max-width: 476px; } + +.extra-user-content blockquote p { + color: #666; + max-width: 460px; } + +.extra-user-content hr { + width: 540px; + text-align: left; + margin: 0 auto 0 0; + color: #999; } + +.extra-user-content table { + border-collapse: collapse; + margin: 1em 1em; + border: 1px solid #CCC; } + +.extra-user-content table thead { + background-color: #EEE; } + +.extra-user-content table thead td { + color: #666; } + +.extra-user-content table td { + padding: 0.5em 1em; + border: 1px solid #CCC; }