From 2b253c811cf94accc9fb058f4091f71513a2b990 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 17 Jun 2025 01:52:17 -0400 Subject: [PATCH] add: infinitely scrolling timelines --- crates/app/src/assets.rs | 2 + crates/app/src/public/html/components.lisp | 5 +- crates/app/src/public/html/root.lisp | 1 + crates/app/src/public/html/timelines/all.lisp | 9 +- .../src/public/html/timelines/following.lisp | 9 +- .../app/src/public/html/timelines/home.lisp | 9 +- .../src/public/html/timelines/popular.lisp | 9 +- .../src/public/html/timelines/swiss_army.lisp | 29 +++++ crates/app/src/public/js/atto.js | 121 ++++++++++++++++++ crates/app/src/routes/api/v1/uploads.rs | 15 ++- crates/app/src/routes/pages/misc.rs | 112 +++++++++++++++- crates/app/src/routes/pages/mod.rs | 4 + 12 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 crates/app/src/public/html/timelines/swiss_army.lisp diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 03e8db3..bf2a64c 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -97,6 +97,7 @@ pub const TIMELINES_FOLLOWING_QUESTIONS: &str = pub const TIMELINES_ALL_QUESTIONS: &str = include_str!("./public/html/timelines/all_questions.lisp"); pub const TIMELINES_SEARCH: &str = include_str!("./public/html/timelines/search.lisp"); +pub const TIMELINES_SWISS_ARMY: &str = include_str!("./public/html/timelines/swiss_army.lisp"); pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.lisp"); pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.lisp"); @@ -385,6 +386,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"timelines/following_questions.html"(crate::assets::TIMELINES_FOLLOWING_QUESTIONS) --config=config --lisp plugins); write_template!(html_path->"timelines/all_questions.html"(crate::assets::TIMELINES_ALL_QUESTIONS) --config=config --lisp plugins); write_template!(html_path->"timelines/search.html"(crate::assets::TIMELINES_SEARCH) --config=config --lisp plugins); + write_template!(html_path->"timelines/swiss_army.html"(crate::assets::TIMELINES_SWISS_ARMY) --config=config --lisp plugins); write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config --lisp plugins); write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config --lisp plugins); diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 154621c..b604f2c 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -117,7 +117,7 @@ (text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}")) (text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false) -%} {% if community and show_community and community.id != config.town_square or question %}") (div - ("class" "card-nest") + ("class" "card-nest post_outer:{{ post.id }}") (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}") (div ("class" "card small") @@ -130,8 +130,7 @@ (text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}"))) (text "{%- endif %} {%- endif %}") (div - ("class" "card flex flex-col gap-2 {% if secondary -%}secondary{%- endif %}") - ("id" "post:{{ post.id }}") + ("class" "card flex flex-col gap-2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}") ("data-community" "{{ post.community }}") ("data-ownsup" "{{ owner.permissions|has_supporter }}") ("hook" "verify_emojis") diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 356e86a..a7cfb4a 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -35,6 +35,7 @@ }; globalThis.no_policy = false; + globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\"; ") (script ("src" "/js/loader.js" )) diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp index ab3e688..9434aab 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -30,6 +30,13 @@ (text "{%- endif %}") (div ("class" "card w-full flex flex-col gap-2") - (text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) + ("ui_ident" "io_data_load") + (text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}") + (div ("ui_ident" "io_data_marker")))) + +(script + (text "setTimeout(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\")]); + });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/following.lisp b/crates/app/src/public/html/timelines/following.lisp index 642ab63..b36d889 100644 --- a/crates/app/src/public/html/timelines/following.lisp +++ b/crates/app/src/public/html/timelines/following.lisp @@ -8,6 +8,13 @@ (text "{{ macros::timelines_nav(selected=\"following\", posts=\"/following\", questions=\"/following/questions\") }}") (div ("class" "card w-full flex flex-col gap-2") - (text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) + ("ui_ident" "io_data_load") + (text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}") + (div ("ui_ident" "io_data_marker")))) + +(script + (text "setTimeout(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\")]); + });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/home.lisp b/crates/app/src/public/html/timelines/home.lisp index 4d1ce9d..2705641 100644 --- a/crates/app/src/public/html/timelines/home.lisp +++ b/crates/app/src/public/html/timelines/home.lisp @@ -27,7 +27,14 @@ (text "{% else %}") (div ("class" "card w-full flex flex-col gap-2") - (text "{% for post in list %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")) + ("ui_ident" "io_data_load") + (text "{% for post in list %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {% endfor %}") + (div ("ui_ident" "io_data_marker"))) (text "{%- endif %}")) +(script + (text "setTimeout(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\")]); + });")) + (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/popular.lisp b/crates/app/src/public/html/timelines/popular.lisp index dfaef71..85ed6f4 100644 --- a/crates/app/src/public/html/timelines/popular.lisp +++ b/crates/app/src/public/html/timelines/popular.lisp @@ -8,6 +8,13 @@ (text "{{ macros::timelines_nav(selected=\"popular\", posts=\"/popular\", questions=\"/popular/questions\") }}") (div ("class" "card w-full flex flex-col gap-2") - (text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) + ("ui_ident" "io_data_load") + (text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}") + (div ("ui_ident" "io_data_marker")))) + +(script + (text "setTimeout(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\")]); + });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp new file mode 100644 index 0000000..c8734bc --- /dev/null +++ b/crates/app/src/public/html/timelines/swiss_army.lisp @@ -0,0 +1,29 @@ +(text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}") +(text "{% for post in list %} + {% if post[2].read_access == \"Everybody\" -%} + {% if post[0].context.repost and post[0].context.repost.reposting -%} + {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} + {% else %} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} + {%- endif %} + {%- endif %} + {% endfor %}") +(datalist + ("ui_ident" "list_posts_{{ page }}") + (text "{% for post in list -%}") + (option ("value" "{{ post[0].id }}")) + (text "{%- endfor %}")) +(text "{% if list|length == 0 -%}") +(div + ("class" "card lowered green flex justify-between items-center gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"shell\" }}") + (span + (text "That's a wrap!"))) + (a + ("class" "button") + ("href" "?page=0") + (icon (text "arrow-up")) + (str (text "chats:label.go_back")))) +(text "{%- endif %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 28cd3fc..9dd1c0e 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1119,6 +1119,127 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} document.getElementById("lightbox").classList.add("hidden"); }, 250); }); + + // intersection observer infinite scrolling + self.IO_DATA_OBSERVER = new IntersectionObserver( + async (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) { + continue; + } + + await self.io_load_data(); + break; + } + }, + { + root: document.body, + rootMargin: "0px", + threshold: 1, + }, + ); + + self.define("io_data_load", (_, tmpl, page) => { + self.IO_DATA_MARKER = document.querySelector( + "[ui_ident=io_data_marker]", + ); + + self.IO_DATA_ELEMENT = document.querySelector( + "[ui_ident=io_data_load]", + ); + + if (!self.IO_DATA_ELEMENT || !self.IO_DATA_MARKER) { + console.warn( + "ui::io_data_load called, but required elements don't exist", + ); + + return; + } + + self.IO_DATA_TMPL = tmpl; + self.IO_DATA_PAGE = page; + self.IO_DATA_SEEN_IDS = []; + + self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER); + }); + + self.define("io_load_data", async () => { + self.IO_DATA_PAGE += 1; + console.log("load page", self.IO_DATA_PAGE); + + const text = await ( + await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`) + ).text(); + + if ( + text.includes( + `That's a wrap!`, + ) + ) { + console.log("io_data_end; disconnect"); + self.IO_DATA_OBSERVER.disconnect(); + self.IO_DATA_ELEMENT.innerHTML += text; + return; + } + + self.IO_DATA_ELEMENT.innerHTML += text; + + setTimeout(() => { + // move marker to bottom of dom hierarchy + self.IO_DATA_ELEMENT.children[ + self.IO_DATA_ELEMENT.children.length - 1 + ].after(self.IO_DATA_MARKER); + + // remove posts we've already seen + function remove_elements(id, outer = false) { + let idx = 0; + for (const element of Array.from( + document.querySelectorAll( + `.post${outer ? "_outer" : ""}\\:${id}`, + ), + )) { + if (idx === 0) { + idx += 1; + continue; + } + + // everything that isn't the first element should be removed + element.remove(); + console.log("removed duplicate post"); + } + } + + for (const id of self.IO_DATA_SEEN_IDS) { + remove_elements(id, false); + remove_elements(id, true); // scoop up questions + } + + // push ids + for (const opt of Array.from( + document.querySelectorAll( + `[ui_ident=list_posts_${self.IO_DATA_PAGE}] option`, + ), + )) { + const v = opt.getAttribute("value"); + + if (!self.IO_DATA_SEEN_IDS[v]) { + self.IO_DATA_SEEN_IDS.push(v); + } + } + }, 150); + + // run hooks + const atto = ns("atto"); + + atto.clean_date_codes(); + atto.clean_poll_date_codes(); + atto.link_filter(); + + atto["hooks::long_text.init"](); + atto["hooks::alt"](); + atto["hooks::online_indicator"](); + atto["hooks::verify_emoji"](); + }); })(); (() => { diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index c90c427..8a6a8bb 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -12,7 +12,20 @@ pub async fn get_request( ) -> impl IntoResponse { let data = &(data.read().await).0; - let upload = data.get_upload_by_id(id).await.unwrap(); + let upload = match data.get_upload_by_id(id).await { + Ok(u) => u, + Err(_) => { + return Err(( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.0.dirs.media.as_str(), + "images", + "default-avatar.svg", + ]))), + )); + } + }; + let path = upload.path(&data.0.0); if !exists(&path).unwrap() { diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 122d82b..3a6c9f9 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -9,7 +9,9 @@ use axum::{ }; use axum_extra::extract::CookieJar; use serde::Deserialize; -use tetratto_core::model::{permissions::FinePermission, requests::ActionType, Error}; +use tetratto_core::model::{ + auth::DefaultTimelineChoice, permissions::FinePermission, requests::ActionType, Error, +}; use std::fs::read_to_string; use pathbufd::PathBufD; @@ -649,3 +651,111 @@ pub async fn search_request( data.1.render("timelines/search.html", &context).unwrap(), )) } + +#[derive(Deserialize)] +pub struct TimelineQuery { + pub tl: DefaultTimelineChoice, + pub page: usize, +} + +/// `/_swiss_army_timeline` +pub async fn swiss_army_timeline_request( + jar: CookieJar, + Extension(data): Extension, + Query(req): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let ignore_users = crate::ignore_users_gen!(user, data); + + let list = match match req.tl { + DefaultTimelineChoice::AllPosts => data.0.get_latest_posts(12, req.page).await, + DefaultTimelineChoice::PopularPosts => { + data.0.get_popular_posts(12, req.page, 604_800_000).await + } + DefaultTimelineChoice::FollowingPosts => { + if let Some(ref ua) = user { + data.0 + .get_posts_from_user_following(ua.id, 12, req.page) + .await + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + DefaultTimelineChoice::MyCommunities => { + if let Some(ref ua) = user { + data.0 + .get_posts_from_user_communities(ua.id, 12, req.page) + .await + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + DefaultTimelineChoice::Stack(ref s) => { + data.0 + .get_posts_by_stack( + match s.parse::() { + Ok(s) => s, + Err(_) => { + return Err(Html( + render_error( + Error::MiscError("ID deserialization error".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + }, + 12, + req.page, + ) + .await + } + // questions bad + _ => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } { + Ok(l) => match data + .0 + .fill_posts_with_community( + l, + if let Some(ref ua) = user { ua.id } else { 0 }, + &ignore_users, + &user, + ) + .await + { + Ok(l) => data.0.posts_muted_phrase_filter( + &l, + if let Some(ref ua) = user { + Some(&ua.settings.muted) + } else { + None + }, + ), + Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + + context.insert("list", &list); + context.insert("page", &req.page); + Ok(Html( + data.1 + .render("timelines/swiss_army.html", &context) + .unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 556b468..2bb9ebf 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -29,6 +29,10 @@ pub fn routes() -> Router { .route("/following", get(misc::following_request)) .route("/all", get(misc::all_request)) .route("/search", get(misc::search_request)) + .route( + "/_swiss_army_timeline", + get(misc::swiss_army_timeline_request), + ) // question timelines .route("/questions", get(misc::index_questions_request)) .route("/popular/questions", get(misc::popular_questions_request))