diff --git a/frontend/index.html b/frontend/index.html index 25871e4..d7056bf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -43,7 +43,8 @@

- No session to join? Create a session instead. + No session to join? + Create a session instead.

@@ -53,8 +54,13 @@
- -
+ +
+
diff --git a/frontend/lib/chat.mjs b/frontend/lib/chat.mjs index 9136a0b..4a48e3b 100644 --- a/frontend/lib/chat.mjs +++ b/frontend/lib/chat.mjs @@ -1,6 +1,23 @@ import { setDebounce, setVideoTime, setPlaying } from "./watch-session.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) => { // clear events by just reconstructing the form const oldChatForm = document.querySelector("#chatbox-send"); @@ -8,28 +25,80 @@ const setupChatboxEvents = (socket) => { const messageInput = chatForm.querySelector("input"); const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete"); oldChatForm.replaceWith(chatForm); - + let autocompleting = false; - - const replaceMessage = message => () => { + + const replaceMessage = (message) => () => { messageInput.value = message; - autocomplete(); - } - async function autocomplete(){ - if(autocompleting) return; - emojiAutocomplete.textContent = ""; + 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); - emojiAutocomplete.append(...(await emojis).filter(e => e.toLowerCase().startsWith(search.toLowerCase())).map(e => Object.assign(document.createElement("button"), {className: "emoji-option", textContent: e, onclick: replaceMessage(prefix + ":" + e + ":" + suffix)}))) - autocompleting = false; + 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("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) => { e.preventDefault(); @@ -81,7 +150,12 @@ const setupChatboxEvents = (socket) => { " /ping [message] - ping all viewers
" + " /sync - resyncs you with other viewers"; - printChatMessage("command-message", "/help", "b57fdc", helpMessageContent); + printChatMessage( + "command-message", + "/help", + "b57fdc", + helpMessageContent + ); handled = true; break; default: @@ -114,8 +188,7 @@ export const setupChat = async (socket) => { window.addEventListener("keydown", (event) => { try { const isSelectionEmpty = window.getSelection().toString().length === 0; - if (event.code.match(/Key\w/) && isSelectionEmpty) - messageInput.focus(); + if (event.code.match(/Key\w/) && isSelectionEmpty) messageInput.focus(); } catch (_err) {} }); }; diff --git a/frontend/lib/emojis.mjs b/frontend/lib/emojis.mjs index e65fc88..8a7c55b 100644 --- a/frontend/lib/emojis.mjs +++ b/frontend/lib/emojis.mjs @@ -1,12 +1,21 @@ -export function emojify(text) { - let last = 0; - let nodes = []; - text.replace(/:([^\s:]+):/g, (match, name, index) => { - if(last <= index) nodes.push(document.createTextNode(text.slice(last, index))) - nodes.push(Object.assign(new Image(), {src: `/emojis/${name}.png`, className: "emoji", alt: name})) - last = index + match.length - }) - if(last < text.length) nodes.push(document.createTextNode(text.slice(last))) - return nodes -} -export const emojis = Promise.resolve(["blobcat", "blobhaj"]) \ No newline at end of file +export function emojify(text) { + let last = 0; + let nodes = []; + text.replace(/:([^\s:]+):/g, (match, name, index) => { + if (last <= index) + nodes.push(document.createTextNode(text.slice(last, index))); + nodes.push( + Object.assign(new Image(), { + src: `/emojis/${name}.png`, + className: "emoji", + alt: name, + }) + ); + 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))); diff --git a/frontend/lib/join-session.mjs b/frontend/lib/join-session.mjs index ae3778e..7873bef 100644 --- a/frontend/lib/join-session.mjs +++ b/frontend/lib/join-session.mjs @@ -80,10 +80,14 @@ export const setupJoinSessionForm = () => { saveNickname(nickname); saveColour(colour); try { - await joinSession(nickname.value, sessionId.value, colour.value.replace(/^#/, "")); + await joinSession( + nickname.value, + sessionId.value, + colour.value.replace(/^#/, "") + ); } catch (e) { - alert(e.message) - button.disabled = false; + alert(e.message); + button.disabled = false; } }); }; diff --git a/frontend/lib/reconnecting-web-socket.mjs b/frontend/lib/reconnecting-web-socket.mjs index 2c66b93..fcea08a 100644 --- a/frontend/lib/reconnecting-web-socket.mjs +++ b/frontend/lib/reconnecting-web-socket.mjs @@ -1,59 +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) - } -} \ No newline at end of file +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); + } +} diff --git a/frontend/lib/watch-session.mjs b/frontend/lib/watch-session.mjs index 9010131..688e47b 100644 --- a/frontend/lib/watch-session.mjs +++ b/frontend/lib/watch-session.mjs @@ -1,6 +1,6 @@ import { setupVideo } from "./video.mjs?v=9"; import { setupChat, logEventToChat, updateViewerList } from "./chat.mjs?v=9"; -import ReconnectingWebSocket from "./reconnecting-web-socket.mjs" +import ReconnectingWebSocket from "./reconnecting-web-socket.mjs"; /** * @param {string} sessionId @@ -169,7 +169,9 @@ const setupOutgoingEvents = (video, socket) => { */ export const joinSession = async (nickname, sessionId, colour) => { // try { // we are handling errors in the join form. - const genericConnectionError = new Error("There was an issue getting the session information."); + const genericConnectionError = new Error( + "There was an issue getting the session information." + ); window.location.hash = sessionId; let response, video_url, subtitle_tracks, current_time_ms, is_playing; try { @@ -178,19 +180,20 @@ export const joinSession = async (nickname, sessionId, colour) => { console.error(e); throw genericConnectionError; } - if(!response.ok) { + if (!response.ok) { let error; try { ({ error } = await response.json()); - if(!error) throw new Error(); + if (!error) throw new Error(); } catch (e) { console.error(e); throw genericConnectionError; } - throw new Error(error) + throw new Error(error); } try { - ({ video_url, subtitle_tracks, current_time_ms, is_playing } = await response.json()); + ({ video_url, subtitle_tracks, current_time_ms, is_playing } = + await response.json()); } catch (e) { console.error(e); throw genericConnectionError; @@ -216,10 +219,10 @@ export const joinSession = async (nickname, sessionId, colour) => { setupIncomingEvents(video, socket); setupChat(socket); }); - socket.addEventListener("reconnecting", e => { + socket.addEventListener("reconnecting", (e) => { console.log("Reconnecting..."); }); - socket.addEventListener("reconnected", e => { + socket.addEventListener("reconnected", (e) => { console.log("Reconnected."); }); //} catch (e) { diff --git a/frontend/styles.css b/frontend/styles.css index 8193498..7b77ee4 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -12,8 +12,14 @@ --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)); + --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 { @@ -158,13 +164,19 @@ button.small-button { overflow-wrap: break-word; } -.chat-message > strong, #viewer-list strong { +.chat-message > strong, +#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))); + .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; } @@ -183,7 +195,7 @@ button.small-button { font-size: 0.85em; } -.chat-message.command-message{ +.chat-message.command-message { font-size: 0.85em; } @@ -240,9 +252,11 @@ button.small-button { position: absolute; bottom: 3.25rem; background-image: var(--autocomplete-bg); - padding: 0.25rem; border-radius: 6px; width: calc(100% - 4.5rem); + max-height: 8.5rem; + overflow-y: auto; + clip-path: inset(0 0 0 0 round 8px); } #emoji-autocomplete:empty { @@ -253,17 +267,29 @@ button.small-button { background: transparent; font-size: 0.75rem; text-align: left; - margin: 0 0 0.25rem; + margin: 0 0.25rem; border-radius: 4px; - width: 100%; + width: calc(100% - 0.5rem); + display: flex; + align-items: center; + padding: 0.25rem 0.5rem; + scroll-margin: 0.25rem; } - -.emoji-option:hover, .emoji-option:focus { - background: var(--fg-transparent); +.emoji-option:first-child { + margin-top: 0.25rem; } - .emoji-option:last-child { - margin: 0; + 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 { @@ -280,7 +306,10 @@ button.small-button { 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. */ +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;