From 2d544620edda54299685e648d149b62d63f1f292 Mon Sep 17 00:00:00 2001 From: easrng Date: Wed, 9 Mar 2022 18:38:22 -0500 Subject: [PATCH] add linkification and time and join chips --- frontend/lib/chat.mjs | 152 ++++++++++++++++++++-------------------- frontend/lib/emojis.mjs | 5 +- frontend/lib/links.mjs | 115 ++++++++++++++++++++++++++++++ frontend/styles.css | 67 +++++++++++++----- 4 files changed, 245 insertions(+), 94 deletions(-) create mode 100644 frontend/lib/links.mjs diff --git a/frontend/lib/chat.mjs b/frontend/lib/chat.mjs index 08a2ea9..15ce7f3 100644 --- a/frontend/lib/chat.mjs +++ b/frontend/lib/chat.mjs @@ -4,6 +4,7 @@ import { setPlaying, } from "./watch-session.mjs?v=048af96"; import { emojify, findEmojis } from "./emojis.mjs?v=048af96"; +import { linkify } from "./links.mjs"; function setCaretPosition(elem, caretPos) { if (elem.createTextRange) { @@ -35,77 +36,81 @@ const setupChatboxEvents = (socket) => { }; async function autocomplete(fromListTimeout) { if (autocompleting) return; - clearInterval(showListTimer); - 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); - if (search.length < 1 && !fromListTimeout) { + try { + clearInterval(showListTimer); + 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); + if (search.length < 1 && !fromListTimeout) { + autocompleting = false; + showListTimer = setTimeout(() => autocomplete(true), 500); + return; + } + const suffix = messageInput.value.slice(messageInput.selectionStart); + let selected; + const select = (button) => { + if (selected) selected.classList.remove("selected"); + selected = button; + button.classList.add("selected"); + }; + let results = await findEmojis(search); + let yieldAt = performance.now() + 13; + for (let i = 0; i < results.length; i += 100) { + emojiAutocomplete.append.apply( + emojiAutocomplete, + results.slice(i, i + 100).map(([name, replaceWith, ext], i) => { + const button = Object.assign(document.createElement("button"), { + className: "emoji-option", + onmousedown: (e) => e.preventDefault(), + onclick: () => { + messageInput.value = prefix + replaceWith + " " + suffix; + setCaretPosition( + messageInput, + (prefix + " " + replaceWith).length + ); + }, + onmouseover: () => select(button), + onfocus: () => select(button), + type: "button", + title: name, + }); + button.append( + replaceWith[0] !== ":" + ? Object.assign(document.createElement("span"), { + textContent: replaceWith, + className: "emoji", + }) + : Object.assign(new Image(), { + loading: "lazy", + src: `/emojis/${name}${ext}`, + className: "emoji", + }), + Object.assign(document.createElement("span"), { + textContent: name, + className: "emoji-name", + }) + ); + return button; + }) + ); + if (i == 0 && emojiAutocomplete.children[0]) { + emojiAutocomplete.children[0].scrollIntoView(); + select(emojiAutocomplete.children[0]); + } + const now = performance.now(); + if (now > yieldAt) { + yieldAt = now + 13; + await new Promise((cb) => setTimeout(cb, 0)); + } + } + autocompleting = false; + } catch (e) { autocompleting = false; - showListTimer = setTimeout(() => autocomplete(true), 500); - return; } - const suffix = messageInput.value.slice(messageInput.selectionStart); - let selected; - const select = (button) => { - if (selected) selected.classList.remove("selected"); - selected = button; - button.classList.add("selected"); - }; - let results = await findEmojis(search); - let yieldAt = performance.now() + 13; - for (let i = 0; i < results.length; i += 100) { - emojiAutocomplete.append.apply( - emojiAutocomplete, - results.slice(i, i + 100).map(([name, replaceWith, ext], i) => { - const button = Object.assign(document.createElement("button"), { - className: "emoji-option", - onmousedown: (e) => e.preventDefault(), - onclick: () => { - messageInput.value = prefix + replaceWith + " " + suffix; - setCaretPosition( - messageInput, - (prefix + " " + replaceWith).length - ); - }, - onmouseover: () => select(button), - onfocus: () => select(button), - type: "button", - title: name, - }); - button.append( - replaceWith[0] !== ":" - ? Object.assign(document.createElement("span"), { - textContent: replaceWith, - className: "emoji", - }) - : Object.assign(new Image(), { - loading: "lazy", - src: `/emojis/${name}${ext}`, - className: "emoji", - }), - Object.assign(document.createElement("span"), { - textContent: name, - className: "emoji-name", - }) - ); - return button; - }) - ); - if (i == 0 && emojiAutocomplete.children[0]) { - emojiAutocomplete.children[0].scrollIntoView(); - select(emojiAutocomplete.children[0]); - } - const now = performance.now(); - if (now > yieldAt) { - yieldAt = now + 13; - await new Promise((cb) => setTimeout(cb, 0)); - } - } - autocompleting = false; } messageInput.addEventListener("input", () => autocomplete()); messageInput.addEventListener("selectionchange", () => autocomplete()); @@ -227,13 +232,6 @@ const setupChatboxEvents = (socket) => { export const setupChat = async (socket) => { document.querySelector("#chatbox-container").style["display"] = "flex"; setupChatboxEvents(socket); - - window.addEventListener("keydown", (event) => { - try { - const isSelectionEmpty = window.getSelection().toString().length === 0; - if (event.code.match(/Key\w/) && isSelectionEmpty) messageInput.focus(); - } catch (_err) {} - }); }; const addToChat = (node) => { @@ -354,7 +352,7 @@ export const logEventToChat = async (event) => { case "ChatMessage": { const messageContent = document.createElement("span"); messageContent.classList.add("message-content"); - messageContent.append(...(await emojify(event.data))); + messageContent.append(...(await linkify(event.data, emojify))); printChatMessage( "chat-message", event.user, diff --git a/frontend/lib/emojis.mjs b/frontend/lib/emojis.mjs index 3f109f8..d05eede 100644 --- a/frontend/lib/emojis.mjs +++ b/frontend/lib/emojis.mjs @@ -5,7 +5,10 @@ export async function emojify(text) { text.replace(/:([^\s:]+):/g, (match, name, index) => { if (last <= index) nodes.push(document.createTextNode(text.slice(last, index))); - let emoji = emojis[name.toLowerCase()[0]].find((e) => e[0] == name); + let emoji; + try { + emoji = emojis[name.toLowerCase()[0]].find((e) => e[0] == name); + } catch (e) {} if (!emoji) { nodes.push(document.createTextNode(match)); } else { diff --git a/frontend/lib/links.mjs b/frontend/lib/links.mjs new file mode 100644 index 0000000..f69442c --- /dev/null +++ b/frontend/lib/links.mjs @@ -0,0 +1,115 @@ +export async function linkify( + text, + next = async (t) => [document.createTextNode(t)] +) { + let last = 0; + let nodes = []; + let promise = Promise.resolve(); + // matching non-urls isn't a problem, we use the browser's url parser to filter them out + text.replace( + /[^:/?#\s]+:\/\/\S+/g, + (match, index) => + (promise = promise.then(async () => { + if (last <= index) nodes.push(...(await next(text.slice(last, index)))); + let url; + try { + url = new URL(match); + if (url.protocol === "javascript:") throw new Error(); + } catch (e) { + url = null; + } + if (!url) { + nodes.push(...(await next(match))); + } else { + let s; + if ( + url.origin == location.origin && + url.pathname == "/" && + url.hash.length > 1 + ) { + nodes.push( + Object.assign(document.createElement("a"), { + href: url.href, + textContent: "Join Session", + className: "chip join-chip", + }) + ); + } else if ( + url.hostname == "xiv.st" && + (s = url.pathname.match(/(\d?\d).?(\d\d)/)) + ) { + if (s) { + const date = new Date(); + date.setUTCSeconds(0); + date.setUTCMilliseconds(0); + date.setUTCHours(s[1]), date.setUTCMinutes(s[2]); + nodes.push( + Object.assign(document.createElement("a"), { + href: url.href, + textContent: date.toLocaleString([], { + hour: "2-digit", + minute: "2-digit", + }), + className: "chip time-chip", + target: "_blank", + }) + ); + } + } else { + nodes.push( + Object.assign(document.createElement("a"), { + href: url.href, + textContent: url.href, + target: "_blank", + }) + ); + } + } + last = index + match.length; + })) + ); + await promise; + if (last < text.length) nodes.push(...(await next(text.slice(last)))); + return nodes; +} +const emojis = {}; + +export const emojisLoaded = Promise.all([ + fetch("/emojis") + .then((e) => e.json()) + .then((a) => { + for (let e of a) { + const name = e.slice(0, -4), + lower = name.toLowerCase(); + emojis[lower[0]] = emojis[lower[0]] || []; + emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]); + } + }), + fetch("/emojis/unicode.json") + .then((e) => e.json()) + .then((a) => { + for (let e of a) { + emojis[e[0][0]] = emojis[e[0][0]] || []; + emojis[e[0][0]].push([e[0], e[1], null, e[0]]); + } + }), +]); + +export async function findEmojis(search) { + await emojisLoaded; + let groups = [[], []]; + if (search.length < 1) { + for (let letter of Object.keys(emojis).sort()) + for (let emoji of emojis[letter]) { + (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); + } + } else { + search = search.toLowerCase(); + for (let emoji of emojis[search[0]]) { + if (search.length == 1 || emoji[3].startsWith(search)) { + (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); + } + } + } + return [...groups[0], ...groups[1]]; +} diff --git a/frontend/styles.css b/frontend/styles.css index ce3e8a0..64f524d 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1,4 +1,6 @@ -* { +*, +*:before, +*:after { box-sizing: border-box; } @@ -17,6 +19,12 @@ var(--fg-transparent) ), linear-gradient(var(--bg), var(--bg)); + --chip-bg: linear-gradient( + var(--accent-transparent), + var(--accent-transparent) + ), + linear-gradient(var(--bg), var(--bg)); + --accent-transparent: rgba(var(--accent-rgb), 0.25); } html { @@ -58,6 +66,45 @@ a { color: var(--accent); } +.chip { + color: var(--fg); + background: var(--chip-bg); + text-decoration: none; + padding: 0 0.5rem 0 1.45rem; + display: inline-flex; + position: relative; + font-size: 0.9rem; + height: 1.125rem; + align-items: center; + border-radius: 2rem; + overflow: hidden; +} + +.chip::before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 1.125rem; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + background: var(--accent-transparent); + background-repeat: no-repeat; + background-size: 18px; + background-position: center; +} + +.join-chip::before { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTggNXYxNGwxMS03eiIvPjwvc3ZnPg=="); +} + +.time-chip::before { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+"); +} + label { display: block; } @@ -164,6 +211,7 @@ button.small-button { .chat-message { overflow-wrap: break-word; + margin-bottom: 0.125rem; } .chat-message > strong, @@ -171,21 +219,6 @@ button.small-button { 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-leave, .chat-message.ping { @@ -280,9 +313,11 @@ button.small-button { 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; }