let smarten_feeds_debounce_timeout; function smarten_feeds() { if (smarten_feeds_debounce_timeout) { clearTimeout(smarten_feeds_debounce_timeout); } smarten_feeds_debounce_timeout = setTimeout(() => { smarten_feeds_debounce_timeout = undefined; const feeds = document?.body?.querySelectorAll("[data-feed]:not([data-smartened])") ?? []; for (const feed of feeds) { if (!feed.dataset.source) { console.warn("No source url for smart feed: " + feed); continue; } feed.__started = false; feed.__newest_id = undefined; feed.__oldest_id = undefined; feed.__templates = feed .querySelectorAll("template[data-for_type]") .values() .reduce((_templates, template) => { _templates[template.dataset.for_type] = template; return _templates; }, {}); feed.__start = () => { feed.__started = true; feed.__update(); }; feed.__stop = async () => { feed.__started = false; if (feed.__request_abort_controller) { await feed.__request_abort_controller.abort("smartfeed:stopped"); delete feed.__request_abort_controller; } if (feed.__refresh_interval) { clearInterval(feed.__refresh_interval); delete feed.__refresh_interval; } }; feed.__clear = () => { const children_to_remove = Array.from(feed.childNodes).filter( (node) => !["script", "template"].includes(node.tagName), ); children_to_remove.forEach((element) => feed.removeChild(element)); feed.__newest_id = undefined; feed.__oldest_id = undefined; }; feed.__reset = async () => { const was_started = feed.__started; await feed.__stop(); feed.__clear(); if (was_started) { setTimeout(feed.__start, 1); } }; feed.__precheck = () => { return feed.dataset.precheck ? eval(feed.dataset.precheck) : true; }; feed.__target = (item) => { if (!feed.__target_element) { return feed; } return feed.__target_element(item); }; feed.__autoscroll_debounce_timeout = undefined; feed.__render = async (item) => { const [item_type, item_id] = item.id?.split(":", 2) ?? []; feed.__newest_id = typeof item_id === "string" && item_id > (feed.__newest_id ?? "") ? item_id : feed.__newest_id; feed.__oldest_id = typeof item_id === "string" && item_id < (feed.__oldest_id ?? "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz") ? item_id : feed.__oldest_id; const template = feed.__templates[item.type]; if (!template) { return; } feed.__context = feed.__context ?? (feed.dataset.context ? new Function(feed.dataset.context) : undefined); const context = feed.__context ? await feed.__context(item, feed) : {}; const rendered_html = eval("`" + template.innerHTML.trim() + "`"); const existing_element = feed.querySelector("#" + item.id?.replace(/([:\.])/g, "\\$1")) ?? feed.querySelector("#" + item.temp_id?.replace(/([:\.])/g, "\\$1")) ?? feed.querySelector("#" + item.meta?.temp_id?.replace(/([:\.])/g, "\\$1")) ?? feed.querySelector( `[data-temp_id='${item.temp_id ?? item.meta?.temp_id ?? ""}']`, ); if (existing_element) { if ( existing_element.id !== item.id || rendered_html !== existing_element.outerHTML ) { existing_element.outerHTML = rendered_html; } } else { const target = feed.__target(item); if (!target) { return; } switch (feed.dataset.insert ?? "append") { case "prepend": target.insertAdjacentHTML("afterbegin", rendered_html); break; case "append": target.insertAdjacentHTML("beforeend", rendered_html); break; default: throw new Error( 'data-insert must be "append" or "prepend" for smart feeds', ); break; } if (target === feed && feed.dataset.autoscroll) { if (feed.__autoscroll_debounce_timeout) { clearTimeout(feed.__autoscroll_debounce_timeout); } feed.__autoscroll_debounce_timeout = setTimeout(() => { feed.scrollTo({ behavior: "auto", left: 0, top: (feed.dataset.insert ?? "append") === "append" ? feed.scrollHeight : 0, }); feed.__autoscroll_debounce_timeout = undefined; }, 15); } } }; // TODO: if the feed is scrolled to one extreme, load more elements? feed.__update = function () { if (!feed.__started) { return; } feed.__attempts_since_last_successful_update = 1 + (feed.__attempts_since_last_successful_update ?? 0); if (!feed.__precheck()) { return; } feed.__request_abort_controller = feed.__request_abort_controller || new AbortController(); const url = eval("`" + feed.dataset.source + "`"); api.fetch(url, { signal: feed.__request_abort_controller.signal, }) .then(async (new_items_response) => { if (!new_items_response.ok) { const body = await new_items_response.json(); throw new Error(body.message ?? body.error?.message ?? "Uknown error."); } const new_items = await new_items_response.json(); const sorted_items = feed.dataset.reverse ? new_items.reverse() : new_items; for await (const item of sorted_items) { await feed.__render(item); } if (/^\d+$/.test(feed.dataset.maxlength ?? "")) { const maxlength = parseInt(feed.dataset.maxlength, 10); switch (feed.dataset.insert ?? "append") { case "prepend": /* remove children at the end */ target.children.forEach((element, index) => index > maxlength ? element.remove() : null, ); break; case "append": /* remove children at the beginning */ const cutoff = target.children.length - maxlength; target.children.forEach((element, index) => index < cutoff ? element.remove() : null, ); break; default: throw new Error( 'data-insert must be "append" or "prepend" for smart feeds', ); break; } } feed.dataset.hydrated = true; feed.dataset.last_update = new Date().toISOString(); feed.__attempts_since_last_successful_update = 0; }) .catch((error) => { if (error === "smartfeed:stopped") { return; } if ( error.name === 'TypeError' && error.message === 'NetworkError when attempting to fetch resource.' ) { console.log( error.message ); return; } feed.dataset.error = JSON.stringify(error); }) .finally(() => { if (feed.__started && feed.dataset.longpolling) { setTimeout( feed.__update, /* logarithmic backoff */ Math.log(feed.__attempts_since_last_successful_update || 1) * 10 /* scale it a bit */ * 1_000 /* 1s in ms */, ); } }); }; if (feed.dataset.refresh) { const refresh_frequency = parseInt(feed.dataset.refresh, 10); feed.__refresh_interval = setInterval(() => { feed.__update(); }, refresh_frequency); } feed.dataset.smartened = true; feed.__start(); } }, 10); } const smarten_feeds_observer = new MutationObserver(smarten_feeds); smarten_feeds_observer.observe(document, { childList: true, subtree: true, });