forked from lavender/watch-party
		
	add linkification and time and join chips
This commit is contained in:
		
							parent
							
								
									60672a04ef
								
							
						
					
					
						commit
						2d544620ed
					
				
					 4 changed files with 245 additions and 94 deletions
				
			
		|  | @ -4,6 +4,7 @@ import { | ||||||
|   setPlaying, |   setPlaying, | ||||||
| } from "./watch-session.mjs?v=048af96"; | } from "./watch-session.mjs?v=048af96"; | ||||||
| import { emojify, findEmojis } from "./emojis.mjs?v=048af96"; | import { emojify, findEmojis } from "./emojis.mjs?v=048af96"; | ||||||
|  | import { linkify } from "./links.mjs"; | ||||||
| 
 | 
 | ||||||
| function setCaretPosition(elem, caretPos) { | function setCaretPosition(elem, caretPos) { | ||||||
|   if (elem.createTextRange) { |   if (elem.createTextRange) { | ||||||
|  | @ -35,77 +36,81 @@ const setupChatboxEvents = (socket) => { | ||||||
|   }; |   }; | ||||||
|   async function autocomplete(fromListTimeout) { |   async function autocomplete(fromListTimeout) { | ||||||
|     if (autocompleting) return; |     if (autocompleting) return; | ||||||
|     clearInterval(showListTimer); |     try { | ||||||
|     emojiAutocomplete.textContent = ""; |       clearInterval(showListTimer); | ||||||
|     autocompleting = true; |       emojiAutocomplete.textContent = ""; | ||||||
|     let text = messageInput.value.slice(0, messageInput.selectionStart); |       autocompleting = true; | ||||||
|     const match = text.match(/(:[^\s:]+)?:([^\s:]*)$/); |       let text = messageInput.value.slice(0, messageInput.selectionStart); | ||||||
|     if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete.
 |       const match = text.match(/(:[^\s:]+)?:([^\s:]*)$/); | ||||||
|     const prefix = text.slice(0, match.index); |       if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete.
 | ||||||
|     const search = text.slice(match.index + 1); |       const prefix = text.slice(0, match.index); | ||||||
|     if (search.length < 1 && !fromListTimeout) { |       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; |       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("input", () => autocomplete()); | ||||||
|   messageInput.addEventListener("selectionchange", () => autocomplete()); |   messageInput.addEventListener("selectionchange", () => autocomplete()); | ||||||
|  | @ -227,13 +232,6 @@ const setupChatboxEvents = (socket) => { | ||||||
| export const setupChat = async (socket) => { | export const setupChat = async (socket) => { | ||||||
|   document.querySelector("#chatbox-container").style["display"] = "flex"; |   document.querySelector("#chatbox-container").style["display"] = "flex"; | ||||||
|   setupChatboxEvents(socket); |   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) => { | const addToChat = (node) => { | ||||||
|  | @ -354,7 +352,7 @@ export const logEventToChat = async (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(...(await emojify(event.data))); |       messageContent.append(...(await linkify(event.data, emojify))); | ||||||
|       printChatMessage( |       printChatMessage( | ||||||
|         "chat-message", |         "chat-message", | ||||||
|         event.user, |         event.user, | ||||||
|  |  | ||||||
|  | @ -5,7 +5,10 @@ export async function emojify(text) { | ||||||
|   text.replace(/:([^\s:]+):/g, (match, name, index) => { |   text.replace(/:([^\s:]+):/g, (match, name, index) => { | ||||||
|     if (last <= index) |     if (last <= index) | ||||||
|       nodes.push(document.createTextNode(text.slice(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) { |     if (!emoji) { | ||||||
|       nodes.push(document.createTextNode(match)); |       nodes.push(document.createTextNode(match)); | ||||||
|     } else { |     } else { | ||||||
|  |  | ||||||
							
								
								
									
										115
									
								
								frontend/lib/links.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								frontend/lib/links.mjs
									
									
									
									
									
										Normal file
									
								
							|  | @ -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]]; | ||||||
|  | } | ||||||
|  | @ -1,4 +1,6 @@ | ||||||
| * { | *, | ||||||
|  | *:before, | ||||||
|  | *:after { | ||||||
|   box-sizing: border-box; |   box-sizing: border-box; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -17,6 +19,12 @@ | ||||||
|       var(--fg-transparent) |       var(--fg-transparent) | ||||||
|     ), |     ), | ||||||
|     linear-gradient(var(--bg), var(--bg)); |     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 { | html { | ||||||
|  | @ -58,6 +66,45 @@ a { | ||||||
|   color: var(--accent); |   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 { | label { | ||||||
|   display: block; |   display: block; | ||||||
| } | } | ||||||
|  | @ -164,6 +211,7 @@ button.small-button { | ||||||
| 
 | 
 | ||||||
| .chat-message { | .chat-message { | ||||||
|   overflow-wrap: break-word; |   overflow-wrap: break-word; | ||||||
|  |   margin-bottom: 0.125rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .chat-message > strong, | .chat-message > strong, | ||||||
|  | @ -171,21 +219,6 @@ button.small-button { | ||||||
|   color: var(--user-color, var(--default-user-color)); |   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, | ||||||
| .chat-message.user-leave, | .chat-message.user-leave, | ||||||
| .chat-message.ping { | .chat-message.ping { | ||||||
|  | @ -280,9 +313,11 @@ button.small-button { | ||||||
|   padding: 0.25rem 0.5rem; |   padding: 0.25rem 0.5rem; | ||||||
|   scroll-margin: 0.25rem; |   scroll-margin: 0.25rem; | ||||||
| } | } | ||||||
|  | 
 | ||||||
| .emoji-option:first-child { | .emoji-option:first-child { | ||||||
|   margin-top: 0.25rem; |   margin-top: 0.25rem; | ||||||
| } | } | ||||||
|  | 
 | ||||||
| .emoji-option:last-child { | .emoji-option:last-child { | ||||||
|   margin-bottom: 0.25rem; |   margin-bottom: 0.25rem; | ||||||
| } | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue