Compare commits

...

6 Commits

9 changed files with 451 additions and 138 deletions

View File

@ -39,12 +39,12 @@
placeholder="English" placeholder="English"
/> />
<button>Create</button> <button>Create</button>
</form>
<p> <p>
Already have a session? Already have a session?
<a href="/">Join your session</a> instead. <a href="/">Join your session</a> instead.
</p> </p>
</form>
</div> </div>
<script type="module" src="/create.mjs?v=9"></script> <script type="module" src="/create.mjs?v=9"></script>

View File

@ -41,11 +41,12 @@
required required
/> />
<button id="join-session-button">Join</button> <button id="join-session-button">Join</button>
</form>
<p> <p>
No session to join? <a href="/create.html">Create a session</a> instead. No session to join?
<a href="/create.html">Create a session</a> instead.
</p> </p>
</form>
</div> </div>
<div id="video-container"></div> <div id="video-container"></div>
@ -53,7 +54,13 @@
<div id="viewer-list"></div> <div id="viewer-list"></div>
<div id="chatbox"></div> <div id="chatbox"></div>
<form id="chatbox-send"> <form id="chatbox-send">
<input type="text" placeholder="Message... (/help for commands)" /> <input
type="text"
placeholder="Message... (/help for commands)"
list="emoji-autocomplete"
/>
<div id="emoji-autocomplete"></div>
<!-- DO NOT ADD SPACING INSIDE THE TAG IT WILL BREAK THE CSS kthxbye -->
</form> </form>
</div> </div>

View File

