// 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}))|localhost)(?:\:(?[0-9]{1,6}))?)\b(?[-a-zA-Z0-9@:%_{}\[\]<>\(\)\+.~&\/="]*?(?\.[^\.?/#"\n<>]+)?)(?:\?(?[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; } 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}
`; } if (mime_types[0].indexOf("audio") === 0) { return `
00:00
00:00
`; } } } return `${link_info.url}`; }, ]; function message_text_to_html(input) { let html_message = (input ?? "").replace(/\n/g, "
"); 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(topic_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 = `
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 { topic_chat_content.insertAdjacentHTML("beforeend", html_content); } } async function get_new_topic_element() { const existing_new_topic_element = document.getElementById("new-topic"); if (existing_new_topic_element) { return existing_new_topic_element; } const topic_list = document.getElementById("topic-list"); topic_list.insertAdjacentHTML( "beforeend", `
  • new topic
  • `, ); await new Promise((resolve) => setTimeout(resolve, 1)); const new_topic_element = document.getElementById("new-topic"); return new_topic_element; } const users = {}; async function append_topic_events(events) { const topic_chat_content = document.getElementById("topic-chat-content"); let last_message_id = topic_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 !== topic_chat_content.dataset.last_message_id) { topic_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(topic_chat_content, event, users[event.creator_id], existing_element); } topic_chat_content.scrollTop = topic_chat_content.scrollHeight; } // TODO: we need some abortcontroller handling here or something // similar for when we change topics, this is the most basic // first pass outline let topic_polling_request_abort_controller = null; async function poll_for_new_events() { const topic_chat_content = document.getElementById("topic-chat-content"); const topic_id = topic_chat_content.dataset.topic_id; const last_message_id = topic_chat_content.dataset.last_message_id; if (!topic_id) { return; } const message_polling_url = `/api/topics/${topic_id}/events?type=chat&limit=100&sort=newest&wait=true${last_message_id ? `&after_id=${last_message_id}` : ""}`; topic_polling_request_abort_controller = topic_polling_request_abort_controller || new AbortController(); api.fetch(message_polling_url, { signal: topic_polling_request_abort_controller.signal, }) .then(async (new_events_response) => { const new_events = ((await new_events_response.json()) ?? []).reverse(); await append_topic_events(new_events.toReversed()); poll_for_new_events(topic_id); }) .catch((error) => { // TODO: poll again? back off? console.error(error); }); } async function load_topic(topic_id) { const topic_chat_content = document.getElementById("topic-chat-content"); if (topic_polling_request_abort_controller) { topic_polling_request_abort_controller.abort(); topic_polling_request_abort_controller = null; delete topic_chat_content.dataset.last_message_id; } const topic_response = await api.fetch(`/api/topics/${topic_id}`); if (!topic_response.ok) { const error = await topic_response.json(); alert(error.message ?? JSON.stringify(error)); return; } const topic = await topic_response.json(); topic_chat_content.dataset.topic_id = topic.id; topic_chat_content.innerHTML = ""; const topic_selectors = document.querySelectorAll("li.topic"); for (const topic_selector of topic_selectors) { topic_selector.classList.remove("active"); if (topic_selector.id === `topic-selector-${topic_id}`) { topic_selector.classList.add("active"); } } const events_response = await api.fetch( `/api/topics/${topic_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_topic_events(events); poll_for_new_events(topic_id); } let last_topic_update = undefined; async function update_chat_topics() { const now = new Date(); const time_since_last_update = now - (last_topic_update ?? 0); if (time_since_last_update < 5_000) { return; } const topics_response = await api.fetch("/api/topics"); if (topics_response.ok) { const topic_list = document.getElementById("topic-list"); topic_list.innerHTML = ""; const topics = await topics_response.json(); for (const topic of topics) { topic_list.insertAdjacentHTML( "beforeend", `
  • ${topic.name}
  • `, ); } last_topic_update = now; } } window.addEventListener("locationchange", update_chat_topics); function check_for_topic_in_url() { const user_json = document.body.dataset.user; if (!user_json) { return; } const hash = window.location.hash; const chat_in_url = hash.indexOf("#/chat") === 0; if (!chat_in_url) { return; } const first_topic_id = document.querySelector("li.topic")?.id.substring(14); // #/chat/topic/{topic_id} // ^ 12 const topic_id = hash.substring(12) || first_topic_id; if (!topic_id) { setTimeout(check_for_topic_in_url, 100); return; } const topic_chat_container = document.getElementById("topic-chat-container"); if (topic_chat_container.dataset.topic_id !== topic_id) { window.location.hash = `/chat/topic/${topic_id}`; topic_chat_container.dataset.topic_id = topic_id; load_topic(topic_id); } } window.addEventListener("locationchange", check_for_topic_in_url); document.addEventListener("DOMContentLoaded", async () => { await update_chat_topics(); check_for_topic_in_url(); });