forked from andyburke/autonomous.contact
feature/refactor: smartfeeds to reduce the client complexity
This commit is contained in:
parent
46090d944a
commit
f6cd05beac
19 changed files with 782 additions and 800 deletions
|
|
@ -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
235
public/js/smartfeeds.js
Normal 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,
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue