From f8b135d2423d0aa4f07f6a57ab9b92bfdd19f1d4 Mon Sep 17 00:00:00 2001 From: alemi Date: Tue, 16 Apr 2024 06:34:50 +0200 Subject: [PATCH] feat: reworked timelines to be persistent a lot of work on uris, fetching, concurrency and caching. now timelines persist and should behave appropriately with your logins! there are still some weird edge cases but all around i think it's pretty solid and also quite nice!! --- web/Cargo.toml | 2 +- web/index.html | 6 +- web/src/context.rs | 217 +++++++++++++++++++++++++++++++++-- web/src/lib.rs | 280 +++++++++++++++++++-------------------------- web/src/main.rs | 117 +++++++++++++------ 5 files changed, 410 insertions(+), 212 deletions(-) diff --git a/web/Cargo.toml b/web/Cargo.toml index 5f09708..824e2b6 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -21,7 +21,7 @@ serde_json = "1" dashmap = "5.5" leptos = { version = "0.6", features = ["csr"] } leptos_router = { version = "0.6", features = ["csr"] } -leptos-use = "0.10" +leptos-use = { version = "0.10", features = ["serde"] } reqwest = { version = "0.12", features = ["json"] } apb = { path = "../apb", features = ["unstructured"] } futures = "0.3.30" diff --git a/web/index.html b/web/index.html index 485f6cd..4489343 100644 --- a/web/index.html +++ b/web/index.html @@ -31,9 +31,13 @@ border-radius: 50%; } div.boxscroll { - max-height: calc(100vh - 8rem); + max-height: calc(100vh - 7rem); overflow-y: scroll; } + div.tl-header { + background-color: #bf616a55; + color: #bf616a; + } @media screen and (max-width: 786px) { div.boxscroll { max-height: 100%; diff --git a/web/src/context.rs b/web/src/context.rs index 69ee496..d4d7c3b 100644 --- a/web/src/context.rs +++ b/web/src/context.rs @@ -1,23 +1,216 @@ use std::sync::Arc; +use apb::{Activity, ActivityMut, Base, Collection, CollectionPage}; use dashmap::DashMap; -use lazy_static::lazy_static; +use leptos::{create_signal, leptos_dom::logging::console_warn, ReadSignal, Signal, SignalGet, SignalSet, WriteSignal}; -lazy_static! { - pub static ref CTX: Context = Context::default(); +use crate::{Auth, URL_BASE}; + +lazy_static::lazy_static! { + pub static ref CACHE: ObjectCache = ObjectCache::default(); } -#[derive(Debug, Default, Clone)] -pub struct Context { - pub cache: Arc, - pub timelines: Arc, +#[derive(Debug, Clone, Default)] +pub struct ObjectCache(pub Arc>); + +impl ObjectCache { + pub fn get(&self, k: &str) -> Option { + self.0.get(k).map(|x| x.clone()) + } + + pub fn put(&self, k: String, v: serde_json::Value) { + self.0.insert(k, v); + } } -#[derive(Debug, Default)] -pub struct Cache { - pub actors: DashMap, +pub struct Uri; + +impl Uri { + + pub fn full(kind: &str, id: &str) -> String { + if id.starts_with('+') { + id.replace('+', "https://").replace('@', "/") + } else { + format!("{URL_BASE}/{kind}/{id}") + } + } + + pub fn short(url: &str) -> String { + if url.starts_with(URL_BASE) { + url.split('/').last().unwrap_or_default().to_string() + } else { + url.replace("https://", "+").replace('/', "@") + } + } + + /// convert url id to valid frontend view id: + /// /web/users/test + /// /web/objects/+social.alemi.dev@objects@1204kasfkl + /// accepts: + /// - https://my.domain.net/users/root + /// - https://other.domain.net/unexpected/path/root + /// - +other.domain.net@users@root + /// - root + pub fn web(kind: &str, url: &str) -> String { + format!("/web/{kind}/{}", Self::short(url)) + } + + /// convert url id to valid backend api id + /// https://feditest.alemi.dev/users/test + /// https://feditest.alemi.dev/users/+social.alemi.dev@users@alemi + /// accepts: + /// - https://my.domain.net/users/root + /// - https://other.domain.net/unexpected/path/root + /// - +other.domain.net@users@root + /// - root + pub fn api(kind: &str, url: &str) -> String { + format!("{URL_BASE}/{kind}/{}", Self::short(url)) + } } -#[derive(Debug, Default)] -pub struct Timelines { +// impl ObjectCache { +// pub async fn user(&self, id: &str, token: Option<&str>) -> Option { +// match self.actors.get(id) { +// Some(x) => Some(x.clone()), +// None => { +// let mut req = reqwest::Client::new() +// .get(format!("{URL_BASE}/users/+?id={id}")); +// if let Some(token) = token { +// req = req.header("Authorization", format!("Bearer {token}")); +// } +// let user = req +// .send() +// .await.ok()? +// .json::() +// .await.ok()?; +// +// self.actors.insert(id.to_string(), user.clone()); +// +// Some(user) +// }, +// } +// } +// } + +pub struct Http; + +impl Http { + pub async fn request(method: reqwest::Method, url: &str, data: Option<&serde_json::Value>, token: &Signal>) -> reqwest::Result { + let mut req = reqwest::Client::new() + .request(method, url); + + if let Some(auth) = token.get() { + req = req.header("Authorization", format!("Bearer {}", auth.token)); + } + + if let Some(data) = data { + req = req.json(data); + } + + req.send() + .await? + .error_for_status()? + .json::() + .await + } + + pub async fn fetch(url: &str, token: &Signal>) -> reqwest::Result { + Self::request(reqwest::Method::GET, url, None, token).await + } + + pub async fn post(url: &str, data: &serde_json::Value, token: &Signal>) -> reqwest::Result { + Self::request(reqwest::Method::POST, url, Some(data), token).await + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Timeline { + pub(crate) feed: ReadSignal>, + pub(crate) set_feed: WriteSignal>, + pub(crate) next: ReadSignal, + pub(crate) set_next: WriteSignal, +} + +impl Timeline { + pub fn new(url: String) -> Self { + let (feed, set_feed) = create_signal(vec![]); + let (next, set_next) = create_signal(url); + Timeline { feed, set_feed, next, set_next } + } + + pub fn feed(&self) -> Vec { + self.feed.get() + } + + pub fn set_feed(&self, feed: Vec) { + self.set_feed.set(feed); + } + + pub fn next(&self) -> String { + self.next.get() + } + + pub fn set_next(&self, feed: String) { + self.set_next.set(feed); + } + + pub async fn more(&self, auth: Signal>) -> reqwest::Result<()> { + let feed_url = self.next(); + + let collection : serde_json::Value = Http::fetch(&feed_url, &auth).await?; + + + let activities : Vec = collection + .ordered_items() + .collect(); + + let mut out = self.feed(); + let mut sub_tasks = Vec::new(); + + for activity in activities { + // save embedded object if present + if let Some(object) = activity.object().get() { + if let Some(object_uri) = object.id() { + CACHE.put(object_uri.to_string(), object.clone()); + } + } else { // try fetching it + if let Some(object_id) = activity.object().id() { + sub_tasks.push(fetch_and_update("objects", object_id, auth)); + } + } + + // save activity, removing embedded object + let object_id = activity.object().id(); + if let Some(activity_id) = activity.id() { + out.push(activity_id.to_string()); + CACHE.put( + activity_id.to_string(), + activity.clone().set_object(apb::Node::maybe_link(object_id)) + ); + } + + if let Some(uid) = activity.actor().id() { + if CACHE.get(&uid).is_none() { + sub_tasks.push(fetch_and_update("users", uid, auth)); + } + } + } + + futures::future::join_all(sub_tasks).await; + + self.set_feed(out); + + if let Some(next) = collection.next().id() { + self.set_next(next); + } + + Ok(()) + } +} + +async fn fetch_and_update(kind: &'static str, id: String, auth: Signal>) { + match Http::fetch(&Uri::api(kind, &id), &auth).await { + Ok(data) => CACHE.put(id, data), + Err(e) => console_warn(&format!("could not fetch '{id}': {e}")), + } } diff --git a/web/src/lib.rs b/web/src/lib.rs index 74d936e..14e3659 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -1,10 +1,10 @@ pub mod context; -use apb::{target::Addressed, Activity, ActivityMut, Actor, Base, Collection, Object, ObjectMut}; -use context::CTX; -use leptos::{leptos_dom::logging::console_log, *}; +use apb::{target::Addressed, Activity, Actor, Base, Collection, Object, ObjectMut}; +use leptos::{leptos_dom::logging::{console_error, console_log}, *}; use leptos_router::*; +use crate::context::{Http, Timeline, Uri, CACHE}; pub const URL_BASE: &str = "https://feditest.alemi.dev"; pub const URL_PREFIX: &str = "/web"; @@ -15,58 +15,57 @@ struct LoginForm { password: String, } -/// convert url id to valid frontend view id -/// accepts: -/// - https://my.domain.net/users/root -/// - https://other.domain.net/unexpected/path/root -/// - +other.domain.net@users@root -/// - root -fn web_uri(kind: &str, url: &str) -> String { - if url.starts_with(URL_BASE) { - format!("/web/{kind}/{}", url.split('/').last().unwrap_or_default()) - } else { - format!("/web/{kind}/{}", url.replace("https://", "+").replace('/', "@")) - } + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Auth { + pub token: String, + pub user: String, + pub expires: chrono::DateTime, } -/// convert url id to valid backend api id -/// accepts: -/// - https://my.domain.net/users/root -/// - https://other.domain.net/unexpected/path/root -/// - +other.domain.net@users@root -/// - root -fn api_uri(kind: &str, url: &str) -> String { - if url.starts_with(URL_BASE) { - url.to_string() - } else { - format!("{URL_BASE}/{kind}/{}", url.replace("https://", "+").replace('/', "@")) - } +pub trait MaybeToken { + fn present(&self) -> bool; + fn token(&self) -> String; + fn username(&self) -> String; } -#[derive(Debug, serde::Deserialize)] -struct AuthSuccess { - token: String, - user: String, - expires: chrono::DateTime, +impl MaybeToken for Option { + fn token(&self) -> String { + match self { + None => String::new(), + Some(x) => x.token.clone(), + } + } + fn present(&self) -> bool { + match self { + None => false, + Some(x) => !x.token.is_empty(), + } + } + fn username(&self) -> String { + match self { + None => "anon".to_string(), + Some(x) => x.user.split('/').last().unwrap_or_default().to_string() + } + } } #[component] pub fn LoginBox( - rx: Signal>, - tx: WriteSignal>, + rx: Signal>, + tx: WriteSignal>, ) -> impl IntoView { - let (username, username_set) = create_signal("".to_string()); let username_ref: NodeRef = create_node_ref(); let password_ref: NodeRef = create_node_ref(); view! {
-
- "Hello "{move || username.get()} + -
+
() + .json::() .await.unwrap(); - tx.set(Some(auth.token)); - username_set.set(auth.user); console_log(&format!("logged in until {}", auth.expires)); + tx.set(Some(auth)); }); } />
@@ -92,32 +90,29 @@ pub fn LoginBox( } #[component] -pub fn PostBox(token: Signal>) -> impl IntoView { +pub fn PostBox() -> impl IntoView { + let auth = use_context::>>().expect("missing auth context"); let summary_ref: NodeRef = create_node_ref(); let content_ref: NodeRef = create_node_ref(); view! { -
- - +
+ +
@@ -153,7 +148,7 @@ pub fn ActorBanner(object: serde_json::Value) -> impl IntoView { }, serde_json::Value::Object(_) => { let uid = object.id().unwrap_or_default().to_string(); - let uri = web_uri("users", &uid); + let uri = Uri::web("users", &uid); let avatar_url = object.icon().get().map(|x| x.url().id().unwrap_or_default()).unwrap_or_default(); let display_name = object.name().unwrap_or_default().to_string(); let username = object.preferred_username().unwrap_or_default().to_string(); @@ -181,19 +176,14 @@ pub fn ActorBanner(object: serde_json::Value) -> impl IntoView { #[component] pub fn UserPage() -> impl IntoView { let params = use_params_map(); - let actor = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), |id| { + let auth = use_context::>>().expect("missing auth context"); + let actor = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), move |id| { async move { - let uri = web_uri("users", &id); - match CTX.cache.actors.get(&uri) { + match CACHE.get(&Uri::full("users", &id)) { Some(x) => Some(x.clone()), None => { - let user = reqwest::get(&uri) - .await - .ok()? - .json::() - .await - .ok()?; - CTX.cache.actors.insert(uri, user.clone()); + let user : serde_json::Value = Http::fetch(&Uri::api("users", &id), &auth).await.ok()?; + CACHE.put(Uri::full("users", &id), user.clone()); Some(user) }, } @@ -230,17 +220,16 @@ pub fn UserPage() -> impl IntoView { #[component] pub fn ObjectPage() -> impl IntoView { let params = use_params_map(); - let object = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), |oid| { + let auth = use_context::>>().expect("missing auth context"); + let object = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), move |oid| { async move { - let uid = format!("{URL_BASE}/objects/{oid}"); - match CTX.cache.actors.get(&uid) { + match CACHE.get(&Uri::full("objects", &oid)) { Some(x) => Some(x.clone()), - None => reqwest::get(uid) - .await - .ok()? - .json::() - .await - .ok() + None => { + let obj = Http::fetch::(&Uri::api("objects", &oid), &auth).await.ok()?; + CACHE.put(Uri::full("objects", &oid), obj.clone()); + Some(obj) + } } } }); @@ -259,7 +248,7 @@ pub fn Object(object: serde_json::Value) -> impl IntoView { let content = object.content().unwrap_or_default().to_string(); let date = object.published().map(|x| x.to_rfc3339()).unwrap_or_default(); let author_id = object.attributed_to().id().unwrap_or_default(); - let author = CTX.cache.actors.get(&author_id).map(|x| view! { }); + let author = CACHE.get(&author_id).map(|x| view! { }); view! { {author} @@ -282,7 +271,7 @@ pub fn InlineActivity(activity: serde_json::Value) -> impl IntoView { serde_json::Value::String(activity.object().id().unwrap_or_default()) ); let object_id = object.id().unwrap_or_default().to_string(); - let object_uri = web_uri("objects", &object_id); + let object_uri = Uri::web("objects", &object_id); let content = dissolve::strip_html_tags(object.content().unwrap_or_default()); let addressed = activity.addressed(); let audience = format!("[ {} ]", addressed.join(", ")); @@ -322,98 +311,61 @@ pub fn InlineActivity(activity: serde_json::Value) -> impl IntoView { } } +#[component] +pub fn About() -> impl IntoView { + view! { +

pick a timeline to start browsing

+ } +} + #[derive(Debug, thiserror::Error)] #[error("{0}")] struct OmgReqwestErrorIsNotClonable(String); #[component] -pub fn Timeline( - token: Signal>, -) -> impl IntoView { - let (timeline, set_timeline) = create_signal(format!("{URL_BASE}/inbox/page")); - let items = create_local_resource(move || timeline.get(), move |feed_url| async move { - fetch_activities_with_users(&feed_url, token).await - }); +pub fn TimelineFeed(name: &'static str, tl: Timeline) -> impl IntoView { + let auth = use_context::>>().expect("missing auth context"); view! {
- -
- {format!("{:?}", err.get())}

} > - {move || items.with(|x| match x { - None => Ok(view! {

loading...

}.into_view()), - Some(data) => match data { - Err(e) => Err(OmgReqwestErrorIsNotClonable(e.to_string())), - Ok(values) => Ok( - values - .iter() - .map(|object| { - let actor = object.actor().extract().unwrap_or_else(|| - serde_json::Value::String(object.actor().id().unwrap_or_default()) - ); - view! { -
- - -
-
- } - }) - .collect::>() - .into_view() - ), +
{name}
+
+ { + let actor_id = object.actor().id().unwrap_or_default(); + let actor = match CACHE.get(&actor_id) { + Some(a) => a, + None => serde_json::Value::String(id), + }; + view! { +
+ + +
+
+ }.into_view() + }, + None => view! { +

{id}" "[go]

+ }.into_view(), } - })} - + } + / > +
+ +
} } - -async fn fetch_activities_with_users( - feed_url: &str, - token: Signal>, -) -> reqwest::Result> { - let mut req = reqwest::Client::new().get(feed_url); - - if let Some(token) = token.get() { - req = req.header("Authorization", format!("Bearer {token}")); - } - - let activities : Vec = req - .send() - .await? - .json::() - .await? - .ordered_items() - .collect(); - - // i could make this fancier with iterators and futures::join_all but they would run - // concurrently and make a ton of parallel request, we actually want these sequential because - // first one may fetch same user as second one - // some fancier logic may make a set of all actors and fetch uniques concurrently... - let mut out = Vec::new(); - for x in activities { - if let Some(uid) = x.actor().id() { - if let Some(actor) = CTX.cache.actors.get(&uid) { - out.push(x.set_actor(apb::Node::object(actor.clone()))) - } else { - let mut req = reqwest::Client::new() - .get(api_uri("users", &uid)); - - if let Some(token) = token.get() { - req = req.header("Authorization", format!("Bearer {token}")); - } - - // TODO don't fail whole timeline fetch when one user fails fetching... - let actor = req.send().await?.json::().await?; - CTX.cache.actors.insert(web_uri("users", &uid), actor.clone()); - - out.push(x.set_actor(apb::Node::object(actor))) - } - } else { - out.push(x) - } - } - - Ok(out) -} diff --git a/web/src/main.rs b/web/src/main.rs index 2ffd247..11d4da6 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -1,56 +1,105 @@ -use leptos::*; +use leptos::{leptos_dom::logging::console_error, *}; use leptos_router::*; -use leptos_use::{use_cookie, utils::FromToStringCodec}; +use leptos_use::{use_cookie, utils::JsonCodec}; use upub_web::{ - LoginBox, ObjectPage, UserPage, PostBox, Timeline + URL_BASE, context::Timeline, About, Auth, LoginBox, MaybeToken, ObjectPage, PostBox, TimelineFeed, UserPage }; - fn main() { _ = console_log::init_with_level(log::Level::Debug); console_error_panic_hook::set_once(); - let (cookie, set_cookie) = use_cookie::("token"); + let (cookie, set_cookie) = use_cookie::("token"); + provide_context(cookie); + + let home_tl = Timeline::new(format!("{URL_BASE}/users/{}/inbox/page", cookie.get().username())); + let server_tl = Timeline::new(format!("{URL_BASE}/inbox/page")); + + spawn_local(async move { + if let Err(e) = server_tl.more(cookie).await { + console_error(&format!("error populating timeline: {e}")); + } + }); + + if cookie.get().is_some() { + spawn_local(async move { + if let Err(e) = home_tl.more(cookie).await { + console_error(&format!("error populating timeline: {e}")); + } + }); + } + mount_to_body( move || view! {
-
-
-
- - -
-
- -

nothing to see here!

-

-
- }.into_view() - > +
+ +

nothing to see here!

+

+
+ }.into_view() + > + // TODO this is kind of ugly: the whole router gets rebuilt every time we log in/out + // in a sense it's what we want: refreshing the home tl is main purpose, but also + // server tl may contain stuff we can no longer see, or otherwise we may now be + // entitled to see new posts. so while being ugly it's techically correct ig? + {move || { + view! {
- - } /> - } /> - - - +
+
+ +
+ + + + + + +
+ +
+
+ + + + } /> + } /> + + + + + } /> + +
+
- -
-
+ } + }} +
} );