feature/refactor: smartfeeds to reduce the client complexity

This commit is contained in:
Andy Burke 2025-10-10 16:39:44 -07:00
parent 46090d944a
commit f6cd05beac
19 changed files with 782 additions and 800 deletions

View file

@ -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: {

View file

@ -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<string, any>): Promise<Response> {
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<SESSION_RESULT> {
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,

View file

@ -63,11 +63,13 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
}
} = /^.*\/events\/(?<event_type>.*?)\/.*\/(?<event_id>[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<string, any>): 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);

View file

@ -34,6 +34,7 @@
<script src="./js/notifications.js" type="text/javascript"></script>
<script src="./js/totp.js" type="text/javascript"></script>
<script src="./js/api.js" type="text/javascript"></script>
<script src="./js/smartfeeds.js" type="text/javascript"></script>
<script src="./js/smartforms.js" type="text/javascript"></script>
<script src="./js/textareaenhancements.js" type="text/javascript"></script>
</head>
@ -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 {

View file

@ -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) {

235
public/js/smartfeeds.js Normal file
View file

@ -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,
});

View file

@ -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);
*/

View file

@ -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",
`<li id="topic-selector-${topic.id}" class="topic" data-topic-selector-for="${topic.id}"><a href="#/topic/${topic.id}/chat">${topic.name}</a></li>`,
@ -87,10 +87,6 @@
const invite_code = await invite_code_response.json();
console.dir({
invite_code,
});
invite_popover.innerHTML = `
<div>
<div class="icon close" onclick="clear_invite_popup()"></div>
@ -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";
};
}

View file

@ -150,158 +150,66 @@
<!-- #include file="./new_blurb.html" -->
<div id="blurbs-list"></div>
<script>
const blurbs_list = document.getElementById("blurbs-list");
<div
id="blurbs-list"
data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.blurbs.read' ) !== -1"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=blurb&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'blurb:able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true"
data-reverse="true"
data-insert="prepend"
data-autoscroll="true"
>
<script>
{
const feed = document.currentScript.closest("[data-feed]");
async function render_blurb(blurb, position = "afterbegin") {
const creator = await USERS.get(blurb.creator_id);
const existing_element =
document.getElementById(blurb.id) ??
document.getElementById(blurb.meta?.temp_id ?? "") ??
document.querySelector(`[data-temp_id="${blurb.meta?.temp_id ?? ""}"]`);
const blurb_datetime = datetime_to_local(blurb.timestamps.created);
document.addEventListener("topic_changed", () => { feed.__reset && feed.__reset(); });
document.addEventListener("user_logged_in", () => { feed.__reset && feed.__reset(); });
const html_content = `<div class="blurb-container" data-creator_id="${creator.id}" data-blurb_id="${blurb.id}" data-temp_id="${blurb.meta?.temp_id ?? ""}">
feed.__target_element = (item) => {
return (
document.querySelector(
`.blurb-container[data-blurb_id='${item.parent_id}'] > .replies-container`,
) ?? feed
);
};
feed.__context = async (item) => {
const blurb_datetime = datetime_to_local(item.timestamps.created);
return {
blurb: item,
creator: await USERS.get(item.creator_id),
blurb_datetime
};
};
}
</script>
<template>
<div id="${ context.blurb.id }" class="blurb-container" data-creator_id="${context.creator.id}" data-blurb_id="${context.blurb.id}" data-temp_id="${context.blurb.meta?.temp_id ?? ""}">
<div class="media-preview-container">
${blurb.data?.media?.length ? blurb.data.media.map((url) => `<img src="${url}" />`).join("\n") : ""}
${context.blurb.data?.media?.length ? context.blurb.data.media.map(function(url) { return `<img src='${url}' />`; }).join('\n') : ''}
</div>
<div class="info-container">
<div class="avatar-container">
<img src="${creator.meta?.avatar ?? "/images/default_avatar.gif"}" alt="user avatar" />
<img src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}" alt="user avatar" />
</div>
<div class="username-container">
<span class="username">${creator.username}</span>
<span class="username">${context.creator.username}</span>
</div>
<div class="datetime-container">
<span class="long">${blurb_datetime.long}</span>
<span class="short">${blurb_datetime.short}</span>
<span class="long">${context.blurb_datetime.long}</span>
<span class="short">${context.blurb_datetime.short}</span>
</div>
</div>
<div class="content-container">${md_to_html(blurb.data.blurb)}</div>
<div class="content-container">${htmlify(md_to_html(context.blurb.data.blurb))}</div>
<!-- #include file="./new_blurb.html" -->
<div class="replies-container"></div>
</div>`;
if (existing_element) {
const template = document.createElement("template");
template.innerHTML = html_content;
existing_element.replaceWith(template.content.firstChild);
existing_element.classList.remove("sending");
} else {
const target_container =
document.querySelector(
`.blurb-container[data-blurb_id='${blurb.parent_id}'] > .replies-container`,
) ?? blurbs_list;
target_container.insertAdjacentHTML(position, html_content);
}
const new_blurb_forms = document.querySelectorAll(".blurb-creation-form");
for (const new_blurb_form of new_blurb_forms) {
new_blurb_form.action =
"/api/topics/" + document.body.dataset?.topic + "/events";
}
blurbs_list.scrollTop = 0;
}
// TODO: we need some abortcontroller handling here or something
// similar for when we change topics, this is the most basic
// first pass outline
let blurb_polling_abort_controller = null;
async function poll_for_new_blurbs() {
const blurbs_list = document.getElementById("blurbs-list");
const topic_id = document.body.dataset.topic;
const last_blurb_id = blurbs_list.dataset.last_blurb_id;
if (!topic_id) {
return;
}
const message_polling_url = `/api/topics/${topic_id}/events?type=blurb&limit=100&sort=newest&wait=true${last_blurb_id ? `&after_id=${last_blurb_id}` : ""}`;
blurb_polling_abort_controller =
blurb_polling_abort_controller || new AbortController();
api.fetch(message_polling_url, {
signal: blurb_polling_abort_controller.signal,
})
.then(async (new_events_response) => {
const new_events = (await new_events_response.json()) ?? [];
for await (const blurb of new_events.reverse()) {
await render_blurb(blurb);
}
const last_blurb_id = new_events.reduce((_last_blurb_id, blurb) => {
return blurb.id > _last_blurb_id && blurb.id.indexOf("TEMP") < 0
? blurb.id
: _last_blurb_id;
}, blurbs_list.dataset.last_blurb_id ?? "");
// if the last blurb has been updated, update the content's dataset to reflect that
if (last_blurb_id !== blurbs_list.dataset.last_blurb_id) {
blurbs_list.dataset.last_blurb_id = last_blurb_id;
}
poll_for_new_blurbs(topic_id);
})
.catch((error) => {
// TODO: poll again? back off?
console.error(error);
});
}
async function load_active_topic_for_blurbs(event) {
const topic_id = event?.detail?.topic_id ?? document.body.dataset.topic;
if (!topic_id) return;
const user = document.body.dataset.user
? JSON.parse(document.body.dataset.user)
: null;
if (!user) return;
const blurbs_list = document.getElementById("blurbs-list");
if (blurb_polling_abort_controller) {
blurb_polling_abort_controller.abort();
blurb_polling_abort_controller = null;
delete blurbs_list.dataset.last_blurb_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();
blurbs_list.innerHTML = "";
const events_response = await api.fetch(
`/api/topics/${topic_id}/events?type=blurb&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();
for await (const blurb of events.reverse()) {
await render_blurb(blurb, "afterbegin");
}
poll_for_new_blurbs();
}
document.addEventListener("topic_changed", load_active_topic_for_blurbs);
document.addEventListener("user_logged_in", load_active_topic_for_blurbs);
</script>
</div>
</template>
</div>
</div>
</div>
</div>

View file

@ -1,4 +1,8 @@
<style>
.new-blurb-container {
margin-bottom: 2rem;
}
.new-blurb-container input[type="file"] {
display: none;
visibility: hidden;
@ -36,8 +40,9 @@
width: 100%;
transition: all 0.5s;
"
on_reply="async (blurb) => { await render_blurb(blurb); }"
on_parsed="async (blurb) => { await render_blurb(blurb); document.getElementById(blurb.id)?.classList.add('sending'); }"
url="/api/topics/${ document.body.dataset.topic }/events"
on_reply="async (event) => { await document.getElementById( 'blurbs-list' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }"
on_parsed="async (event) => { await document.getElementById( 'blurbs-list' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }"
>
<input type="hidden" name="type" value="blurb" />

View file

@ -73,6 +73,10 @@
border-radius: var(--border-radius);
}
#chat .message-container .html-from-markdown {
padding: 0;
}
#chat .message-container.user-tick.time-tick + .message-container.user-tick.time-tick,
#chat .message-container.user-tick.time-tock + .message-container.user-tick.time-tock,
#chat .message-container.user-tock.time-tick + .message-container.user-tock.time-tick,

View file

@ -1,175 +1,3 @@
<script>
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";
async function render_chat_event(event, position = "beforeend") {
const creator = await USERS.get(event.creator_id);
const existing_element =
document.getElementById(event.id) ??
document.getElementById(event.meta?.temp_id ?? "") ??
document.querySelector(`[data-temp_id="${event.meta?.temp_id ?? ""}" ]`);
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="${event.id}" class="message-container ${user_tick_tock_class} ${time_tick_tock_class}" data-creator_id="${creator.id}" data-temp_id="${event.meta?.temp_id ?? ""}">
<div class="message-actions-container">
<input
type="checkbox"
id="show_message_actions-${event.id}"
class="message-actions-display-toggle"
/>
<label class="message-actions-display-toggle-label" for="show_message_actions-${event.id}">
<div class="icon more-borderless"></div>
</label>
<button class="message-action mockup" data-action="react"><i class="icon circle"></i><span class="action-name">React</span></button>
<button class="message-action" data-action="reply" onclick="document.getElementById( 'parent-id' ).value = '${event.id}';"><i class="icon reply"></i><span class="action-name">Reply</span></button>
<button class="message-action mockup" data-action="forward_copy"><i class="icon forward-copy"></i><span class="action-name">Copy Link</span></button>
<button class="message-action mockup" data-action="delete"><i class="icon trash"></i><span class="action-name">Delete</span></button>
</div>
<div class="info-container">
<div class="avatar-container">
<img src="${creator.meta?.avatar ?? "/images/default_avatar.gif"}" alt="user avatar" />
</div>
<div class="username-container">
<span class="username">${creator.username ?? "unknown"}</span>
</div>
<div class="datetime-container">
<span class="long">${event_datetime.long}</span>
<span class="short">${event_datetime.short}</span>
</div>
</div>
<div class="message-content-container">${htmlify(event.data.content)}</div>
<div class="message-media-container">${htmlify(event.data.media?.join("\n") ?? "")}</div>
</div>`;
if (existing_element) {
const template = document.createElement("template");
template.innerHTML = html_content;
existing_element.replaceWith(template.content.firstChild);
} else {
// TODO: threading
document.getElementById("chat-content")?.insertAdjacentHTML(position, html_content);
}
}
async function handle_chat_events(events) {
if (!Array.isArray(events)) {
debugger;
console.warn("got unexpected value for handle_chat_events `events` argument.");
return;
}
const topic_chat_content = document.getElementById("chat-content");
const sorted = events.sort((lhs, rhs) => lhs.id.localeCompare(rhs.id));
for await (const event of sorted) {
await render_chat_event(event);
}
const last_message_id = sorted.reduce((_last_message_id, event) => {
return event.id > _last_message_id && event.id.indexOf("TEMP") < 0
? event.id
: _last_message_id;
}, topic_chat_content.dataset.last_message_id ?? "");
// if the last blurb 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;
}
setTimeout(() => {
topic_chat_content.scrollTop = topic_chat_content.scrollHeight;
}, 50);
}
// 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_chat_events() {
const topic_chat_content = document.getElementById("chat-content");
const topic_id = document.body.dataset.topic;
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();
handle_chat_events(new_events);
poll_for_new_chat_events(topic_id);
})
.catch((error) => {
// TODO: poll again? back off?
console.error(error);
});
}
async function load_active_topic_for_chat(event) {
const topic_id = event?.detail?.topic_id ?? document.body.dataset.topic;
if (!topic_id) return;
const topic_chat_content = document.getElementById("chat-content");
topic_chat_content.innerHTML = "";
const user = document.body.dataset.user ? JSON.parse(document.body.dataset.user) : null;
if (!user) return;
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();
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();
handle_chat_events(events);
poll_for_new_chat_events();
}
document.addEventListener("topic_changed", load_active_topic_for_chat);
document.addEventListener("user_logged_in", load_active_topic_for_chat);
</script>
<div id="chat" class="tab">
<input
type="radio"
@ -190,7 +18,131 @@
<script src="/js/external/punycode.js" type="text/javascript"></script>
<div id="chat-container">
<div id="chat-content"></div>
<div
id="chat-content"
data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.chat.read' ) !== -1"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=chat&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'chat:able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true"
data-reverse="true"
data-insert="append"
data-autoscroll="true"
>
<script>
{
const feed = document.currentScript.closest("[data-feed]");
document.addEventListener("topic_changed", () => {
feed.__reset && feed.__reset();
});
document.addEventListener("user_logged_in", () => {
feed.__reset && feed.__reset();
});
const time_tick_tock_timeout = 60_000;
let last_event_datetime_value = 0;
let time_tick_tock_class = "time-tock";
let last_creator_id = "";
let user_tick_tock_class = "user-tock";
feed.__context = async (item) => {
const event_datetime = datetime_to_local(item.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 !== item.creator_id) {
user_tick_tock_class =
user_tick_tock_class === "user-tick"
? "user-tock"
: "user-tick";
last_creator_id = item.creator_id;
}
return {
event: item,
creator: await USERS.get(item.creator_id),
event_datetime,
time_tick_tock_class,
user_tick_tock_class,
};
};
}
</script>
<template>
<div
id="${context.event.id}"
class="message-container ${context.user_tick_tock_class} ${context.time_tick_tock_class}"
data-creator_id="${context.creator.id}"
data-temp_id="${context.event.meta?.temp_id ?? ''}"
>
<div class="message-actions-container">
<input
type="checkbox"
id="show_message_actions-${context.event.id}"
class="message-actions-display-toggle"
/>
<label
class="message-actions-display-toggle-label"
for="show_message_actions-${context.event.id}"
>
<div class="icon more-borderless"></div>
</label>
<button class="message-action mockup" data-action="react">
<i class="icon circle"></i><span class="action-name">React</span>
</button>
<button
class="message-action"
data-action="reply"
onclick="document.getElementById( 'parent-id' ).value = '${context.event.id}';"
>
<i class="icon reply"></i><span class="action-name">Reply</span>
</button>
<button class="message-action mockup" data-action="forward_copy">
<i class="icon forward-copy"></i
><span class="action-name">Copy Link</span>
</button>
<button class="message-action mockup" data-action="delete">
<i class="icon trash"></i><span class="action-name">Delete</span>
</button>
</div>
<div class="info-container">
<div class="avatar-container">
<img
src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}"
alt="user avatar"
/>
</div>
<div class="username-container">
<span class="username"
>${context.creator.username ?? 'unknown'}</span
>
</div>
<div class="datetime-container">
<span class="long">${context.event_datetime.long}</span>
<span class="short">${context.event_datetime.short}</span>
</div>
</div>
<div class="message-content-container">
${htmlify(md_to_html(context.event.data.content))}
</div>
<div class="message-media-container">
${htmlify(context.event.data.media?.join("\n") ?? "")}
</div>
</div>
</template>
</div>
<div id="chat-entry-container">
<form
id="chat-entry"
@ -203,23 +155,12 @@
width: 100%;
transition: all 0.5s;
"
on_reply="async (event) => { await render_chat_event(event); document.getElementById(event.id)?.classList.remove('sending'); document.getElementById( 'chat-input' ).value = ''; document.getElementById( 'chat-input' ).focus(); }"
on_parsed="async (event) => { await render_chat_event(event); document.getElementById(event.id)?.classList.add('sending'); }"
on_reply="async (event) => { await document.getElementById( 'chat-content' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }"
on_parsed="async (event) => { await document.getElementById( 'chat-content' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }"
>
<script>
{
const form = document.currentScript.closest("form");
document.addEventListener("DOMContentLoaded", () => {
const chat_input = form.querySelector(
'textarea[name="data.content"]',
);
chat_input.addEventListener("keypress", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
form.requestSubmit();
}
});
});
document.addEventListener(
"topic_changed",
({ detail: { topic_id } }) => {
@ -283,6 +224,8 @@
rows="1"
name="data.content"
reset-on-submit
focus-on-submit
enter-key-submits
></textarea>
<button id="chat-send" class="primary" aria-label="Send a message">

View file

@ -119,157 +119,83 @@
<!-- #include file="./README.md" -->
<!-- #include file="./new_essay.html" -->
<div id="essays-list"></div>
<script>
const essays_list = document.getElementById("essays-list");
<div
id="essays-list"
data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.essays.read' ) !== -1"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=essay&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'essay:able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true"
data-reverse="true"
data-insert="prepend"
data-autoscroll="true"
>
<script>
{
const feed = document.currentScript.closest("[data-feed]");
async function render_essay(essay, position = "beforeend") {
const creator = await USERS.get(essay.creator_id);
const existing_element =
document.getElementById(essay.id) ??
document.getElementById(essay.meta?.temp_id ?? "") ??
document.querySelector(`[data-temp_id="${essay.meta?.temp_id ?? ""}"]`);
const essay_datetime = datetime_to_local(essay.timestamps.created);
document.addEventListener("topic_changed", () => {
feed.__reset && feed.__reset();
});
document.addEventListener("user_logged_in", () => {
feed.__reset && feed.__reset();
});
const html_content = `<div class="essay-container" data-creator_id="${creator.id}" data-essay_id="${essay.id}" data-temp_id="${essay.meta?.temp_id ?? ""}">
feed.__target_element = (item) => {
return (
document.querySelector(
`.essay-container[data-essay_id='${item.parent_id}'] > .replies-container`,
) ?? feed
);
};
feed.__context = async (item) => {
const essay_datetime = datetime_to_local(item.timestamps.created);
return {
essay: item,
creator: await USERS.get(item.creator_id),
essay_datetime,
};
};
}
</script>
<template>
<div
id="${context.essay.id}"
class="essay-container"
data-creator_id="${context.creator.id}"
data-essay_id="${context.essay.id}"
data-temp_id="${context.essay.meta?.temp_id ?? ''}"
>
<div class="media-preview-container">
${essay.data?.media?.length ? essay.data.media.map((url) => `<img src="${url}" />`).join("\n") : ""}
${context.essay.data?.media?.length ?
context.essay.data.media.map(function(url) { return `<img
src="${url}"
/>` }).join('\n') : ''}
</div>
<div class="info-container">
<div class="avatar-container">
<img src="${creator.meta?.avatar ?? "/images/default_avatar.gif"}" alt="user avatar" />
<img
src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}"
alt="user avatar"
/>
</div>
<div class="username-container">
<span class="username">${creator.username}</span>
<span class="username">${context.creator.username}</span>
</div>
<div class="datetime-container">
<span class="long">${essay_datetime.long}</span>
<span class="short">${essay_datetime.short}</span>
<span class="long">${context.essay_datetime.long}</span>
<span class="short">${context.essay_datetime.short}</span>
</div>
</div>
<div class="title-container">${essay.data.title}</div>
<div class="content-container">${htmlify(md_to_html(essay.data.essay))}</div>
</div>`;
if (existing_element) {
const template = document.createElement("template");
template.innerHTML = html_content;
existing_element.replaceWith(template.content.firstChild);
existing_element.classList.remove("sending");
} else {
const target_container =
document.querySelector(
`.essay-container[data-essay_id='${essay.parent_id}'] > .replies-container`,
) ?? essays_list;
target_container.insertAdjacentHTML(position, html_content);
}
}
async function append_essays(essays) {
let last_essay_id = essays_list.dataset.last_essay_id ?? "";
for await (const essay of essays) {
// if the last essay is undefined, it becomes this event, otherwise, if this event's id is newer,
// it becomes the latest essay
last_essay_id =
essay.id > last_essay_id && essay.id.indexOf("TEMP") !== 0
? essay.id
: last_essay_id;
// if the last essay has been updated, update the content's dataset to reflect that
if (last_essay_id !== essays_list.dataset.last_essay_id) {
essays_list.dataset.last_essay_id = last_essay_id;
}
await render_essay(essay);
}
const new_essay_forms = document.querySelectorAll(".essay-creation-form");
for (const new_essay_form of new_essay_forms) {
new_essay_form.action =
"/api/topics/" + document.body.dataset?.topic + "/events";
}
essays_list.scrollTop = 0;
}
// TODO: we need some abortcontroller handling here or something
// similar for when we change topics, this is the most basic
// first pass outline
let essay_polling_abort_controller = null;
async function poll_for_new_essays() {
const essays_list = document.getElementById("essays-list");
const topic_id = document.body.dataset.topic;
const last_essay_id = essays_list.dataset.last_essay_id;
if (!topic_id) {
return;
}
const message_polling_url = `/api/topics/${topic_id}/events?type=essay&limit=100&sort=newest&wait=true${last_essay_id ? `&after_id=${last_essay_id}` : ""}`;
essay_polling_abort_controller =
essay_polling_abort_controller || new AbortController();
api.fetch(message_polling_url, {
signal: essay_polling_abort_controller.signal,
})
.then(async (new_events_response) => {
const new_events = (await new_events_response.json()) ?? [];
await append_essays(new_events.reverse(), "afterbegin");
poll_for_new_essays(topic_id);
})
.catch((error) => {
// TODO: poll again? back off?
console.error(error);
});
}
async function load_active_topic_for_essays() {
const topic_id = document.body.dataset.topic;
if (!topic_id) return;
const user = document.body.dataset.user
? JSON.parse(document.body.dataset.user)
: null;
if (!user) return;
const essays_list = document.getElementById("essays-list");
if (essay_polling_abort_controller) {
essay_polling_abort_controller.abort();
essay_polling_abort_controller = null;
delete essays_list.dataset.last_essay_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();
essays_list.innerHTML = "";
const events_response = await api.fetch(
`/api/topics/${topic_id}/events?type=essay&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();
await append_essays(events);
poll_for_new_essays();
}
document.addEventListener("topic_changed", load_active_topic_for_essays);
document.addEventListener("user_logged_in", load_active_topic_for_essays);
</script>
<div class="title-container">${context.essay.data.title}</div>
<div class="content-container">
${htmlify(md_to_html(context.essay.data.essay))}
</div>
</div>
</template>
</div>
</div>
</div>
</div>

