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; }