From 06a7463af5482bed065d7e3fcf7b3c4c1ec6ff1b Mon Sep 17 00:00:00 2001 From: alemi Date: Wed, 17 Apr 2024 21:10:20 +0200 Subject: [PATCH] feat(web): more compact, breadcrumbs, tl cleaning --- web/index.html | 26 +++++++-- web/src/context.rs | 130 +++++++++++++++++++++------------------------ web/src/lib.rs | 73 +++++++++++++++++++------ web/src/main.rs | 3 +- 4 files changed, 143 insertions(+), 89 deletions(-) diff --git a/web/index.html b/web/index.html index 89c07cb..0a6dbcc 100644 --- a/web/index.html +++ b/web/index.html @@ -60,11 +60,17 @@ padding-bottom: 0; line-height: 1rem; } + main { + margin: 0em 1em; + } blockquote { margin-left: 1.25em; margin-top: 0; margin-bottom: 0; } + blockquote p { + margin: .5em 1em; + } span.footer { padding: .1em; font-size: .6em; @@ -80,10 +86,15 @@ position: sticky; } div.sticky { - top: 1.75rem; + top: 2rem; position: sticky; background-color: var(--background); - padding-top: .5em; + } + @media screen and (max-width: 786px) { + div.sticky { + top: 1.75rem; + padding-top: .25rem; + } } a.upub-title { color: var(--primary); @@ -98,6 +109,14 @@ a.hover:hover { text-decoration: underline; } + a.breadcrumb { + text-decoration: none; + color: var(--secondary); + } + a.breadcrumb:hover { + font-weight: bold; + color: var(--primary); + } img.avatar-circle { display: inline; max-height: 2em; @@ -127,9 +146,6 @@ color: var(--background); cursor: pointer; } - main { - margin: 1em; - } @media screen and (max-width: 786px) { .hidden-on-mobile { display: none; diff --git a/web/src/context.rs b/web/src/context.rs index 3037ea6..b2b22de 100644 --- a/web/src/context.rs +++ b/web/src/context.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeSet, sync::Arc}; use apb::{Activity, ActivityMut, Base, Collection, CollectionPage}; use dashmap::DashMap; -use leptos::{create_signal, leptos_dom::logging::console_warn, ReadSignal, Signal, SignalGet, SignalSet, WriteSignal}; +use leptos::{create_rw_signal, create_signal, leptos_dom::logging::console_warn, ReadSignal, RwSignal, Signal, SignalGet, SignalSet, WriteSignal}; use crate::URL_BASE; @@ -141,94 +141,88 @@ impl Http { #[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, + pub feed: RwSignal>, + pub next: RwSignal, } 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 } + let feed = create_rw_signal(vec![]); + let next = create_rw_signal(url); + Timeline { feed, 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 fn reset(&self, url: String) { + self.feed.set(vec![]); + self.next.set(url); } pub async fn more(&self, auth: Signal>) -> reqwest::Result<()> { - let feed_url = self.next(); - + let feed_url = self.next.get(); 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(); - let mut gonna_fetch = BTreeSet::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() { - if !gonna_fetch.contains(&object_id) { - gonna_fetch.insert(object_id.clone()); - 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() && !gonna_fetch.contains(&uid) { - gonna_fetch.insert(uid.clone()); - sub_tasks.push(fetch_and_update("users", uid, auth)); - } - } - } - - futures::future::join_all(sub_tasks).await; - - self.set_feed(out); + let mut feed = self.feed.get(); + let mut older = process_activities(activities, auth).await; + feed.append(&mut older); + self.feed.set(feed); if let Some(next) = collection.next().id() { - self.set_next(next); + self.next.set(next); } - + Ok(()) } } +async fn process_activities( + activities: Vec, + auth: Signal>, +) -> Vec { + let mut sub_tasks = Vec::new(); + let mut gonna_fetch = BTreeSet::new(); + let mut out = 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() { + if !gonna_fetch.contains(&object_id) { + gonna_fetch.insert(object_id.clone()); + 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() && !gonna_fetch.contains(&uid) { + gonna_fetch.insert(uid.clone()); + sub_tasks.push(fetch_and_update("users", uid, auth)); + } + } + } + + futures::future::join_all(sub_tasks).await; + + out +} + 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), diff --git a/web/src/lib.rs b/web/src/lib.rs index d331231..c04ed27 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -8,6 +8,7 @@ use crate::context::{Http, Timeline, Uri, CACHE}; pub const URL_BASE: &str = "https://feditest.alemi.dev"; pub const URL_PREFIX: &str = "/web"; +pub const NAME: &str = "μ"; #[derive(Debug, serde::Serialize)] struct LoginForm { @@ -50,15 +51,23 @@ pub fn LoginBox( username: Signal>, username_tx: WriteSignal>, home_tl: Timeline, + server_tl: Timeline, ) -> impl IntoView { let username_ref: NodeRef = create_node_ref(); let password_ref: NodeRef = create_node_ref(); view! {
@@ -79,8 +88,19 @@ pub fn LoginBox( console_log(&format!("logged in until {}", auth.expires)); let username = auth.user.split('/').last().unwrap_or_default().to_string(); // reset home feed and point it to our user's inbox - home_tl.set_feed(vec![]); - home_tl.set_next(format!("{URL_BASE}/users/{}/inbox/page", username)); + home_tl.reset(format!("{URL_BASE}/users/{}/inbox/page", username)); + spawn_local(async move { + if let Err(e) = home_tl.more(token).await { + console_error(&format!("failed refreshing home timeline: {e}")); + } + }); + // reset server feed: there may be more content now that we're authed + server_tl.reset(format!("{URL_BASE}/inbox/page")); + spawn_local(async move { + if let Err(e) = server_tl.more(token).await { + console_error(&format!("failed refreshing server timeline: {e}")); + } + }); // update our username and token cookies username_tx.set(Some(username)); token_tx.set(Some(auth.token)); @@ -139,7 +159,7 @@ pub fn PostBox(username: Signal>) -> impl IntoView { - + @@ -257,7 +277,7 @@ pub fn UserPage() -> impl IntoView { }); view! {
-
view::user
+ users::view
{move || match actor.get() { None => view! {

loading...

}.into_view(), @@ -313,7 +333,7 @@ pub fn ObjectPage() -> impl IntoView { }); view! {
-
view::object
+ objects::view
{move || match object.get() { Some(Some(o)) => view!{ }.into_view(), @@ -426,7 +446,7 @@ pub fn InlineActivity(activity: serde_json::Value) -> impl IntoView { pub fn About() -> impl IntoView { view! {
-
about
+ about

μpub" is a micro social network powered by "ActivityPub

@@ -434,15 +454,40 @@ pub fn About() -> impl IntoView { } } -#[derive(Debug, thiserror::Error)] -#[error("{0}")] -struct OmgReqwestErrorIsNotClonable(String); +#[component] +pub fn Breadcrumb( + #[prop(optional)] + back: bool, + children: Children, +) -> impl IntoView { + view! { +
+ {if back { Some(view! { + "<<" + })} else { None }} + {NAME}" :: "{children()} +
+ } +} #[component] pub fn TimelinePage(name: &'static str, tl: Timeline) -> impl IntoView { + let auth = use_context::>>().expect("missing auth context"); view! {
-
{name}
+ + {name} + + "\u{1f5d8}" + +
@@ -461,9 +506,7 @@ pub fn TimelineFeed(tl: Timeline) -> impl IntoView { match CACHE.get(&id) { Some(object) => { view! { -
- -
+
}.into_view() }, @@ -473,7 +516,7 @@ pub fn TimelineFeed(tl: Timeline) -> impl IntoView { } } / > -
+