diff --git a/web/src/app.rs b/web/src/app.rs new file mode 100644 index 0000000..21bbb78 --- /dev/null +++ b/web/src/app.rs @@ -0,0 +1,109 @@ +use leptos::*; +use leptos_router::*; +use crate::prelude::*; + +use leptos_use::{use_cookie, utils::FromToStringCodec}; + + +#[component] +pub fn App() -> impl IntoView { + let (token, set_token) = use_cookie::("token"); + let (username, set_username) = use_cookie::("username"); + provide_context(token); + + let home_tl = Timeline::new(format!("{URL_BASE}/users/{}/inbox/page", username.get().unwrap_or_default())); + let server_tl = Timeline::new(format!("{URL_BASE}/inbox/page")); + + let screen_width = window().screen().map(|x| x.avail_width().unwrap_or_default()).unwrap_or_default(); + + let (menu, set_menu) = create_signal(screen_width <= 786); + + spawn_local(async move { + if let Err(e) = server_tl.more(token).await { + tracing::error!("error populating timeline: {e}"); + } + }); + + if token.get().is_some() { + spawn_local(async move { + if let Err(e) = home_tl.more(token).await { + tracing::error!("error populating timeline: {e}"); + } + }); + } + + 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! { +
+ + } + } else { + view! { } + } + /> + + } /> + } /> + + + + + + + } /> + +
+ } + }} + +
+
+ +
+
+
+ "\u{26fc} woven under moonlight :: "src" :: wip by alemi " +
+
+ } +} diff --git a/web/src/auth.rs b/web/src/auth.rs new file mode 100644 index 0000000..f4d1dd2 --- /dev/null +++ b/web/src/auth.rs @@ -0,0 +1,106 @@ +use leptos::*; +use crate::prelude::*; + + +#[component] +pub fn LoginBox( + token_tx: WriteSignal>, + token: Signal>, + 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! { +
+ +
+ + + () + .await.unwrap(); + logging::log!("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.reset(format!("{URL_BASE}/users/{}/inbox/page", username)); + spawn_local(async move { + if let Err(e) = home_tl.more(token).await { + tracing::error!("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 { + tracing::error!("failed refreshing server timeline: {e}"); + } + }); + // update our username and token cookies + username_tx.set(Some(username)); + token_tx.set(Some(auth.token)); + }); + } /> +
+
+ } +} + + +#[derive(Debug, serde::Serialize)] +struct LoginForm { + email: String, + password: String, +} + + +#[derive(Debug, Clone, serde::Deserialize)] +struct AuthResponse { + token: String, + user: String, + expires: chrono::DateTime, +} + +pub type Auth = Signal>; +pub trait AuthToken { + fn present(&self) -> bool; + fn token(&self) -> String; +} + +impl AuthToken for Signal> { + fn token(&self) -> String { + match self.get() { + None => String::new(), + Some(x) => x.clone(), + } + } + fn present(&self) -> bool { + match self.get() { + None => false, + Some(x) => !x.is_empty(), + } + } +} diff --git a/web/src/context.rs b/web/src/context.rs deleted file mode 100644 index b2b22de..0000000 --- a/web/src/context.rs +++ /dev/null @@ -1,231 +0,0 @@ -use std::{collections::BTreeSet, sync::Arc}; - -use apb::{Activity, ActivityMut, Base, Collection, CollectionPage}; -use dashmap::DashMap; -use leptos::{create_rw_signal, create_signal, leptos_dom::logging::console_warn, ReadSignal, RwSignal, Signal, SignalGet, SignalSet, WriteSignal}; - -use crate::URL_BASE; - -lazy_static::lazy_static! { - pub static ref CACHE: ObjectCache = ObjectCache::default(); -} - -#[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); - } -} - -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 pretty(url: &str) -> String { - if url.len() < 50 { - url.replace("https://", "") - } else { - format!("{}..", url.replace("https://", "").get(..50).unwrap_or_default()) - }.replace('/', "\u{200B}/\u{200B}") - } - - 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)) - } -} - -// 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<&T>, - 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)); - } - - if let Some(data) = data { - req = req.json(data); - } - - req.send() - .await? - .error_for_status() - } - - pub async fn fetch(url: &str, token: Signal>) -> reqwest::Result { - Self::request::<()>(reqwest::Method::GET, url, None, token) - .await? - .json::() - .await - } - - pub async fn post(url: &str, data: &T, token: Signal>) -> reqwest::Result<()> { - Self::request(reqwest::Method::POST, url, Some(data), token) - .await?; - Ok(()) - } -} - -#[derive(Debug, Clone, Copy)] -pub struct Timeline { - pub feed: RwSignal>, - pub next: RwSignal, -} - -impl Timeline { - pub fn new(url: String) -> Self { - let feed = create_rw_signal(vec![]); - let next = create_rw_signal(url); - Timeline { feed, next } - } - - 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.get(); - let collection : serde_json::Value = Http::fetch(&feed_url, auth).await?; - let activities : Vec = collection - .ordered_items() - .collect(); - - 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.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), - Err(e) => console_warn(&format!("could not fetch '{id}': {e}")), - } -} diff --git a/web/src/control.rs b/web/src/control.rs new file mode 100644 index 0000000..7a8c6e3 --- /dev/null +++ b/web/src/control.rs @@ -0,0 +1,114 @@ +use apb::ObjectMut; + +use leptos::*; +use crate::prelude::*; + +#[component] +pub fn Navigator() -> impl IntoView { + let auth = use_context::().expect("missing auth context"); + view! { + + + + + + + + + + } +} + +#[component] +pub fn PostBox(username: Signal>) -> impl IntoView { + let auth = use_context::().expect("missing auth context"); + let summary_ref: NodeRef = create_node_ref(); + let content_ref: NodeRef = create_node_ref(); + let public_ref: NodeRef = create_node_ref(); + let followers_ref: NodeRef = create_node_ref(); + view! { +
+ + + + + + + + + + + + +
+ +
+ +
+
+ } +} + +#[component] +pub fn Breadcrumb( + #[prop(optional)] + back: bool, + children: Children, +) -> impl IntoView { + view! { +
+ {if back { Some(view! { + "<<" + })} else { None }} + {crate::NAME}" :: "{children()} +
+ } +} diff --git a/web/src/lib.rs b/web/src/lib.rs index c04ed27..ecf1d76 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -1,531 +1,132 @@ -pub mod context; +mod app; +mod auth; +mod timeline; +mod view; +mod page; +mod control; -use apb::{target::Addressed, Activity, Actor, Base, Collection, Object, ObjectMut}; -use leptos::{leptos_dom::logging::{console_error, console_log}, *}; -use leptos_router::*; +pub use app::App; +pub use timeline::Timeline; +pub use auth::{Auth, AuthToken}; -use crate::context::{Http, Timeline, Uri, CACHE}; +pub mod prelude; 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 { - email: String, - password: String, +use std::sync::Arc; + + + +lazy_static::lazy_static! { + pub static ref CACHE: ObjectCache = ObjectCache::default(); +} + +#[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, Clone, serde::Serialize, serde::Deserialize)] -pub struct Auth { - pub token: String, - pub user: String, - pub expires: chrono::DateTime, + +pub struct Http; + +impl Http { + pub async fn request( + method: reqwest::Method, + url: &str, + data: Option<&T>, + token: leptos::Signal> + ) -> reqwest::Result { + use leptos::SignalGet; + + let mut req = reqwest::Client::new() + .request(method, url); + + if let Some(auth) = token.get() { + req = req.header("Authorization", format!("Bearer {}", auth)); + } + + if let Some(data) = data { + req = req.json(data); + } + + req.send() + .await? + .error_for_status() + } + + pub async fn fetch(url: &str, token: leptos::Signal>) -> reqwest::Result { + Self::request::<()>(reqwest::Method::GET, url, None, token) + .await? + .json::() + .await + } + + pub async fn post(url: &str, data: &T, token: leptos::Signal>) -> reqwest::Result<()> { + Self::request(reqwest::Method::POST, url, Some(data), token) + .await?; + Ok(()) + } } -pub trait MaybeToken { - fn present(&self) -> bool; - fn token(&self) -> String; -} +pub struct Uri; -impl MaybeToken for Option { - fn token(&self) -> String { - match self { - None => String::new(), - Some(x) => x.clone(), +impl Uri { + pub fn full(kind: &str, id: &str) -> String { + if id.starts_with('+') { + id.replace('+', "https://").replace('@', "/") + } else { + format!("{URL_BASE}/{kind}/{id}") } } - fn present(&self) -> bool { - match self { - None => false, - Some(x) => !x.is_empty(), + + pub fn pretty(url: &str) -> String { + if url.len() < 50 { + url.replace("https://", "") + } else { + format!("{}..", url.replace("https://", "").get(..50).unwrap_or_default()) + }.replace('/', "\u{200B}/\u{200B}") + } + + 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('/', "@") } } -} -#[component] -pub fn LoginBox( - token_tx: WriteSignal>, - token: Signal>, - 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! { -
- -
- - - () - .await.unwrap(); - 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.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)); - }); - } /> -
-
- } -} - -#[component] -pub fn Navigator() -> impl IntoView { - let auth = use_context::>>().expect("missing auth context"); - view! { - - - - - - - - - - } -} - -#[component] -pub fn PostBox(username: Signal>) -> impl IntoView { - let auth = use_context::>>().expect("missing auth context"); - let summary_ref: NodeRef = create_node_ref(); - let content_ref: NodeRef = create_node_ref(); - let public_ref: NodeRef = create_node_ref(); - let followers_ref: NodeRef = create_node_ref(); - view! { -
- - - - - - - - - - - - -
- -
- -
-
- } -} - -#[component] -pub fn TimelinePicker( - tx: WriteSignal, - rx: ReadSignal, -) -> impl IntoView { - let targets = ( - "https://feditest.alemi.dev/users/test/inbox/page".to_string(), - "https://feditest.alemi.dev/users/test/outbox/page".to_string(), - "https://feditest.alemi.dev/inbox/page".to_string(), - "https://feditest.alemi.dev/outbox/page".to_string(), - ); - let (my_in, my_out, our_in, our_out) = targets.clone(); - let (my_in_, my_out_, our_in_, our_out_) = targets; - view! { - - - - - } -} - -#[component] -pub fn ActorBanner(object: serde_json::Value) -> impl IntoView { - match object { - serde_json::Value::String(id) => view! { -
{id}
- }, - serde_json::Value::Object(_) => { - let uid = object.id().unwrap_or_default().to_string(); - 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(); - let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string(); - view! { -
- - - - - - - - -
{display_name}
{username}@{domain}
-
- } - }, - _ => view! { -
invalid actor
- } - } -} - -#[component] -pub fn UserPage() -> impl IntoView { - let params = use_params_map(); - 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 { - match CACHE.get(&Uri::full("users", &id)) { - Some(x) => Some(x.clone()), - None => { - let user : serde_json::Value = Http::fetch(&Uri::api("users", &id), auth).await.ok()?; - CACHE.put(Uri::full("users", &id), user.clone()); - Some(user) - }, - } - } - }); - view! { -
- users::view -
- {move || match actor.get() { - None => view! {

loading...

}.into_view(), - Some(None) => view! {

error loading

}.into_view(), - Some(Some(x)) => view! { -
- -

- { - dissolve::strip_html_tags(x.summary().unwrap_or("")) - .into_iter() - .map(|x| view! {

{x}

}) - .collect_view() - } -

-
    -
  • type" "{x.actor_type().unwrap_or(apb::ActorType::Person).as_ref().to_string()}
  • -
  • following" "{x.following().get().map(|x| x.total_items().unwrap_or(0))}
  • -
  • followers" "{x.followers().get().map(|x| x.total_items().unwrap_or(0))}
  • -
  • created" "{x.published().map(|x| x.to_rfc3339())}
  • -
-
-
- - }.into_view(), - }} -
-
- } -} - -#[component] -pub fn ObjectPage() -> impl IntoView { - let params = use_params_map(); - 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 { - match CACHE.get(&Uri::full("objects", &oid)) { - Some(x) => Some(x.clone()), - None => { - let obj = Http::fetch::(&Uri::api("objects", &oid), auth).await.ok()?; - CACHE.put(Uri::full("objects", &oid), obj.clone()); - Some(obj) - } - } - } - }); - view! { -
- objects::view -
- {move || match object.get() { - Some(Some(o)) => view!{ }.into_view(), - Some(None) => view! {

loading failed

}.into_view(), - None => view! {

loading ...

}.into_view(), - }} - - - } -} - -#[component] -pub fn Object(object: serde_json::Value) -> impl IntoView { - let summary = object.summary().unwrap_or_default().to_string(); - let content = dissolve::strip_html_tags(object.content().unwrap_or_default()); - let date = object.published().map(|x| x.format("%Y/%m/%d %H:%M:%S").to_string()).unwrap_or_default(); - let date_rfc = object.published().map(|x| x.to_rfc3339()).unwrap_or_default(); - let author_id = object.attributed_to().id().unwrap_or_default(); - let author = CACHE.get(&author_id).unwrap_or(serde_json::Value::String(author_id.clone())); - view! { -
- - {move || if !summary.is_empty() { - view! { - - - - }.into_view() - } else { - view! { }.into_view() - }} - - - - - - - -
{summary.clone()}
{ - content.into_iter().map(|x| view! {

{x}

}).collect_view() - }
{date}
-
- } -} - -#[component] -pub fn ObjectInline(object: serde_json::Value) -> impl IntoView { - let summary = object.summary().unwrap_or_default().to_string(); - let content = dissolve::strip_html_tags(object.content().unwrap_or_default()); - view! { - {if summary.is_empty() { None } else { Some(view! { {summary} })}} -
- {content.into_iter().map(|x| view! {

{x}

}).collect_view()} -
- } -} - -#[component] -pub fn InlineActivity(activity: serde_json::Value) -> impl IntoView { - let object_id = activity.object().id().unwrap_or_default(); - let object = CACHE.get(&object_id).unwrap_or(serde_json::Value::String(object_id.clone())); - let addressed = activity.addressed(); - let audience = format!("[ {} ]", addressed.join(", ")); - let actor_id = activity.actor().id().unwrap_or_default(); - let actor = match CACHE.get(&actor_id) { - Some(a) => a, - None => serde_json::Value::String(actor_id.clone()), - }; - let privacy = if addressed.iter().any(|x| x == apb::target::PUBLIC) { - "🌐" - } else if addressed.iter().any(|x| x.ends_with("/followers")) { - "🔒" - } else { - "🔗" - }; - let date = object.published().map(|x| x.format("%Y/%m/%d %H:%M:%S").to_string()).unwrap_or_else(|| - activity.published().map(|x| x.format("%Y/%m/%d %H:%M:%S").to_string()).unwrap_or_default() - ); - let kind = activity.activity_type().unwrap_or(apb::ActivityType::Activity); - view! { -
- - - - - - - - -
- - - {kind.as_ref().to_string()} - {privacy} -
- - {date} - -
-
- {match kind { - // post - apb::ActivityType::Create => view! { }.into_view(), - _ => view! {}.into_view(), - }} - } -} - -#[component] -pub fn About() -> impl IntoView { - view! { -
- about -
-

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

-
-
- } -} - -#[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} - - "\u{1f5d8}" - - -
- -
-
- } -} - -#[component] -pub fn TimelineFeed(tl: Timeline) -> impl IntoView { - let auth = use_context::>>().expect("missing auth context"); - view! { - { - view! { - -
- }.into_view() - }, - None => view! { -

{id}" "[go]

- }.into_view(), - } - } - / > -
- -
+ /// 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)) } } diff --git a/web/src/main.rs b/web/src/main.rs index ffe1f22..48291ec 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -1,114 +1,6 @@ -use leptos::{leptos_dom::logging::console_error, *}; -use leptos_router::*; - -use leptos_use::{use_cookie, utils::FromToStringCodec}; -use upub_web::{ - URL_BASE, context::Timeline, About, LoginBox, MaybeToken, ObjectPage, PostBox, - TimelinePage, Navigator, UserPage, Breadcrumb -}; - fn main() { _ = console_log::init_with_level(log::Level::Info); console_error_panic_hook::set_once(); - let (token, set_token) = use_cookie::("token"); - let (username, set_username) = use_cookie::("username"); - provide_context(token); - let home_tl = Timeline::new(format!("{URL_BASE}/users/{}/inbox/page", username.get().unwrap_or_default())); - let server_tl = Timeline::new(format!("{URL_BASE}/inbox/page")); - - let screen_width = window().screen().map(|x| x.avail_width().unwrap_or_default()).unwrap_or_default(); - - let (menu, set_menu) = create_signal(screen_width <= 786); - - spawn_local(async move { - if let Err(e) = server_tl.more(token).await { - console_error(&format!("error populating timeline: {e}")); - } - }); - - if token.get().is_some() { - spawn_local(async move { - if let Err(e) = home_tl.more(token).await { - console_error(&format!("error populating timeline: {e}")); - } - }); - } - - mount_to_body( - move || 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! { -
- - } - } else { - view! { } - } - /> - - } /> - } /> - - - - - - - } /> - -
- } - }} - -
-
- -
-
-
- "\u{26fc} woven under moonlight :: "src" :: wip by alemi " -
-
- } - ); + leptos::mount_to_body(upub_web::App); } diff --git a/web/src/page.rs b/web/src/page.rs new file mode 100644 index 0000000..76d0a9e --- /dev/null +++ b/web/src/page.rs @@ -0,0 +1,128 @@ +use apb::{Actor, Base, Collection, Object}; + +use leptos::*; +use leptos_router::*; +use crate::prelude::*; + +#[component] +pub fn AboutPage() -> impl IntoView { + view! { +
+ about +
+

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

