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;