// wow https://stackoverflow.com/questions/3891641/regex-test-only-works-every-other-time // watch out for places we need to set `lastIndex` ... :frown: const URL_MATCHING_REGEX = /(?:(?[a-zA-Z]+):\/\/)?(?:(?(?\S.+)\:(?.+))\@)?(?(?:(?[-a-zA-Z0-9\.]+)\.)?(?[-a-zA-Z0-9]+?\.(?[-a-zA-Z0-9]{2,64}))(?:\:(?[0-9]{1,6}))?)\b(?[-a-zA-Z0-9@:%_{}\[\]<>\(\)\+.~&\/="]*?(?\.[^\.?/#"]+)?)(?:\?(?[a-zA-Z0-9!$%&<>()*+,-\.\/\:\;\=\?\@_~"]+))?(?:#(?[a-zA-Z0-9!$&'()*+,-\.\/\:\;\=\?\@_~"]*?))?(?:$|\s)/gim; const VIDEO_ID_EXTRACTOR = /(?vimeo\.com|youtu(?:be\.com|\.be|be\.googleapis\.com))(?:\/(?video|embed|watch|shorts|v))?.*(?:(?:\/|v=)(?[A-Za-z0-9._%-]*))\S*/gi; const SPOTIFY_EXTRACTOR = /^\/(?(?:album|artist|episode|playlist|tracks?))\/?(?[a-zA-Z0-9]{22})/gi; const TIDAL_EXTRACTOR = /^\/(?:(?.*?)\/)?(?(?:album|artist|episode|playlist|tracks?))\/(?[0-9]+)/gi; const URL_MATCH_HANDLERS = [ // Tidal (link_info) => { const is_tidal_link = ["tidal.com", "tidalhi.fi"].includes(link_info.domain?.toLowerCase()); if (!is_tidal_link) { return; } TIDAL_EXTRACTOR.lastIndex = 0; const { groups: { action, item_type, item_id }, } = TIDAL_EXTRACTOR.exec(link_info.path ?? "") ?? { groups: {} }; if (!(item_type && item_id)) { return; } return `
`; }, // Spotify (link_info) => { const is_spotify_link = ["spotify.com"].includes(link_info.domain?.toLowerCase()); if (!is_spotify_link) { return; } SPOTIFY_EXTRACTOR.lastIndex = 0; const { groups: { item_type, item_id }, } = SPOTIFY_EXTRACTOR.exec(link_info.path ?? "") ?? { groups: {} }; if (!item_id) { return; } return `
`; }, // YouTube (link_info) => { const is_youtube_link = ["youtube.com", "youtu.be", "youtube.googleapis.com"].includes( link_info.domain?.toLowerCase(), ); if (!is_youtube_link) { return; } VIDEO_ID_EXTRACTOR.lastIndex = 0; const { groups: { video_domain, action, video_id }, } = VIDEO_ID_EXTRACTOR.exec(link_info.url) ?? { groups: {} }; if (!video_id) { return; } console.dir({ action, video_id, }); return `
`; }, // Vimeo (link_info) => { const is_vimeo_link = ["vimeo.com"].includes(link_info.domain?.toLowerCase()); if (!is_vimeo_link) { return; } VIDEO_ID_EXTRACTOR.lastIndex = 0; const { groups: { video_domain, action, video_id }, } = VIDEO_ID_EXTRACTOR.exec(link_info.url) ?? { groups: {} }; if (!video_id) { return; } return `
`; }, // linkify generic url (link_info) => { // TODO: punycoding if something has unicode? // const punycode = get_punycode(); // const punycoded_url = punycode.encode(match[0]); 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 `
A gif from ${link_info.domain}
`; } if (mime_types.includes("video/mp4")) { return ``; } if (mime_types[0].indexOf("image") === 0) { return `
An image from ${link_info.domain}
`; } } } return `${link_info.url}`; }, ]; function message_text_to_html(input) { let html_message = input; let match; URL_MATCHING_REGEX.lastIndex = 0; while ((match = URL_MATCHING_REGEX.exec(input)) !== null) { const url = match[0]; const { groups: { protocol, host, hostname, domain, tld, path, extension, query, hash }, } = match; for (const handler of URL_MATCH_HANDLERS) { const result = handler({ url, protocol, host, hostname, domain, tld, path, extension, query, hash, }); if (typeof result === "string") { html_message = html_message.replace(url, result); break; } } } 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 message_id = event.id.substring(0, 49); const html_content = `
Reply
Forward (Copy Link)
Delete
user avatar
${creator.username ?? "unknown"}
${event_datetime.long} ${event_datetime.short}
${message_text_to_html(event.data.message)}
`; 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", `
  • new room
  • `, ); 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.substring(0, 49)}`) ?? (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) => { const new_events = ((await new_events_response.json()) ?? []).reverse(); 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", `
  • ${room.name}
  • `, ); } 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(); });