Merge pull request #120 from owncast/0809gw-messagemodule
frontend refactor with Preact
1
doc
@ -1 +0,0 @@
|
|||||||
Subproject commit 54a0ee13964c70585c24a9b5869604373faaa926
|
|
||||||
@ -29,23 +29,23 @@
|
|||||||
<meta property="twitter:description" content="{{.Config.Summary}}">
|
<meta property="twitter:description" content="{{.Config.Summary}}">
|
||||||
<meta property="twitter:image" content="{{.Image}}">
|
<meta property="twitter:image" content="{{.Image}}">
|
||||||
|
|
||||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
|
<link rel="apple-touch-icon" sizes="57x57" href="/img/favicon/apple-icon-57x57.png">
|
||||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
|
<link rel="apple-touch-icon" sizes="60x60" href="/img/favicon/apple-icon-60x60.png">
|
||||||
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png">
|
<link rel="apple-touch-icon" sizes="72x72" href="/img/favicon/apple-icon-72x72.png">
|
||||||
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png">
|
<link rel="apple-touch-icon" sizes="76x76" href="/img/favicon/apple-icon-76x76.png">
|
||||||
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png">
|
<link rel="apple-touch-icon" sizes="114x114" href="/img/favicon/apple-icon-114x114.png">
|
||||||
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png">
|
<link rel="apple-touch-icon" sizes="120x120" href="/img/favicon/apple-icon-120x120.png">
|
||||||
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png">
|
<link rel="apple-touch-icon" sizes="144x144" href="/img/favicon/apple-icon-144x144.png">
|
||||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
|
<link rel="apple-touch-icon" sizes="152x152" href="/img/favicon/apple-icon-152x152.png">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-icon-180x180.png">
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
|
<link rel="icon" type="image/png" sizes="192x192" href="/img/favicon/android-icon-192x192.png">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
|
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon/favicon-96x96.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png">
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
|
||||||
<meta name="msapplication-TileColor" content="#ffffff">
|
<meta name="msapplication-TileColor" content="#ffffff">
|
||||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
@ -67,11 +67,11 @@
|
|||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
<h3>Connect with {{.Config.Name}} elsewhere by visiting:</h3>
|
<h3>Connect with {{.Config.Name}} elsewhere by visiting:</h3>
|
||||||
|
|
||||||
{{range .Config.SocialHandles}}
|
{{range .Config.SocialHandles}}
|
||||||
<li><a href="{{.URL}}">{{.Platform}}</a></li>
|
<li><a href="{{.URL}}">{{.Platform}}</a></li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
24
webroot/index-standalone-chat.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||||
|
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
|
||||||
|
<link href="./styles/chat.css" rel="stylesheet" />
|
||||||
|
<link href="./styles/standalone-chat.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
<script src="//unpkg.com/showdown/dist/showdown.min.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="messages-only"></div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { render, html } from 'https://unpkg.com/htm/preact/standalone.module.js';
|
||||||
|
import StandaloneChat from './js/app-standalone-chat.js';
|
||||||
|
render(
|
||||||
|
html`<${StandaloneChat} messagesOnly />`, document.getElementById("messages-only")
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
webroot/index-video-only.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||||
|
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
<link href="//unpkg.com/video.js@7.9.2/dist/video-js.css" rel="stylesheet">
|
||||||
|
<link href="https://unpkg.com/@videojs/themes@1/dist/fantasy/index.css" rel="stylesheet" />
|
||||||
|
<script src="//unpkg.com/video.js@7.9.2/dist/video.js"></script>
|
||||||
|
|
||||||
|
<link href="./styles/video.css" rel="stylesheet" />
|
||||||
|
<link href="./styles/video-only.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
<script src="//unpkg.com/showdown/dist/showdown.min.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="video-only"></div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { render, html } from 'https://unpkg.com/htm/preact/standalone.module.js';
|
||||||
|
import VideoOnly from './js/app-video-only.js';
|
||||||
|
render(html`<${VideoOnly} />`, document.getElementById("video-only"));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,221 +1,74 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||||
|
|
||||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
|
<link rel="apple-touch-icon" sizes="57x57" href="/img/favicon/apple-icon-57x57.png">
|
||||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
|
<link rel="apple-touch-icon" sizes="60x60" href="/img/favicon/apple-icon-60x60.png">
|
||||||
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png">
|
<link rel="apple-touch-icon" sizes="72x72" href="/img/favicon/apple-icon-72x72.png">
|
||||||
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png">
|
<link rel="apple-touch-icon" sizes="76x76" href="/img/favicon/apple-icon-76x76.png">
|
||||||
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png">
|
<link rel="apple-touch-icon" sizes="114x114" href="/img/favicon/apple-icon-114x114.png">
|
||||||
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png">
|
<link rel="apple-touch-icon" sizes="120x120" href="/img/favicon/apple-icon-120x120.png">
|
||||||
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png">
|
<link rel="apple-touch-icon" sizes="144x144" href="/img/favicon/apple-icon-144x144.png">
|
||||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
|
<link rel="apple-touch-icon" sizes="152x152" href="/img/favicon/apple-icon-152x152.png">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-icon-180x180.png">
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
|
<link rel="icon" type="image/png" sizes="192x192" href="/img/favicon/android-icon-192x192.png">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
|
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon/favicon-96x96.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png">
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="msapplication-TileColor" content="#ffffff">
|
<meta name="msapplication-TileColor" content="#ffffff">
|
||||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
|
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
|
||||||
<script src="//cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js"></script>
|
|
||||||
|
|
||||||
<link href="//unpkg.com/video.js@7.9.2/dist/video-js.css" rel="stylesheet">
|
<link href="//unpkg.com/video.js@7.9.2/dist/video-js.css" rel="stylesheet">
|
||||||
<link href="https://unpkg.com/@videojs/themes@1/dist/fantasy/index.css" rel="stylesheet" />
|
<link href="https://unpkg.com/@videojs/themes@1/dist/fantasy/index.css" rel="stylesheet" />
|
||||||
<script src="//unpkg.com/video.js@7.9.2/dist/video.js"></script>
|
<script src="//unpkg.com/video.js@7.9.2/dist/video.js"></script>
|
||||||
<script src="//unpkg.com/showdown/dist/showdown.min.js"></script>
|
<!-- markdown renderer -->
|
||||||
|
<script src="//unpkg.com/showdown/dist/showdown.min.js"></script>
|
||||||
<link href="./styles/layout.css" rel="stylesheet" />
|
<script type="module" src="https://cdn.jsdelivr.net/npm/@justinribeiro/lite-youtube@0.6.2/lite-youtube.js"></script>
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="bg-gray-300 text-gray-800">
|
|
||||||
|
|
||||||
<div id="app-container" class="flex chat">
|
|
||||||
<div id="top-content">
|
|
||||||
<header class="flex border-b border-gray-900 border-solid shadow-md">
|
|
||||||
<h1 v-cloak class="flex text-gray-400">
|
|
||||||
<span
|
|
||||||
id="logo-container"
|
|
||||||
class="rounded-full bg-white px-1 py-1"
|
|
||||||
v-bind:style="{ backgroundImage: 'url(' + logo + ')' }"
|
|
||||||
>
|
|
||||||
<img class="logo visually-hidden" v-bind:src="logo">
|
|
||||||
</span>
|
|
||||||
<span class="instance-title">{{title}}</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div id="user-options-container" class="flex">
|
|
||||||
<div id="user-info">
|
|
||||||
<div id="user-info-display" title="Click to update user name" class="flex">
|
|
||||||
<img
|
|
||||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
|
||||||
alt=""
|
|
||||||
id="username-avatar"
|
|
||||||
class="rounded-full bg-black bg-opacity-50 border border-solid border-gray-700"
|
|
||||||
/>
|
|
||||||
<span id="username-display" class="text-indigo-600"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="user-info-change">
|
|
||||||
<input type="text"
|
|
||||||
id="username-change-input"
|
|
||||||
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-black-500 rounded py-1 px-1 leading-tight focus:bg-white"
|
|
||||||
value="Random Username 123"
|
|
||||||
maxlength="100"
|
|
||||||
placeholder="Update username"
|
|
||||||
>
|
|
||||||
<button id="button-update-username" class="bg-blue-500 hover:bg-blue-700 text-white py-1 px-1 rounded user-btn">Update</button>
|
|
||||||
<button id="button-cancel-change" class="bg-gray-900 hover:bg-gray-800 py-1 px-2 rounded user-btn text-white text-opacity-50" title="cancel">X</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" id="chat-toggle" class="flex bg-gray-800 hover:bg-gray-700">💬</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main v-bind:class="{ online: playerOn }">
|
|
||||||
<div
|
|
||||||
id="video-container"
|
|
||||||
class="flex owncast-video-container bg-black"
|
|
||||||
v-bind:style="{ backgroundImage: 'url(' + logoLarge + ')' }"
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
class="video-js vjs-big-play-centered"
|
|
||||||
id="video"
|
|
||||||
preload="auto"
|
|
||||||
controls
|
|
||||||
playsinline
|
|
||||||
>
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<section id="stream-info" aria-label="Stream status" v-cloak class="flex font-mono bg-gray-900 text-indigo-200 shadow-md border-b border-gray-100 border-solid">
|
<link href="./styles/video.css" rel="stylesheet" />
|
||||||
<span>{{ streamStatus }}</span>
|
<link href="./styles/chat.css" rel="stylesheet" />
|
||||||
<span v-if="isOnline">{{ viewerCount }} {{ 'viewer' | plural(viewerCount) }}.</span>
|
<link href="./styles/user-content.css" rel="stylesheet" />
|
||||||
<span v-if="isOnline">Max {{ sessionMaxViewerCount }} {{ 'viewer' | plural(sessionMaxViewerCount) }}.</span>
|
<link href="./styles/app.css" rel="stylesheet" />
|
||||||
<span v-if="isOnline">{{ overallMaxViewerCount }} overall.</span>
|
</head>
|
||||||
</section>
|
<body class="bg-gray-300 text-gray-800">
|
||||||
</main>
|
<div id="app"></div>
|
||||||
|
|
||||||
<section id="user-content" aria-label="User information">
|
<script type="module">
|
||||||
<user-details
|
import { render, html } from 'https://unpkg.com/htm/preact/standalone.module.js';
|
||||||
v-bind:logo="logo"
|
import App from './js/app.js';
|
||||||
v-bind:platforms="socialHandles"
|
render(html`<${App} />`, document.getElementById("app"));
|
||||||
v-bind:summary="summary"
|
</script>
|
||||||
v-bind:tags="tags"
|
|
||||||
>{{streamerName}}</user-details>
|
|
||||||
|
|
||||||
<div v-html="extraUserContent" class="extra-user-content">{{extraUserContent}}</div>
|
<noscript>
|
||||||
|
<style>
|
||||||
|
.noscript {
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
</section>
|
.noscript a {
|
||||||
|
display: inline;
|
||||||
<owncast-footer v-bind:app-version="appVersion"></owncast-footer>
|
color: blue;
|
||||||
|
text-decoration: underline;
|
||||||
</div>
|
}
|
||||||
|
</style>
|
||||||
<section id="chat-container-wrap" class="flex">
|
<div class="noscript">
|
||||||
<div id="chat-container" class="bg-gray-800">
|
<img src="https://owncast.online/images/logo.png" />
|
||||||
<div id="messages-container">
|
<br/>
|
||||||
<div v-for="message in messages" v-cloak>
|
<p>
|
||||||
<!-- Regular user chat message-->
|
This <a href="https://owncast.online" target="_blank">Owncast</a> stream requires Javascript to play.
|
||||||
<div class="message flex" v-if="message.type === 'CHAT'">
|
</p>
|
||||||
<div class="message-avatar rounded-full flex items-center justify-center" v-bind:style="{ backgroundColor: message.userColor() }">
|
|
||||||
<img
|
|
||||||
v-bind:src="message.image"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="message-content">
|
|
||||||
<p class="message-author text-white font-bold">{{ message.author }}</p>
|
|
||||||
<p class="message-text text-gray-400 font-thin " v-html="message.formatText()"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Username change message -->
|
|
||||||
<div class="message flex" v-else-if="message.type === 'NAME_CHANGE'">
|
|
||||||
<img
|
|
||||||
class="mr-2"
|
|
||||||
width="30px"
|
|
||||||
v-bind:src="message.image"
|
|
||||||
/>
|
|
||||||
<div class="text-white text-center">
|
|
||||||
<span class="font-bold">{{ message.oldName }}</span> is now known as <span class="font-bold">{{ message.newName }}</span>.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div id="message-input-container" class="shadow-md bg-gray-900 border-t border-gray-700 border-solid">
|
|
||||||
<form id="message-form" class="flex">
|
|
||||||
|
|
||||||
<input type="hidden" name="inputAuthor" id="self-message-author" />
|
|
||||||
<div id="message-body-form" contenteditable="true" placeholder=""
|
|
||||||
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-black-500 rounded py-2 px-2 my-2 focus:bg-white"
|
|
||||||
></div>
|
|
||||||
<div id="emoji-button">😏</div>
|
|
||||||
|
|
||||||
<div id="message-form-actions" class="flex">
|
|
||||||
<span id="message-form-warning" class="text-red-600 text-xs"></span>
|
|
||||||
<button
|
|
||||||
id="button-submit-message"
|
|
||||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 rounded"
|
|
||||||
> Chat
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</noscript>
|
||||||
|
</body>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="js/usercolors.js"></script>
|
|
||||||
<script src="js/utils.js?v=2"></script>
|
|
||||||
<script type="module" src="js/message.js?v=2"></script>
|
|
||||||
<script src="js/social.js"></script>
|
|
||||||
<script src="js/components.js"></script>
|
|
||||||
<script type="module">
|
|
||||||
import Owncast from './js/app.js';
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
const app = new Owncast();
|
|
||||||
app.init();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<noscript>
|
|
||||||
<style>
|
|
||||||
[v-cloak] { display: none; }
|
|
||||||
.noscript {
|
|
||||||
text-align: center;
|
|
||||||
padding: 30px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noscript a {
|
|
||||||
display: inline;
|
|
||||||
color: blue;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<div class="noscript">
|
|
||||||
<img src="https://github.com/gabek/owncast/raw/master/doc/logo.png">
|
|
||||||
<br/>
|
|
||||||
<p>
|
|
||||||
This <a href="https://github.com/gabek/owncast" target="_blank">Owncast</a> stream requires Javascript to play.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
<script type='module' src="/js/emoji.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
46
webroot/js/app-standalone-chat.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||||
|
import htm from 'https://unpkg.com/htm?module';
|
||||||
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
import Chat from './components/chat/chat.js';
|
||||||
|
import Websocket from './utils/websocket.js';
|
||||||
|
import { getLocalStorage, generateAvatar, generateUsername } from './utils/helpers.js';
|
||||||
|
import { KEY_USERNAME, KEY_AVATAR } from './utils/constants.js';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUsernameChange(newName, newAvatar) {
|
||||||
|
this.setState({
|
||||||
|
username: newName,
|
||||||
|
userAvatarImage: newAvatar,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render(props, state) {
|
||||||
|
const { username, userAvatarImage, websocket } = state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
html`
|
||||||
|
<${Chat}
|
||||||
|
websocket=${websocket}
|
||||||
|
username=${username}
|
||||||
|
userAvatarImage=${userAvatarImage}
|
||||||
|
messagesOnly
|
||||||
|
/>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
265
webroot/js/app-video-only.js
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||||
|
import htm from 'https://unpkg.com/htm?module';
|
||||||
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
import { OwncastPlayer } from './components/player.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addNewlines,
|
||||||
|
pluralize,
|
||||||
|
} from './utils/helpers.js';
|
||||||
|
import {
|
||||||
|
URL_CONFIG,
|
||||||
|
URL_STATUS,
|
||||||
|
TIMER_STATUS_UPDATE,
|
||||||
|
TIMER_STREAM_DURATION_COUNTER,
|
||||||
|
TEMP_IMAGE,
|
||||||
|
MESSAGE_OFFLINE,
|
||||||
|
MESSAGE_ONLINE,
|
||||||
|
} from './utils/constants.js';
|
||||||
|
|
||||||
|
export default class VideoOnly extends Component {
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
configData: {},
|
||||||
|
|
||||||
|
playerActive: false, // player object is active
|
||||||
|
streamOnline: false, // stream is active/online
|
||||||
|
|
||||||
|
//status
|
||||||
|
streamStatusMessage: MESSAGE_OFFLINE,
|
||||||
|
viewerCount: '',
|
||||||
|
sessionMaxViewerCount: '',
|
||||||
|
overallMaxViewerCount: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// timers
|
||||||
|
this.playerRestartTimer = null;
|
||||||
|
this.offlineTimer = null;
|
||||||
|
this.statusTimer = null;
|
||||||
|
this.streamDurationTimer = null;
|
||||||
|
|
||||||
|
this.handleOfflineMode = this.handleOfflineMode.bind(this);
|
||||||
|
this.handleOnlineMode = this.handleOnlineMode.bind(this);
|
||||||
|
|
||||||
|
// player events
|
||||||
|
this.handlePlayerReady = this.handlePlayerReady.bind(this);
|
||||||
|
this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this);
|
||||||
|
this.handlePlayerEnded = this.handlePlayerEnded.bind(this);
|
||||||
|
this.handlePlayerError = this.handlePlayerError.bind(this);
|
||||||
|
|
||||||
|
// fetch events
|
||||||
|
this.getConfig = this.getConfig.bind(this);
|
||||||
|
this.getStreamStatus = this.getStreamStatus.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.getConfig();
|
||||||
|
|
||||||
|
this.player = new OwncastPlayer();
|
||||||
|
this.player.setupPlayerCallbacks({
|
||||||
|
onReady: this.handlePlayerReady,
|
||||||
|
onPlaying: this.handlePlayerPlaying,
|
||||||
|
onEnded: this.handlePlayerEnded,
|
||||||
|
onError: this.handlePlayerError,
|
||||||
|
});
|
||||||
|
this.player.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
// clear all the timers
|
||||||
|
clearInterval(this.playerRestartTimer);
|
||||||
|
clearInterval(this.offlineTimer);
|
||||||
|
clearInterval(this.statusTimer);
|
||||||
|
clearInterval(this.streamDurationTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch /config data
|
||||||
|
getConfig() {
|
||||||
|
fetch(URL_CONFIG)
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Network response was not ok ${response.ok}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(json => {
|
||||||
|
this.setConfigData(json);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.handleNetworkingError(`Fetch config: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch stream status
|
||||||
|
getStreamStatus() {
|
||||||
|
fetch(URL_STATUS)
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Network response was not ok ${response.ok}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(json => {
|
||||||
|
this.updateStreamStatus(json);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.handleOfflineMode();
|
||||||
|
this.handleNetworkingError(`Stream status: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfigData(data = {}) {
|
||||||
|
const { title, summary } = data;
|
||||||
|
window.document.title = title;
|
||||||
|
this.setState({
|
||||||
|
configData: {
|
||||||
|
...data,
|
||||||
|
summary: summary && addNewlines(summary),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle UI things from stream status result
|
||||||
|
updateStreamStatus(status = {}) {
|
||||||
|
const { streamOnline: curStreamOnline } = this.state;
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
viewerCount,
|
||||||
|
sessionMaxViewerCount,
|
||||||
|
overallMaxViewerCount,
|
||||||
|
online,
|
||||||
|
} = status;
|
||||||
|
|
||||||
|
this.lastDisconnectTime = status.lastDisconnectTime;
|
||||||
|
|
||||||
|
if (status.online && !curStreamOnline) {
|
||||||
|
// stream has just come online.
|
||||||
|
this.handleOnlineMode();
|
||||||
|
} else if (!status.online && curStreamOnline) {
|
||||||
|
// stream has just flipped offline.
|
||||||
|
this.handleOfflineMode();
|
||||||
|
}
|
||||||
|
if (status.online) {
|
||||||
|
// only do this if video is paused, so no unnecessary img fetches
|
||||||
|
if (this.player.vjsPlayer && this.player.vjsPlayer.paused()) {
|
||||||
|
this.player.setPoster();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setState({
|
||||||
|
viewerCount,
|
||||||
|
sessionMaxViewerCount,
|
||||||
|
overallMaxViewerCount,
|
||||||
|
streamOnline: online,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// when videojs player is ready, start polling for stream
|
||||||
|
handlePlayerReady() {
|
||||||
|
this.getStreamStatus();
|
||||||
|
this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePlayerPlaying() {
|
||||||
|
// do something?
|
||||||
|
}
|
||||||
|
|
||||||
|
// likely called some time after stream status has gone offline.
|
||||||
|
// basically hide video and show underlying "poster"
|
||||||
|
handlePlayerEnded() {
|
||||||
|
this.setState({
|
||||||
|
playerActive: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePlayerError() {
|
||||||
|
// do something?
|
||||||
|
this.handleOfflineMode();
|
||||||
|
this.handlePlayerEnded();
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop status timer and disable chat after some time.
|
||||||
|
handleOfflineMode() {
|
||||||
|
clearInterval(this.streamDurationTimer);
|
||||||
|
this.setState({
|
||||||
|
streamOnline: false,
|
||||||
|
streamStatusMessage: MESSAGE_OFFLINE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// play video!
|
||||||
|
handleOnlineMode() {
|
||||||
|
this.player.startPlayer();
|
||||||
|
|
||||||
|
this.streamDurationTimer =
|
||||||
|
setInterval(this.setCurrentStreamDuration, TIMER_STREAM_DURATION_COUNTER);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
playerActive: true,
|
||||||
|
streamOnline: true,
|
||||||
|
streamStatusMessage: MESSAGE_ONLINE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNetworkingError(error) {
|
||||||
|
console.log(`>>> App Error: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(props, state) {
|
||||||
|
const {
|
||||||
|
configData,
|
||||||
|
|
||||||
|
viewerCount,
|
||||||
|
sessionMaxViewerCount,
|
||||||
|
overallMaxViewerCount,
|
||||||
|
playerActive,
|
||||||
|
streamOnline,
|
||||||
|
streamStatusMessage,
|
||||||
|
} = state;
|
||||||
|
|
||||||
|
const {
|
||||||
|
version: appVersion,
|
||||||
|
logo = {},
|
||||||
|
socialHandles = [],
|
||||||
|
name: streamerName,
|
||||||
|
summary,
|
||||||
|
tags = [],
|
||||||
|
title,
|
||||||
|
} = configData;
|
||||||
|
const { small: smallLogo = TEMP_IMAGE, large: largeLogo = TEMP_IMAGE } = logo;
|
||||||
|
|
||||||
|
const bgLogoLarge = { backgroundImage: `url(${largeLogo})` };
|
||||||
|
|
||||||
|
const mainClass = playerActive ? 'online' : '';
|
||||||
|
return (
|
||||||
|
html`
|
||||||
|
<main class=${mainClass}>
|
||||||
|
<div
|
||||||
|
id="video-container"
|
||||||
|
class="flex owncast-video-container bg-black w-full bg-center bg-no-repeat flex flex-col items-center justify-start"
|
||||||
|
style=${bgLogoLarge}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
class="video-js vjs-big-play-centered display-block w-full h-full"
|
||||||
|
id="video"
|
||||||
|
preload="auto"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
></video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="stream-info" aria-label="Stream status" class="flex text-center flex-row justify-between items-center font-mono py-2 px-8 bg-gray-900 text-indigo-200">
|
||||||
|
<span>${streamStatusMessage}</span>
|
||||||
|
<span>${viewerCount} ${pluralize('viewer', viewerCount)}.</span>
|
||||||
|
<span>Max ${pluralize('viewer', sessionMaxViewerCount)}.</span>
|
||||||
|
<span>${overallMaxViewerCount} overall.</span>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,28 +1,69 @@
|
|||||||
import Websocket from './websocket.js';
|
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||||
import { MessagingInterface, Message } from './message.js';
|
import htm from 'https://unpkg.com/htm?module';
|
||||||
import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js';
|
const html = htm.bind(h);
|
||||||
import { OwncastPlayer } from './player.js';
|
|
||||||
|
|
||||||
const MESSAGE_OFFLINE = 'Stream is offline.';
|
import { OwncastPlayer } from './components/player.js';
|
||||||
const MESSAGE_ONLINE = 'Stream is online';
|
import SocialIcon from './components/social.js';
|
||||||
|
import UsernameForm from './components/chat/username.js';
|
||||||
|
import Chat from './components/chat/chat.js';
|
||||||
|
import Websocket from './utils/websocket.js';
|
||||||
|
|
||||||
const TEMP_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
import {
|
||||||
|
addNewlines,
|
||||||
|
classNames,
|
||||||
|
clearLocalStorage,
|
||||||
|
debounce,
|
||||||
|
generateAvatar,
|
||||||
|
generateUsername,
|
||||||
|
getLocalStorage,
|
||||||
|
pluralize,
|
||||||
|
setLocalStorage,
|
||||||
|
} from './utils/helpers.js';
|
||||||
|
import {
|
||||||
|
HEIGHT_SHORT_WIDE,
|
||||||
|
KEY_AVATAR,
|
||||||
|
KEY_CHAT_DISPLAYED,
|
||||||
|
KEY_USERNAME,
|
||||||
|
MESSAGE_OFFLINE,
|
||||||
|
MESSAGE_ONLINE,
|
||||||
|
TEMP_IMAGE,
|
||||||
|
TIMER_DISABLE_CHAT_AFTER_OFFLINE,
|
||||||
|
TIMER_STATUS_UPDATE,
|
||||||
|
TIMER_STREAM_DURATION_COUNTER,
|
||||||
|
URL_CONFIG,
|
||||||
|
URL_OWNCAST,
|
||||||
|
URL_STATUS,
|
||||||
|
WIDTH_SINGLE_COL,
|
||||||
|
} from './utils/constants.js';
|
||||||
|
|
||||||
const URL_CONFIG = `/config`;
|
export default class App extends Component {
|
||||||
const URL_STATUS = `/status`;
|
constructor(props, context) {
|
||||||
const URL_CHAT_HISTORY = `/chat`;
|
super(props, context);
|
||||||
|
|
||||||
const TIMER_STATUS_UPDATE = 5000; // ms
|
this.state = {
|
||||||
const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins
|
websocket: new Websocket(),
|
||||||
const TIMER_STREAM_DURATION_COUNTER = 1000;
|
displayChat: getLocalStorage(KEY_CHAT_DISPLAYED), // chat panel state
|
||||||
|
chatEnabled: false, // chat input box state
|
||||||
|
username: getLocalStorage(KEY_USERNAME) || generateUsername(),
|
||||||
|
userAvatarImage:
|
||||||
|
getLocalStorage(KEY_AVATAR) || generateAvatar(`${this.username}${Date.now()}`),
|
||||||
|
|
||||||
class Owncast {
|
configData: {},
|
||||||
constructor() {
|
extraUserContent: '',
|
||||||
this.player;
|
|
||||||
|
|
||||||
this.configData;
|
playerActive: false, // player object is active
|
||||||
this.vueApp;
|
streamOnline: false, // stream is active/online
|
||||||
this.messagingInterface = null;
|
|
||||||
|
// status
|
||||||
|
streamStatusMessage: MESSAGE_OFFLINE,
|
||||||
|
viewerCount: '',
|
||||||
|
sessionMaxViewerCount: '',
|
||||||
|
overallMaxViewerCount: '',
|
||||||
|
|
||||||
|
// dom
|
||||||
|
windowWidth: window.innerWidth,
|
||||||
|
windowHeight: window.innerHeight,
|
||||||
|
};
|
||||||
|
|
||||||
// timers
|
// timers
|
||||||
this.playerRestartTimer = null;
|
this.playerRestartTimer = null;
|
||||||
@ -31,67 +72,30 @@ class Owncast {
|
|||||||
this.disableChatTimer = null;
|
this.disableChatTimer = null;
|
||||||
this.streamDurationTimer = null;
|
this.streamDurationTimer = null;
|
||||||
|
|
||||||
// misc
|
// misc dom events
|
||||||
this.streamStatus = null;
|
this.handleChatPanelToggle = this.handleChatPanelToggle.bind(this);
|
||||||
|
this.handleUsernameChange = this.handleUsernameChange.bind(this);
|
||||||
|
this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 400);
|
||||||
|
|
||||||
Vue.filter('plural', pluralize);
|
|
||||||
|
|
||||||
// bindings
|
|
||||||
this.vueAppMounted = this.vueAppMounted.bind(this);
|
|
||||||
this.setConfigData = this.setConfigData.bind(this);
|
|
||||||
this.getStreamStatus = this.getStreamStatus.bind(this);
|
|
||||||
this.getExtraUserContent = this.getExtraUserContent.bind(this);
|
|
||||||
this.updateStreamStatus = this.updateStreamStatus.bind(this);
|
|
||||||
this.handleNetworkingError = this.handleNetworkingError.bind(this);
|
|
||||||
this.handleOfflineMode = this.handleOfflineMode.bind(this);
|
this.handleOfflineMode = this.handleOfflineMode.bind(this);
|
||||||
this.handleOnlineMode = this.handleOnlineMode.bind(this);
|
this.handleOnlineMode = this.handleOnlineMode.bind(this);
|
||||||
this.handleNetworkingError = this.handleNetworkingError.bind(this);
|
this.disableChatInput = this.disableChatInput.bind(this);
|
||||||
|
|
||||||
|
// player events
|
||||||
this.handlePlayerReady = this.handlePlayerReady.bind(this);
|
this.handlePlayerReady = this.handlePlayerReady.bind(this);
|
||||||
this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this);
|
this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this);
|
||||||
this.handlePlayerEnded = this.handlePlayerEnded.bind(this);
|
this.handlePlayerEnded = this.handlePlayerEnded.bind(this);
|
||||||
this.handlePlayerError = this.handlePlayerError.bind(this);
|
this.handlePlayerError = this.handlePlayerError.bind(this);
|
||||||
this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this);
|
|
||||||
|
// fetch events
|
||||||
|
this.getConfig = this.getConfig.bind(this);
|
||||||
|
this.getStreamStatus = this.getStreamStatus.bind(this);
|
||||||
|
this.getExtraUserContent = this.getExtraUserContent.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
componentDidMount() {
|
||||||
this.messagingInterface = new MessagingInterface();
|
|
||||||
this.setupWebsocket();
|
|
||||||
|
|
||||||
this.vueApp = new Vue({
|
|
||||||
el: '#app-container',
|
|
||||||
data: {
|
|
||||||
playerOn: false,
|
|
||||||
messages: [],
|
|
||||||
overallMaxViewerCount: 0,
|
|
||||||
sessionMaxViewerCount: 0,
|
|
||||||
streamStatus: MESSAGE_OFFLINE, // Default state.
|
|
||||||
viewerCount: 0,
|
|
||||||
isOnline: false,
|
|
||||||
|
|
||||||
// from config
|
|
||||||
appVersion: '',
|
|
||||||
extraUserContent: '',
|
|
||||||
logo: TEMP_IMAGE,
|
|
||||||
logoLarge: TEMP_IMAGE,
|
|
||||||
socialHandles: [],
|
|
||||||
streamerName: '',
|
|
||||||
summary: '',
|
|
||||||
tags: [],
|
|
||||||
title: '',
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
messages: {
|
|
||||||
deep: true,
|
|
||||||
handler: this.messagingInterface.onReceivedMessages,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted: this.vueAppMounted,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// do all these things after Vue.js has mounted, else we'll get weird DOM issues.
|
|
||||||
vueAppMounted() {
|
|
||||||
this.getConfig();
|
this.getConfig();
|
||||||
this.messagingInterface.init();
|
window.addEventListener('resize', this.handleWindowResize);
|
||||||
|
|
||||||
this.player = new OwncastPlayer();
|
this.player = new OwncastPlayer();
|
||||||
this.player.setupPlayerCallbacks({
|
this.player.setupPlayerCallbacks({
|
||||||
@ -101,50 +105,16 @@ class Owncast {
|
|||||||
onError: this.handlePlayerError,
|
onError: this.handlePlayerError,
|
||||||
});
|
});
|
||||||
this.player.init();
|
this.player.init();
|
||||||
|
|
||||||
this.getChatHistory();
|
|
||||||
};
|
|
||||||
|
|
||||||
setConfigData(data) {
|
|
||||||
this.vueApp.appVersion = data.version;
|
|
||||||
this.vueApp.logo = data.logo.small;
|
|
||||||
this.vueApp.logoLarge = data.logo.large;
|
|
||||||
this.vueApp.socialHandles = data.socialHandles;
|
|
||||||
this.vueApp.streamerName = data.name;
|
|
||||||
this.vueApp.summary = data.summary && addNewlines(data.summary);
|
|
||||||
this.vueApp.tags = data.tags;
|
|
||||||
this.vueApp.title = data.title;
|
|
||||||
|
|
||||||
window.document.title = data.title;
|
|
||||||
|
|
||||||
this.getExtraUserContent(`${data.extraUserInfoFileName}`);
|
|
||||||
|
|
||||||
this.configData = data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// websocket for messaging
|
componentWillUnmount() {
|
||||||
setupWebsocket() {
|
// clear all the timers
|
||||||
this.websocket = new Websocket();
|
clearInterval(this.playerRestartTimer);
|
||||||
this.websocket.addListener('rawWebsocketMessageReceived', this.receivedWebsocketMessage.bind(this));
|
clearInterval(this.offlineTimer);
|
||||||
this.messagingInterface.send = this.websocket.send;
|
clearInterval(this.statusTimer);
|
||||||
};
|
clearTimeout(this.disableChatTimer);
|
||||||
|
clearInterval(this.streamDurationTimer);
|
||||||
receivedWebsocketMessage(model) {
|
window.removeEventListener('resize', this.handleWindowResize);
|
||||||
if (model.type === SOCKET_MESSAGE_TYPES.CHAT) {
|
|
||||||
const message = new Message(model);
|
|
||||||
this.addMessage(message);
|
|
||||||
} else if (model.type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
|
|
||||||
this.addMessage(model);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessage(message) {
|
|
||||||
const existing = this.vueApp.messages.filter(function (item) {
|
|
||||||
return item.id === message.id;
|
|
||||||
})
|
|
||||||
if (existing.length === 0 || !existing) {
|
|
||||||
this.vueApp.messages = [...this.vueApp.messages, message];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch /config data
|
// fetch /config data
|
||||||
@ -180,7 +150,7 @@ class Owncast {
|
|||||||
this.handleOfflineMode();
|
this.handleOfflineMode();
|
||||||
this.handleNetworkingError(`Stream status: ${error}`);
|
this.handleNetworkingError(`Stream status: ${error}`);
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
// fetch content.md
|
// fetch content.md
|
||||||
getExtraUserContent(path) {
|
getExtraUserContent(path) {
|
||||||
@ -192,141 +162,319 @@ class Owncast {
|
|||||||
return response.text();
|
return response.text();
|
||||||
})
|
})
|
||||||
.then(text => {
|
.then(text => {
|
||||||
const descriptionHTML = new showdown.Converter().makeHtml(text);
|
this.setState({
|
||||||
this.vueApp.extraUserContent = descriptionHTML;
|
extraUserContent: new showdown.Converter().makeHtml(text),
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
this.handleNetworkingError(`Fetch extra content: ${error}`);
|
this.handleNetworkingError(`Fetch extra content: ${error}`);
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
// fetch chat history
|
setConfigData(data = {}) {
|
||||||
getChatHistory() {
|
const { title, extraUserInfoFileName, summary } = data;
|
||||||
fetch(URL_CHAT_HISTORY)
|
|
||||||
.then(response => {
|
window.document.title = title;
|
||||||
if (!response.ok) {
|
if (extraUserInfoFileName) {
|
||||||
throw new Error(`Network response was not ok ${response.ok}`);
|
this.getExtraUserContent(extraUserInfoFileName);
|
||||||
}
|
}
|
||||||
return response.json();
|
|
||||||
})
|
this.setState({
|
||||||
.then(data => {
|
configData: {
|
||||||
const formattedMessages = data.map(function (message) {
|
...data,
|
||||||
return new Message(message);
|
summary: summary && addNewlines(summary),
|
||||||
})
|
},
|
||||||
this.vueApp.messages = formattedMessages.concat(this.vueApp.messages);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
this.handleNetworkingError(`Fetch getChatHistory: ${error}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// handle UI things from stream status result
|
// handle UI things from stream status result
|
||||||
updateStreamStatus(status = {}) {
|
updateStreamStatus(status = {}) {
|
||||||
|
const { streamOnline: curStreamOnline } = this.state;
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// update UI
|
const {
|
||||||
this.vueApp.viewerCount = status.viewerCount;
|
viewerCount,
|
||||||
this.vueApp.sessionMaxViewerCount = status.sessionMaxViewerCount;
|
sessionMaxViewerCount,
|
||||||
this.vueApp.overallMaxViewerCount = status.overallMaxViewerCount;
|
overallMaxViewerCount,
|
||||||
|
online,
|
||||||
|
} = status;
|
||||||
|
|
||||||
this.lastDisconnectTime = status.lastDisconnectTime;
|
this.lastDisconnectTime = status.lastDisconnectTime;
|
||||||
|
|
||||||
if (!this.streamStatus) {
|
if (status.online && !curStreamOnline) {
|
||||||
// display offline mode the first time we get status, and it's offline.
|
// stream has just come online.
|
||||||
if (!status.online) {
|
this.handleOnlineMode();
|
||||||
this.handleOfflineMode();
|
} else if (!status.online && curStreamOnline) {
|
||||||
} else {
|
// stream has just flipped offline.
|
||||||
this.handleOnlineMode();
|
this.handleOfflineMode();
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (status.online && !this.streamStatus.online) {
|
|
||||||
// stream has just come online.
|
|
||||||
this.handleOnlineMode();
|
|
||||||
} else if (!status.online && this.streamStatus.online) {
|
|
||||||
// stream has just flipped offline.
|
|
||||||
this.handleOfflineMode();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// keep a local copy
|
|
||||||
this.streamStatus = status;
|
|
||||||
|
|
||||||
if (status.online) {
|
if (status.online) {
|
||||||
// only do this if video is paused, so no unnecessary img fetches
|
// only do this if video is paused, so no unnecessary img fetches
|
||||||
if (this.player.vjsPlayer && this.player.vjsPlayer.paused()) {
|
if (this.player.vjsPlayer && this.player.vjsPlayer.paused()) {
|
||||||
this.player.setPoster();
|
this.player.setPoster();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
this.setState({
|
||||||
|
viewerCount,
|
||||||
// update vueApp.streamStatus text when online
|
sessionMaxViewerCount,
|
||||||
setCurrentStreamDuration() {
|
overallMaxViewerCount,
|
||||||
// Default to something
|
streamOnline: online,
|
||||||
let streamDurationString = '';
|
});
|
||||||
|
|
||||||
if (this.streamStatus.lastConnectTime) {
|
|
||||||
const diff = (Date.now() - Date.parse(this.streamStatus.lastConnectTime)) / 1000;
|
|
||||||
streamDurationString = secondsToHMMSS(diff);
|
|
||||||
}
|
|
||||||
this.vueApp.streamStatus = `${MESSAGE_ONLINE} ${streamDurationString}.`
|
|
||||||
}
|
|
||||||
|
|
||||||
handleNetworkingError(error) {
|
|
||||||
console.log(`>>> App Error: ${error}`)
|
|
||||||
};
|
|
||||||
|
|
||||||
// stop status timer and disable chat after some time.
|
|
||||||
handleOfflineMode() {
|
|
||||||
this.vueApp.isOnline = false;
|
|
||||||
clearInterval(this.streamDurationTimer);
|
|
||||||
this.vueApp.streamStatus = MESSAGE_OFFLINE;
|
|
||||||
if (this.streamStatus) {
|
|
||||||
const remainingChatTime = TIMER_DISABLE_CHAT_AFTER_OFFLINE - (Date.now() - new Date(this.lastDisconnectTime));
|
|
||||||
const countdown = (remainingChatTime < 0) ? 0 : remainingChatTime;
|
|
||||||
this.disableChatTimer = setTimeout(this.messagingInterface.disableChat, countdown);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// play video!
|
|
||||||
handleOnlineMode() {
|
|
||||||
this.vueApp.playerOn = true;
|
|
||||||
this.vueApp.isOnline = true;
|
|
||||||
this.vueApp.streamStatus = MESSAGE_ONLINE;
|
|
||||||
|
|
||||||
this.player.startPlayer();
|
|
||||||
clearTimeout(this.disableChatTimer);
|
|
||||||
this.disableChatTimer = null;
|
|
||||||
this.messagingInterface.enableChat();
|
|
||||||
|
|
||||||
this.streamDurationTimer =
|
|
||||||
setInterval(this.setCurrentStreamDuration, TIMER_STREAM_DURATION_COUNTER);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// when videojs player is ready, start polling for stream
|
// when videojs player is ready, start polling for stream
|
||||||
handlePlayerReady() {
|
handlePlayerReady() {
|
||||||
this.getStreamStatus();
|
this.getStreamStatus();
|
||||||
this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE);
|
this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
handlePlayerPlaying() {
|
handlePlayerPlaying() {
|
||||||
// do something?
|
// do something?
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
// likely called some time after stream status has gone offline.
|
// likely called some time after stream status has gone offline.
|
||||||
// basically hide video and show underlying "poster"
|
// basically hide video and show underlying "poster"
|
||||||
handlePlayerEnded() {
|
handlePlayerEnded() {
|
||||||
this.vueApp.playerOn = false;
|
this.setState({
|
||||||
};
|
playerActive: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handlePlayerError() {
|
handlePlayerError() {
|
||||||
// do something?
|
// do something?
|
||||||
this.handleOfflineMode();
|
this.handleOfflineMode();
|
||||||
this.handlePlayerEnded();
|
this.handlePlayerEnded();
|
||||||
};
|
}
|
||||||
};
|
|
||||||
|
// stop status timer and disable chat after some time.
|
||||||
|
handleOfflineMode() {
|
||||||
|
clearInterval(this.streamDurationTimer);
|
||||||
|
const remainingChatTime = TIMER_DISABLE_CHAT_AFTER_OFFLINE - (Date.now() - new Date(this.lastDisconnectTime));
|
||||||
|
const countdown = (remainingChatTime < 0) ? 0 : remainingChatTime;
|
||||||
|
this.disableChatTimer = setTimeout(this.disableChatInput, countdown);
|
||||||
|
this.setState({
|
||||||
|
streamOnline: false,
|
||||||
|
streamStatusMessage: MESSAGE_OFFLINE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// play video!
|
||||||
|
handleOnlineMode() {
|
||||||
|
this.player.startPlayer();
|
||||||
|
clearTimeout(this.disableChatTimer);
|
||||||
|
this.disableChatTimer = null;
|
||||||
|
|
||||||
|
this.streamDurationTimer =
|
||||||
|
setInterval(this.setCurrentStreamDuration, TIMER_STREAM_DURATION_COUNTER);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
playerActive: true,
|
||||||
|
streamOnline: true,
|
||||||
|
chatEnabled: true,
|
||||||
|
streamStatusMessage: MESSAGE_ONLINE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
handleUsernameChange(newName, newAvatar) {
|
||||||
|
this.setState({
|
||||||
|
username: newName,
|
||||||
|
userAvatarImage: newAvatar,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChatPanelToggle() {
|
||||||
|
const { displayChat: curDisplayed } = this.state;
|
||||||
|
|
||||||
|
const displayChat = !curDisplayed;
|
||||||
|
if (displayChat) {
|
||||||
|
setLocalStorage(KEY_CHAT_DISPLAYED, displayChat);
|
||||||
|
} else {
|
||||||
|
clearLocalStorage(KEY_CHAT_DISPLAYED);
|
||||||
|
}
|
||||||
|
this.setState({
|
||||||
|
displayChat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disableChatInput() {
|
||||||
|
this.setState({
|
||||||
|
chatEnabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNetworkingError(error) {
|
||||||
|
console.log(`>>> App Error: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWindowResize() {
|
||||||
|
this.setState({
|
||||||
|
windowWidth: window.innerWidth,
|
||||||
|
windowHeight: window.innerHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render(props, state) {
|
||||||
|
const {
|
||||||
|
chatEnabled,
|
||||||
|
configData,
|
||||||
|
displayChat,
|
||||||
|
extraUserContent,
|
||||||
|
overallMaxViewerCount,
|
||||||
|
playerActive,
|
||||||
|
sessionMaxViewerCount,
|
||||||
|
streamOnline,
|
||||||
|
streamStatusMessage,
|
||||||
|
userAvatarImage,
|
||||||
|
username,
|
||||||
|
viewerCount,
|
||||||
|
websocket,
|
||||||
|
windowHeight,
|
||||||
|
windowWidth,
|
||||||
|
} = state;
|
||||||
|
|
||||||
|
const {
|
||||||
|
version: appVersion,
|
||||||
|
logo = {},
|
||||||
|
socialHandles = [],
|
||||||
|
name: streamerName,
|
||||||
|
summary,
|
||||||
|
tags = [],
|
||||||
|
title,
|
||||||
|
} = configData;
|
||||||
|
const { small: smallLogo = TEMP_IMAGE, large: largeLogo = TEMP_IMAGE } = logo;
|
||||||
|
|
||||||
|
const bgLogo = { backgroundImage: `url(${smallLogo})` };
|
||||||
|
const bgLogoLarge = { backgroundImage: `url(${largeLogo})` };
|
||||||
|
|
||||||
|
const tagList = !tags.length ?
|
||||||
|
null :
|
||||||
|
tags.map((tag, index) => html`
|
||||||
|
<li key="tag${index}" class="tag rounded-sm text-gray-100 bg-gray-700 text-xs uppercase mr-3 p-2 whitespace-no-wrap">${tag}</li>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const socialIconsList =
|
||||||
|
!socialHandles.length ?
|
||||||
|
null :
|
||||||
|
socialHandles.map((item, index) => html`
|
||||||
|
<li key="social${index}">
|
||||||
|
<${SocialIcon} platform=${item.platform} url=${item.url} />
|
||||||
|
</li>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const mainClass = playerActive ? 'online' : '';
|
||||||
|
const streamInfoClass = streamOnline ? 'online' : ''; // need?
|
||||||
|
|
||||||
|
const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE;
|
||||||
|
const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight;
|
||||||
|
const extraAppClasses = classNames({
|
||||||
|
'chat': displayChat,
|
||||||
|
'no-chat': !displayChat,
|
||||||
|
'single-col': singleColMode,
|
||||||
|
'bg-gray-800': singleColMode && displayChat,
|
||||||
|
'short-wide': shortHeight,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
html`
|
||||||
|
<div id="app-container" class="flex w-full flex-col justify-start relative ${extraAppClasses}">
|
||||||
|
<div id="top-content" class="z-50">
|
||||||
|
<header class="flex border-b border-gray-900 border-solid shadow-md fixed z-10 w-full top-0 left-0 flex flex-row justify-between flex-no-wrap">
|
||||||
|
<h1 class="flex flex-row items-center justify-start p-2 uppercase text-gray-400 text-xl font-thin tracking-wider overflow-hidden whitespace-no-wrap">
|
||||||
|
<span
|
||||||
|
id="logo-container"
|
||||||
|
class="inline-block rounded-full bg-white w-8 min-w-8 min-h-8 h-8 p-1 mr-2 bg-no-repeat bg-center"
|
||||||
|
style=${bgLogo}
|
||||||
|
>
|
||||||
|
<img class="logo visually-hidden" src=${smallLogo} alt=""/>
|
||||||
|
</span>
|
||||||
|
<span class="instance-title overflow-hidden truncate">${title}</span>
|
||||||
|
</h1>
|
||||||
|
<div id="user-options-container" class="flex flex-row justify-end items-center flex-no-wrap">
|
||||||
|
<${UsernameForm}
|
||||||
|
username=${username}
|
||||||
|
userAvatarImage=${userAvatarImage}
|
||||||
|
handleUsernameChange=${this.handleUsernameChange}
|
||||||
|
/>
|
||||||
|
<button type="button" id="chat-toggle" onClick=${this.handleChatPanelToggle} class="flex cursor-pointer text-center justify-center items-center min-w-12 h-full bg-gray-800 hover:bg-gray-700">💬</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class=${mainClass}>
|
||||||
|
<div
|
||||||
|
id="video-container"
|
||||||
|
class="flex owncast-video-container bg-black w-full bg-center bg-no-repeat flex flex-col items-center justify-start"
|
||||||
|
style=${bgLogoLarge}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
class="video-js vjs-big-play-centered display-block w-full h-full"
|
||||||
|
id="video"
|
||||||
|
preload="auto"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
></video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="stream-info" aria-label="Stream status" class="flex text-center flex-row justify-between font-mono py-2 px-8 bg-gray-900 text-indigo-200 shadow-md border-b border-gray-100 border-solid ${streamInfoClass}">
|
||||||
|
<span>${streamStatusMessage}</span>
|
||||||
|
<span>${viewerCount} ${pluralize('viewer', viewerCount)}.</span>
|
||||||
|
<span>Max ${pluralize('viewer', sessionMaxViewerCount)}.</span>
|
||||||
|
<span>${overallMaxViewerCount} overall.</span>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section id="user-content" aria-label="User information" class="p-8">
|
||||||
|
<div class="user-content flex flex-row p-8">
|
||||||
|
<div
|
||||||
|
class="user-image rounded-full bg-white p-4 mr-8 bg-no-repeat bg-center"
|
||||||
|
style=${bgLogoLarge}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="logo visually-hidden"
|
||||||
|
alt="Logo"
|
||||||
|
src=${largeLogo}/>
|
||||||
|
</div>
|
||||||
|
<div class="user-content-header border-b border-gray-500 border-solid">
|
||||||
|
<h2 class="font-semibold text-5xl">
|
||||||
|
About <span class="streamer-name text-indigo-600">${streamerName}</span>
|
||||||
|
</h2>
|
||||||
|
<ul id="social-list" class="social-list flex flex-row items-center justify-start flex-wrap">
|
||||||
|
<span class="follow-label text-xs font-bold mr-2 uppercase">Follow me: </span>
|
||||||
|
${socialIconsList}
|
||||||
|
</ul>
|
||||||
|
<div id="stream-summary" class="stream-summary my-4" dangerouslySetInnerHTML=${{ __html: summary }}></div>
|
||||||
|
<ul id="tag-list" class="tag-list flex flex-row my-4">
|
||||||
|
${tagList}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="extra-user-content"
|
||||||
|
class="extra-user-content px-8"
|
||||||
|
dangerouslySetInnerHTML=${{ __html: extraUserContent }}
|
||||||
|
></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="flex flex-row justify-start p-8 opacity-50 text-xs">
|
||||||
|
<span class="mx-1 inline-block">
|
||||||
|
<a href="${URL_OWNCAST}" target="_blank">About Owncast</a>
|
||||||
|
</span>
|
||||||
|
<span class="mx-1 inline-block">Version ${appVersion}</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<${Chat}
|
||||||
|
websocket=${websocket}
|
||||||
|
username=${username}
|
||||||
|
userAvatarImage=${userAvatarImage}
|
||||||
|
chatEnabled //=${chatEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default Owncast;
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* These are the types of messages that we can handle with the websocket.
|
|
||||||
* Mostly used by `websocket.js` but if other components need to handle
|
|
||||||
* different types then it can import this file.
|
|
||||||
*/
|
|
||||||
export default {
|
|
||||||
CHAT: 'CHAT',
|
|
||||||
PING: 'PING',
|
|
||||||
NAME_CHANGE: 'NAME_CHANGE',
|
|
||||||
PONG: 'PONG'
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
Vue.component('owncast-footer', {
|
|
||||||
props: {
|
|
||||||
appVersion: {
|
|
||||||
type: String,
|
|
||||||
default: '0.1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
template: `
|
|
||||||
<footer class="flex">
|
|
||||||
<span>
|
|
||||||
<a href="${URL_OWNCAST}" target="_blank">About Owncast</a>
|
|
||||||
</span>
|
|
||||||
<span>Version {{appVersion}}</span>
|
|
||||||
</footer>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
Vue.component('stream-tags', {
|
|
||||||
props: ['tags'],
|
|
||||||
template: `
|
|
||||||
<ul
|
|
||||||
class="tag-list flex"
|
|
||||||
v-if="this.tags.length"
|
|
||||||
>
|
|
||||||
<li class="tag rounded-sm text-gray-100 bg-gray-700"
|
|
||||||
v-for="tag in this.tags"
|
|
||||||
v-bind:key="tag"
|
|
||||||
>
|
|
||||||
{{tag}}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
Vue.component('user-details', {
|
|
||||||
props: ['logo', 'platforms', 'summary', 'tags'],
|
|
||||||
template: `
|
|
||||||
<div class="user-content">
|
|
||||||
<div
|
|
||||||
class="user-image rounded-full bg-white"
|
|
||||||
v-bind:style="{ backgroundImage: 'url(' + logo + ')' }"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
class="logo visually-hidden"
|
|
||||||
alt="Logo"
|
|
||||||
v-bind:src="logo">
|
|
||||||
</div>
|
|
||||||
<div class="user-content-header border-b border-gray-500 border-solid">
|
|
||||||
<h2 class="font-semibold">
|
|
||||||
About <span class="streamer-name text-indigo-600">
|
|
||||||
<slot></slot>
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<social-list v-bind:platforms="platforms"></social-list>
|
|
||||||
<div class="stream-summary" v-html="summary"></div>
|
|
||||||
<stream-tags v-bind:tags="tags"></stream-tags>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
291
webroot/js/components/chat/chat-input.js
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
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 ContentEditable from './content-editable.js';
|
||||||
|
import { generatePlaceholderText, getCaretPosition } from '../../utils/chat.js';
|
||||||
|
import { getLocalStorage, setLocalStorage } from '../../utils/helpers.js';
|
||||||
|
import { URL_CUSTOM_EMOJIS, KEY_CHAT_FIRST_MESSAGE_SENT } from '../../utils/constants.js';
|
||||||
|
|
||||||
|
export default class ChatInput extends Component {
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
this.formMessageInput = createRef();
|
||||||
|
this.emojiPickerButton = createRef();
|
||||||
|
|
||||||
|
this.messageCharCount = 0;
|
||||||
|
this.maxMessageLength = 500;
|
||||||
|
this.maxMessageBuffer = 20;
|
||||||
|
|
||||||
|
this.emojiPicker = null;
|
||||||
|
|
||||||
|
this.prepNewLine = false;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
inputHTML: '',
|
||||||
|
inputWarning: '',
|
||||||
|
hasSentFirstChatMessage: getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT),
|
||||||
|
};
|
||||||
|
|
||||||
|
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.handleSubmitChatButton = this.handleSubmitChatButton.bind(this);
|
||||||
|
this.handlePaste = this.handlePaste.bind(this);
|
||||||
|
|
||||||
|
this.handleContentEditableChange = this.handleContentEditableChange.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,
|
||||||
|
emojiSize: '30px',
|
||||||
|
position: 'right-start',
|
||||||
|
strategy: 'absolute',
|
||||||
|
});
|
||||||
|
this.emojiPicker.on('emoji', emoji => {
|
||||||
|
this.handleEmojiSelected(emoji);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// this.handleNetworkingError(`Emoji Fetch: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEmojiButtonClick() {
|
||||||
|
if (this.emojiPicker) {
|
||||||
|
this.emojiPicker.togglePicker(this.emojiPickerButton.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEmojiSelected(emoji) {
|
||||||
|
const { inputHTML } = this.state;
|
||||||
|
let content = '';
|
||||||
|
if (emoji.url) {
|
||||||
|
const url = location.protocol + "//" + location.host + "/" + emoji.url;
|
||||||
|
const name = url.split('\\').pop().split('/').pop();
|
||||||
|
content = "<img class=\"emoji\" alt=\"" + name + "\" src=\"" + url + "\"/>";
|
||||||
|
} else {
|
||||||
|
content = emoji.emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
inputHTML: inputHTML + content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// autocomplete user names
|
||||||
|
autoCompleteNames() {
|
||||||
|
const { chatUserNames } = this.props;
|
||||||
|
const { inputHTML } = this.state;
|
||||||
|
const position = getCaretPosition(this.formMessageInput.current);
|
||||||
|
const at = inputHTML.lastIndexOf('@', position - 1);
|
||||||
|
if (at === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let partial = inputHTML.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];
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
inputHTML: inputHTML.substring(0, at + 1) + this.suggestion + ' ' + inputHTML.substring(position),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessageInputKeydown(event) {
|
||||||
|
const okCodes = [
|
||||||
|
'ArrowLeft',
|
||||||
|
'ArrowUp',
|
||||||
|
'ArrowRight',
|
||||||
|
'ArrowDown',
|
||||||
|
'Shift',
|
||||||
|
'Meta',
|
||||||
|
'Alt',
|
||||||
|
'Delete',
|
||||||
|
'Backspace',
|
||||||
|
];
|
||||||
|
const formField = this.formMessageInput.current;
|
||||||
|
|
||||||
|
let textValue = formField.innerText.trim(); // get this only to count chars
|
||||||
|
|
||||||
|
let numCharsLeft = this.maxMessageLength - textValue.length;
|
||||||
|
const key = event.key;
|
||||||
|
|
||||||
|
if (key === 'Enter') {
|
||||||
|
if (!this.prepNewLine) {
|
||||||
|
this.sendMessage();
|
||||||
|
event.preventDefault();
|
||||||
|
this.prepNewLine = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === 'Control' || key === 'Shift') {
|
||||||
|
this.prepNewLine = true;
|
||||||
|
}
|
||||||
|
if (key === 'Tab') {
|
||||||
|
if (this.autoCompleteNames()) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// value could have been changed, update char count
|
||||||
|
textValue = formField.innerText.trim();
|
||||||
|
numCharsLeft = this.maxMessageLength - textValue.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// text count
|
||||||
|
if (numCharsLeft <= this.maxMessageBuffer) {
|
||||||
|
this.setState({
|
||||||
|
inputWarning: `${numCharsLeft} chars left`,
|
||||||
|
});
|
||||||
|
if (numCharsLeft <= 0 && !okCodes.includes(key)) {
|
||||||
|
event.preventDefault(); // prevent typing more
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
inputWarning: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessageInputKeyup(event) {
|
||||||
|
if (event.key === 'Control' || event.key === 'Shift') {
|
||||||
|
this.prepNewLine = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessageInputBlur(event) {
|
||||||
|
this.prepNewLine = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePaste(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
document.execCommand('inserttext', false, event.clipboardData.getData('text/plain'));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmitChatButton(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.sendMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage() {
|
||||||
|
const { handleSendMessage } = this.props;
|
||||||
|
const { hasSentFirstChatMessage, inputHTML } = this.state;
|
||||||
|
const message = inputHTML.trim();
|
||||||
|
const newStates = {
|
||||||
|
inputWarning: '',
|
||||||
|
inputHTML: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSendMessage(message);
|
||||||
|
|
||||||
|
if (!hasSentFirstChatMessage) {
|
||||||
|
newStates.hasSentFirstChatMessage = true;
|
||||||
|
setLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear things out.
|
||||||
|
this.setState(newStates);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleContentEditableChange(event) {
|
||||||
|
this.setState({ inputHTML: event.target.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
render(props, state) {
|
||||||
|
const { hasSentFirstChatMessage, inputWarning, inputHTML } = state;
|
||||||
|
const { inputEnabled } = props;
|
||||||
|
const emojiButtonStyle = {
|
||||||
|
display: this.emojiPicker ? 'block' : 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderText = generatePlaceholderText(inputEnabled, hasSentFirstChatMessage);
|
||||||
|
return (
|
||||||
|
html`
|
||||||
|
<div id="message-input-container" class="fixed bottom-0 shadow-md bg-gray-900 border-t border-gray-700 border-solid p-4">
|
||||||
|
|
||||||
|
<${ContentEditable}
|
||||||
|
id="message-input"
|
||||||
|
class="appearance-none block w-full bg-gray-200 text-sm text-gray-700 border border-black-500 rounded py-2 px-2 my-2 focus:bg-white h-20 overflow-auto"
|
||||||
|
|
||||||
|
placeholderText=${placeholderText}
|
||||||
|
innerRef=${this.formMessageInput}
|
||||||
|
html=${inputHTML}
|
||||||
|
disabled=${!inputEnabled}
|
||||||
|
onChange=${this.handleContentEditableChange}
|
||||||
|
onKeyDown=${this.handleMessageInputKeydown}
|
||||||
|
onKeyUp=${this.handleMessageInputKeyup}
|
||||||
|
onBlur=${this.handleMessageInputBlur}
|
||||||
|
|
||||||
|
onPaste=${this.handlePaste}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div id="message-form-actions" class="flex flex-row justify-between items-center w-full">
|
||||||
|
<span id="message-form-warning" class="text-red-600 text-xs">${inputWarning}</span>
|
||||||
|
|
||||||
|
<div id="message-form-actions-buttons" class="flex flex-row justify-end items-center">
|
||||||
|
<button
|
||||||
|
ref=${this.emojiPickerButton}
|
||||||
|
id="emoji-button"
|
||||||
|
class="mr-2 text-2xl cursor-pointer"
|
||||||
|
type="button"
|
||||||
|
style=${emojiButtonStyle}
|
||||||
|
onclick=${this.handleEmojiButtonClick}
|
||||||
|
disabled=${!inputEnabled}
|
||||||
|
>😏</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick=${this.handleSubmitChatButton}
|
||||||
|
disabled=${!inputEnabled}
|
||||||
|
type="button"
|
||||||
|
id="button-submit-message"
|
||||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 rounded"
|
||||||
|
> Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
218
webroot/js/components/chat/chat.js
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import { h, Component, createRef } from 'https://unpkg.com/preact?module';
|
||||||
|
import htm from 'https://unpkg.com/htm?module';
|
||||||
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
import Message from './message.js';
|
||||||
|
import ChatInput from './chat-input.js';
|
||||||
|
import { CALLBACKS, SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||||
|
import { setVHvar, hasTouchScreen, jumpToBottom } from '../../utils/helpers.js';
|
||||||
|
import { extraUserNamesFromMessageHistory } from '../../utils/chat.js';
|
||||||
|
import { URL_CHAT_HISTORY } from '../../utils/constants.js';
|
||||||
|
|
||||||
|
export default class Chat extends Component {
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
inputEnabled: true,
|
||||||
|
messages: [],
|
||||||
|
chatUserNames: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.scrollableMessagesContainer = createRef();
|
||||||
|
|
||||||
|
this.websocket = null;
|
||||||
|
|
||||||
|
this.getChatHistory = this.getChatHistory.bind(this);
|
||||||
|
this.receivedWebsocketMessage = this.receivedWebsocketMessage.bind(this);
|
||||||
|
this.websocketDisconnected = this.websocketDisconnected.bind(this);
|
||||||
|
this.submitChat = this.submitChat.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.setupWebSocketCallbacks();
|
||||||
|
this.getChatHistory();
|
||||||
|
|
||||||
|
if (hasTouchScreen()) {
|
||||||
|
setVHvar();
|
||||||
|
window.addEventListener("orientationchange", setVHvar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
const { username: prevName } = prevProps;
|
||||||
|
const { username, userAvatarImage } = this.props;
|
||||||
|
|
||||||
|
const { messages: prevMessages } = prevState;
|
||||||
|
const { messages } = this.state;
|
||||||
|
|
||||||
|
// if username updated, send a message
|
||||||
|
if (prevName !== username) {
|
||||||
|
this.sendUsernameChange(prevName, username, userAvatarImage);
|
||||||
|
}
|
||||||
|
// scroll to bottom of messages list when new ones come in
|
||||||
|
if (messages.length > prevMessages.length) {
|
||||||
|
jumpToBottom(this.scrollableMessagesContainer.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (hasTouchScreen()) {
|
||||||
|
window.removeEventListener("orientationchange", setVHvar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
getChatHistory() {
|
||||||
|
fetch(URL_CHAT_HISTORY)
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Network response was not ok ${response.ok}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
// extra user names
|
||||||
|
const chatUserNames = extraUserNamesFromMessageHistory(data);
|
||||||
|
this.setState({
|
||||||
|
messages: data,
|
||||||
|
chatUserNames,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// this.handleNetworkingError(`Fetch getChatHistory: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendUsernameChange(oldName, newName, image) {
|
||||||
|
const nameChange = {
|
||||||
|
type: SOCKET_MESSAGE_TYPES.NAME_CHANGE,
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
image,
|
||||||
|
};
|
||||||
|
this.websocket.send(nameChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
receivedWebsocketMessage(message) {
|
||||||
|
this.addMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage(message) {
|
||||||
|
const { messages: curMessages } = this.state;
|
||||||
|
|
||||||
|
// if incoming message has same id as existing message, don't add it
|
||||||
|
const existing = curMessages.filter(function (item) {
|
||||||
|
return item.id === message.id;
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing.length === 0 || !existing) {
|
||||||
|
const newState = {
|
||||||
|
messages: [...curMessages, message],
|
||||||
|
};
|
||||||
|
const updatedChatUserNames = this.updateAuthorList(message);
|
||||||
|
if (updatedChatUserNames.length) {
|
||||||
|
newState.chatUserNames = [...updatedChatUserNames];
|
||||||
|
}
|
||||||
|
this.setState(newState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
websocketDisconnected() {
|
||||||
|
// this.websocket = null;
|
||||||
|
this.disableChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
submitChat(content) {
|
||||||
|
if (!content) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { username, userAvatarImage } = this.props;
|
||||||
|
const message = {
|
||||||
|
body: content,
|
||||||
|
author: username,
|
||||||
|
image: userAvatarImage,
|
||||||
|
type: SOCKET_MESSAGE_TYPES.CHAT,
|
||||||
|
};
|
||||||
|
this.websocket.send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
disableChat() {
|
||||||
|
this.setState({
|
||||||
|
inputEnabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enableChat() {
|
||||||
|
this.setState({
|
||||||
|
inputEnabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAuthorList(message) {
|
||||||
|
const { type } = message;
|
||||||
|
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, messagesOnly, chatEnabled } = props;
|
||||||
|
const { messages, inputEnabled, chatUserNames } = state;
|
||||||
|
|
||||||
|
const messageList = messages.map((message) => (html`<${Message} message=${message} username=${username} key=${message.id} />`));
|
||||||
|
|
||||||
|
if (messagesOnly) {
|
||||||
|
return (
|
||||||
|
html`
|
||||||
|
<div
|
||||||
|
id="messages-container"
|
||||||
|
ref=${this.scrollableMessagesContainer}
|
||||||
|
class="py-1 overflow-auto"
|
||||||
|
>
|
||||||
|
${messageList}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
html`
|
||||||
|
<section id="chat-container-wrap" class="flex flex-col">
|
||||||
|
<div id="chat-container" class="bg-gray-800 flex flex-col justify-end overflow-auto">
|
||||||
|
<div
|
||||||
|
id="messages-container"
|
||||||
|
ref=${this.scrollableMessagesContainer}
|
||||||
|
class="py-1 overflow-auto"
|
||||||
|
>
|
||||||
|
${messageList}
|
||||||
|
</div>
|
||||||
|
<${ChatInput}
|
||||||
|
chatUserNames=${chatUserNames}
|
||||||
|
inputEnabled=${chatEnabled && inputEnabled}
|
||||||
|
handleSendMessage=${this.submitChat}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
130
webroot/js/components/chat/content-editable.js
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/*
|
||||||
|
Since we can't really import react-contenteditable here, I'm borrowing code for this component from here:
|
||||||
|
github.com/lovasoa/react-contenteditable/
|
||||||
|
|
||||||
|
and here:
|
||||||
|
https://stackoverflow.com/questions/22677931/react-js-onchange-event-for-contenteditable/27255103#27255103
|
||||||
|
|
||||||
|
*/
|
||||||
|
import { Component, createRef, h } from 'https://unpkg.com/preact?module';
|
||||||
|
|
||||||
|
function replaceCaret(el) {
|
||||||
|
// Place the caret at the end of the element
|
||||||
|
const target = document.createTextNode('');
|
||||||
|
el.appendChild(target);
|
||||||
|
// do not move caret if element was not focused
|
||||||
|
const isTargetFocused = document.activeElement === el;
|
||||||
|
if (target !== null && target.nodeValue !== null && isTargetFocused) {
|
||||||
|
var sel = window.getSelection();
|
||||||
|
if (sel !== null) {
|
||||||
|
var range = document.createRange();
|
||||||
|
range.setStart(target, target.nodeValue.length);
|
||||||
|
range.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
if (el) el.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHtml(str) {
|
||||||
|
return str && str.replace(/ |\u202F|\u00A0/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default class ContentEditable extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.el = createRef();
|
||||||
|
|
||||||
|
this.lastHtml = '';
|
||||||
|
|
||||||
|
this.emitChange = this.emitChange.bind(this);
|
||||||
|
this.getDOMElement = this.getDOMElement.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldComponentUpdate(nextProps) {
|
||||||
|
const { props } = this;
|
||||||
|
const el = this.getDOMElement();
|
||||||
|
|
||||||
|
// We need not rerender if the change of props simply reflects the user's edits.
|
||||||
|
// Rerendering in this case would make the cursor/caret jump
|
||||||
|
|
||||||
|
// Rerender if there is no element yet... (somehow?)
|
||||||
|
if (!el) return true;
|
||||||
|
|
||||||
|
// ...or if html really changed... (programmatically, not by user edit)
|
||||||
|
if (
|
||||||
|
normalizeHtml(nextProps.html) !== normalizeHtml(el.innerHTML)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle additional properties
|
||||||
|
return props.disabled !== nextProps.disabled ||
|
||||||
|
props.tagName !== nextProps.tagName ||
|
||||||
|
props.className !== nextProps.className ||
|
||||||
|
props.innerRef !== nextProps.innerRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
const el = this.getDOMElement();
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Perhaps React (whose VDOM gets outdated because we often prevent
|
||||||
|
// rerendering) did not update the DOM. So we update it manually now.
|
||||||
|
if (this.props.html !== el.innerHTML) {
|
||||||
|
el.innerHTML = this.props.html;
|
||||||
|
}
|
||||||
|
this.lastHtml = this.props.html;
|
||||||
|
replaceCaret(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDOMElement() {
|
||||||
|
return (this.props.innerRef && typeof this.props.innerRef !== 'function' ? this.props.innerRef : this.el).current;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
emitChange(originalEvt) {
|
||||||
|
const el = this.getDOMElement();
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const html = el.innerHTML;
|
||||||
|
if (this.props.onChange && html !== this.lastHtml) {
|
||||||
|
// Clone event with Object.assign to avoid
|
||||||
|
// "Cannot assign to read only property 'target' of object"
|
||||||
|
const evt = Object.assign({}, originalEvt, {
|
||||||
|
target: {
|
||||||
|
value: html
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.props.onChange(evt);
|
||||||
|
}
|
||||||
|
this.lastHtml = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(props) {
|
||||||
|
const { html, innerRef } = props;
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
ref: typeof innerRef === 'function' ? (current) => {
|
||||||
|
innerRef(current)
|
||||||
|
this.el.current = current
|
||||||
|
} : innerRef || this.el,
|
||||||
|
onInput: this.emitChange,
|
||||||
|
onBlur: this.props.onBlur || this.emitChange,
|
||||||
|
onKeyup: this.props.onKeyUp || this.emitChange,
|
||||||
|
onKeydown: this.props.onKeyDown || this.emitChange,
|
||||||
|
contentEditable: !this.props.disabled,
|
||||||
|
dangerouslySetInnerHTML: { __html: html },
|
||||||
|
},
|
||||||
|
this.props.children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
webroot/js/components/chat/message.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||||
|
import htm from 'https://unpkg.com/htm?module';
|
||||||
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
import { messageBubbleColorForString } from '../../utils/user-colors.js';
|
||||||
|
import { formatMessageText } from '../../utils/chat.js';
|
||||||
|
import { generateAvatar } from '../../utils/helpers.js';
|
||||||
|
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||||
|
|
||||||
|
export default class Message extends Component {
|
||||||
|
render(props) {
|
||||||
|
const { message, username } = props;
|
||||||
|
const { type } = message;
|
||||||
|
|
||||||
|
if (type === SOCKET_MESSAGE_TYPES.CHAT) {
|
||||||
|
const { image, author, body } = message;
|
||||||
|
const formattedMessage = formatMessageText(body, username);
|
||||||
|
const avatar = image || generateAvatar(author);
|
||||||
|
|
||||||
|
const authorColor = messageBubbleColorForString(author);
|
||||||
|
const avatarBgColor = { backgroundColor: authorColor };
|
||||||
|
const authorTextColor = { color: authorColor };
|
||||||
|
return (
|
||||||
|
html`
|
||||||
|
<div class="message flex flex-row items-start p-3">
|
||||||
|
<div
|
||||||
|
class="message-avatar rounded-full flex items-center justify-center mr-3"
|
||||||
|
style=${avatarBgColor}
|
||||||
|
>
|
||||||
|
<img src=${avatar} class="p-1" />
|
||||||
|
</div>
|
||||||
|
<div class="message-content text-sm break-words">
|
||||||
|
<div class="message-author text-white font-bold" style=${authorTextColor}>
|
||||||
|
${author}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="message-text text-gray-300 font-normal"
|
||||||
|
dangerouslySetInnerHTML=${
|
||||||
|
{ __html: formattedMessage }
|
||||||
|
}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
|
||||||
|
const { oldName, newName, image } = message;
|
||||||
|
return (
|
||||||
|
html`
|
||||||
|
<div class="message message-name-change flex items-center justify-start p-3">
|
||||||
|
<div class="message-content flex flex-row items-center justify-center text-sm">
|
||||||
|
<div
|
||||||
|
class="message-avatar rounded-full mr-3 bg-gray-900"
|
||||||
|
>
|
||||||
|
<img class="mr-2 p-1" src=${image} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-white text-center opacity-50">
|
||||||
|
<span class="font-bold">${oldName}</span> is now known as <span class="font-bold">${newName}</span>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
webroot/js/components/chat/username.js
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { h, Component, createRef } from 'https://unpkg.com/preact?module';
|
||||||
|
import htm from 'https://unpkg.com/htm?module';
|
||||||
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
import { generateAvatar, setLocalStorage } from '../../utils/helpers.js';
|
||||||
|
import { KEY_USERNAME, KEY_AVATAR } from '../../utils/constants.js';
|
||||||
|
|
||||||
|
export default class UsernameForm 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() {
|
||||||
|
const { displayForm: curDisplay } = this.state;
|
||||||
|
this.setState({
|
||||||
|
displayForm: !curDisplay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } = props;
|
||||||
|
const { displayForm } = state;
|
||||||
|
|
||||||
|
const narrowSpace = document.body.clientWidth < 640;
|
||||||
|
const formDisplayStyle = narrowSpace ? 'inline-block' : 'flex';
|
||||||
|
const styles = {
|
||||||
|
info: {
|
||||||
|
display: displayForm ? 'none' : 'flex',
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
display: displayForm ? formDisplayStyle : 'none',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
html`
|
||||||
|
<div id="user-info">
|
||||||
|
<div id="user-info-display" style=${styles.info} title="Click to update user name" class="flex flex-row justify-end items-center cursor-pointer py-2 px-4 overflow-hidden w-full opacity-1 transition-opacity duration-200 hover:opacity-75" onClick=${this.handleDisplayForm}>
|
||||||
|
<img
|
||||||
|
src=${userAvatarImage}
|
||||||
|
alt=""
|
||||||
|
id="username-avatar"
|
||||||
|
class="rounded-full bg-black bg-opacity-50 border border-solid border-gray-700 mr-2 h-8 w-8"
|
||||||
|
/>
|
||||||
|
<span id="username-display" class="text-indigo-600 text-xs font-semibold truncate overflow-hidden whitespace-no-wrap">${username}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="user-info-change" class="flex flex-no-wrap p-1 items-center justify-end" style=${styles.form}>
|
||||||
|
<input type="text"
|
||||||
|
id="username-change-input"
|
||||||
|
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-black-500 rounded py-1 px-1 leading-tight text-xs focus:bg-white"
|
||||||
|
maxlength="100"
|
||||||
|
placeholder="Update username"
|
||||||
|
value=${username}
|
||||||
|
onKeydown=${this.handleKeydown}
|
||||||
|
ref=${this.textInput}
|
||||||
|
/>
|
||||||
|
<button id="button-update-username" onClick=${this.handleUpdateUsername} type="button" class="bg-blue-500 hover:bg-blue-700 text-white text-xs uppercase p-1 mx-1 rounded cursor-pointer user-btn">Update</button>
|
||||||
|
|
||||||
|
<button id="button-cancel-change" onClick=${this.handleHideForm} type="button" class="bg-gray-900 hover:bg-gray-800 py-1 px-2 mx-1 rounded cursor-pointer user-btn text-white text-xs uppercase text-opacity-50" title="cancel">X</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,7 +17,6 @@ const VIDEO_OPTIONS = {
|
|||||||
vhs: {
|
vhs: {
|
||||||
// used to select the lowest bitrate playlist initially. This helps to decrease playback start time. This setting is false by default.
|
// used to select the lowest bitrate playlist initially. This helps to decrease playback start time. This setting is false by default.
|
||||||
enableLowInitialPlaylist: true,
|
enableLowInitialPlaylist: true,
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
liveTracker: {
|
liveTracker: {
|
||||||
@ -26,6 +25,8 @@ const VIDEO_OPTIONS = {
|
|||||||
sources: [VIDEO_SRC],
|
sources: [VIDEO_SRC],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const POSTER_DEFAULT = `/img/logo.png`;
|
||||||
|
export const POSTER_THUMB = `/thumbnail.jpg`;
|
||||||
|
|
||||||
class OwncastPlayer {
|
class OwncastPlayer {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -125,27 +126,25 @@ class OwncastPlayer {
|
|||||||
if (window.WebKitPlaybackTargetAvailabilityEvent) {
|
if (window.WebKitPlaybackTargetAvailabilityEvent) {
|
||||||
var videoJsButtonClass = videojs.getComponent('Button');
|
var videoJsButtonClass = videojs.getComponent('Button');
|
||||||
var concreteButtonClass = videojs.extend(videoJsButtonClass, {
|
var concreteButtonClass = videojs.extend(videoJsButtonClass, {
|
||||||
|
|
||||||
// The `init()` method will also work for constructor logic here, but it is
|
// The `init()` method will also work for constructor logic here, but it is
|
||||||
// deprecated. If you provide an `init()` method, it will override the
|
// deprecated. If you provide an `init()` method, it will override the
|
||||||
// `constructor()` method!
|
// `constructor()` method!
|
||||||
constructor: function () {
|
constructor: function () {
|
||||||
videoJsButtonClass.call(this, player);
|
videoJsButtonClass.call(this, player);
|
||||||
}, // notice the comma
|
},
|
||||||
|
|
||||||
handleClick: function () {
|
handleClick: function () {
|
||||||
const videoElement = document.getElementsByTagName('video')[0];
|
const videoElement = document.getElementsByTagName('video')[0];
|
||||||
videoElement.webkitShowPlaybackTargetPicker();
|
videoElement.webkitShowPlaybackTargetPicker();
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
var concreteButtonInstance = this.vjsPlayer.controlBar.addChild(new concreteButtonClass());
|
var concreteButtonInstance = this.vjsPlayer.controlBar.addChild(new concreteButtonClass());
|
||||||
concreteButtonInstance.addClass("vjs-airplay");
|
concreteButtonInstance.addClass("vjs-airplay");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { OwncastPlayer };
|
export { OwncastPlayer };
|
||||||
42
webroot/js/components/social.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { h } from 'https://unpkg.com/preact?module';
|
||||||
|
import htm from 'https://unpkg.com/htm?module';
|
||||||
|
const html = htm.bind(h);
|
||||||
|
import { SOCIAL_PLATFORMS } from '../utils/social.js';
|
||||||
|
import { classNames } from '../utils/helpers.js';
|
||||||
|
|
||||||
|
export default function SocialIcon(props) {
|
||||||
|
const { platform, url } = props;
|
||||||
|
const platformInfo = SOCIAL_PLATFORMS[platform.toLowerCase()];
|
||||||
|
const inList = !!platformInfo;
|
||||||
|
const imgRow = inList ? platformInfo.imgPos[0] : 0;
|
||||||
|
const imgCol = inList ? platformInfo.imgPos[1] : 0;
|
||||||
|
|
||||||
|
const name = inList ? platformInfo.name : platform;
|
||||||
|
|
||||||
|
const style = `--imgRow: -${imgRow}; --imgCol: -${imgCol};`;
|
||||||
|
const itemClass = classNames({
|
||||||
|
"user-social-item": true,
|
||||||
|
"flex": true,
|
||||||
|
"justify-start": true,
|
||||||
|
"items-center": true,
|
||||||
|
"-mr-1": true,
|
||||||
|
"use-default": !inList,
|
||||||
|
});
|
||||||
|
const labelClass = classNames({
|
||||||
|
"platform-label": true,
|
||||||
|
"visually-hidden": inList,
|
||||||
|
"text-indigo-800": true,
|
||||||
|
"text-xs": true,
|
||||||
|
"uppercase": true,
|
||||||
|
"max-w-xs": true,
|
||||||
|
"inline-block": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
html`
|
||||||
|
<a class=${itemClass} target="_blank" href=${url}>
|
||||||
|
<span class="platform-icon rounded-lg bg-no-repeat" style=${style}></span>
|
||||||
|
<span class=${labelClass}>Find me on ${name}</span>
|
||||||
|
</a>
|
||||||
|
`);
|
||||||
|
}
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import { EmojiButton } from 'https://cdn.skypack.dev/@joeattardi/emoji-button'
|
|
||||||
|
|
||||||
fetch('/emoji')
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Network response was not ok ${response.ok}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(json => {
|
|
||||||
setupEmojiPickerWithCustomEmoji(json);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
this.handleNetworkingError(`Emoji Fetch: ${error}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
function setupEmojiPickerWithCustomEmoji(customEmoji) {
|
|
||||||
const picker = new EmojiButton({
|
|
||||||
zIndex: 100,
|
|
||||||
theme: 'dark',
|
|
||||||
custom: customEmoji,
|
|
||||||
initialCategory: 'custom',
|
|
||||||
showPreview: false,
|
|
||||||
position: {
|
|
||||||
top: '50%',
|
|
||||||
right: '100'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const trigger = document.querySelector('#emoji-button');
|
|
||||||
|
|
||||||
trigger.addEventListener('click', () => picker.togglePicker(picker));
|
|
||||||
picker.on('emoji', 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 += "<img class=\"emoji\" alt=\"" + name + "\" src=\"" + url + "\"/>";
|
|
||||||
} else {
|
|
||||||
document.querySelector('#message-body-form').innerHTML += emoji.emoji;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,522 +0,0 @@
|
|||||||
import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js';
|
|
||||||
|
|
||||||
const KEY_USERNAME = 'owncast_username';
|
|
||||||
const KEY_AVATAR = 'owncast_avatar';
|
|
||||||
const KEY_CHAT_DISPLAYED = 'owncast_chat';
|
|
||||||
const KEY_CHAT_FIRST_MESSAGE_SENT = 'owncast_first_message_sent';
|
|
||||||
const CHAT_INITIAL_PLACEHOLDER_TEXT = 'Type here to chat, no account necessary.';
|
|
||||||
const CHAT_PLACEHOLDER_TEXT = 'Message';
|
|
||||||
const CHAT_PLACEHOLDER_OFFLINE = 'Chat is offline.';
|
|
||||||
|
|
||||||
class Message {
|
|
||||||
constructor(model) {
|
|
||||||
this.author = model.author;
|
|
||||||
this.body = model.body;
|
|
||||||
this.image = model.image || generateAvatar(model.author);
|
|
||||||
this.id = model.id;
|
|
||||||
this.type = model.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
formatText() {
|
|
||||||
showdown.setFlavor('github');
|
|
||||||
let formattedText = new showdown.Converter({
|
|
||||||
emoji: true,
|
|
||||||
openLinksInNewWindow: true,
|
|
||||||
tables: false,
|
|
||||||
simplifiedAutoLink: false,
|
|
||||||
literalMidWordUnderscores: true,
|
|
||||||
strikethrough: true,
|
|
||||||
ghMentions: false,
|
|
||||||
}).makeHtml(this.body);
|
|
||||||
|
|
||||||
formattedText = this.linkify(formattedText, this.body);
|
|
||||||
formattedText = this.highlightUsername(formattedText);
|
|
||||||
|
|
||||||
return addNewlines(formattedText);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Move this into a util function once we can organize code
|
|
||||||
// and split things up.
|
|
||||||
linkify(text, rawText) {
|
|
||||||
const urls = getURLs(stripTags(rawText));
|
|
||||||
if (urls) {
|
|
||||||
urls.forEach(function (url) {
|
|
||||||
let linkURL = url;
|
|
||||||
|
|
||||||
// Add http prefix if none exist in the URL so it actually
|
|
||||||
// will work in an anchor tag.
|
|
||||||
if (linkURL.indexOf('http') === -1) {
|
|
||||||
linkURL = 'http://' + linkURL;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the protocol prefix in the display URLs just to make
|
|
||||||
// things look a little nicer.
|
|
||||||
const displayURL = url.replace(/(^\w+:|^)\/\//, '');
|
|
||||||
const link = `<a href="${linkURL}" target="_blank">${displayURL}</a>`;
|
|
||||||
text = text.replace(url, link);
|
|
||||||
|
|
||||||
if (getYoutubeIdFromURL(url)) {
|
|
||||||
if (this.isTextJustURLs(text, [url, displayURL])) {
|
|
||||||
text = '';
|
|
||||||
} else {
|
|
||||||
text += '<br/>';
|
|
||||||
}
|
|
||||||
|
|
||||||
const youtubeID = getYoutubeIdFromURL(url);
|
|
||||||
text += getYoutubeEmbedFromID(youtubeID);
|
|
||||||
} else if (url.indexOf('instagram.com/p/') > -1) {
|
|
||||||
if (this.isTextJustURLs(text, [url, displayURL])) {
|
|
||||||
text = '';
|
|
||||||
} else {
|
|
||||||
text += `<br/>`;
|
|
||||||
}
|
|
||||||
text += getInstagramEmbedFromURL(url);
|
|
||||||
} else if (isImage(url)) {
|
|
||||||
if (this.isTextJustURLs(text, [url, displayURL])) {
|
|
||||||
text = '';
|
|
||||||
} else {
|
|
||||||
text += `<br/>`;
|
|
||||||
}
|
|
||||||
text += getImageForURL(url);
|
|
||||||
}
|
|
||||||
}.bind(this));
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
isTextJustURLs(text, urls) {
|
|
||||||
for (var i = 0; i < urls.length; i++) {
|
|
||||||
const url = urls[i];
|
|
||||||
if (stripTags(text) === url) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
userColor() {
|
|
||||||
return messageBubbleColorForString(this.author);
|
|
||||||
}
|
|
||||||
|
|
||||||
highlightUsername(message) {
|
|
||||||
const username = document.getElementById('self-message-author').value;
|
|
||||||
const pattern = new RegExp('@?' + username.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'gi');
|
|
||||||
return message.replace(pattern, '<span class="highlighted">$&</span>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class MessagingInterface {
|
|
||||||
constructor() {
|
|
||||||
this.chatDisplayed = false;
|
|
||||||
this.username = '';
|
|
||||||
this.messageCharCount = 0;
|
|
||||||
this.maxMessageLength = 500;
|
|
||||||
this.maxMessageBuffer = 20;
|
|
||||||
this.chatUsernames = [];
|
|
||||||
|
|
||||||
this.onReceivedMessages = this.onReceivedMessages.bind(this);
|
|
||||||
this.disableChat = this.disableChat.bind(this);
|
|
||||||
this.enableChat = this.enableChat.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.tagAppContainer = document.getElementById('app-container');
|
|
||||||
this.tagChatToggle = document.getElementById('chat-toggle');
|
|
||||||
this.tagUserInfoChanger = document.getElementById('user-info-change');
|
|
||||||
this.tagUsernameDisplay = document.getElementById('username-display');
|
|
||||||
this.tagMessageFormWarning = document.getElementById('message-form-warning');
|
|
||||||
|
|
||||||
this.inputMessageAuthor = document.getElementById('self-message-author');
|
|
||||||
this.inputChangeUserName = document.getElementById('username-change-input');
|
|
||||||
|
|
||||||
this.btnUpdateUserName = document.getElementById('button-update-username');
|
|
||||||
this.btnCancelUpdateUsername = document.getElementById('button-cancel-change');
|
|
||||||
this.btnSubmitMessage = document.getElementById('button-submit-message');
|
|
||||||
|
|
||||||
this.formMessageInput = document.getElementById('message-body-form');
|
|
||||||
|
|
||||||
this.imgUsernameAvatar = document.getElementById('username-avatar');
|
|
||||||
this.textUserInfoDisplay = document.getElementById('user-info-display');
|
|
||||||
|
|
||||||
this.scrollableMessagesContainer = document.getElementById('messages-container');
|
|
||||||
|
|
||||||
// add events
|
|
||||||
this.tagChatToggle.addEventListener('click', this.handleChatToggle.bind(this));
|
|
||||||
this.textUserInfoDisplay.addEventListener('click', this.handleShowChangeNameForm.bind(this));
|
|
||||||
|
|
||||||
this.btnUpdateUserName.addEventListener('click', this.handleUpdateUsername.bind(this));
|
|
||||||
this.btnCancelUpdateUsername.addEventListener('click', this.handleHideChangeNameForm.bind(this));
|
|
||||||
|
|
||||||
this.inputChangeUserName.addEventListener('keydown', this.handleUsernameKeydown.bind(this));
|
|
||||||
this.formMessageInput.addEventListener('keydown', this.handleMessageInputKeydown.bind(this));
|
|
||||||
this.formMessageInput.addEventListener('keyup', this.handleMessageInputKeyup.bind(this));
|
|
||||||
this.formMessageInput.addEventListener('blur', this.handleMessageInputBlur.bind(this));
|
|
||||||
this.btnSubmitMessage.addEventListener('click', this.handleSubmitChatButton.bind(this));
|
|
||||||
|
|
||||||
this.initLocalStates();
|
|
||||||
|
|
||||||
if (hasTouchScreen()) {
|
|
||||||
setVHvar();
|
|
||||||
window.addEventListener("orientationchange", setVHvar);
|
|
||||||
this.tagAppContainer.classList.add('touch-screen');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initLocalStates() {
|
|
||||||
this.username = getLocalStorage(KEY_USERNAME) || generateUsername();
|
|
||||||
this.imgUsernameAvatar.src =
|
|
||||||
getLocalStorage(KEY_AVATAR) || generateAvatar(`${this.username}${Date.now()}`);
|
|
||||||
this.updateUsernameFields(this.username);
|
|
||||||
|
|
||||||
this.chatDisplayed = getLocalStorage(KEY_CHAT_DISPLAYED) || true;
|
|
||||||
this.displayChat();
|
|
||||||
this.disableChat(); // Disabled by default.
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUsernameFields(username) {
|
|
||||||
this.tagUsernameDisplay.innerText = username;
|
|
||||||
this.inputChangeUserName.value = username;
|
|
||||||
this.inputMessageAuthor.value = username;
|
|
||||||
}
|
|
||||||
|
|
||||||
displayChat() {
|
|
||||||
if (this.chatDisplayed) {
|
|
||||||
this.tagAppContainer.classList.add('chat');
|
|
||||||
this.tagAppContainer.classList.remove('no-chat');
|
|
||||||
jumpToBottom(this.scrollableMessagesContainer);
|
|
||||||
} else {
|
|
||||||
this.tagAppContainer.classList.add('no-chat');
|
|
||||||
this.tagAppContainer.classList.remove('chat');
|
|
||||||
}
|
|
||||||
this.setChatPlaceholderText();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
handleChatToggle() {
|
|
||||||
this.chatDisplayed = !this.chatDisplayed;
|
|
||||||
if (this.chatDisplayed) {
|
|
||||||
setLocalStorage(KEY_CHAT_DISPLAYED, this.chatDisplayed);
|
|
||||||
} else {
|
|
||||||
clearLocalStorage(KEY_CHAT_DISPLAYED);
|
|
||||||
}
|
|
||||||
this.displayChat();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleShowChangeNameForm() {
|
|
||||||
this.textUserInfoDisplay.style.display = 'none';
|
|
||||||
this.tagUserInfoChanger.style.display = 'flex';
|
|
||||||
if (document.body.clientWidth < 640) {
|
|
||||||
this.tagChatToggle.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHideChangeNameForm() {
|
|
||||||
this.textUserInfoDisplay.style.display = 'flex';
|
|
||||||
this.tagUserInfoChanger.style.display = 'none';
|
|
||||||
if (document.body.clientWidth < 640) {
|
|
||||||
this.tagChatToggle.style.display = 'inline-block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleUpdateUsername() {
|
|
||||||
const oldName = this.username;
|
|
||||||
var newValue = this.inputChangeUserName.value;
|
|
||||||
newValue = newValue.trim();
|
|
||||||
// do other string cleanup?
|
|
||||||
|
|
||||||
if (newValue) {
|
|
||||||
this.username = newValue;
|
|
||||||
this.updateUsernameFields(newValue);
|
|
||||||
this.imgUsernameAvatar.src = generateAvatar(`${newValue}${Date.now()}`);
|
|
||||||
setLocalStorage(KEY_USERNAME, newValue);
|
|
||||||
setLocalStorage(KEY_AVATAR, this.imgUsernameAvatar.src);
|
|
||||||
}
|
|
||||||
this.handleHideChangeNameForm();
|
|
||||||
|
|
||||||
if (oldName !== newValue) {
|
|
||||||
this.sendUsernameChange(oldName, newValue, this.imgUsernameAvatar.src);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleUsernameKeydown(event) {
|
|
||||||
if (event.keyCode === 13) { // enter
|
|
||||||
this.handleUpdateUsername();
|
|
||||||
} else if (event.keyCode === 27) { // esc
|
|
||||||
this.handleHideChangeNameForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendUsernameChange(oldName, newName, image) {
|
|
||||||
const nameChange = {
|
|
||||||
type: SOCKET_MESSAGE_TYPES.NAME_CHANGE,
|
|
||||||
oldName: oldName,
|
|
||||||
newName: newName,
|
|
||||||
image: image,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.send(nameChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
tryToComplete() {
|
|
||||||
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 = this.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) {
|
|
||||||
var okCodes = [37,38,39,40,16,91,18,46,8];
|
|
||||||
var value = this.formMessageInput.innerHTML.trim();
|
|
||||||
var 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.tryToComplete()) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmitChatButton(event) {
|
|
||||||
var value = this.formMessageInput.innerHTML.trim();
|
|
||||||
if (value) {
|
|
||||||
this.submitChat(value);
|
|
||||||
event.preventDefault();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitChat(content) {
|
|
||||||
if (!content) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var message = new Message({
|
|
||||||
body: content,
|
|
||||||
author: this.username,
|
|
||||||
image: this.imgUsernameAvatar.src,
|
|
||||||
type: SOCKET_MESSAGE_TYPES.CHAT,
|
|
||||||
});
|
|
||||||
this.send(message);
|
|
||||||
|
|
||||||
// clear out things.
|
|
||||||
this.formMessageInput.innerHTML = '';
|
|
||||||
this.tagMessageFormWarning.innerText = '';
|
|
||||||
|
|
||||||
const hasSentFirstChatMessage = getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT);
|
|
||||||
if (!hasSentFirstChatMessage) {
|
|
||||||
setLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT, true);
|
|
||||||
this.setChatPlaceholderText();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disableChat() {
|
|
||||||
if (this.formMessageInput) {
|
|
||||||
this.formMessageInput.contentEditable = false;
|
|
||||||
this.formMessageInput.innerHTML = '';
|
|
||||||
this.formMessageInput.setAttribute("placeholder", CHAT_PLACEHOLDER_OFFLINE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enableChat() {
|
|
||||||
if (this.formMessageInput) {
|
|
||||||
this.formMessageInput.contentEditable = true;
|
|
||||||
this.setChatPlaceholderText();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle Vue.js message display
|
|
||||||
onReceivedMessages(newMessages, oldMessages) {
|
|
||||||
// update the list of chat usernames
|
|
||||||
newMessages.slice(oldMessages.length).forEach(function (message) {
|
|
||||||
var username;
|
|
||||||
|
|
||||||
switch (message.type) {
|
|
||||||
case SOCKET_MESSAGE_TYPES.CHAT:
|
|
||||||
username = message.author;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SOCKET_MESSAGE_TYPES.NAME_CHANGE:
|
|
||||||
username = message.newName;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.chatUsernames.includes(username)) {
|
|
||||||
this.chatUsernames.push(username);
|
|
||||||
}
|
|
||||||
}, this);
|
|
||||||
|
|
||||||
if (newMessages.length !== oldMessages.length) {
|
|
||||||
// jump to bottom
|
|
||||||
jumpToBottom(this.scrollableMessagesContainer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
send(messageJSON) {
|
|
||||||
console.error('MessagingInterface send() is not linked to the websocket component.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Message, MessagingInterface }
|
|
||||||
|
|
||||||
function stripTags(str) {
|
|
||||||
return str.replace(/<\/?[^>]+(>|$)/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getURLs(str) {
|
|
||||||
var exp = /((\w+:\/\/\S+)|(\w+[\.:]\w+\S+))[^\s,\.]/ig;
|
|
||||||
return str.match(exp);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getYoutubeIdFromURL(url) {
|
|
||||||
try {
|
|
||||||
var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
|
||||||
var match = url.match(regExp);
|
|
||||||
|
|
||||||
if (match && match[2].length == 11) {
|
|
||||||
return match[2];
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getYoutubeEmbedFromID(id) {
|
|
||||||
return `<iframe class="chat-embed" src="//www.youtube.com/embed/${id}" frameborder="0" allowfullscreen></iframe>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInstagramEmbedFromURL(url) {
|
|
||||||
const urlObject = new URL(url.replace(/\/$/, ""));
|
|
||||||
urlObject.pathname += "/embed";
|
|
||||||
return `<iframe class="chat-embed instagram-embed" height="150px" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isImage(url) {
|
|
||||||
const re = /\.(jpe?g|png|gif)$/;
|
|
||||||
const isImage = re.test(url);
|
|
||||||
return isImage;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImageForURL(url) {
|
|
||||||
return `<a target="_blank" href="${url}"><img class="embedded-image" src="${url}" width="100%" height="150px"/></a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Taken from https://stackoverflow.com/questions/3972014/get-contenteditable-caret-index-position
|
|
||||||
function getCaretPosition(editableDiv) {
|
|
||||||
var caretPos = 0,
|
|
||||||
sel, range;
|
|
||||||
if (window.getSelection) {
|
|
||||||
sel = window.getSelection();
|
|
||||||
if (sel.rangeCount) {
|
|
||||||
range = sel.getRangeAt(0);
|
|
||||||
if (range.commonAncestorContainer.parentNode == editableDiv) {
|
|
||||||
caretPos = range.endOffset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (document.selection && document.selection.createRange) {
|
|
||||||
range = document.selection.createRange();
|
|
||||||
if (range.parentElement() == editableDiv) {
|
|
||||||
var tempEl = document.createElement("span");
|
|
||||||
editableDiv.insertBefore(tempEl, editableDiv.firstChild);
|
|
||||||
var tempRange = range.duplicate();
|
|
||||||
tempRange.moveToElementText(tempEl);
|
|
||||||
tempRange.setEndPoint("EndToEnd", range);
|
|
||||||
caretPos = tempRange.text.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return caretPos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pieced together from parts of https://stackoverflow.com/questions/6249095/how-to-set-caretcursor-position-in-contenteditable-element-div
|
|
||||||
function setCaretPosition(editableDiv, position) {
|
|
||||||
var range = document.createRange();
|
|
||||||
var sel = window.getSelection();
|
|
||||||
range.selectNode(editableDiv);
|
|
||||||
range.setStart(editableDiv.childNodes[0], position);
|
|
||||||
range.collapse(true);
|
|
||||||
|
|
||||||
sel.removeAllRanges();
|
|
||||||
sel.addRange(range);
|
|
||||||
}
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
const SOCIAL_PLATFORMS = {
|
|
||||||
default: {
|
|
||||||
name: "default",
|
|
||||||
imgPos: [0,0], // [row,col]
|
|
||||||
},
|
|
||||||
|
|
||||||
facebook: {
|
|
||||||
name: "Facebook",
|
|
||||||
imgPos: [0,1],
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
name: "Twitter",
|
|
||||||
imgPos: [0,2],
|
|
||||||
},
|
|
||||||
instagram: {
|
|
||||||
name: "Instagram",
|
|
||||||
imgPos: [0,3],
|
|
||||||
},
|
|
||||||
snapchat: {
|
|
||||||
name: "Snapchat",
|
|
||||||
imgPos: [0,4],
|
|
||||||
},
|
|
||||||
tiktok: {
|
|
||||||
name: "TikTok",
|
|
||||||
imgPos: [0,5],
|
|
||||||
},
|
|
||||||
soundcloud: {
|
|
||||||
name: "Soundcloud",
|
|
||||||
imgPos: [0,6],
|
|
||||||
},
|
|
||||||
bandcamp: {
|
|
||||||
name: "Bandcamp",
|
|
||||||
imgPos: [0,7],
|
|
||||||
},
|
|
||||||
patreon: {
|
|
||||||
name: "Patreon",
|
|
||||||
imgPos: [0,1],
|
|
||||||
},
|
|
||||||
youtube: {
|
|
||||||
name: "YouTube",
|
|
||||||
imgPos: [0,9 ],
|
|
||||||
},
|
|
||||||
spotify: {
|
|
||||||
name: "Spotify",
|
|
||||||
imgPos: [0,10],
|
|
||||||
},
|
|
||||||
twitch: {
|
|
||||||
name: "Twitch",
|
|
||||||
imgPos: [0,11],
|
|
||||||
},
|
|
||||||
paypal: {
|
|
||||||
name: "Paypal",
|
|
||||||
imgPos: [0,12],
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
name: "Github",
|
|
||||||
imgPos: [0,13],
|
|
||||||
},
|
|
||||||
linkedin: {
|
|
||||||
name: "LinkedIn",
|
|
||||||
imgPos: [0,14],
|
|
||||||
},
|
|
||||||
discord: {
|
|
||||||
name: "Discord",
|
|
||||||
imgPos: [0,15],
|
|
||||||
},
|
|
||||||
mastodon: {
|
|
||||||
name: "Mastodon",
|
|
||||||
imgPos: [0,16],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
Vue.component('social-list', {
|
|
||||||
props: ['platforms'],
|
|
||||||
|
|
||||||
template: `
|
|
||||||
<ul class="social-list flex" v-if="this.platforms.length">
|
|
||||||
<span class="follow-label">Follow me: </span>
|
|
||||||
<user-social-icon
|
|
||||||
v-for="(item, index) in this.platforms"
|
|
||||||
v-if="item.platform && item.url"
|
|
||||||
v-bind:key="index"
|
|
||||||
v-bind:platform="item.platform"
|
|
||||||
v-bind:url="item.url"
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
`,
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
Vue.component('user-social-icon', {
|
|
||||||
props: ['platform', 'url'],
|
|
||||||
data: function() {
|
|
||||||
const platformInfo = SOCIAL_PLATFORMS[this.platform.toLowerCase()];
|
|
||||||
const inList = !!platformInfo;
|
|
||||||
const imgRow = inList ? platformInfo.imgPos[0] : 0;
|
|
||||||
const imgCol = inList ? platformInfo.imgPos[1] : 0;
|
|
||||||
return {
|
|
||||||
name: inList ? platformInfo.name : this.platform,
|
|
||||||
link: this.url,
|
|
||||||
|
|
||||||
style: `--imgRow: -${imgRow}; --imgCol: -${imgCol};`,
|
|
||||||
itemClass: {
|
|
||||||
"user-social-item": true,
|
|
||||||
"flex": true,
|
|
||||||
"use-default": !inList,
|
|
||||||
},
|
|
||||||
labelClass: {
|
|
||||||
"platform-label": true,
|
|
||||||
"visually-hidden": inList,
|
|
||||||
"text-indigo-800": true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
template: `
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
v-bind:class="itemClass"
|
|
||||||
target="_blank"
|
|
||||||
:href="link"
|
|
||||||
>
|
|
||||||
<span class="platform-icon rounded-lg" :style="style" />
|
|
||||||
<span v-bind:class="labelClass">Find me on {{platform}}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
function getHashFromString(string) {
|
|
||||||
let hash = 1;
|
|
||||||
for (let i = 0; i < string.length; i++) {
|
|
||||||
const codepoint = string.charCodeAt(i);
|
|
||||||
hash *= codepoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.abs(hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
function digitsFromNumber(number) {
|
|
||||||
const numberString = number.toString();
|
|
||||||
let digits = [];
|
|
||||||
|
|
||||||
for (let i = 0, len = numberString.length; i < len; i += 1) {
|
|
||||||
digits.push(numberString.charAt(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
return digits;
|
|
||||||
}
|
|
||||||
|
|
||||||
// function avatarFromString(string) {
|
|
||||||
// const hash = getHashFromString(string);
|
|
||||||
// const digits = digitsFromNumber(hash);
|
|
||||||
// // eslint-disable-next-line
|
|
||||||
// const sum = digits.reduce(function (total, number) {
|
|
||||||
// return total + number;
|
|
||||||
// });
|
|
||||||
// const sumDigits = digitsFromNumber(sum);
|
|
||||||
// const first = sumDigits[0];
|
|
||||||
// const second = sumDigits[1];
|
|
||||||
// let filename = '/avatars/';
|
|
||||||
|
|
||||||
// // eslint-disable-next-line
|
|
||||||
// if (first == 1 || first == 2) {
|
|
||||||
// filename += '1' + second.toString();
|
|
||||||
// // eslint-disable-next-line
|
|
||||||
// } else if (first == 3 || first == 4) {
|
|
||||||
// filename += '2' + second.toString();
|
|
||||||
// // eslint-disable-next-line
|
|
||||||
// } else if (first == 5 || first == 6) {
|
|
||||||
// filename += '3' + second.toString();
|
|
||||||
// // eslint-disable-next-line
|
|
||||||
// } else if (first == 7 || first == 8) {
|
|
||||||
// filename += '4' + second.toString();
|
|
||||||
// } else {
|
|
||||||
// filename += '5';
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return filename + '.svg';
|
|
||||||
// }
|
|
||||||
|
|
||||||
function colorForString(str) {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
}
|
|
||||||
let colour = '#';
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
let value = (hash >> (i * 8)) & 0xff;
|
|
||||||
colour += ('00' + value.toString(16)).substr(-2);
|
|
||||||
}
|
|
||||||
return colour;
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageBubbleColorForString(str) {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
}
|
|
||||||
let color = '#';
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
let value = (hash >> (i * 8)) & 0xff;
|
|
||||||
color += ('00' + value.toString(16)).substr(-2);
|
|
||||||
}
|
|
||||||
// Convert to RGBA
|
|
||||||
let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
|
||||||
let rgb = result ? {
|
|
||||||
r: parseInt(result[1], 16),
|
|
||||||
g: parseInt(result[2], 16),
|
|
||||||
b: parseInt(result[3], 16),
|
|
||||||
} : null;
|
|
||||||
return 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ', 0.4)';
|
|
||||||
}
|
|
||||||
193
webroot/js/utils/chat.js
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { addNewlines } from './helpers.js';
|
||||||
|
import {
|
||||||
|
CHAT_INITIAL_PLACEHOLDER_TEXT,
|
||||||
|
CHAT_PLACEHOLDER_TEXT,
|
||||||
|
CHAT_PLACEHOLDER_OFFLINE,
|
||||||
|
} from './constants.js';
|
||||||
|
|
||||||
|
export function formatMessageText(message, username) {
|
||||||
|
showdown.setFlavor('github');
|
||||||
|
let formattedText = new showdown.Converter({
|
||||||
|
emoji: true,
|
||||||
|
openLinksInNewWindow: true,
|
||||||
|
tables: false,
|
||||||
|
simplifiedAutoLink: false,
|
||||||
|
literalMidWordUnderscores: true,
|
||||||
|
strikethrough: true,
|
||||||
|
ghMentions: false,
|
||||||
|
}).makeHtml(message);
|
||||||
|
|
||||||
|
formattedText = linkify(formattedText, message);
|
||||||
|
formattedText = highlightUsername(formattedText, username);
|
||||||
|
|
||||||
|
return addNewlines(formattedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightUsername(message, username) {
|
||||||
|
const pattern = new RegExp('@?' + username.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'gi');
|
||||||
|
return message.replace(pattern, '<span class="highlighted font-bold bg-orange-500">$&</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkify(text, rawText) {
|
||||||
|
const urls = getURLs(stripTags(rawText));
|
||||||
|
if (urls) {
|
||||||
|
urls.forEach(function (url) {
|
||||||
|
let linkURL = url;
|
||||||
|
|
||||||
|
// Add http prefix if none exist in the URL so it actually
|
||||||
|
// will work in an anchor tag.
|
||||||
|
if (linkURL.indexOf('http') === -1) {
|
||||||
|
linkURL = 'http://' + linkURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the protocol prefix in the display URLs just to make
|
||||||
|
// things look a little nicer.
|
||||||
|
const displayURL = url.replace(/(^\w+:|^)\/\//, '');
|
||||||
|
const link = `<a href="${linkURL}" target="_blank">${displayURL}</a>`;
|
||||||
|
text = text.replace(url, link);
|
||||||
|
|
||||||
|
if (getYoutubeIdFromURL(url)) {
|
||||||
|
if (isTextJustURLs(text, [url, displayURL])) {
|
||||||
|
text = '';
|
||||||
|
} else {
|
||||||
|
text += '<br/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const youtubeID = getYoutubeIdFromURL(url);
|
||||||
|
text += getYoutubeEmbedFromID(youtubeID);
|
||||||
|
} else if (url.indexOf('instagram.com/p/') > -1) {
|
||||||
|
if (isTextJustURLs(text, [url, displayURL])) {
|
||||||
|
text = '';
|
||||||
|
} else {
|
||||||
|
text += `<br/>`;
|
||||||
|
}
|
||||||
|
text += getInstagramEmbedFromURL(url);
|
||||||
|
} else if (isImage(url)) {
|
||||||
|
if (isTextJustURLs(text, [url, displayURL])) {
|
||||||
|
text = '';
|
||||||
|
} else {
|
||||||
|
text += `<br/>`;
|
||||||
|
}
|
||||||
|
text += getImageForURL(url);
|
||||||
|
}
|
||||||
|
}.bind(this));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextJustURLs(text, urls) {
|
||||||
|
for (var i = 0; i < urls.length; i++) {
|
||||||
|
const url = urls[i];
|
||||||
|
if (stripTags(text) === url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function stripTags(str) {
|
||||||
|
return str.replace(/<\/?[^>]+(>|$)/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getURLs(str) {
|
||||||
|
var exp = /((\w+:\/\/\S+)|(\w+[\.:]\w+\S+))[^\s,\.]/ig;
|
||||||
|
return str.match(exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getYoutubeIdFromURL(url) {
|
||||||
|
try {
|
||||||
|
var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||||
|
var match = url.match(regExp);
|
||||||
|
|
||||||
|
if (match && match[2].length == 11) {
|
||||||
|
return match[2];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getYoutubeEmbedFromID(id) {
|
||||||
|
return `
|
||||||
|
<div class="chat-embed youtube-embed">
|
||||||
|
<lite-youtube videoid="${id}" />
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstagramEmbedFromURL(url) {
|
||||||
|
const urlObject = new URL(url.replace(/\/$/, ""));
|
||||||
|
urlObject.pathname += "/embed";
|
||||||
|
return `<iframe class="chat-embed instagram-embed" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(url) {
|
||||||
|
const re = /\.(jpe?g|png|gif)$/i;
|
||||||
|
return re.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageForURL(url) {
|
||||||
|
return `<a target="_blank" href="${url}"><img class="chat-embed embedded-image" src="${url}" /></a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken from https://stackoverflow.com/questions/3972014/get-contenteditable-caret-index-position
|
||||||
|
export function getCaretPosition(editableDiv) {
|
||||||
|
var caretPos = 0,
|
||||||
|
sel, range;
|
||||||
|
if (window.getSelection) {
|
||||||
|
sel = window.getSelection();
|
||||||
|
if (sel.rangeCount) {
|
||||||
|
range = sel.getRangeAt(0);
|
||||||
|
if (range.commonAncestorContainer.parentNode == editableDiv) {
|
||||||
|
caretPos = range.endOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (document.selection && document.selection.createRange) {
|
||||||
|
range = document.selection.createRange();
|
||||||
|
if (range.parentElement() == editableDiv) {
|
||||||
|
var tempEl = document.createElement("span");
|
||||||
|
editableDiv.insertBefore(tempEl, editableDiv.firstChild);
|
||||||
|
var tempRange = range.duplicate();
|
||||||
|
tempRange.moveToElementText(tempEl);
|
||||||
|
tempRange.setEndPoint("EndToEnd", range);
|
||||||
|
caretPos = tempRange.text.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return caretPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Might not need this anymore
|
||||||
|
// Pieced together from parts of https://stackoverflow.com/questions/6249095/how-to-set-caretcursor-position-in-contenteditable-element-div
|
||||||
|
export function setCaretPosition(editableDiv, position) {
|
||||||
|
var range = document.createRange();
|
||||||
|
var sel = window.getSelection();
|
||||||
|
range.selectNode(editableDiv);
|
||||||
|
range.setStart(editableDiv.childNodes[0], position);
|
||||||
|
range.collapse(true);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extraUserNamesFromMessageHistory(messages) {
|
||||||
|
const list = [];
|
||||||
|
if (messages) {
|
||||||
|
messages.forEach(function(message) {
|
||||||
|
if (!list.includes(message.author)) {
|
||||||
|
list.push(message.author);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
34
webroot/js/utils/constants.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// misc constants used throughout the app
|
||||||
|
|
||||||
|
export const URL_STATUS = `/status`;
|
||||||
|
export const URL_CHAT_HISTORY = `/chat`;
|
||||||
|
export const URL_CUSTOM_EMOJIS = `/emoji`;
|
||||||
|
export const URL_CONFIG = `/config`;
|
||||||
|
|
||||||
|
// TODO: This directory is customizable in the config. So we should expose this via the config API.
|
||||||
|
export const URL_STREAM = `/hls/stream.m3u8`;
|
||||||
|
export const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`;
|
||||||
|
|
||||||
|
export const TIMER_STATUS_UPDATE = 5000; // ms
|
||||||
|
export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins
|
||||||
|
export const TIMER_STREAM_DURATION_COUNTER = 1000;
|
||||||
|
export const TEMP_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
|
|
||||||
|
export const MESSAGE_OFFLINE = 'Stream is offline.';
|
||||||
|
export const MESSAGE_ONLINE = 'Stream is online.';
|
||||||
|
|
||||||
|
export const URL_OWNCAST = 'https://owncast.online'; // used in footer
|
||||||
|
|
||||||
|
|
||||||
|
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.';
|
||||||
|
|
||||||
|
|
||||||
|
// app styling
|
||||||
|
export const WIDTH_SINGLE_COL = 730;
|
||||||
|
export const HEIGHT_SHORT_WIDE = 500;
|
||||||
@ -1,16 +1,4 @@
|
|||||||
|
export function getLocalStorage(key) {
|
||||||
const URL_STATUS = `/status`;
|
|
||||||
const URL_CHAT_HISTORY = `/chat`;
|
|
||||||
// TODO: This directory is customizable in the config. So we should expose this via the config API.
|
|
||||||
const URL_STREAM = `/hls/stream.m3u8`;
|
|
||||||
const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`;
|
|
||||||
|
|
||||||
const POSTER_DEFAULT = `/img/logo.png`;
|
|
||||||
const POSTER_THUMB = `/thumbnail.jpg`;
|
|
||||||
|
|
||||||
const URL_OWNCAST = 'https://github.com/gabek/owncast'; // used in footer
|
|
||||||
|
|
||||||
function getLocalStorage(key) {
|
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(key);
|
return localStorage.getItem(key);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -18,7 +6,7 @@ function getLocalStorage(key) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLocalStorage(key, value) {
|
export function setLocalStorage(key, value) {
|
||||||
try {
|
try {
|
||||||
if (value !== "" && value !== null) {
|
if (value !== "" && value !== null) {
|
||||||
localStorage.setItem(key, value);
|
localStorage.setItem(key, value);
|
||||||
@ -30,12 +18,12 @@ function setLocalStorage(key, value) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLocalStorage(key) {
|
export function clearLocalStorage(key) {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// jump down to the max height of a div, with a slight delay
|
// jump down to the max height of a div, with a slight delay
|
||||||
function jumpToBottom(element) {
|
export function jumpToBottom(element) {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -48,11 +36,11 @@ function jumpToBottom(element) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// convert newlines to <br>s
|
// convert newlines to <br>s
|
||||||
function addNewlines(str) {
|
export function addNewlines(str) {
|
||||||
return str.replace(/(?:\r\n|\r|\n)/g, '<br />');
|
return str.replace(/(?:\r\n|\r|\n)/g, '<br />');
|
||||||
}
|
}
|
||||||
|
|
||||||
function pluralize(string, count) {
|
export function pluralize(string, count) {
|
||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
return string;
|
return string;
|
||||||
} else {
|
} else {
|
||||||
@ -63,45 +51,45 @@ function pluralize(string, count) {
|
|||||||
|
|
||||||
// Trying to determine if browser is mobile/tablet.
|
// Trying to determine if browser is mobile/tablet.
|
||||||
// Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
|
// Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
|
||||||
function hasTouchScreen() {
|
export function hasTouchScreen() {
|
||||||
var hasTouchScreen = false;
|
let hasTouch = false;
|
||||||
if ("maxTouchPoints" in navigator) {
|
if ("maxTouchPoints" in navigator) {
|
||||||
hasTouchScreen = navigator.maxTouchPoints > 0;
|
hasTouch = navigator.maxTouchPoints > 0;
|
||||||
} else if ("msMaxTouchPoints" in navigator) {
|
} else if ("msMaxTouchPoints" in navigator) {
|
||||||
hasTouchScreen = navigator.msMaxTouchPoints > 0;
|
hasTouch = navigator.msMaxTouchPoints > 0;
|
||||||
} else {
|
} else {
|
||||||
var mQ = window.matchMedia && matchMedia("(pointer:coarse)");
|
var mQ = window.matchMedia && matchMedia("(pointer:coarse)");
|
||||||
if (mQ && mQ.media === "(pointer:coarse)") {
|
if (mQ && mQ.media === "(pointer:coarse)") {
|
||||||
hasTouchScreen = !!mQ.matches;
|
hasTouch = !!mQ.matches;
|
||||||
} else if ('orientation' in window) {
|
} else if ('orientation' in window) {
|
||||||
hasTouchScreen = true; // deprecated, but good fallback
|
hasTouch = true; // deprecated, but good fallback
|
||||||
} else {
|
} else {
|
||||||
// Only as a last resort, fall back to user agent sniffing
|
// Only as a last resort, fall back to user agent sniffing
|
||||||
var UA = navigator.userAgent;
|
var UA = navigator.userAgent;
|
||||||
hasTouchScreen = (
|
hasTouch = (
|
||||||
/\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) ||
|
/\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) ||
|
||||||
/\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA)
|
/\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hasTouchScreen;
|
return hasTouch;
|
||||||
}
|
}
|
||||||
|
|
||||||
// generate random avatar from https://robohash.org
|
// generate random avatar from https://robohash.org
|
||||||
function generateAvatar(hash) {
|
export function generateAvatar(hash) {
|
||||||
const avatarSource = 'https://robohash.org/';
|
const avatarSource = 'https://robohash.org/';
|
||||||
const optionSize = '?size=80x80';
|
const optionSize = '?size=80x80';
|
||||||
const optionSet = '&set=set3';
|
const optionSet = '&set=set2';
|
||||||
const optionBg = ''; // or &bgset=bg1 or bg2
|
const optionBg = ''; // or &bgset=bg1 or bg2
|
||||||
|
|
||||||
return avatarSource + hash + optionSize + optionSet + optionBg;
|
return avatarSource + hash + optionSize + optionSet + optionBg;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateUsername() {
|
export function generateUsername() {
|
||||||
return `User ${(Math.floor(Math.random() * 42) + 1)}`;
|
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 finiteSeconds = Number.isFinite(+seconds) ? Math.abs(seconds) : 0;
|
||||||
|
|
||||||
const hours = Math.floor(finiteSeconds / 3600);
|
const hours = Math.floor(finiteSeconds / 3600);
|
||||||
@ -116,13 +104,41 @@ function secondsToHMMSS(seconds = 0) {
|
|||||||
return hoursString + minString + secsString;
|
return hoursString + minString + secsString;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVHvar() {
|
export function setVHvar() {
|
||||||
var vh = window.innerHeight * 0.01;
|
var vh = window.innerHeight * 0.01;
|
||||||
// Then we set the value in the --vh custom property to the root of the document
|
// Then we set the value in the --vh custom property to the root of the document
|
||||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||||
console.log("== new vh", vh)
|
console.log("== new vh", vh)
|
||||||
}
|
}
|
||||||
|
|
||||||
function doesObjectSupportFunction(object, functionName) {
|
export function doesObjectSupportFunction(object, functionName) {
|
||||||
return typeof object[functionName] === "function";
|
return typeof object[functionName] === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// return a string of css classes
|
||||||
|
export function classNames(json) {
|
||||||
|
const classes = [];
|
||||||
|
|
||||||
|
Object.entries(json).map(function(item) {
|
||||||
|
const [ key, value ] = item;
|
||||||
|
if (value) {
|
||||||
|
classes.push(key);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return classes.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// taken from
|
||||||
|
// https://medium.com/@TCAS3/debounce-deep-dive-javascript-es6-e6f8d983b7a1
|
||||||
|
export function debounce(fn, time) {
|
||||||
|
let timeout;
|
||||||
|
|
||||||
|
return function() {
|
||||||
|
const functionCall = () => fn.apply(this, arguments);
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(functionCall, time);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
webroot/js/utils/social.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
// x, y pixel psitions of /img/social.gif image.
|
||||||
|
export const SOCIAL_PLATFORMS = {
|
||||||
|
default: {
|
||||||
|
name: "default",
|
||||||
|
imgPos: [0,0], // [row,col]
|
||||||
|
},
|
||||||
|
|
||||||
|
facebook: {
|
||||||
|
name: "Facebook",
|
||||||
|
imgPos: [0,1],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
name: "Twitter",
|
||||||
|
imgPos: [0,2],
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
name: "Instagram",
|
||||||
|
imgPos: [0,3],
|
||||||
|
},
|
||||||
|
snapchat: {
|
||||||
|
name: "Snapchat",
|
||||||
|
imgPos: [0,4],
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
name: "TikTok",
|
||||||
|
imgPos: [0,5],
|
||||||
|
},
|
||||||
|
soundcloud: {
|
||||||
|
name: "Soundcloud",
|
||||||
|
imgPos: [0,6],
|
||||||
|
},
|
||||||
|
bandcamp: {
|
||||||
|
name: "Bandcamp",
|
||||||
|
imgPos: [0,7],
|
||||||
|
},
|
||||||
|
patreon: {
|
||||||
|
name: "Patreon",
|
||||||
|
imgPos: [0,1],
|
||||||
|
},
|
||||||
|
youtube: {
|
||||||
|
name: "YouTube",
|
||||||
|
imgPos: [0,9 ],
|
||||||
|
},
|
||||||
|
spotify: {
|
||||||
|
name: "Spotify",
|
||||||
|
imgPos: [0,10],
|
||||||
|
},
|
||||||
|
twitch: {
|
||||||
|
name: "Twitch",
|
||||||
|
imgPos: [0,11],
|
||||||
|
},
|
||||||
|
paypal: {
|
||||||
|
name: "Paypal",
|
||||||
|
imgPos: [0,12],
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
name: "Github",
|
||||||
|
imgPos: [0,13],
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
name: "LinkedIn",
|
||||||
|
imgPos: [0,14],
|
||||||
|
},
|
||||||
|
discord: {
|
||||||
|
name: "Discord",
|
||||||
|
imgPos: [0,15],
|
||||||
|
},
|
||||||
|
mastodon: {
|
||||||
|
name: "Mastodon",
|
||||||
|
imgPos: [0,16],
|
||||||
|
},
|
||||||
|
};
|
||||||
15
webroot/js/utils/user-colors.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export function messageBubbleColorForString(str) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tweak these to adjust the result of the color
|
||||||
|
const saturation = 70;
|
||||||
|
const lightness = 50;
|
||||||
|
const alpha = 1.0;
|
||||||
|
const hue = parseInt(Math.abs(hash), 16) % 300;
|
||||||
|
|
||||||
|
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
|
||||||
|
}
|
||||||
@ -1,17 +1,25 @@
|
|||||||
import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js';
|
/**
|
||||||
|
* These are the types of messages that we can handle with the websocket.
|
||||||
|
* Mostly used by `websocket.js` but if other components need to handle
|
||||||
|
* different types then it can import this file.
|
||||||
|
*/
|
||||||
|
export const SOCKET_MESSAGE_TYPES = {
|
||||||
|
CHAT: 'CHAT',
|
||||||
|
PING: 'PING',
|
||||||
|
NAME_CHANGE: 'NAME_CHANGE',
|
||||||
|
PONG: 'PONG'
|
||||||
|
};
|
||||||
|
|
||||||
const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`;
|
export const CALLBACKS = {
|
||||||
|
|
||||||
const TIMER_WEBSOCKET_RECONNECT = 5000; // ms
|
|
||||||
|
|
||||||
const CALLBACKS = {
|
|
||||||
RAW_WEBSOCKET_MESSAGE_RECEIVED: 'rawWebsocketMessageReceived',
|
RAW_WEBSOCKET_MESSAGE_RECEIVED: 'rawWebsocketMessageReceived',
|
||||||
WEBSOCKET_CONNECTED: 'websocketConnected',
|
WEBSOCKET_CONNECTED: 'websocketConnected',
|
||||||
WEBSOCKET_DISCONNECTED: 'websocketDisconnected',
|
WEBSOCKET_DISCONNECTED: 'websocketDisconnected',
|
||||||
}
|
}
|
||||||
|
|
||||||
class Websocket {
|
const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`;
|
||||||
|
const TIMER_WEBSOCKET_RECONNECT = 5000; // ms
|
||||||
|
|
||||||
|
export default class Websocket {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.websocket = null;
|
this.websocket = null;
|
||||||
this.websocketReconnectTimer = null;
|
this.websocketReconnectTimer = null;
|
||||||
@ -42,7 +50,7 @@ class Websocket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Interface with other components
|
// Interface with other components
|
||||||
|
|
||||||
// Outbound: Other components can pass an object to `send`.
|
// Outbound: Other components can pass an object to `send`.
|
||||||
@ -51,7 +59,7 @@ class Websocket {
|
|||||||
if (!message.type || !SOCKET_MESSAGE_TYPES[message.type]) {
|
if (!message.type || !SOCKET_MESSAGE_TYPES[message.type]) {
|
||||||
console.warn(`Outbound message: Unknown socket message type: "${message.type}" sent.`);
|
console.warn(`Outbound message: Unknown socket message type: "${message.type}" sent.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageJSON = JSON.stringify(message);
|
const messageJSON = JSON.stringify(message);
|
||||||
this.websocket.send(messageJSON);
|
this.websocket.send(messageJSON);
|
||||||
}
|
}
|
||||||
@ -114,7 +122,7 @@ class Websocket {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send PONGs
|
// Send PONGs
|
||||||
if (model.type === SOCKET_MESSAGE_TYPES.PING) {
|
if (model.type === SOCKET_MESSAGE_TYPES.PING) {
|
||||||
this.sendPong();
|
this.sendPong();
|
||||||
@ -133,7 +141,5 @@ class Websocket {
|
|||||||
|
|
||||||
handleNetworkingError(error) {
|
handleNetworkingError(error) {
|
||||||
console.error(`Websocket Error: ${error}`)
|
console.error(`Websocket Error: ${error}`)
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Websocket;
|
|
||||||
@ -2,40 +2,40 @@
|
|||||||
"name": "App",
|
"name": "App",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "\/android-icon-36x36.png",
|
"src": "\/img\/favicon\/android-icon-36x36.png",
|
||||||
"sizes": "36x36",
|
"sizes": "36x36",
|
||||||
"type": "image\/png",
|
"type": "image\/png",
|
||||||
"density": "0.75"
|
"density": "0.75"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "\/android-icon-48x48.png",
|
"src": "\/img\/favicon\/android-icon-48x48.png",
|
||||||
"sizes": "48x48",
|
"sizes": "48x48",
|
||||||
"type": "image\/png",
|
"type": "image\/png",
|
||||||
"density": "1.0"
|
"density": "1.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "\/android-icon-72x72.png",
|
"src": "\/img\/favicon\/android-icon-72x72.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image\/png",
|
"type": "image\/png",
|
||||||
"density": "1.5"
|
"density": "1.5"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "\/android-icon-96x96.png",
|
"src": "\/img\/favicon\/android-icon-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image\/png",
|
"type": "image\/png",
|
||||||
"density": "2.0"
|
"density": "2.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "\/android-icon-144x144.png",
|
"src": "\/img\/favicon\/android-icon-144x144.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image\/png",
|
"type": "image\/png",
|
||||||
"density": "3.0"
|
"density": "3.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "\/android-icon-192x192.png",
|
"src": "\/img\/favicon\/android-icon-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image\/png",
|
"type": "image\/png",
|
||||||
"density": "4.0"
|
"density": "4.0"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
248
webroot/styles/app.css
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
/*
|
||||||
|
Specific styles for main app layout.
|
||||||
|
May have overrides for other components with own stylesheets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* variables */
|
||||||
|
:root {
|
||||||
|
--header-height: 3.5em;
|
||||||
|
--right-col-width: 24em;
|
||||||
|
--video-container-height: calc((9 / 16) * 100vw);
|
||||||
|
--header-bg-color: rgba(20,0,40,1);
|
||||||
|
--user-image-width: 10em;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 0px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
transition: all .25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[disabled] {
|
||||||
|
opacity: .5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute !important;
|
||||||
|
height: 1px;
|
||||||
|
width: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||||
|
clip: rect(1px, 1px, 1px, 1px);
|
||||||
|
white-space: nowrap; /* added line */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
header {
|
||||||
|
height: var(--header-height);
|
||||||
|
background-color: var(--header-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#logo-container {
|
||||||
|
background-size: 1.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-toggle {
|
||||||
|
min-width: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#user-info-change {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#stream-info span {
|
||||||
|
font-size: .70rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ************************************************ */
|
||||||
|
|
||||||
|
#video-container {
|
||||||
|
height: var(--video-container-height);
|
||||||
|
margin-top: var(--header-height);
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
min-height: 480px;
|
||||||
|
background-size: 30%;
|
||||||
|
}
|
||||||
|
#video-container #video {
|
||||||
|
transition: opacity .5s;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.online #video-container #video {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* *********** overrides when chat is off ***************************** */
|
||||||
|
|
||||||
|
|
||||||
|
.no-chat footer {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-chat #chat-toggle {
|
||||||
|
opacity: .75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-chat #chat-container-wrap {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* *********** overrides when chat is on ***************************** */
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
--content-width: calc(100vw - var(--right-col-width));
|
||||||
|
}
|
||||||
|
.chat #chat-container-wrap {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat #video-container,
|
||||||
|
.chat #stream-info,
|
||||||
|
.chat #user-content {
|
||||||
|
width: var(--content-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat #video-container {
|
||||||
|
height: calc((9 / 16) * var(--content-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.short-wide.chat #video-container {
|
||||||
|
height: calc(100vh - var(--header-height) - 3rem);
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-wide #message-input {
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* *********** single col layout ***************************** */
|
||||||
|
|
||||||
|
.single-col {
|
||||||
|
--right-col-width: 0px;
|
||||||
|
}
|
||||||
|
.single-col main {
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
.single-col #chat-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.single-col #video-container {
|
||||||
|
min-height: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.single-col #user-content,
|
||||||
|
.single-col #chat-container-wrap {
|
||||||
|
margin-top: calc(var(--video-container-height) + var(--header-height) + 1rem);
|
||||||
|
}
|
||||||
|
.single-col #user-content .user-content {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.single-col.chat #user-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.single-col #message-input-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-col #message-input {
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ************************************************8 */
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (max-width: 860px) {
|
||||||
|
:root {
|
||||||
|
--right-col-width: 20em;
|
||||||
|
--user-image-width: 6em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ************************************************8 */
|
||||||
|
|
||||||
|
|
||||||
|
/* single col layout */
|
||||||
|
/* @media screen and (max-width: 640px ) {
|
||||||
|
:root {
|
||||||
|
--right-col-width: 0;
|
||||||
|
--video-container-height: 40vh;
|
||||||
|
}
|
||||||
|
#logo-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
max-width: 58%;
|
||||||
|
}
|
||||||
|
#user-options-container {
|
||||||
|
max-width: 41%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-container {
|
||||||
|
width: 100%;
|
||||||
|
position: static;
|
||||||
|
height: calc(100vh - var(--header-height) - var(--video-container-height) - 3vh)
|
||||||
|
}
|
||||||
|
#messages-container {
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
#user-content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#stream-info {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#video-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.chat #video-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.chat #user-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.chat footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* @media screen and (max-height: 860px ) {
|
||||||
|
:root {
|
||||||
|
--video-container-height: 40vh;
|
||||||
|
}
|
||||||
|
.user-content {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
} */
|
||||||
140
webroot/styles/chat.css
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
/* some base styles for chat and messaging components */
|
||||||
|
|
||||||
|
#chat-container {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9;
|
||||||
|
top: var(--header-height);
|
||||||
|
right: 0;
|
||||||
|
width: var(--right-col-width);
|
||||||
|
|
||||||
|
height: calc(100vh - var(--header-height));
|
||||||
|
}
|
||||||
|
|
||||||
|
#message-input-container {
|
||||||
|
width: var(--right-col-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
#messages-container {
|
||||||
|
padding-bottom: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************/
|
||||||
|
/******************************/
|
||||||
|
|
||||||
|
#message-input img {
|
||||||
|
display: inline;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#message-input .emoji {
|
||||||
|
width: 2.2rem;
|
||||||
|
padding: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* If the div is empty then show the placeholder */
|
||||||
|
#message-input:empty:before{
|
||||||
|
content: attr(placeholderText);
|
||||||
|
pointer-events: none;
|
||||||
|
display: block; /* For Firefox */
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When chat is enabled (contenteditable=true) */
|
||||||
|
#message-input[contenteditable=true]:before {
|
||||||
|
opacity: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* When chat is disabled (contenteditable=false) chat input div should appear disabled. */
|
||||||
|
#message-input:disabled,
|
||||||
|
#message-input[contenteditable=false] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
/******************************/
|
||||||
|
/******************************/
|
||||||
|
|
||||||
|
|
||||||
|
.emoji-picker__emoji {
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
height: 3.0em;
|
||||||
|
width: 3.0em;
|
||||||
|
}
|
||||||
|
.message-avatar img {
|
||||||
|
max-width: unset;
|
||||||
|
height: 3.0em;
|
||||||
|
width: 3.0em;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* MESSAGE TEXT HTML */
|
||||||
|
/* MESSAGE TEXT HTML */
|
||||||
|
/* MESSAGE TEXT HTML */
|
||||||
|
.message-text a {
|
||||||
|
color: #7F9CF5; /* indigo-400 */
|
||||||
|
}
|
||||||
|
.message-text a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text img {
|
||||||
|
display: inline;
|
||||||
|
padding-left: 0 .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.message-text .emoji {
|
||||||
|
width: 3rem;
|
||||||
|
padding: .25rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text code {
|
||||||
|
font-family: monospace;
|
||||||
|
background-color:darkslategrey;
|
||||||
|
padding: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.message-text .chat-embed {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text .instagram-embed {
|
||||||
|
height: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.message-text .embedded-image {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
/* height: 15rem; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text .youtube-embed {
|
||||||
|
width: 100%;
|
||||||
|
height: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MESSAGE TEXT CONTENT */
|
||||||
|
/* MESSAGE TEXT CONTENT */
|
||||||
|
/* MESSAGE TEXT CONTENT */
|
||||||
|
/* MESSAGE TEXT CONTENT */
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,3 +1,9 @@
|
|||||||
|
/*
|
||||||
|
Overall layout styles for all of owncast app.
|
||||||
|
|
||||||
|
DE[RECATE THIS LAYOUT.CSS FILE.
|
||||||
|
*/
|
||||||
|
|
||||||
/* variables */
|
/* variables */
|
||||||
:root {
|
:root {
|
||||||
--header-height: 3.5em;
|
--header-height: 3.5em;
|
||||||
@ -7,25 +13,22 @@
|
|||||||
--user-image-width: 10em;
|
--user-image-width: 10em;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
/* vuejs attribute to hide things before content ready */
|
|
||||||
[v-cloak] { visibility: hidden; }
|
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 0px;
|
width: 0px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
.visually-hidden {
|
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||||
@ -33,243 +36,25 @@ a:hover {
|
|||||||
white-space: nowrap; /* added line */
|
white-space: nowrap; /* added line */
|
||||||
}
|
}
|
||||||
|
|
||||||
#app-container {
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
header {
|
||||||
position: fixed;
|
|
||||||
width: 100%;
|
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
background-color: var(--header-bg-color);
|
background-color: var(--header-bg-color);
|
||||||
z-index: 10;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
#logo-container {
|
||||||
font-size: 1.25em;
|
|
||||||
font-weight: 100;
|
|
||||||
letter-spacing: 1.2;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: .5em;
|
|
||||||
white-space: nowrap;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: row;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#logo-container{
|
|
||||||
height: 1.75em;
|
|
||||||
width: 1.75em;
|
|
||||||
min-height: 1.75em;
|
|
||||||
min-width: 1.75em;
|
|
||||||
margin-right: .5em;
|
|
||||||
display: inline-block;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center center;
|
|
||||||
background-size: 1.35em;
|
background-size: 1.35em;
|
||||||
}
|
}
|
||||||
header .instance-title {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
#chat-toggle {
|
#chat-toggle {
|
||||||
cursor: pointer;
|
min-width: 3rem;
|
||||||
text-align: center;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 3em;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-start;
|
|
||||||
font-size: .75em;
|
|
||||||
padding: 2em;
|
|
||||||
opacity: .5;
|
|
||||||
}
|
|
||||||
footer span {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ************************************************8 */
|
/* ************************************************8 */
|
||||||
|
|
||||||
#stream-info {
|
|
||||||
padding: .5em 2em;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
}
|
|
||||||
#stream-info span {
|
|
||||||
font-size: .7em;
|
|
||||||
}
|
|
||||||
.user-content {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
/* #user-content {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
#user-content-touch {
|
|
||||||
display: none;
|
|
||||||
} */
|
|
||||||
/* ************************************************8 */
|
|
||||||
|
|
||||||
|
|
||||||
.user-content {
|
|
||||||
padding: 3em;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
.user-content .user-image {
|
|
||||||
padding: 1em;
|
|
||||||
margin-right: 2em;
|
|
||||||
min-width: var(--user-image-width);
|
|
||||||
width: var(--user-image-width);
|
|
||||||
height: var(--user-image-width);
|
|
||||||
max-height: var(--user-image-width);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center center;
|
|
||||||
background-size: calc(var(--user-image-width) - 1em);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* .user-image img {
|
|
||||||
display: inline-block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
} */
|
|
||||||
.stream-summary {
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 3em;
|
|
||||||
}
|
|
||||||
.user-content-header {
|
|
||||||
margin-bottom: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-list {
|
|
||||||
flex-direction: row;
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
.tag-list li {
|
|
||||||
font-size: .75em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-right: .75em;
|
|
||||||
padding: .5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.social-list {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.social-list .follow-label {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: .75em;
|
|
||||||
margin-right: .5em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-social-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
margin-right: -.25em;
|
|
||||||
}
|
|
||||||
.user-social-item .platform-icon {
|
|
||||||
--icon-width: 40px;
|
|
||||||
height: var(--icon-width);
|
|
||||||
width: var(--icon-width);
|
|
||||||
background-image: url(../img/social-icons.gif);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: calc(var(--imgCol) * var(--icon-width)) calc(var(--imgRow) * var(--icon-width));
|
|
||||||
transform: scale(.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-social-item.use-default .platform-label {
|
|
||||||
font-size: .7em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
display: inline-block;
|
|
||||||
max-width: 10em;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ************************************************8 */
|
|
||||||
|
|
||||||
#user-options-container {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
#user-info-display {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: .5em 1em;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#username-avatar {
|
|
||||||
height: 2.1em;
|
|
||||||
width: 2.1em;
|
|
||||||
margin-right: .5em;
|
|
||||||
}
|
|
||||||
#username-display {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: .75em;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#user-info-display:hover {
|
|
||||||
transition: opacity .2s;
|
|
||||||
opacity: .75;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#user-info-change {
|
#user-info-change {
|
||||||
display: none;
|
display: none;
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
padding: .25em;
|
|
||||||
}
|
|
||||||
#username-change-input {
|
|
||||||
font-size: .75em;
|
|
||||||
}
|
|
||||||
#button-update-username {
|
|
||||||
font-size: .65em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
height: 2.5em;
|
|
||||||
}
|
|
||||||
#button-cancel-change {
|
|
||||||
cursor: pointer;
|
|
||||||
height: 2.5em;
|
|
||||||
font-size: .65em;
|
|
||||||
}
|
|
||||||
.user-btn {
|
|
||||||
margin: 0 .25em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ************************************************8 */
|
/* ************************************************8 */
|
||||||
@ -277,20 +62,12 @@ h2 {
|
|||||||
|
|
||||||
#video-container {
|
#video-container {
|
||||||
height: calc(var(--video-container-height));
|
height: calc(var(--video-container-height));
|
||||||
width: 100%;
|
|
||||||
margin-top: var(--header-height);
|
margin-top: var(--header-height);
|
||||||
background-position: center center;
|
background-size: 30%;
|
||||||
background-repeat: no-repeat;
|
|
||||||
|
|
||||||
background-size: 30%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.owncast-video-container {
|
.owncast-video-container {
|
||||||
height: auto;
|
height: auto;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
.owncast-video-container .video-js {
|
.owncast-video-container .video-js {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -304,7 +81,6 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vjs-airplay .vjs-icon-placeholder::before {
|
.vjs-airplay .vjs-icon-placeholder::before {
|
||||||
/* content: 'AP'; */
|
|
||||||
content: url("../img/airplay.png");
|
content: url("../img/airplay.png");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -323,16 +99,10 @@ h2 {
|
|||||||
/* ************************************************8 */
|
/* ************************************************8 */
|
||||||
|
|
||||||
|
|
||||||
.no-chat #chat-container-wrap {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.no-chat footer {
|
.no-chat footer {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat #chat-container-wrap {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat #video-container,
|
.chat #video-container,
|
||||||
.chat #stream-info,
|
.chat #stream-info,
|
||||||
@ -340,102 +110,9 @@ h2 {
|
|||||||
width: calc(100% - var(--right-col-width));
|
width: calc(100% - var(--right-col-width));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#stream-info span {
|
||||||
#chat-container {
|
font-size: .70rem;
|
||||||
position: fixed;
|
|
||||||
z-index: 9;
|
|
||||||
top: var(--header-height);
|
|
||||||
right: 0;
|
|
||||||
width: var(--right-col-width);
|
|
||||||
|
|
||||||
height: calc(100vh - var(--header-height));
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
}
|
||||||
.touch-screen #chat-container {
|
|
||||||
height: calc(100vh - var(--header-height) - 3vh);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ************************************************8 */
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ************************************************8 */
|
/* ************************************************8 */
|
||||||
|
|
||||||
@ -452,7 +129,7 @@ h2 {
|
|||||||
--right-col-width: 20em;
|
--right-col-width: 20em;
|
||||||
--user-image-width: 6em;
|
--user-image-width: 6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat-container {
|
#chat-container {
|
||||||
width: var(--right-col-width);
|
width: var(--right-col-width);
|
||||||
}
|
}
|
||||||
@ -504,21 +181,6 @@ h2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* try not making the video fixed position for now */
|
|
||||||
@media (min-height: 861px) {
|
|
||||||
/* main {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 9;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
#user-content {
|
|
||||||
margin-top: calc(var(--video-container-height) + var(--header-height) + 2em)
|
|
||||||
} */
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -530,194 +192,3 @@ h2 {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
.message-text iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 170px;
|
|
||||||
border-radius: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-text .instagram-embed {
|
|
||||||
height: 314px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-text code {
|
|
||||||
background-color:darkslategrey;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
|
||||||
/* Emoji picker */
|
|
||||||
#emoji-button {
|
|
||||||
position: relative;
|
|
||||||
top: -65px;
|
|
||||||
right: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-text .embedded-image {
|
|
||||||
width: 100%;
|
|
||||||
height: 170px;
|
|
||||||
border-radius: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-text code {
|
|
||||||
background-color:darkslategrey;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Emoji picker */
|
|
||||||
#emoji-button {
|
|
||||||
position: relative;
|
|
||||||
top: -65px;
|
|
||||||
right: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.message-text .embedded-image {
|
|
||||||
width: 100%;
|
|
||||||
height: 170px;
|
|
||||||
border-radius: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-text code {
|
|
||||||
background-color:darkslategrey;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
|
||||||
.message-text .highlighted {
|
|
||||||
color: orange;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-text code {
|
|
||||||
background-color:darkslategrey;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
The chat input has a fake placeholder that is styled below.
|
|
||||||
It pulls the placeholder text from the div's placeholder attribute.
|
|
||||||
But really it's just the innerHTML content.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* If the div is empty then show the placeholder */
|
|
||||||
#message-body-form:empty:before{
|
|
||||||
content: attr(placeholder);
|
|
||||||
pointer-events: none;
|
|
||||||
display: block; /* For Firefox */
|
|
||||||
|
|
||||||
/* Style the div's placeholder text color */
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When chat is enabled (contenteditable=true) */
|
|
||||||
#message-body-form[contenteditable=true]:before {
|
|
||||||
opacity: 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* When chat is disabled (contenteditable=false) chat input div should appear disabled. */
|
|
||||||
#message-body-form[contenteditable=false] {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
33
webroot/styles/standalone-chat.css
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
The styles in this file mostly ovveride those coming from chat.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* modify this px number if you want things to be relatively bigger or smaller */
|
||||||
|
#messages-only {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
#messages-only .message-content {
|
||||||
|
text-shadow: 1px 1px 0px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
#messages-only .message-avatar {
|
||||||
|
display: none;
|
||||||
|
box-shadow: 0px 0px 3px 0px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
#messages-only .message-avatar img {
|
||||||
|
height: 1.8em;
|
||||||
|
width: 1.8em;
|
||||||
|
}
|
||||||
|
#messages-only .message {
|
||||||
|
padding: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#messages-only .message-text {
|
||||||
|
font-weight: 400;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
#messages-only .message-text a {
|
||||||
|
color: #fc0;
|
||||||
|
}
|
||||||
|
#messages-only .message-author {
|
||||||
|
color: rgba(20,0,40,1);
|
||||||
|
}
|
||||||
150
webroot/styles/user-content.css
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
.user-content .user-image {
|
||||||
|
min-width: var(--user-image-width);
|
||||||
|
width: var(--user-image-width);
|
||||||
|
height: var(--user-image-width);
|
||||||
|
max-height: var(--user-image-width);
|
||||||
|
background-size: calc(var(--user-image-width) - 1em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-social-item .platform-icon {
|
||||||
|
--icon-width: 40px;
|
||||||
|
height: var(--icon-width);
|
||||||
|
width: var(--icon-width);
|
||||||
|
background-image: url(/img/social-icons.gif);
|
||||||
|
background-position: calc(var(--imgCol) * var(--icon-width)) calc(var(--imgRow) * var(--icon-width));
|
||||||
|
transform: scale(.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
EXTRA CUSTOM CONTENT STYLES
|
||||||
|
Assumes markup converted from markdown input.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
#extra-user-content ul,
|
||||||
|
#extra-user-content ol {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content ol {
|
||||||
|
list-style: decimal;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content ul {
|
||||||
|
list-style: unset;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content h1,
|
||||||
|
#extra-user-content h2,
|
||||||
|
#extra-user-content h3,
|
||||||
|
#extra-user-content h4,
|
||||||
|
#extra-user-content h5,
|
||||||
|
#extra-user-content h6 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 1.5rem 0 .5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#extra-user-content h1 {
|
||||||
|
font-size: 2.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content h4 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content h5 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
#extra-user-content h6 {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content a {
|
||||||
|
color: #0099ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content li {
|
||||||
|
line-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content li ul,
|
||||||
|
#extra-user-content li ul {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#extra-user-content blockquote {
|
||||||
|
border-left: .25rem solid #bbc;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
#extra-user-content blockquote p {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content pre,
|
||||||
|
#extra-user-content code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: .85rem;
|
||||||
|
background-color: #eee;
|
||||||
|
color: #900;
|
||||||
|
}
|
||||||
|
#extra-user-content pre {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 80%;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content aside {
|
||||||
|
display: block;
|
||||||
|
float: right;
|
||||||
|
width: 35%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content hr {
|
||||||
|
width: 100%;
|
||||||
|
border-top: 1px solid #666;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extra-user-content table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 1rem;
|
||||||
|
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.5rem 1rem;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
}
|
||||||
30
webroot/styles/video-only.css
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
The styles in this file mostly ovveride those coming from chat.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* modify this px number if you want things to be relatively bigger or smaller */
|
||||||
|
#video-only {
|
||||||
|
font-size: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#video-only #video-container {
|
||||||
|
background-size: 30%;
|
||||||
|
width: 100%;
|
||||||
|
height: calc((9 / 16) * 100vw);
|
||||||
|
}
|
||||||
|
#video-only #video-container #video {
|
||||||
|
transition: opacity .5s;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#video-only .online #video-container #video {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#video-only #stream-info {
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
55
webroot/styles/video.css
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
video.video-js {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
min-height: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
.vjs-airplay .vjs-icon-placeholder::before {
|
||||||
|
content: url("../img/airplay.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: calc((9 / 16) * 100vw);
|
||||||
|
max-height: calc(100vh - 169px);
|
||||||
|
min-height: 480px;
|
||||||
|
background: #000; */
|
||||||
|
|
||||||
|
/*
|
||||||
|
YOUTUBE
|
||||||
|
style="--ytd-watch-flexy-scrollbar-width: 15px; --ytd-watch-flexy-panel-max-height: 460px; --ytd-watch-flexy-chat-max-height: 460px;"
|
||||||
|
|
||||||
|
--ytd-watch-flexy-scrollbar-width: 15px;
|
||||||
|
--ytd-watch-flexy-panel-max-height: 460px;
|
||||||
|
--ytd-watch-flexy-chat-max-height: 460px;
|
||||||
|
|
||||||
|
--ytd-watch-flexy-width-ratio: 16;
|
||||||
|
--ytd-watch-flexy-height-ratio: 9;
|
||||||
|
--ytd-watch-flexy-space-below-player: 136px;
|
||||||
|
|
||||||
|
--ytd-watch-flexy-non-player-height: calc(var(--ytd-watch-flexy-masthead-height) + var(--ytd-margin-6x) + var(--ytd-watch-flexy-space-below-player));
|
||||||
|
|
||||||
|
--ytd-watch-flexy-non-player-width: calc(var(--ytd-watch-flexy-sidebar-width) + (3 * var(--ytd-margin-6x)));
|
||||||
|
|
||||||
|
--ytd-watch-flexy-min-player-height: 240px;
|
||||||
|
|
||||||
|
--ytd-watch-flexy-min-player-width: calc(var(--ytd-watch-flexy-min-player-height) * (var(--ytd-watch-flexy-width-ratio) / var(--ytd-watch-flexy-height-ratio)));
|
||||||
|
|
||||||
|
--ytd-watch-flexy-max-player-height: calc(100vh -
|
||||||
|
(var(--ytd-watch-flexy-masthead-height) + var(--ytd-margin-6x) + var(--ytd-watch-flexy-space-below-player)));
|
||||||
|
|
||||||
|
--ytd-watch-flexy-max-player-width:
|
||||||
|
calc((100vh - (var(--ytd-watch-flexy-masthead-height) + var(--ytd-margin-6x) + var(--ytd-watch-flexy-space-below-player))) *
|
||||||
|
(var(--ytd-watch-flexy-width-ratio) / var(--ytd-watch-flexy-height-ratio)));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--ytd-watch-flexy-sidebar-width: 402px;
|
||||||
|
--ytd-watch-flexy-sidebar-min-width: 300px;
|
||||||
|
--ytd-watch-flexy-masthead-height: 56px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
*/
|
||||||