+
+
+ } +} + +#[component] +pub fn UserPage() -> impl IntoView { + let params = use_params_map(); + 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 { + match CACHE.get(&Uri::full("users", &id)) { + Some(x) => Some(x.clone()), + None => { + let user : serde_json::Value = Http::fetch(&Uri::api("users", &id), auth).await.ok()?; + CACHE.put(Uri::full("users", &id), user.clone()); + Some(user) + }, + } + } + }); + view! { +
+ users::view +
+ {move || match actor.get() { + None => view! {

loading...

}.into_view(), + Some(None) => view! {

error loading

}.into_view(), + Some(Some(x)) => view! { +
+ +

+ { + dissolve::strip_html_tags(x.summary().unwrap_or("")) + .into_iter() + .map(|x| view! {

{x}

}) + .collect_view() + } +

+
    +
  • type" "{x.actor_type().unwrap_or(apb::ActorType::Person).as_ref().to_string()}
  • +
  • following" "{x.following().get().map(|x| x.total_items().unwrap_or(0))}
  • +
  • followers" "{x.followers().get().map(|x| x.total_items().unwrap_or(0))}
  • +
  • created" "{x.published().map(|x| x.to_rfc3339())}
  • +
+
+
+ + }.into_view(), + }} +
+
+ } +} + +#[component] +pub fn ObjectPage() -> impl IntoView { + let params = use_params_map(); + 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 { + match CACHE.get(&Uri::full("objects", &oid)) { + Some(x) => Some(x.clone()), + None => { + let obj = Http::fetch::(&Uri::api("objects", &oid), auth).await.ok()?; + CACHE.put(Uri::full("objects", &oid), obj.clone()); + Some(obj) + } + } + } + }); + view! { +
+ objects::view +
+ {move || match object.get() { + Some(Some(o)) => view!{ }.into_view(), + Some(None) => view! {

loading failed

}.into_view(), + None => view! {

loading ...

}.into_view(), + }} + + + } +} + +#[component] +pub fn TimelinePage(name: &'static str, tl: Timeline) -> impl IntoView { + let auth = use_context::().expect("missing auth context"); + view! { +
+ + {name} + + "\u{1f5d8}" + + +
+ +
+
+ } +} diff --git a/web/src/prelude.rs b/web/src/prelude.rs new file mode 100644 index 0000000..0022e1f --- /dev/null +++ b/web/src/prelude.rs @@ -0,0 +1,11 @@ +pub use crate::{ + AuthToken, + Auth, Timeline, Http, Uri, + CACHE, URL_BASE, + page::*, + control::*, + view::*, + app::App, + auth::LoginBox, + timeline::TimelineFeed, +}; diff --git a/web/src/timeline.rs b/web/src/timeline.rs new file mode 100644 index 0000000..2f72703 --- /dev/null +++ b/web/src/timeline.rs @@ -0,0 +1,134 @@ +use std::collections::BTreeSet; + +use leptos::*; +use crate::prelude::*; + +#[derive(Debug, Clone, Copy)] +pub struct Timeline { + pub feed: RwSignal>, + pub next: RwSignal, +} + +impl Timeline { + pub fn new(url: String) -> Self { + let feed = create_rw_signal(vec![]); + let next = create_rw_signal(url); + Timeline { feed, next } + } + + pub fn reset(&self, url: String) { + self.feed.set(vec![]); + self.next.set(url); + } + + pub async fn more(&self, auth: Signal>) -> reqwest::Result<()> { + use apb::{Collection, CollectionPage}; + + 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 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.next.set(next); + } + + Ok(()) + } +} + +#[component] +pub fn TimelineFeed(tl: Timeline) -> impl IntoView { + let auth = use_context::().expect("missing auth context"); + view! { + { + view! { + +
+ }.into_view() + }, + None => view! { +

{id}" "[go]

+ }.into_view(), + } + } + / > +
+ +
+ } +} + +async fn process_activities( + activities: Vec, + auth: Signal>, +) -> Vec { + use apb::{Base, Activity, ActivityMut}; + 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), + Err(e) => console_warn(&format!("could not fetch '{id}': {e}")), + } +} + diff --git a/web/src/view.rs b/web/src/view.rs new file mode 100644 index 0000000..f51793a --- /dev/null +++ b/web/src/view.rs @@ -0,0 +1,135 @@ +use leptos::*; +use crate::prelude::*; + +use apb::{target::Addressed, Activity, Actor, Base, Object}; + + +#[component] +pub fn InlineActivity(activity: serde_json::Value) -> impl IntoView { + let object_id = activity.object().id().unwrap_or_default(); + let object = CACHE.get(&object_id).unwrap_or(serde_json::Value::String(object_id.clone())); + let addressed = activity.addressed(); + let audience = format!("[ {} ]", addressed.join(", ")); + let actor_id = activity.actor().id().unwrap_or_default(); + let actor = match CACHE.get(&actor_id) { + Some(a) => a, + None => serde_json::Value::String(actor_id.clone()), + }; + let privacy = if addressed.iter().any(|x| x == apb::target::PUBLIC) { + "🌐" + } else if addressed.iter().any(|x| x.ends_with("/followers")) { + "🔒" + } else { + "🔗" + }; + let date = object.published().map(|x| x.format("%Y/%m/%d %H:%M:%S").to_string()).unwrap_or_else(|| + activity.published().map(|x| x.format("%Y/%m/%d %H:%M:%S").to_string()).unwrap_or_default() + ); + let kind = activity.activity_type().unwrap_or(apb::ActivityType::Activity); + view! { +
+ + + + + + + + +
+ + + {kind.as_ref().to_string()} + {privacy} +
+ + {date} + +
+
+ {match kind { + // post + apb::ActivityType::Create => view! { }.into_view(), + _ => view! {}.into_view(), + }} + } +} + +#[component] +pub fn ActorBanner(object: serde_json::Value) -> impl IntoView { + match object { + serde_json::Value::String(id) => view! { +
{id}
+ }, + serde_json::Value::Object(_) => { + let uid = object.id().unwrap_or_default().to_string(); + 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(); + let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string(); + view! { +
+ + + + + + + + +
{display_name}
{username}@{domain}
+
+ } + }, + _ => view! { +
invalid actor
+ } + } +} + +#[component] +pub fn Object(object: serde_json::Value) -> impl IntoView { + let summary = object.summary().unwrap_or_default().to_string(); + let content = dissolve::strip_html_tags(object.content().unwrap_or_default()); + let date = object.published().map(|x| x.format("%Y/%m/%d %H:%M:%S").to_string()).unwrap_or_default(); + let date_rfc = object.published().map(|x| x.to_rfc3339()).unwrap_or_default(); + let author_id = object.attributed_to().id().unwrap_or_default(); + let author = CACHE.get(&author_id).unwrap_or(serde_json::Value::String(author_id.clone())); + view! { +
+ + {move || if !summary.is_empty() { + view! { + + + + }.into_view() + } else { + view! { }.into_view() + }} + + + + + + + +
{summary.clone()}
{ + content.into_iter().map(|x| view! {

{x}

}).collect_view() + }
{date}
+
+ } +} + +#[component] +pub fn ObjectInline(object: serde_json::Value) -> impl IntoView { + let summary = object.summary().unwrap_or_default().to_string(); + let content = dissolve::strip_html_tags(object.content().unwrap_or_default()); + view! { + {if summary.is_empty() { None } else { Some(view! { {summary} })}} +
+ {content.into_iter().map(|x| view! {

{x}

}).collect_view()} +
+ } +}