diff --git a/models/event.ts b/models/event.ts index 90bb390..110f23f 100644 --- a/models/event.ts +++ b/models/event.ts @@ -142,7 +142,7 @@ export function get_events_collection_for_topic(topic_id: string): FSDB_COLLECTI event_id.slice(0, 14), event_id.slice(0, 34), event_id, - `${event_id}.json` + `${event_id}.json` /* TODO: this should be ${id}.json */ ]; }, indexers: { diff --git a/public/api/auth/index.ts b/public/api/auth/index.ts index d3284ed..c69d8cf 100644 --- a/public/api/auth/index.ts +++ b/public/api/auth/index.ts @@ -162,7 +162,7 @@ export type SESSION_INFO = { // DELETE /api/auth - log out (delete session) PRECHECKS.DELETE = [get_session, get_user, require_user]; -const back_then = new Date(0).toISOString(); +const back_then = new Date(0).toUTCString(); export async function DELETE(_request: Request, meta: Record): Promise { await SESSIONS.delete(meta.session); @@ -186,7 +186,7 @@ const session_secret_buffer = new Uint8Array(20); export async function create_new_session(session_settings: SESSION_INFO): Promise { const now = new Date().toISOString(); const expires: string = session_settings.expires ?? - new Date(new Date(now).valueOf() + DEFAULT_SESSION_TIME).toUTCString(); + new Date(new Date(now).valueOf() + DEFAULT_SESSION_TIME).toISOString(); crypto.getRandomValues(session_secret_buffer); @@ -205,12 +205,13 @@ export async function create_new_session(session_settings: SESSION_INFO): Promis const headers = new Headers(); - headers.append('Set-Cookie', `${SESSION_ID_TOKEN}=${session.id}; Path=/; Expires=${expires}`); + const expires_in_utc = new Date(session.timestamps.expires).toUTCString(); + headers.append('Set-Cookie', `${SESSION_ID_TOKEN}=${session.id}; Path=/; Secure; Expires=${expires_in_utc}`); headers.append(`x-${SESSION_ID_TOKEN}`, session.id); // TODO: this wasn't really intended to be persisted in a cookie, but we are using it to // generate the TOTP for the call to /api/users/me - headers.append('Set-Cookie', `${SESSION_SECRET_TOKEN}=${session.secret}; Path=/; Expires=${expires}`); + headers.append('Set-Cookie', `${SESSION_SECRET_TOKEN}=${session.secret}; Path=/; Secure; Expires=${expires_in_utc}`); return { session, diff --git a/public/api/topics/:topic_id/events/index.ts b/public/api/topics/:topic_id/events/index.ts index e01655a..68748c0 100644 --- a/public/api/topics/:topic_id/events/index.ts +++ b/public/api/topics/:topic_id/events/index.ts @@ -63,11 +63,13 @@ export async function GET(request: Request, meta: Record): Promise< } } = /^.*\/events\/(?.*?)\/.*\/(?[A-Za-z-]+)\.json$/.exec(entry.path) ?? { groups: {} }; - if (meta.query.after_id && event_id <= meta.query.after_id) { + const id = `${event_type}:${event_id}`; + + if (meta.query.after_id && id <= meta.query.after_id) { return false; } - if (meta.query.before_id && event_id >= meta.query.before_id) { + if (meta.query.before_id && id >= meta.query.before_id) { return false; } @@ -91,6 +93,10 @@ export async function GET(request: Request, meta: Record): Promise< if (results.length === 0 && meta.query.wait) { return new Promise((resolve, reject) => { function on_create(create_event: any) { + if (meta.query.type && create_event.item.type !== meta.query.type) { + return; + } + results.push(create_event.item); clearTimeout(timeout); events.off('create', on_create); diff --git a/public/index.html b/public/index.html index 837cff6..fe47560 100644 --- a/public/index.html +++ b/public/index.html @@ -34,6 +34,7 @@ + @@ -196,16 +197,9 @@ } document.addEventListener("DOMContentLoaded", async () => { - window.addEventListener("locationchange", update_topics); + window.addEventListener( 'locationchange', update_topics); document.addEventListener( 'user_logged_in', update_topics ); - document.addEventListener( 'user_logged_in', () => { - const user = document.body.dataset.user && JSON.parse( document.body.dataset.user ); - if ( !user ) { - return; - } - }); - /* check if we are logged in */ (async () => { try { diff --git a/public/js/api.js b/public/js/api.js index 6aaea4b..a306e84 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -1,5 +1,10 @@ const api = { - fetch: async function (url, options = { method: "GET" }) { + fetch: async function (url, __options) { + const options = { + method: "GET", + ...__options, + }; + const session_id = (document.cookie.match( /^(?:.*;)?\s*session_id\s*=\s*([^;]+)(?:.*)?$/, ) || [, null])[1]; @@ -19,6 +24,7 @@ const api = { const fetch_options = { method: options.method, headers, + signal: options.signal, }; if (options.json) { diff --git a/public/js/smartfeeds.js b/public/js/smartfeeds.js new file mode 100644 index 0000000..e46378f --- /dev/null +++ b/public/js/smartfeeds.js @@ -0,0 +1,235 @@ +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; + } + + const feed_item_template = feed.querySelector("template"); + if (!feed_item_template) { + console.warn("No template for smart feed: " + feed); + continue; + } + + 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) => { + return feed.__target_element?.(item) ?? feed; + }; + + feed.__render = async (item) => { + 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("`" + feed_item_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 ?? ""}']`, + ); + + 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; + + 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); + 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; + } + } + }; + + // 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; + + if (feed.dataset.autoscroll) { + setTimeout(() => { + feed.scrollTop = + (feed.dataset.insert ?? "append") === "append" + ? feed.scrollHeight + : 0; + }, 50); + } + }) + .catch((error) => { + if (error === "smartfeed:stopped") { + return; + } + + debugger; + feed.dataset.error = JSON.stringify(error); + console.trace(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, +}); diff --git a/public/js/smartforms.js b/public/js/smartforms.js index 2bc4aa9..e28c9bd 100644 --- a/public/js/smartforms.js +++ b/public/js/smartforms.js @@ -1,191 +1,213 @@ +let smarten_forms_debounce_timeout; function smarten_forms() { - const forms = document?.body?.querySelectorAll("form[data-smart]:not([data-smartened])") ?? []; - for (const form of forms) { - async function on_submit(event) { - event.preventDefault(); - form.disabled = true; - form.__submitted_at = new Date(); + if (smarten_forms_debounce_timeout) { + clearTimeout(smarten_forms_debounce_timeout); + } - if (form.on_submit) { - const result = await form.on_submit(event); - if (result === false) { - form.disabled = false; - return; + smarten_forms_debounce_timeout = setTimeout(() => { + smarten_forms_debounce_timeout = undefined; + + const forms = + document?.body?.querySelectorAll("form[data-smart]:not([data-smartened])") ?? []; + for (const form of forms) { + async function on_submit(event) { + event.preventDefault(); + form.disabled = true; + form.__submitted_at = new Date(); + + if (form.on_submit) { + const result = await form.on_submit(event); + if (result === false) { + form.disabled = false; + return; + } } - } - const url = form.action; - const method = form.dataset.method ?? "POST"; + const url = eval("`" + (form.attributes.url?.textContent ?? form.action) + "`"); + const method = form.dataset.method ?? "POST"; - const json = {}; + const json = {}; - const form_data = new FormData(form); - for (const [key, value] of form_data.entries()) { - const input = form.querySelector(`[name="${key}"]`); + const form_data = new FormData(form); + for (const [key, value] of form_data.entries()) { + const input = form.querySelector(`[name="${key}"]`); - if (input.type === "file") { - if (input.dataset["smartformsSaveToHome"]) { - form.uploaded = []; - form.errors = []; + if (input.type === "file") { + if (input.dataset["smartformsSaveToHome"]) { + form.uploaded = []; + form.errors = []; - const user = document.body.dataset.user - ? JSON.parse(document.body.dataset.user) - : undefined; - if (!user) { - throw new Error("You must be logged in to upload files here."); - } - - for await (const file of input.files) { - const body = new FormData(); - body.append("file", file, encodeURIComponent(file.name)); - - const file_path = - "/files/users/" + user.id + "/" + encodeURIComponent(file.name); - - const file_upload_response = await api.fetch(file_path, { - method: "PUT", - body, - }); - - if (!file_upload_response.ok) { - const error = await file_upload_response.json(); - form.errors.push(error?.error?.message ?? "Unknown error."); - continue; + const user = document.body.dataset.user + ? JSON.parse(document.body.dataset.user) + : undefined; + if (!user) { + throw new Error("You must be logged in to upload files here."); } - const file_url = - window.location.protocol + "//" + window.location.host + file_path; - form.uploaded.push(file_url); - } + for await (const file of input.files) { + const body = new FormData(); + body.append("file", file, encodeURIComponent(file.name)); - if (form.errors.length) { - const errors = form.errors.join("\n\n"); - alert(errors); - return false; - } + const file_path = + "/files/users/" + user.id + "/" + encodeURIComponent(file.name); + const file_upload_response = await api.fetch(file_path, { + method: "PUT", + body, + }); + + if (!file_upload_response.ok) { + const error = await file_upload_response.json(); + form.errors.push(error?.error?.message ?? "Unknown error."); + continue; + } + + const file_url = + window.location.protocol + + "//" + + window.location.host + + file_path; + form.uploaded.push(file_url); + } + + if (form.errors.length) { + const errors = form.errors.join("\n\n"); + alert(errors); + return false; + } + + continue; + } + } + + if (key.length === 0) { continue; } - } - if (key.length === 0) { - continue; - } + const generator = input.getAttribute("generator"); + const generated_value = + typeof generator === "string" && generator.length + ? eval(generator)(input, form) + : undefined; - const generator = input.getAttribute("generator"); - const generated_value = - typeof generator === "string" && generator.length - ? eval(generator)(input, form) - : undefined; + const resolved_value = + typeof value === "string" && value.length ? value : generated_value; - const resolved_value = - typeof value === "string" && value.length ? value : generated_value; - - if (typeof resolved_value === "undefined") { - const should_submit_empty = input && input.dataset["smartformsSubmitEmpty"]; - if (!should_submit_empty) { - continue; + if (typeof resolved_value === "undefined") { + const should_submit_empty = input && input.dataset["smartformsSubmitEmpty"]; + if (!should_submit_empty) { + continue; + } } + + const elements = key.split("."); + let current = json; + for (const element of elements.slice(0, elements.length - 1)) { + current[element] = current[element] ?? {}; + current = current[element]; + } + + current[elements.slice(elements.length - 1).shift()] = resolved_value; } - const elements = key.split("."); - let current = json; - for (const element of elements.slice(0, elements.length - 1)) { - current[element] = current[element] ?? {}; - current = current[element]; + if (form.uploaded?.length > 0) { + json.data = json.data ?? {}; + json.data.media = [...form.uploaded]; } - current[elements.slice(elements.length - 1).shift()] = resolved_value; - } + const on_parsed = + form.on_parsed ?? + (form.getAttribute("on_parsed") + ? eval(form.getAttribute("on_parsed")) + : undefined); - if (form.uploaded?.length > 0) { - json.data = json.data ?? {}; - json.data.media = [...form.uploaded]; - } - - const on_parsed = - form.on_parsed ?? - (form.getAttribute("on_parsed") ? eval(form.getAttribute("on_parsed")) : undefined); - - if (on_parsed) { - await on_parsed(json); - } - - try { - const options = { - method, - headers: { - Accept: "application/json", - }, - }; - - if (["POST", "PUT", "PATCH"].includes(method)) { - options.json = json; + if (on_parsed) { + await on_parsed(json); } - const response = await api.fetch(url, options); + try { + const options = { + method, + headers: { + Accept: "application/json", + }, + }; - if (!response.ok) { - const error_body = await response.json(); - const error = error_body?.error; + if (["POST", "PUT", "PATCH"].includes(method)) { + options.json = json; + } + + const response = await api.fetch(url, options); + + if (!response.ok) { + const error_body = await response.json(); + const error = error_body?.error; + + if (form.on_error) { + return form.on_error(error); + } + + alert(error.message ?? "Unknown error:\n\n" + error); + return; + } + + if (form.on_response) { + await form.on_response(response); + } + + const response_body = await response.json(); + + const on_reply = + form.on_reply ?? + (form.getAttribute("on_reply") + ? eval(form.getAttribute("on_reply")) + : undefined); + if (on_reply) { + try { + await on_reply(response_body); + } catch (error) { + console.trace(error); + } + } + + const inputs_for_reset = form.querySelectorAll("[reset-on-submit]"); + for (const input of inputs_for_reset) { + const reset_value = input.getAttribute("reset-on-submit"); + input.value = reset_value ?? ""; + } + + form.querySelector("[focus-on-submit]")?.focus(); + } catch (error) { + console.dir({ + error, + }); if (form.on_error) { return form.on_error(error); } - alert(error.message ?? "Unknown error:\n\n" + error); - return; + alert(error); + } finally { + form.disabled = false; } - - if (form.on_response) { - await form.on_response(response); - } - - const response_body = await response.json(); - - const on_reply = - form.on_reply ?? - (form.getAttribute("on_reply") - ? eval(form.getAttribute("on_reply")) - : undefined); - if (on_reply) { - await on_reply(response_body); - } - - const inputs_for_reset = form.querySelectorAll("[reset-on-submit]"); - for (const input of inputs_for_reset) { - const reset_value = input.getAttribute("reset-on-submit"); - input.value = reset_value ?? ""; - } - } catch (error) { - console.dir({ - error, - }); - - if (form.on_error) { - return form.on_error(error); - } - - alert(error); - } finally { - form.disabled = false; } - } - form.addEventListener("submit", on_submit); - form.dataset.smartened = true; - } + form.querySelectorAll("[enter-key-submits]").forEach((element) => { + element.addEventListener("keypress", (event) => { + if (event.key === "Enter" && !event.shiftKey) { + form.requestSubmit(); + } + }); + }); + + form.addEventListener("submit", on_submit); + form.dataset.smartened = true; + } + }, 10); } -const smarten_observer = new MutationObserver(smarten_forms); -smarten_observer.observe(document, { +const smarten_forms_observer = new MutationObserver(smarten_forms); +smarten_forms_observer.observe(document, { childList: true, subtree: true, }); - -/* - -document.addEventListener("DOMContentLoaded", smarten_forms); -document.addEventListener("DOMSubtreeModified", smarten_forms); - -*/ diff --git a/public/sidebar/sidebar.html b/public/sidebar/sidebar.html index 84bbded..fd469db 100644 --- a/public/sidebar/sidebar.html +++ b/public/sidebar/sidebar.html @@ -2,7 +2,7 @@ document.addEventListener("topics_updated", ({ detail: { topics } }) => { const topic_list = document.getElementById("topic-list"); topic_list.innerHTML = ""; - for (const topic of topics) { + for (const topic of topics.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name))) { topic_list.insertAdjacentHTML( "beforeend", `
  • ${topic.name}
  • `, @@ -87,10 +87,6 @@ const invite_code = await invite_code_response.json(); - console.dir({ - invite_code, - }); - invite_popover.innerHTML = `
    @@ -608,7 +604,7 @@ ); new_topic_name_input.value = ""; - window.location.hash = `/topic/${new_topic.id}`; + window.location.hash = `/topic/${new_topic.id}/chat`; topic_create_form.style["height"] = "0"; }; } diff --git a/public/tabs/blurbs/blurbs.html b/public/tabs/blurbs/blurbs.html index 49ee9a1..e00e458 100644 --- a/public/tabs/blurbs/blurbs.html +++ b/public/tabs/blurbs/blurbs.html @@ -150,158 +150,66 @@ -
    - + +
    diff --git a/public/tabs/blurbs/new_blurb.html b/public/tabs/blurbs/new_blurb.html index 67b47ec..3724d02 100644 --- a/public/tabs/blurbs/new_blurb.html +++ b/public/tabs/blurbs/new_blurb.html @@ -1,4 +1,8 @@