2025-07-25 20:07:29 -07:00
// wow https://stackoverflow.com/questions/3891641/regex-test-only-works-every-other-time
// watch out for places we need to set `lastIndex` ... :frown:
2025-07-11 18:33:32 -07:00
const URL _MATCHING _REGEX =
2025-07-25 20:07:29 -07:00
/(?:(?<protocol>[a-zA-Z]+):\/\/)?(?:(?<auth>(?<username>\S.+)\:(?<password>.+))\@)?(?<host>(?:(?<hostname>[-a-zA-Z0-9\.]+)\.)?(?<domain>[-a-zA-Z0-9]+?\.(?<tld>[-a-zA-Z0-9]{2,64}))(?:\:(?<port>[0-9]{1,6}))?)\b(?<path>[-a-zA-Z0-9@:%_{}\[\]<>\(\)\+.~&\/="]*?(?<extension>\.[^\.?/#"]+)?)(?:\?(?<query>[a-zA-Z0-9!$%&<>()*+,-\.\/\:\;\=\?\@_~"]+))?(?:#(?<hash>[a-zA-Z0-9!$&'()*+,-\.\/\:\;\=\?\@_~"]*?))?(?:$|\s)/gim ;
2025-07-11 18:33:32 -07:00
2025-07-25 20:07:29 -07:00
const VIDEO _ID _EXTRACTOR =
/(?<video_domain>vimeo\.com|youtu(?:be\.com|\.be|be\.googleapis\.com))(?:\/(?<action>video|embed|watch|v))?.*(?:(?:\/|v=)(?<video_id>[A-Za-z0-9._%-]*))\S*/gi ;
2025-07-25 17:11:12 -07:00
const SPOTIFY _EXTRACTOR =
2025-07-25 20:07:29 -07:00
/^\/(?<item_type>(?:album|artist|episode|playlist|tracks?))\/?(?<item_id>[a-zA-Z0-9]{22})/gi ;
2025-07-25 17:11:12 -07:00
const TIDAL _EXTRACTOR =
2025-07-25 20:07:29 -07:00
/^\/(?:(?<action>.*?)\/)?(?<item_type>(?:album|artist|episode|playlist|tracks?))\/(?<item_id>[0-9]+)/gi ;
2025-07-25 17:11:12 -07:00
2025-07-11 18:33:32 -07:00
const URL _MATCH _HANDLERS = [
2025-07-24 20:46:35 -07:00
// Tidal
2025-07-25 20:07:29 -07:00
( link _info ) => {
const is _tidal _link = [ "tidal.com" , "tidalhi.fi" ] . includes ( link _info . domain ? . toLowerCase ( ) ) ;
if ( ! is _tidal _link ) {
return ;
}
2025-07-25 17:11:12 -07:00
TIDAL _EXTRACTOR . lastIndex = 0 ;
2025-07-24 18:34:44 -07:00
const {
2025-07-25 20:07:29 -07:00
groups : { action , item _type , item _id } ,
} = TIDAL _EXTRACTOR . exec ( link _info . path ? ? "" ) ? ? { groups : { } } ;
2025-07-24 18:34:44 -07:00
if ( ! ( item _type && item _id ) ) {
return ;
}
return `
2025-07-25 20:07:29 -07:00
< div class = "embed-container iframe ${item_type.toLowerCase().indexOf(" track ") === 0 ? " short " : " square "} tidal" >
2025-07-24 20:46:35 -07:00
< div class = "embed-actions-container" >
< button class = "icon plus" onclick = "console.log(\"close\");" / >
< button class = "icon talk" onclick = "console.log(\"stop\");" / >
< / d i v >
2025-07-24 18:34:44 -07:00
< iframe
src = "https://embed.tidal.com/${item_type.at(-1) === " s " ? item_type : `${item_type}s`}/${item_id}"
allow = "encrypted-media"
sandbox = "allow-same-origin allow-scripts allow-forms allow-popups"
title = "TIDAL Embed Player"
2025-07-24 20:46:35 -07:00
loading = "lazy"
> < / i f r a m e >
2025-07-24 18:34:44 -07:00
< / d i v > ` ;
2025-07-11 18:33:32 -07:00
} ,
2025-07-24 20:46:35 -07:00
// Spotify
2025-07-25 20:07:29 -07:00
( link _info ) => {
const is _spotify _link = [ "spotify.com" ] . includes ( link _info . domain ? . toLowerCase ( ) ) ;
if ( ! is _spotify _link ) {
return ;
}
2025-07-25 17:11:12 -07:00
SPOTIFY _EXTRACTOR . lastIndex = 0 ;
2025-07-24 20:46:35 -07:00
const {
2025-07-25 20:07:29 -07:00
groups : { item _type , item _id } ,
} = SPOTIFY _EXTRACTOR . exec ( link _info . path ? ? "" ) ? ? { groups : { } } ;
2025-07-24 20:46:35 -07:00
if ( ! item _id ) {
return ;
}
return `
2025-07-25 20:07:29 -07:00
< div class = "embed-container iframe ${!item_type || item_type.toLowerCase().indexOf(" track ") === 0 ? " short " : " square "} spotify rounded" >
2025-07-24 20:46:35 -07:00
< div class = "embed-actions-container" >
< button class = "icon plus" onclick = "console.log(\"close\");" / >
< button class = "icon talk" onclick = "console.log(\"stop\");" / >
< / d i v >
< iframe
src = "https://open.spotify.com/embed/${item_type ?? " track "}/${item_id}"
allowfullscreen
allow = "clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading = "lazy" > < / i f r a m e >
< / d i v > ` ;
} ,
// YouTube
2025-07-25 20:07:29 -07:00
( link _info ) => {
const is _youtube _link = [ "youtube.com" , "youtu.be" , "youtube.googleapis.com" ] . includes (
link _info . domain ? . toLowerCase ( ) ,
) ;
if ( ! is _youtube _link ) {
return ;
}
2025-07-25 17:11:12 -07:00
VIDEO _ID _EXTRACTOR . lastIndex = 0 ;
2025-07-24 13:07:41 -07:00
const {
2025-07-25 20:07:29 -07:00
groups : { video _domain , action , video _id } ,
} = VIDEO _ID _EXTRACTOR . exec ( link _info . url ) ? ? { groups : { } } ;
2025-07-24 13:07:41 -07:00
2025-07-25 20:07:29 -07:00
if ( ! video _id ) {
2025-07-24 13:07:41 -07:00
return ;
}
2025-07-24 18:34:44 -07:00
return `
2025-07-25 20:07:29 -07:00
< div class = "embed-container iframe letterbox youtube" >
2025-07-24 20:46:35 -07:00
< div class = "embed-actions-container" >
< button class = "icon plus" onclick = "console.log(\"close\");" / >
< button class = "icon talk" onclick = "console.log(\"stop\");" / >
< / d i v >
2025-07-24 18:34:44 -07:00
< iframe
src = "https://www.youtube.com/embed/${video_id}"
title = "YouTube video player"
allow = "clipboard-write; encrypted-media; picture-in-picture; web-share;"
referrerpolicy = "strict-origin-when-cross-origin"
2025-07-24 20:46:35 -07:00
allowfullscreen
loading = "lazy"
> < / i f r a m e >
2025-07-24 18:34:44 -07:00
< / d i v > ` ;
2025-07-24 13:07:41 -07:00
} ,
2025-07-25 17:11:12 -07:00
// Vimeo
2025-07-25 20:07:29 -07:00
( link _info ) => {
const is _vimeo _link = [ "vimeo.com" ] . includes ( link _info . domain ? . toLowerCase ( ) ) ;
if ( ! is _vimeo _link ) {
return ;
}
2025-07-25 17:11:12 -07:00
VIDEO _ID _EXTRACTOR . lastIndex = 0 ;
const {
2025-07-25 20:07:29 -07:00
groups : { video _domain , action , video _id } ,
} = VIDEO _ID _EXTRACTOR . exec ( link _info . url ) ? ? { groups : { } } ;
2025-07-25 17:11:12 -07:00
2025-07-25 20:07:29 -07:00
if ( ! video _id ) {
2025-07-25 17:11:12 -07:00
return ;
}
return `
2025-07-25 20:07:29 -07:00
< div class = "embed-container iframe letterbox vimeo" >
2025-07-25 17:11:12 -07:00
< div class = "embed-actions-container" >
< button class = "icon plus" onclick = "console.log(\"close\");" / >
< button class = "icon talk" onclick = "console.log(\"stop\");" / >
< / d i v >
< iframe
src = "https://player.vimeo.com/video/${video_id}"
frameborder = "0"
allow = "fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share"
referrerpolicy = "strict-origin-when-cross-origin"
title = "Star Trek: Legacy"
loading = "lazy" > < / i f r a m e >
< / d i v > ` ;
} ,
2025-07-24 20:46:35 -07:00
// linkify generic url
2025-07-25 20:07:29 -07:00
( link _info ) => {
2025-07-11 18:33:32 -07:00
// TODO: punycoding if something has unicode?
// const punycode = get_punycode();
// const punycoded_url = punycode.encode(match[0]);
2025-07-25 20:07:29 -07:00
if ( typeof link _info . extension === "string" ) {
const mime _types = get _mime _types ( link _info . extension ) ;
if ( mime _types . length ) {
if ( mime _types . includes ( "image/gif" ) ) {
return ` <div class="embed-container image gif"><img src=" ${ link _info . url } " alt="A gif from ${ link _info . domain } " /></div> ` ;
}
if ( mime _types . includes ( "video/mp4" ) ) {
return ` <div class="embed-container image gif"><video autoplay muted loop><source src=" ${ link _info . url } " type="video/mp4">Your browser does not support video embeds.</video></div> ` ;
}
if ( mime _types [ 0 ] . indexOf ( "image" ) === 0 ) {
return ` <div class="embed-container image"><img src=" ${ link _info . url } " alt="An image from ${ link _info . domain } " /></div> ` ;
}
}
}
return ` <a href=" ${ link _info . url } "> ${ link _info . url } </a> ` ;
2025-07-11 18:33:32 -07:00
} ,
] ;
function message _text _to _html ( input ) {
let html _message = input ;
let match ;
2025-07-25 20:07:29 -07:00
URL _MATCHING _REGEX . lastIndex = 0 ;
2025-07-11 18:33:32 -07:00
while ( ( match = URL _MATCHING _REGEX . exec ( input ) ) !== null ) {
2025-07-25 20:07:29 -07:00
const url = match [ 0 ] ;
const {
groups : { protocol , host , hostname , domain , tld , path , extension , query , hash } ,
} = match ;
2025-07-11 18:33:32 -07:00
for ( const handler of URL _MATCH _HANDLERS ) {
2025-07-25 20:07:29 -07:00
const result = handler ( {
url ,
protocol ,
host ,
hostname ,
domain ,
tld ,
path ,
extension ,
query ,
hash ,
} ) ;
2025-07-11 18:33:32 -07:00
if ( typeof result === "string" ) {
2025-07-25 20:07:29 -07:00
html _message = html _message . replace ( url , result ) ;
break ;
2025-07-11 18:33:32 -07:00
}
}
}
return html _message ;
}
const time _tick _tock _timeout = 60 * 1000 ; // 1 minute
let last _event _datetime _value = 0 ;
let time _tick _tock _class = "time-tock" ;
let last _creator _id = null ;
let user _tick _tock _class = "user-tock" ;
function render _text _event ( room _chat _content , event , creator , existing _element ) {
const event _datetime = datetime _to _local ( event . timestamps . created ) ;
if ( event _datetime . value - last _event _datetime _value > time _tick _tock _timeout ) {
time _tick _tock _class = time _tick _tock _class === "time-tick" ? "time-tock" : "time-tick" ;
}
last _event _datetime _value = event _datetime . value ;
if ( last _creator _id !== creator . id ) {
user _tick _tock _class = user _tick _tock _class === "user-tick" ? "user-tock" : "user-tick" ;
last _creator _id = creator . id ;
}
const html _content = ` <div id="chat- ${ event . id } " class="message-container ${ user _tick _tock _class } ${ time _tick _tock _class } " data-creator_id=" ${ creator . id } ">
< div class = "info-container" >
< div class = "avatar-container" >
< img src = "${creator.meta?.avatar ?? " / images / default _avatar . gif "}" alt = "user avatar" / >
< / d i v >
< div class = "username-container" >
< span class = "username" > $ { creator . username ? ? "unknown" } < / s p a n >
< / d i v >
< div class = "datetime-container" >
< span class = "long" > $ { event _datetime . long } < / s p a n >
< span class = "short" > $ { event _datetime . short } < / s p a n >
< / d i v >
< / d i v >
< div class = "message-content-container" > $ { message _text _to _html ( event . data . message ) } < / d i v >
< / d i v > ` ;
if ( existing _element ) {
const template = document . createElement ( "template" ) ;
template . innerHTML = html _content ;
existing _element . replaceWith ( template . content . firstChild ) ;
} else {
room _chat _content . insertAdjacentHTML ( "beforeend" , html _content ) ;
}
}
async function get _new _room _element ( ) {
const existing _new _room _element = document . getElementById ( "new-room" ) ;
if ( existing _new _room _element ) {
return existing _new _room _element ;
}
const room _list = document . getElementById ( "room-list" ) ;
room _list . insertAdjacentHTML (
"beforeend" ,
` <li id="new-room" class="room"><a href="" contenteditable="true">new room</a></li> ` ,
) ;
await new Promise ( ( resolve ) => setTimeout ( resolve , 1 ) ) ;
const new _room _element = document . getElementById ( "new-room" ) ;
return new _room _element ;
}
const users = { } ;
async function append _room _events ( events ) {
const room _chat _content = document . getElementById ( "room-chat-content" ) ;
let last _message _id = room _chat _content . dataset . last _message _id ? ? "" ;
for ( const event of events ) {
// if the last message is undefined, it becomes this event, otherwise, if this event's id is newer,
// it becomes the latest message
last _message _id =
event . id > last _message _id && event . id . indexOf ( "TEMP" ) !== 0
? event . id
: last _message _id ;
// if the last message has been updated, update the content's dataset to reflect that
if ( last _message _id !== room _chat _content . dataset . last _message _id ) {
room _chat _content . dataset . last _message _id = last _message _id ;
}
users [ event . creator _id ] =
users [ event . creator _id ] ? ?
( await ( await api . fetch ( ` /api/users/ ${ event . creator _id } ` ) ) . json ( ) ) ;
const existing _element =
document . getElementById ( ` chat- ${ event . id } ` ) ? ?
( event . meta ? . temp _id
? document . getElementById ( ` chat- ${ event . meta . temp _id } ` )
: undefined ) ;
render _text _event ( room _chat _content , event , users [ event . creator _id ] , existing _element ) ;
}
room _chat _content . scrollTop = room _chat _content . scrollHeight ;
}
// TODO: we need some abortcontroller handling here or something
// similar for when we change rooms, this is the most basic
// first pass outline
let room _polling _request _abort _controller = null ;
async function poll _for _new _events ( ) {
const room _chat _content = document . getElementById ( "room-chat-content" ) ;
const room _id = room _chat _content . dataset . room _id ;
const last _message _id = room _chat _content . dataset . last _message _id ;
if ( ! room _id ) {
return ;
}
const message _polling _url = ` /api/rooms/ ${ room _id } /events?type=chat&limit=100&sort=newest&wait=true ${ last _message _id ? ` &after_id= ${ last _message _id } ` : "" } ` ;
room _polling _request _abort _controller =
room _polling _request _abort _controller || new AbortController ( ) ;
api . fetch ( message _polling _url , {
signal : room _polling _request _abort _controller . signal ,
} )
. then ( async ( new _events _response ) => {
2025-07-25 20:07:29 -07:00
const new _events = ( ( await new _events _response . json ( ) ) ? ? [ ] ) . reverse ( ) ;
2025-07-11 18:33:32 -07:00
await append _room _events ( new _events . toReversed ( ) ) ;
poll _for _new _events ( room _id ) ;
} )
. catch ( ( error ) => {
// TODO: poll again? back off?
console . error ( error ) ;
} ) ;
}
async function load _room ( room _id ) {
const room _chat _content = document . getElementById ( "room-chat-content" ) ;
if ( room _polling _request _abort _controller ) {
room _polling _request _abort _controller . abort ( ) ;
room _polling _request _abort _controller = null ;
delete room _chat _content . dataset . last _message _id ;
}
const room _response = await api . fetch ( ` /api/rooms/ ${ room _id } ` ) ;
if ( ! room _response . ok ) {
const error = await room _response . json ( ) ;
alert ( error . message ? ? JSON . stringify ( error ) ) ;
return ;
}
const room = await room _response . json ( ) ;
room _chat _content . dataset . room _id = room . id ;
room _chat _content . innerHTML = "" ;
const room _selectors = document . querySelectorAll ( "li.room" ) ;
for ( const room _selector of room _selectors ) {
room _selector . classList . remove ( "active" ) ;
if ( room _selector . id === ` room-selector- ${ room _id } ` ) {
room _selector . classList . add ( "active" ) ;
}
}
const events _response = await api . fetch (
` /api/rooms/ ${ room _id } /events?type=chat&limit=100&sort=newest ` ,
) ;
if ( ! events _response . ok ) {
const error = await events _response . json ( ) ;
alert ( error . message ? ? JSON . stringify ( error ) ) ;
return ;
}
const events = ( await events _response . json ( ) ) . reverse ( ) ;
await append _room _events ( events ) ;
poll _for _new _events ( room _id ) ;
}
let last _room _update = undefined ;
async function update _chat _rooms ( ) {
const now = new Date ( ) ;
const time _since _last _update = now - ( last _room _update ? ? 0 ) ;
if ( time _since _last _update < 5_000 ) {
return ;
}
const rooms _response = await api . fetch ( "/api/rooms" ) ;
if ( rooms _response . ok ) {
const room _list = document . getElementById ( "room-list" ) ;
room _list . innerHTML = "" ;
const rooms = await rooms _response . json ( ) ;
for ( const room of rooms ) {
room _list . insertAdjacentHTML (
"beforeend" ,
` <li id="room-selector- ${ room . id } " class="room"><a href="#/talk/room/ ${ room . id } "> ${ room . name } </a></li> ` ,
) ;
}
last _room _update = now ;
}
}
window . addEventListener ( "locationchange" , update _chat _rooms ) ;
function check _for _room _in _url ( ) {
const user _json = document . body . dataset . user ;
if ( ! user _json ) {
return ;
}
const hash = window . location . hash ;
const talk _in _url = hash . indexOf ( "#/talk" ) === 0 ;
if ( ! talk _in _url ) {
return ;
}
const first _room _id = document . querySelector ( "li.room" ) ? . id . substring ( 14 ) ;
// #/talk/room/{room_id}
// ^ 12
const room _id = hash . substring ( 12 ) || first _room _id ;
if ( ! room _id ) {
setTimeout ( check _for _room _in _url , 100 ) ;
return ;
}
const room _chat _container = document . getElementById ( "room-chat-container" ) ;
if ( room _chat _container . dataset . room _id !== room _id ) {
window . location . hash = ` /talk/room/ ${ room _id } ` ;
room _chat _container . dataset . room _id = room _id ;
load _room ( room _id ) ;
}
}
window . addEventListener ( "locationchange" , check _for _room _in _url ) ;
document . addEventListener ( "DOMContentLoaded" , async ( ) => {
await update _chat _rooms ( ) ;
check _for _room _in _url ( ) ;
} ) ;