View file

@ -32,13 +32,14 @@
data-smart="true"
method="POST"
class="essay-creation-form collapsible"
url="/api/topics/${ document.body.dataset.topic }/events"
style="
margin-top: 1rem
width: 100%;
transition: all 0.5s;
"
on_reply="async (essay) => { await render_essay(essay, 'afterbegin'); document.getElementById(essay.id)?.classList.remove('sending'); }"
on_parsed="async (essay) => { await render_essay(essay, 'afterbegin'); document.getElementById(essay.id)?.classList.add('sending'); }"
on_reply="async (event) => { await document.getElementById( 'essays-list' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }"
on_parsed="async (event) => { await document.getElementById( 'essays-list' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }"
>
<input type="hidden" name="type" value="essay" />

View file

@ -103,12 +103,12 @@
.post-container .info-container .datetime-container .long {
font-size: x-small;
text-transform: uppercase;
visibility: hidden;
display: none;
}
.post-container .info-container .datetime-container .short {
font-size: xx-small;
visibility: hidden;
display: none;
}
.post-container .subject-container {
@ -149,165 +149,89 @@
<div class="tab-content forum-container">
<!-- #include file="./README.md" -->
<div id="forum-posts" class="container">
<div id="forum-posts-list"></div>
<div
id="posts-list"
data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.essays.read' ) !== -1"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=post&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'post:able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true"
data-reverse="true"
data-insert="prepend"
data-autoscroll="true"
>
<script>
const forum_posts_list = document.getElementById("forum-posts-list");
{
const feed = document.currentScript.closest("[data-feed]");
async function render_post(post) {
const creator = await USERS.get(post.creator_id);
const existing_element =
document.getElementById(`post-${post.id.substring(0, 49)}`) ??
document.getElementById(`post-${post.meta?.temp_id}`);
const post_datetime = datetime_to_local(post.timestamps.created);
document.addEventListener("topic_changed", () => {
feed.__reset && feed.__reset();
});
document.addEventListener("user_logged_in", () => {
feed.__reset && feed.__reset();
});
const html_content = `<div class="post-container" data-creator_id="${creator.id}" data-post_id="${post.id}">
<div class="expand-toggle-container">
<label>
<input type="checkbox" name="expanded"/><i class="icon plus"></i><i class="icon minus"></i>
</label>
</div>
<div class="media-preview-container">
<img src="/images/placeholders/${String(post_datetime.ms % 10).padStart(2, "0")}.svg" />
</div>
<div class="info-container">
<div class="avatar-container">
<img src="${creator.meta?.avatar ?? "/images/default_avatar.gif"}" alt="user avatar" />
</div>
<div class="username-container">
<span class="username">${creator.username}</span>
</div>
<div class="datetime-container">
<span class="long">${post_datetime.long}</span>
<span class="short">${post_datetime.short}</span>
</div>
</div>
<div class="subject-container">${post.data.subject}</div>
<div class="content-container">${htmlify(post.data.content)}</div>
<!-- #include file="./new_post.html" -->
<div class="replies-container"></div>
</div>`;
if (existing_element) {
const template = document.createElement("template");
template.innerHTML = html_content;
existing_element.replaceWith(template.content.firstChild);
existing_element.classList.remove("sending");
} else {
const target_container =
feed.__target_element = (item) => {
return (
document.querySelector(
`.post-container[data-post_id='${post.parent_id}'] > .replies-container`,
) ?? forum_posts_list;
target_container.insertAdjacentHTML("beforeend", html_content);
}
`.post-container[data-post_id='${item.parent_id}'] > .replies-container`,
) ?? feed
);
};
feed.__context = async (item) => {
const post_datetime = datetime_to_local(item.timestamps.created);
return {
post: item,
creator: await USERS.get(item.creator_id),
post_datetime,
};
};
}
async function append_posts(posts) {
let last_post_id = forum_posts_list.dataset.last_post_id ?? "";
for await (const post of posts) {
// if the last post is undefined, it becomes this event, otherwise, if this event's id is newer,
// it becomes the latest post
last_post_id =
post.id > last_post_id && post.id.indexOf("TEMP") !== 0
? post.id
: last_post_id;
// if the last post has been updated, update the content's dataset to reflect that
if (last_post_id !== forum_posts_list.dataset.last_post_id) {
forum_posts_list.dataset.last_post_id = last_post_id;
}
await render_post(post);
}
const new_post_forms = document.querySelectorAll(".post-creation-form");
for (const new_post_form of new_post_forms) {
new_post_form.action =
"/api/topics/" + document.body.dataset?.topic + "/events";
}
forum_posts_list.scrollTop = 0;
}
// TODO: we need some abortcontroller handling here or something
// similar for when we change topics, this is the most basic
// first pass outline
let post_polling_abort_controller = null;
async function poll_for_new_posts() {
const forum_posts_list = document.getElementById("forum-posts-list");
const topic_id = document.body.dataset.topic;
const last_post_id = forum_posts_list.dataset.last_post_id;
if (!topic_id) {
return;
}
const message_polling_url = `/api/topics/${topic_id}/events?type=post&limit=100&sort=newest&wait=true${last_post_id ? `&after_id=${last_post_id}` : ""}`;
post_polling_abort_controller =
post_polling_abort_controller || new AbortController();
api.fetch(message_polling_url, {
signal: post_polling_abort_controller.signal,
})
.then(async (new_events_response) => {
const new_events = (await new_events_response.json()) ?? [];
await append_posts(new_events.reverse());
poll_for_new_posts(topic_id);
})
.catch((error) => {
// TODO: poll again? back off?
console.error(error);
});
}
async function load_active_topic_for_forum() {
const topic_id = document.body.dataset.topic;
if (!topic_id) return;
const user = document.body.dataset.user
? JSON.parse(document.body.dataset.user)
: null;
if (!user) return;
const forum_posts_list = document.getElementById("forum-posts-list");
if (post_polling_abort_controller) {
post_polling_abort_controller.abort();
post_polling_abort_controller = null;
delete forum_posts_list.dataset.last_post_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();
forum_posts_list.innerHTML = "";
const events_response = await api.fetch(
`/api/topics/${topic_id}/events?type=post&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();
await append_posts(events.reverse());
poll_for_new_posts();
}
document.addEventListener("topic_changed", load_active_topic_for_forum);
document.addEventListener("user_logged_in", load_active_topic_for_forum);
</script>
<template>
<div
id="${context.post.id}"
class="post-container"
data-creator_id="${context.creator.id}"
data-post_id="${context.post.id}"
>
<div class="expand-toggle-container">
<label>
<input type="checkbox" name="expanded" /><i class="icon plus"></i
><i class="icon minus"></i>
</label>
</div>
<div class="media-preview-container">
<img
src="/images/placeholders/${String((context.post_datetime.ms % 9) + 1).padStart(2, '0')}.svg"
/>
</div>
<div class="info-container">
<div class="avatar-container">
<img
src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}"
alt="user avatar"
/>
</div>
<div class="username-container">
<span class="username">${context.creator.username}</span>
</div>
<div class="datetime-container">
<span class="long">${context.post_datetime.long}</span>
<span class="short">${context.post_datetime.short}</span>
</div>
</div>
<div class="subject-container">${context.post.data.subject}</div>
<div class="content-container">
${htmlify(md_to_html(context.post.data.content))}
</div>
<!-- #include file="./new_post.html" -->
<div class="replies-container"></div>
</div>
</template>
</div>
<!-- #include file="./new_post.html" -->

View file

@ -14,8 +14,9 @@
width: 100%;
transition: all 0.5s;
"
on_reply="async (post) => { await append_posts([post]); }"
on_parsed="async (post) => { await render_post(post); document.getElementById('post-' + post.id)?.classList.add('sending'); }"
url="/api/topics/${ document.body.dataset.topic }/events"
on_reply="async (event) => { await document.getElementById( 'posts-list' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }"
on_parsed="async (event) => { await document.getElementById( 'posts-list' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }"
>
<input type="hidden" name="type" value="post" />

View file

@ -64,8 +64,8 @@ export function api(api_config?: Record<string, any>): API_CLIENT {
cookies.push({
name: options.totp_token ?? 'totp',
value: await generateTotp(options.session.secret),
maxAge: 30,
expires: Date.now() + 30_000,
maxAge: 29,
expires: Date.now() + 29_000,
path: '/'
});
@ -80,7 +80,8 @@ export function api(api_config?: Record<string, any>): API_CLIENT {
method: options?.method ?? 'GET',
credentials: options?.credentials ?? 'include',
redirect: options?.redirect ?? 'follow',
headers
headers,
signal: options?.signal
};
const response_transform = transform ?? DEFAULT_TRANSFORM;

View file

@ -34,6 +34,10 @@ export function require_user(
meta: Record<string, any>
): undefined | Response {
if (!meta.user) {
console.dir({
require_user: true,
meta
});
return CANNED_RESPONSES.permission_denied();
}
}

View file

@ -110,5 +110,10 @@ export async function verifyTotp(
t0: number = 0,
t: number = Date.now()
): Promise<boolean> {
return otp === await generateTotp(key, t0, t);
const valid_otps = [
await generateTotp(key, t0, t - 30_000),
await generateTotp(key, t0, t),
await generateTotp(key, t0, t + 30_000)
];
return valid_otps.includes(otp);
}