@ -1,19 +1,110 @@
import { setDebounce, setVideoTime, setPlaying } from "./watch-session.mjs?v=9"; import { setDebounce, setVideoTime, setPlaying } from "./watch-session.mjs?v=9";
import { emojify } from "./emojis.mjs?v=9"; import { emojify, emojis } from "./emojis.mjs?v=9";
function insertAtCursor(input, textToInsert) {
const isSuccess = document.execCommand("insertText", false, textToInsert);
// Firefox (non-standard method)
if (!isSuccess && typeof input.setRangeText === "function") {
const start = input.selectionStart;
input.setRangeText(textToInsert);
// update cursor to be at the end of insertion
input.selectionStart = input.selectionEnd = start + textToInsert.length;
// Notify any possible listeners of the change
const e = document.createEvent("UIEvent");
e.initEvent("input", true, false);
input.dispatchEvent(e);
}
}
const setupChatboxEvents = (socket) => { const setupChatboxEvents = (socket) => {
// clear events by just reconstructing the form // clear events by just reconstructing the form
const oldChatForm = document.querySelector("#chatbox-send"); const oldChatForm = document.querySelector("#chatbox-send");
const chatForm = oldChatForm.cloneNode(true); const chatForm = oldChatForm.cloneNode(true);
const messageInput = chatForm.querySelector("input");
const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete");
oldChatForm.replaceWith(chatForm); oldChatForm.replaceWith(chatForm);
let autocompleting = false;
const replaceMessage = (message) => () => {
messageInput.value = message;
autocomplete();
};
async function autocomplete() {
if (autocompleting) return;
emojiAutocomplete.textContent = "";
autocompleting = true;
let text = messageInput.value.slice(0, messageInput.selectionStart);
const match = text.match(/(:[^\s:]+)?:([^\s:]*)$/);
if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete.
const prefix = text.slice(0, match.index);
const search = text.slice(match.index + 1);
const suffix = messageInput.value.slice(messageInput.selectionStart);
const select = (button) => {
const selected = document.querySelector(".emoji-option.selected");
if (selected) selected.classList.remove("selected");
button.classList.add("selected");
};
emojiAutocomplete.append(
...(await emojis)
.filter((e) => e.toLowerCase().startsWith(search.toLowerCase()))
.map((name, i) => {
const button = Object.assign(document.createElement("button"), {
className: "emoji-option" + (i === 0 ? " selected" : ""),
onmousedown: (e) => e.preventDefault(),
onmouseup: () =>
insertAtCursor(button, name.slice(match[2].length) + ": "),
onmouseover: () => select(button),
onfocus: () => select(button),
});
button.append(
Object.assign(new Image(), {
loading: "lazy",
src: `/emojis/${name}.png`,
className: "emoji",
}),
Object.assign(document.createElement("span"), { textContent: name })
);
return button;
})
);
if (emojiAutocomplete.children[0])
emojiAutocomplete.children[0].scrollIntoView();
autocompleting = false;
}
messageInput.addEventListener("input", autocomplete);
messageInput.addEventListener("selectionchange", autocomplete);
messageInput.addEventListener("keydown", (event) => {
if (event.key == "ArrowUp" || event.key == "ArrowDown") {
let selected = document.querySelector(".emoji-option.selected");
if (!selected) return;
event.preventDefault();
selected.classList.remove("selected");
selected =
event.key == "ArrowDown"
? selected.nextElementSibling || selected.parentElement.children[0]
: selected.previousElementSibling ||
selected.parentElement.children[
selected.parentElement.children.length - 1
];
selected.classList.add("selected");
selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" });
}
if (event.key == "Tab") {
let selected = document.querySelector(".emoji-option.selected");
if (!selected) return;
event.preventDefault();
selected.onmouseup();
}
});
chatForm.addEventListener("submit", async (e) => { chatForm.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const content = messageInput.value;
const input = chatForm.querySelector("input");
const content = input.value;
if (content.trim().length) { if (content.trim().length) {
input.value = ""; messageInput.value = "";
// handle commands // handle commands
if (content.startsWith("/")) { if (content.startsWith("/")) {
@ -59,7 +150,12 @@ const setupChatboxEvents = (socket) => {
"&emsp;<code>/ping [message]</code> - ping all viewers<br>" + "&emsp;<code>/ping [message]</code> - ping all viewers<br>" +
"&emsp;<code>/sync</code> - resyncs you with other viewers"; "&emsp;<code>/sync</code> - resyncs you with other viewers";
printChatMessage("command-message", "/help", "b57fdc", helpMessageContent); printChatMessage(
"command-message",
"/help",
"b57fdc",
helpMessageContent
);
handled = true; handled = true;
break; break;
default: default:
@ -82,39 +178,19 @@ const setupChatboxEvents = (socket) => {
}); });
}; };
const fixChatSize = () => {
const video = document.querySelector("video");
const chatbox = document.querySelector("#chatbox");
const chatboxContainer = document.querySelector("#chatbox-container");
if (video && chatbox && chatboxContainer) {
const delta = chatboxContainer.clientHeight - chatbox.clientHeight;
chatbox.style["height"] = `calc(${
window.innerHeight - video.clientHeight
}px - ${delta}px - 1em)`;
}
};
/** /**
* @param {WebSocket} socket * @param {WebSocket} socket
*/ */
export const setupChat = async (socket) => { export const setupChat = async (socket) => {
document.querySelector("#chatbox-container").style["display"] = "block"; document.querySelector("#chatbox-container").style["display"] = "flex";
setupChatboxEvents(socket); setupChatboxEvents(socket);
window.addEventListener("keydown", (event) => { window.addEventListener("keydown", (event) => {
try { try {
const isSelectionEmpty = window.getSelection().toString().length === 0; const isSelectionEmpty = window.getSelection().toString().length === 0;
if (event.code.match(/Key\w/) && isSelectionEmpty) if (event.code.match(/Key\w/) && isSelectionEmpty) messageInput.focus();
document.querySelector("#chatbox-send > input").focus();
} catch (_err) {} } catch (_err) {}
}); });
fixChatSize();
window.addEventListener("resize", () => {
fixChatSize();
});
}; };
const addToChat = (node) => { const addToChat = (node) => {
@ -185,7 +261,7 @@ const printChatMessage = (eventType, user, colour, content) => {
if (user != null) { if (user != null) {
const userName = document.createElement("strong"); const userName = document.createElement("strong");
userName.style = `color: #${colour}`; userName.style = `--user-color: #${colour}`;
userName.textContent = user; userName.textContent = user;
chatMessage.appendChild(userName); chatMessage.appendChild(userName);
} }
@ -210,7 +286,7 @@ const formatTime = (ms) => {
}:${seconds < 10 ? "0" + seconds : seconds}`; }:${seconds < 10 ? "0" + seconds : seconds}`;
}; };
export const logEventToChat = (event) => { export const logEventToChat = async (event) => {
if (checkDebounce(event)) { if (checkDebounce(event)) {
return; return;
} }
@ -237,7 +313,7 @@ export const logEventToChat = (event) => {
case "ChatMessage": { case "ChatMessage": {
const messageContent = document.createElement("span"); const messageContent = document.createElement("span");
messageContent.classList.add("message-content"); messageContent.classList.add("message-content");
messageContent.append(...emojify(event.data)); messageContent.append(...(await emojify(event.data)));
printChatMessage( printChatMessage(
"chat-message", "chat-message",
event.user, event.user,
@ -327,7 +403,7 @@ export const updateViewerList = (viewers) => {
const viewerElem = document.createElement("div"); const viewerElem = document.createElement("div");
const content = document.createElement("strong"); const content = document.createElement("strong");
content.textContent = viewer.nickname; content.textContent = viewer.nickname;
content.style = `color: #${viewer.colour}`; content.style = `--user-color: #${viewer.colour}`;
viewerElem.appendChild(content); viewerElem.appendChild(content);
listContainer.appendChild(viewerElem); listContainer.appendChild(viewerElem);
} }

View File

@ -1,11 +1,26 @@
export function emojify(text) { export async function emojify(text) {
const emojiList = await emojis;
let last = 0; let last = 0;
let nodes = []; let nodes = [];
text.replace(/:([^\s:]+):/g, (match, name, index) => { text.replace(/:([^\s:]+):/g, (match, name, index) => {
if(last <= index) nodes.push(document.createTextNode(text.slice(last, index))) if (last <= index)
nodes.push(Object.assign(new Image(), {src: `/emojis/${name}.png`, className: "emoji", alt: name})) nodes.push(document.createTextNode(text.slice(last, index)));
last = index + match.length if (!emojiList.includes(name)) {
nodes.push(document.createTextNode(match));
} else {
nodes.push(
Object.assign(new Image(), {
src: `/emojis/${name}.png`,
className: "emoji",
alt: name,
}) })
if(last < text.length) nodes.push(document.createTextNode(text.slice(last))) );
return nodes
} }
last = index + match.length;
});
if (last < text.length) nodes.push(document.createTextNode(text.slice(last)));
return nodes;
}
export const emojis = fetch("/emojis")
.then((e) => e.json())
.then((e) => e.map((e) => e.slice(0, -4)));

View File

@ -72,13 +72,22 @@ export const setupJoinSessionForm = () => {
sessionId.value = window.location.hash.substring(1); sessionId.value = window.location.hash.substring(1);
} }
form.addEventListener("submit", (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
button.disabled = true; button.disabled = true;
saveNickname(nickname); saveNickname(nickname);
saveColour(colour); saveColour(colour);
joinSession(nickname.value, sessionId.value, colour.value.replace(/^#/, "")); try {
await joinSession(
nickname.value,
sessionId.value,
colour.value.replace(/^#/, "")
);
} catch (e) {
alert(e.message);
button.disabled = false;
}
}); });
}; };

View File

@ -0,0 +1,65 @@
export default class ReconnectingWebSocket {
constructor(url) {
if (url instanceof URL) {
this.url = url;
} else {
this.url = new URL(url);
}
this.connected = false;
this._eventTarget = new EventTarget();
this._backoff = 250; // milliseconds, doubled before use
this._lastConnect = 0;
this._socket = null;
this._unsent = [];
this._connect(true);
}
_connect(first) {
if (this._socket)
try {
this._socket.close();
} catch (e) {}
try {
this._socket = new WebSocket(this.url.href);
} catch (e) {
this._reconnecting = false;
return this._reconnect();
}
this._socket.addEventListener("close", () => this._reconnect());
this._socket.addEventListener("error", () => this._reconnect());
this._socket.addEventListener("message", ({ data }) =>
this._eventTarget.dispatchEvent(new MessageEvent("message", { data }))
);
this._socket.addEventListener("open", (e) => {
if (first) this._eventTarget.dispatchEvent(new Event("open"));
if (this._reconnecting)
this._eventTarget.dispatchEvent(new Event("reconnected"));
this._reconnecting = false;
this._backoff = 250;
this.connected = true;
while (this._unsent.length > 0) this._socket.send(this._unsent.shift());
});
}
_reconnect() {
if (this._reconnecting) return;
this._eventTarget.dispatchEvent(new Event("reconnecting"));
this._reconnecting = true;
this.connected = false;
this._backoff *= 2; // exponential backoff
setTimeout(() => {
this._connect();
}, Math.floor(this._backoff + Math.random() * this._backoff * 0.25 - this._backoff * 0.125));
}
send(message) {
if (this.connected) {
this._socket.send(message);
} else {
this._unsent.push(message);
}
}
addEventListener(...a) {
return this._eventTarget.addEventListener(...a);
}
removeEventListener(...a) {
return this._eventTarget.removeEventListener(...a);
}
}

View File

@ -140,7 +140,9 @@ const createVideoElement = (videoUrl, subtitles) => {
export const setupVideo = async (videoUrl, subtitles, currentTime, playing) => { export const setupVideo = async (videoUrl, subtitles, currentTime, playing) => {
document.querySelector("#pre-join-controls").style["display"] = "none"; document.querySelector("#pre-join-controls").style["display"] = "none";
const video = createVideoElement(videoUrl, subtitles); const video = createVideoElement(videoUrl, subtitles);
document.querySelector("#video-container").appendChild(video); const videoContainer = document.querySelector("#video-container");
videoContainer.style.display = "block";
videoContainer.appendChild(video);
video.currentTime = currentTime / 1000.0; video.currentTime = currentTime / 1000.0;

View File

@ -1,10 +1,11 @@
import { setupVideo } from "./video.mjs?v=9"; import { setupVideo } from "./video.mjs?v=9";
import { setupChat, logEventToChat, updateViewerList } from "./chat.mjs?v=9"; import { setupChat, logEventToChat, updateViewerList } from "./chat.mjs?v=9";
import ReconnectingWebSocket from "./reconnecting-web-socket.mjs";
/** /**
* @param {string} sessionId * @param {string} sessionId
* @param {string} nickname * @param {string} nickname
* @returns {WebSocket} * @returns {ReconnectingWebSocket}
*/ */
const createWebSocket = (sessionId, nickname, colour) => { const createWebSocket = (sessionId, nickname, colour) => {
const wsUrl = new URL( const wsUrl = new URL(
@ -13,8 +14,8 @@ const createWebSocket = (sessionId, nickname, colour) => {
`&colour=${encodeURIComponent(colour)}`, `&colour=${encodeURIComponent(colour)}`,
window.location.href window.location.href
); );
wsUrl.protocol = { "http:": "ws:", "https:": "wss:" }[wsUrl.protocol]; wsUrl.protocol = "ws" + window.location.protocol.slice(4);
const socket = new WebSocket(wsUrl.toString()); const socket = new ReconnectingWebSocket(wsUrl);
return socket; return socket;
}; };
@ -60,7 +61,7 @@ export const setPlaying = async (playing, video = null) => {
/** /**
* @param {HTMLVideoElement} video * @param {HTMLVideoElement} video
* @param {WebSocket} socket * @param {ReconnectingWebSocket} socket
*/ */
const setupIncomingEvents = (video, socket) => { const setupIncomingEvents = (video, socket) => {
socket.addEventListener("message", async (messageEvent) => { socket.addEventListener("message", async (messageEvent) => {
@ -97,7 +98,7 @@ const setupIncomingEvents = (video, socket) => {
/** /**
* @param {HTMLVideoElement} video * @param {HTMLVideoElement} video
* @param {WebSocket} socket * @param {ReconnectingWebSocket} socket
*/ */
const setupOutgoingEvents = (video, socket) => { const setupOutgoingEvents = (video, socket) => {
const currentVideoTime = () => (video.currentTime * 1000) | 0; const currentVideoTime = () => (video.currentTime * 1000) | 0;
@ -167,11 +168,36 @@ const setupOutgoingEvents = (video, socket) => {
* @param {string} sessionId * @param {string} sessionId
*/ */
export const joinSession = async (nickname, sessionId, colour) => { export const joinSession = async (nickname, sessionId, colour) => {
try { // try { // we are handling errors in the join form.
const genericConnectionError = new Error(
"There was an issue getting the session information."
);
window.location.hash = sessionId; window.location.hash = sessionId;
let response, video_url, subtitle_tracks, current_time_ms, is_playing;
const { video_url, subtitle_tracks, current_time_ms, is_playing } = try {
await fetch(`/sess/${sessionId}`).then((r) => r.json()); response = await fetch(`/sess/${sessionId}`);
} catch (e) {
console.error(e);
throw genericConnectionError;
}
if (!response.ok) {
let error;
try {
({ error } = await response.json());
if (!error) throw new Error();
} catch (e) {
console.error(e);
throw genericConnectionError;
}
throw new Error(error);
}
try {
({ video_url, subtitle_tracks, current_time_ms, is_playing } =
await response.json());
} catch (e) {
console.error(e);
throw genericConnectionError;
}
const socket = createWebSocket(sessionId, nickname, colour); const socket = createWebSocket(sessionId, nickname, colour);
socket.addEventListener("open", async () => { socket.addEventListener("open", async () => {
@ -193,11 +219,15 @@ export const joinSession = async (nickname, sessionId, colour) => {
setupIncomingEvents(video, socket); setupIncomingEvents(video, socket);
setupChat(socket); setupChat(socket);
}); });
// TODO: Close listener ? socket.addEventListener("reconnecting", (e) => {
} catch (err) { console.log("Reconnecting...");
// TODO: Show an error on the screen });
console.error(err); socket.addEventListener("reconnected", (e) => {
} console.log("Reconnected.");
});
//} catch (e) {
// alert(e.message)
//}
}; };
/** /**

View File

@ -1,7 +1,25 @@
* {
box-sizing: border-box;
}
:root { :root {
--bg: rgb(28, 23, 36); --bg-rgb: 28, 23, 36;
--fg: rgb(234, 234, 248); --fg-rgb: 234, 234, 248;
--accent: hsl(275, 57%, 68%); --accent-rgb: 181, 127, 220;
--fg: rgb(var(--fg-rgb));
--bg: rgb(var(--bg-rgb));
--default-user-color: rgb(126, 208, 255);
--accent: rgb(var(--accent-rgb));
--fg-transparent: rgba(var(--fg-rgb), 0.125);
--bg-transparent: rgba(var(--bg-rgb), 0.125);
--chat-bg: linear-gradient(var(--fg-transparent), var(--fg-transparent)),
linear-gradient(var(--bg), var(--bg));
--autocomplete-bg: linear-gradient(
var(--fg-transparent),
var(--fg-transparent)
),
linear-gradient(var(--fg-transparent), var(--fg-transparent)),
linear-gradient(var(--bg), var(--bg));
} }
html { html {
@ -9,30 +27,34 @@ html {
color: var(--fg); color: var(--fg);
font-size: 1.125rem; font-size: 1.125rem;
font-family: sans-serif; font-family: sans-serif;
overflow-y: scroll;
scrollbar-width: none;
-ms-overflow-style: none;
}
::-webkit-scrollbar {
width: 0;
background: transparent;
} }
html, html,
body { body {
margin: 0; margin: 0;
padding: 0;
overflow: hidden;
overscroll-behavior: none;
width: 100%;
height: 100%;
}
body {
display: flex;
flex-direction: column;
} }
video { video {
display: block; display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
width: 100vw; #video-container {
height: auto; flex-grow: 1;
flex-shrink: 1;
max-width: auto; display: none;
max-height: 100vh;
} }
a { a {
@ -45,8 +67,6 @@ label {
input[type="url"], input[type="url"],
input[type="text"] { input[type="text"] {
box-sizing: border-box;
background: #fff; background: #fff;
background-clip: padding-box; background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, 0.12); border: 1px solid rgba(0, 0, 0, 0.12);
@ -60,8 +80,7 @@ input[type="text"] {
font-family: sans-serif; font-family: sans-serif;
font-size: 1em; font-size: 1em;
width: 500px; width: 100%;
max-width: 100%;
resize: none; resize: none;
overflow-x: wrap; overflow-x: wrap;
@ -82,12 +101,19 @@ button {
font-family: sans-serif; font-family: sans-serif;
font-size: 1em; font-size: 1em;
width: 500px; width: 100%;
max-width: 100%;
user-select: none; user-select: none;
border: 1px solid rgba(0, 0, 0, 0); border: 1px solid rgba(0, 0, 0, 0);
line-height: 1.5; line-height: 1.5;
cursor: pointer;
margin: 0.5em 0;
}
button:disabled {
filter: saturate(0.75);
opacity: 0.75;
cursor: default;
} }
button.small-button { button.small-button {
@ -108,20 +134,25 @@ button.small-button {
#pre-join-controls, #pre-join-controls,
#create-controls { #create-controls {
width: 60%; margin: 0;
margin: 0 auto; flex-grow: 1;
margin-top: 4em; overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
} }
#join-session-form, #join-session-form,
#create-session-form { #create-session-form {
margin-bottom: 4em; width: 500px;
max-width: 100%;
padding: 1rem;
} }
#post-create-message { #post-create-message {
display: none; display: none;
width: 500px; width: 100%;
max-width: 100%;
font-size: 0.85em; font-size: 0.85em;
} }
@ -133,8 +164,22 @@ button.small-button {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
.chat-message > strong { .chat-message > strong,
color: rgb(126, 208, 255); #viewer-list strong {
color: var(--user-color, var(--default-user-color));
}
@supports (-webkit-background-clip: text) {
.chat-message > strong,
#viewer-list strong {
background: linear-gradient(var(--fg-transparent), var(--fg-transparent)),
linear-gradient(
var(--user-color, var(--default-user-color)),
var(--user-color, var(--default-user-color))
);
-webkit-background-clip: text;
color: transparent !important;
}
} }
.chat-message.user-join, .chat-message.user-join,
@ -168,27 +213,34 @@ button.small-button {
#chatbox { #chatbox {
padding: 0.5em 2em; padding: 0.5em 2em;
min-height: 8em;
overflow-y: scroll; overflow-y: scroll;
flex-shrink: 1;
flex-grow: 1;
} }
#viewer-list { #viewer-list {
padding: 0.5em 2em; padding: 0.5em 2em;
/* TODO: turn this into max-height instead of fixed height without breaking the chatbox height */ /* TODO: turn this into max-height instead of fixed height without breaking the chatbox height */
height: 4em;
overflow-y: scroll; overflow-y: scroll;
color: rgb(126, 208, 255);
border-bottom: var(--fg); border-bottom: var(--fg);
border-bottom-style: solid; border-bottom-style: solid;
max-height: 4rem;
flex-shrink: 0;
} }
#chatbox-container { #chatbox-container {
background-color: #222; background-image: var(--chat-bg);
flex-direction: column;
flex-grow: 0;
flex-shrink: 1;
flex-basis: 400px;
overflow: hidden;
} }
#chatbox-send { #chatbox-send {
padding: 0 2em; padding: 0 2em;
padding-bottom: 0.5em; padding-bottom: 0.5em;
position: relative;
} }
#chatbox-send > input { #chatbox-send > input {
@ -196,22 +248,79 @@ button.small-button {
width: 100%; width: 100%;
} }
@media (min-aspect-ratio: 4/3) { #emoji-autocomplete {
#video-container video {
width: calc(100vw - 400px);
position: absolute; position: absolute;
height: 100vh; bottom: 3.25rem;
background-color: black; background-image: var(--autocomplete-bg);
border-radius: 6px;
width: calc(100% - 4.5rem);
max-height: 8.5rem;
overflow-y: auto;
clip-path: inset(0 0 0 0 round 8px);
} }
#video-container { #emoji-autocomplete:empty {
float: left; display: none;
height: 100vh; }
position: relative;
.emoji-option {
background: transparent;
font-size: 0.75rem;
text-align: left;
margin: 0 0.25rem;
border-radius: 4px;
width: calc(100% - 0.5rem);
display: flex;
align-items: center;
padding: 0.25rem 0.5rem;
scroll-margin: 0.25rem;
}
.emoji-option:first-child {
margin-top: 0.25rem;
}
.emoji-option:last-child {
margin-bottom: 0.25rem;
}
.emoji-option .emoji {
width: 1.25rem;
height: 1.25rem;
margin: 0 0.5rem 0 0;
}
.emoji-option.selected {
background: var(--fg-transparent);
}
#join-session-colour {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
border: none;
padding: 0;
border-radius: 6px;
overflow: hidden;
margin: 0.5em 0;
height: 2rem;
width: 2.5rem;
cursor: pointer;
}
input[type="color"]::-moz-color-swatch,
input[type="color"]::-webkit-color-swatch,
input[type="color"]::-webkit-color-swatch-wrapper {
/* This *should* be working in Chrome, but it doesn't for reasons that are beyond me. */
border: none;
margin: 0;
padding: 0;
}
@media (min-aspect-ratio: 4/3) {
body {
flex-direction: row;
} }
#chatbox-container { #chatbox-container {
float: right;
width: 400px; width: 400px;
height: 100vh !important; height: 100vh !important;
} }