From 4d7f99dfaafa19a0a909df94a7afb2525ebffeaa Mon Sep 17 00:00:00 2001 From: alemi Date: Mon, 13 May 2024 19:13:40 +0200 Subject: [PATCH] chore(web): restructured project a little less messy i think --- web/src/auth.rs | 100 +--- web/src/components/login.rs | 98 ++++ web/src/components/mod.rs | 9 + web/src/components/navigation.rs | 48 ++ web/src/{control.rs => components/post.rs} | 46 -- web/src/lib.rs | 5 +- web/src/page.rs | 506 --------------------- web/src/page/about.rs | 19 + web/src/page/config.rs | 67 +++ web/src/page/debug.rs | 69 +++ web/src/page/mod.rs | 23 + web/src/page/object.rs | 84 ++++ web/src/page/register.rs | 48 ++ web/src/page/search.rs | 59 +++ web/src/page/timeline.rs | 27 ++ web/src/page/user.rs | 156 +++++++ web/src/prelude.rs | 2 - 17 files changed, 710 insertions(+), 656 deletions(-) create mode 100644 web/src/components/login.rs create mode 100644 web/src/components/navigation.rs rename web/src/{control.rs => components/post.rs} (87%) delete mode 100644 web/src/page.rs create mode 100644 web/src/page/about.rs create mode 100644 web/src/page/config.rs create mode 100644 web/src/page/debug.rs create mode 100644 web/src/page/mod.rs create mode 100644 web/src/page/object.rs create mode 100644 web/src/page/register.rs create mode 100644 web/src/page/search.rs create mode 100644 web/src/page/timeline.rs create mode 100644 web/src/page/user.rs diff --git a/web/src/auth.rs b/web/src/auth.rs index ea94679d..a7dd7703 100644 --- a/web/src/auth.rs +++ b/web/src/auth.rs @@ -1,6 +1,5 @@ use leptos::*; -use crate::prelude::*; - +use crate::URL_BASE; pub trait AuthToken { fn present(&self) -> bool; @@ -16,103 +15,6 @@ pub struct Auth { pub userid: Signal>, } - -#[component] -pub fn LoginBox( - token_tx: WriteSignal>, - userid_tx: WriteSignal>, - home_tl: Timeline, - server_tl: Timeline, -) -> impl IntoView { - let auth = use_context::().expect("missing auth context"); - let username_ref: NodeRef = create_node_ref(); - let password_ref: NodeRef = create_node_ref(); - view! { -
- -
-
() - .await - else { if let Some(rf) = password_ref.get() { rf.set_value("") }; return }; - logging::log!("logged in until {}", auth_response.expires); - // update our username and token cookies - let username = auth_response.user.split('/').last().unwrap_or_default().to_string(); - userid_tx.set(Some(auth_response.user)); - token_tx.set(Some(auth_response.token)); - // 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(auth).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(auth).await { - tracing::error!("failed refreshing server timeline: {e}"); - } - }); - }); - } > - - - - - - - - - - - -
-
-
-
- } -} - - -#[derive(Debug, serde::Serialize)] -struct LoginForm { - email: String, - password: String, -} - - -#[derive(Debug, Clone, serde::Deserialize)] -struct AuthResponse { - token: String, - user: String, - expires: chrono::DateTime, -} - impl AuthToken for Auth { fn token(&self) -> String { self.token.get().unwrap_or_default() diff --git a/web/src/components/login.rs b/web/src/components/login.rs new file mode 100644 index 00000000..d3320ebe --- /dev/null +++ b/web/src/components/login.rs @@ -0,0 +1,98 @@ +use leptos::*; +use crate::prelude::*; + +#[component] +pub fn LoginBox( + token_tx: WriteSignal>, + userid_tx: WriteSignal>, + home_tl: Timeline, + server_tl: Timeline, +) -> impl IntoView { + let auth = use_context::().expect("missing auth context"); + let username_ref: NodeRef = create_node_ref(); + let password_ref: NodeRef = create_node_ref(); + view! { +
+ +
+
() + .await + else { if let Some(rf) = password_ref.get() { rf.set_value("") }; return }; + logging::log!("logged in until {}", auth_response.expires); + // update our username and token cookies + let username = auth_response.user.split('/').last().unwrap_or_default().to_string(); + userid_tx.set(Some(auth_response.user)); + token_tx.set(Some(auth_response.token)); + // 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(auth).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(auth).await { + tracing::error!("failed refreshing server timeline: {e}"); + } + }); + }); + } > + + + + + + + + + + + +
+
+
+
+ } +} + + +#[derive(Debug, serde::Serialize)] +struct LoginForm { + email: String, + password: String, +} + + +#[derive(Debug, Clone, serde::Deserialize)] +struct AuthResponse { + token: String, + user: String, + expires: chrono::DateTime, +} diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index c7e0fdc0..76155aa3 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -1,12 +1,21 @@ +mod login; +pub use login::*; + mod activity; pub use activity::*; +mod navigation; +pub use navigation::*; + mod object; pub use object::*; mod user; pub use user::*; +mod post; +pub use post::*; + mod timeline; pub use timeline::*; diff --git a/web/src/components/navigation.rs b/web/src/components/navigation.rs new file mode 100644 index 00000000..7e492ec1 --- /dev/null +++ b/web/src/components/navigation.rs @@ -0,0 +1,48 @@ +use leptos::*; +use crate::prelude::*; + +#[component] +pub fn Breadcrumb( + #[prop(optional)] + back: bool, + children: Children, +) -> impl IntoView { + view! { +
+ {if back { Some(view! { + "<<" + })} else { None }} + {crate::NAME}" :: "{children()} +
+ } +} + +#[component] +pub fn Navigator() -> impl IntoView { + let auth = use_context::().expect("missing auth context"); + let (query, set_query) = create_signal("".to_string()); + view! { +
+ + + + + +
+ + + +
+
+ + + + + + + +
+ } +} diff --git a/web/src/control.rs b/web/src/components/post.rs similarity index 87% rename from web/src/control.rs rename to web/src/components/post.rs index ddbd4db1..0697b2c9 100644 --- a/web/src/control.rs +++ b/web/src/components/post.rs @@ -3,36 +3,6 @@ use apb::{ActivityMut, Base, BaseMut, Object, ObjectMut}; use leptos::*; use crate::prelude::*; -#[component] -pub fn Navigator() -> impl IntoView { - let auth = use_context::().expect("missing auth context"); - let (query, set_query) = create_signal("".to_string()); - view! { -
- - - - - -
- - - -
-
- - - - - - - -
- } -} - #[derive(Debug, Clone, Copy, Default)] pub struct ReplyControls { pub context: RwSignal>, @@ -306,22 +276,6 @@ fn get_checked(node: NodeRef) -> bool { .unwrap_or_default() } -#[component] -pub fn Breadcrumb( - #[prop(optional)] - back: bool, - children: Children, -) -> impl IntoView { - view! { -
- {if back { Some(view! { - "<<" - })} else { None }} - {crate::NAME}" :: "{children()} -
- } -} - #[component] fn SelectOption(is: &'static str, value: ReadSignal) -> impl IntoView { view! { diff --git a/web/src/lib.rs b/web/src/lib.rs index 7fa325c9..c636b3c1 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -1,12 +1,12 @@ -mod app; mod auth; +mod app; mod components; mod page; -mod control; mod config; pub use app::App; pub use config::Config; +pub use auth::Auth; pub mod prelude; @@ -17,7 +17,6 @@ pub const DEFAULT_AVATAR_URL: &str = "https://cdn.alemi.dev/social/gradient.png" pub const NAME: &str = "μ"; use std::sync::Arc; -use auth::Auth; diff --git a/web/src/page.rs b/web/src/page.rs deleted file mode 100644 index f84b12a2..00000000 --- a/web/src/page.rs +++ /dev/null @@ -1,506 +0,0 @@ -use std::sync::Arc; - -use apb::{ActivityMut, Actor, Base, Object, ObjectMut}; - -use leptos::*; -use leptos_router::*; -use crate::{prelude::*, Config, DEFAULT_AVATAR_URL}; - -#[component] -pub fn AboutPage() -> impl IntoView { - view! { -
- about -
-

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

-

"the "fediverse" is an ensemble of social networks, which, while independently hosted, can communicate with each other"

-

content is aggregated in timelines, logged out users can only access the global server timeline

-
-

"while somewhat usable, "μpub" is under active development and still lacks some mainstream features (such as hashtags or lists)"

-

"if you would like to contribute to "μpub"'s development, get in touch and check "github" or "forgejo

-
-
- } -} - -#[component] -pub fn RegisterPage() -> impl IntoView { - view! { -
- register -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
username
password

display name
summary
avatar url
banner url

-
-
- } -} - -#[component] -pub fn ConfigPage(setter: WriteSignal) -> impl IntoView { - let config = use_context::>().expect("missing config context"); - - macro_rules! get_cfg { - (filter $field:ident) => { - move || config.get().filters.$field - }; - ($field:ident) => { - move || config.get().$field - }; - } - - macro_rules! set_cfg { - ($field:ident) => { - move |ev| { - let mut mock = config.get(); - mock.$field = event_target_checked(&ev); - setter.set(mock); - } - }; - (filter $field:ident) => { - move |ev| { - let mut mock = config.get(); - mock.filters.$field = event_target_checked(&ev); - setter.set(mock); - } - }; - } - - view! { -
- config -

config is saved in your browser local storage

-

- - loop videos - -

-

- - collapse content warnings - -

-
-

filters

-
    -
  • " likes"
  • -
  • " creates"
  • -
  • " announces"
  • -
  • " follows"
  • -
  • " orphans"
  • -
-
-

devtools

-
- } -} - - -fn send_follow_request(target: String) { - let auth = use_context::().expect("missing auth context"); - spawn_local(async move { - let payload = serde_json::Value::Object(serde_json::Map::default()) - .set_activity_type(Some(apb::ActivityType::Follow)) - .set_object(apb::Node::link(target.clone())) - .set_to(apb::Node::links(vec![target])); - if let Err(e) = Http::post(&auth.outbox(), &payload, auth).await { - tracing::error!("failed sending follow request: {e}"); - } - }) -} - -#[component] -pub fn UserPage(tl: Timeline) -> impl IntoView { - let params = use_params_map(); - let auth = use_context::().expect("missing auth context"); - let id = params.get() - .get("id") - .cloned() - .unwrap_or_default(); - let mut uid = id - .replace("/web/objects/", "") - .replacen('+', "https://", 1) - .replace('@', "/"); - if !uid.starts_with("http") { - uid = format!("{URL_BASE}/web/objects/{uid}"); - } - let actor = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), move |id| { - async move { - match CACHE.get(&Uri::full(FetchKind::User, &id)) { - Some(x) => Some(x.clone()), - None => { - let user : serde_json::Value = Http::fetch(&Uri::api(FetchKind::User, &id, true), auth).await.ok()?; - let user = Arc::new(user); - CACHE.put(Uri::full(FetchKind::User, &id), user.clone()); - Some(user) - }, - } - } - }); - view! { -
- - users::view - - "\u{1f5d8}" - - -
- {move || { - let uid = uid.clone(); - match actor.get() { - None => view! {

loading...

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

loading failed"↗"

}.into_view() - }, - Some(Some(object)) => { - let uid = object.id().unwrap_or_default().to_string(); - let avatar_url = object.icon().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into()); - let background_url = object.image().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into()); - let display_name = object.name().unwrap_or_default().to_string(); - let username = object.preferred_username().unwrap_or_default().to_string(); - let summary = object.summary().unwrap_or_default().to_string(); - let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string(); - let actor_type = object.actor_type().unwrap_or(apb::ActorType::Person); - let actor_type_tag = if actor_type == apb::ActorType::Person { None } else { - Some(view! { "["{actor_type.as_ref().to_lowercase()}"]" } ) - }; - let created = object.published(); - let following = object.following_count().unwrap_or(0); - let followers = object.followers_count().unwrap_or(0); - let statuses = object.statuses_count().unwrap_or(0); - let tl_url = format!("{}/outbox/page", Uri::api(FetchKind::User, &id.clone(), false)); - if !tl.next.get().starts_with(&tl_url) { - tl.reset(tl_url); - } - let following_me = object.following_me().unwrap_or(false); - let followed_by_me = object.followed_by_me().unwrap_or(false); - let _uid = uid.clone(); - - view! { -
- -
- - - - - - - - - - - - - - - -
- - - {display_name}{actor_type_tag} - {statuses}" ""\u{1f582}"
- {username.clone()}@{domain} - {following}" ""👥"
- - {followers}" ""📢"
-
- {if followed_by_me { - view! { following }.into_view() - } else { - view! { }.into_view() - }} - {if following_me { - Some(view! { follows you }) - } else { - None - }} -
-

-
-
- - }.into_view() - }, - } - }} -
-
- } -} - -#[component] -pub fn ObjectPage(tl: Timeline) -> impl IntoView { - let params = use_params_map(); - let auth = use_context::().expect("missing auth context"); - let mut uid = params.get().get("id") - .cloned() - .unwrap_or_default() - .replace("/web/objects/", "") - .replacen('+', "https://", 1) - .replace('@', "/"); - if !uid.starts_with("http") { - uid = format!("{URL_BASE}/web/objects/{uid}"); - } - let object = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), move |oid| { - async move { - match CACHE.get(&Uri::full(FetchKind::Object, &oid)) { - Some(x) => Some(x.clone()), - None => { - let obj = Http::fetch::(&Uri::api(FetchKind::Object, &oid, true), auth).await.ok()?; - let obj = Arc::new(obj); - if let Some(author) = obj.attributed_to().id() { - if let Ok(user) = Http::fetch::( - &Uri::api(FetchKind::User, &author, true), auth - ).await { - CACHE.put(Uri::full(FetchKind::User, &author), Arc::new(user)); - } - } - CACHE.put(Uri::full(FetchKind::Object, &oid), obj.clone()); - Some(obj) - } - } - } - }); - view! { -
- - objects::view - - "\u{1f5d8}" - - -
- {move || match object.get() { - None => view! {

loading ...

}.into_view(), - Some(None) => { - let uid = uid.clone(); - view! {

loading failed"↗"

}.into_view() - }, - Some(Some(o)) => { - let object = o.clone(); - let tl_url = format!("{}/page", Uri::api(FetchKind::Context, &o.context().id().unwrap_or_default(), false)); - if !tl.next.get().starts_with(&tl_url) { - tl.reset(tl_url); - } - view!{ - -
- -
- }.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}" - - -
- -
-
- } -} - -#[component] -pub fn DebugPage() -> impl IntoView { - let (object, set_object) = create_signal(Arc::new(serde_json::Value::String( - "use this view to fetch remote AP objects and inspect their content".into()) - )); - let cached_ref: NodeRef = create_node_ref(); - let auth = use_context::().expect("missing auth context"); - let (query, set_query) = create_signal("".to_string()); - view! { -
- config :: devtools -
-
set_object.set(x), - None => set_object.set(Arc::new(serde_json::Value::String("not in cache!".into()))), - } - } else { - let url = format!("{URL_BASE}/dbg?id={fetch_url}"); - spawn_local(async move { set_object.set(Arc::new(debug_fetch(&url, auth).await)) }); - } - } > - - - - - - - -
- obj - " " - usr -
-
-
-
-				{move || serde_json::to_string_pretty(object.get().as_ref()).unwrap_or("unserializable".to_string())}
-			
-
- } -} - -#[component] -pub fn SearchPage() -> impl IntoView { - let auth = use_context::().expect("missing auth context"); - - let user = create_local_resource( - move || use_query_map().get().get("q").cloned().unwrap_or_default(), - move |q| { - let user_fetch = Uri::api(FetchKind::User, &q, true); - async move { Some(Arc::new(Http::fetch::(&user_fetch, auth).await.ok()?)) } - } - ); - - let object = create_local_resource( - move || use_query_map().get().get("q").cloned().unwrap_or_default(), - move |q| { - let object_fetch = Uri::api(FetchKind::Object, &q, true); - async move { Some(Arc::new(Http::fetch::(&object_fetch, auth).await.ok()?)) } - } - ); - - view! { - search -
-
- - users - -
- {move || match user.get() { - None => view! {

searching...

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

N/A

}, - Some(Some(u)) => view! {

}, - }} -
-
-
- -
-
- - objects - -
- {move || match object.get() { - None => view! {

searching...

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

N/A

}, - Some(Some(o)) => view! {

}, - }} - - - - } -} - -// this is a rather weird way to fetch but i want to see the bare error text if it fails! -async fn debug_fetch(url: &str, token: Auth) -> serde_json::Value { - match Http::request::<()>(reqwest::Method::GET, url, None, token).await { - Err(e) => serde_json::Value::String(format!("[!] failed sending request: {e}")), - Ok(res) => match res.text().await { - Err(e) => serde_json::Value::String(format!("[!] invalid response body: {e}")), - Ok(x) => match serde_json::from_str(&x) { - Err(_) => serde_json::Value::String(x), - Ok(v) => v, - }, - } - } -} diff --git a/web/src/page/about.rs b/web/src/page/about.rs new file mode 100644 index 00000000..9ca5fc22 --- /dev/null +++ b/web/src/page/about.rs @@ -0,0 +1,19 @@ +use leptos::*; +use crate::prelude::*; + +#[component] +pub fn AboutPage() -> impl IntoView { + view! { +
+ about +
+

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

+

"the "fediverse" is an ensemble of social networks, which, while independently hosted, can communicate with each other"

+

content is aggregated in timelines, logged out users can only access the global server timeline

+
+

"while somewhat usable, "μpub" is under active development and still lacks some mainstream features (such as hashtags or lists)"

+

"if you would like to contribute to "μpub"'s development, get in touch and check "github" or "forgejo

+
+
+ } +} diff --git a/web/src/page/config.rs b/web/src/page/config.rs new file mode 100644 index 00000000..c7f92ba5 --- /dev/null +++ b/web/src/page/config.rs @@ -0,0 +1,67 @@ +use leptos::*; +use crate::prelude::*; + +#[component] +pub fn ConfigPage(setter: WriteSignal) -> impl IntoView { + let config = use_context::>().expect("missing config context"); + + macro_rules! get_cfg { + (filter $field:ident) => { + move || config.get().filters.$field + }; + ($field:ident) => { + move || config.get().$field + }; + } + + macro_rules! set_cfg { + ($field:ident) => { + move |ev| { + let mut mock = config.get(); + mock.$field = event_target_checked(&ev); + setter.set(mock); + } + }; + (filter $field:ident) => { + move |ev| { + let mut mock = config.get(); + mock.filters.$field = event_target_checked(&ev); + setter.set(mock); + } + }; + } + + view! { +
+ config +

config is saved in your browser local storage

+

+ + loop videos + +

+

+ + collapse content warnings + +

+
+

filters

+
    +
  • " likes"
  • +
  • " creates"
  • +
  • " announces"
  • +
  • " follows"
  • +
  • " orphans"
  • +
+
+

devtools

+
+ } +} diff --git a/web/src/page/debug.rs b/web/src/page/debug.rs new file mode 100644 index 00000000..4c4e7262 --- /dev/null +++ b/web/src/page/debug.rs @@ -0,0 +1,69 @@ +use std::sync::Arc; + +use leptos::*; +use crate::prelude::*; + +#[component] +pub fn DebugPage() -> impl IntoView { + let (object, set_object) = create_signal(Arc::new(serde_json::Value::String( + "use this view to fetch remote AP objects and inspect their content".into()) + )); + let cached_ref: NodeRef = create_node_ref(); + let auth = use_context::().expect("missing auth context"); + let (query, set_query) = create_signal("".to_string()); + view! { +
+ config :: devtools +
+
set_object.set(x), + None => set_object.set(Arc::new(serde_json::Value::String("not in cache!".into()))), + } + } else { + let url = format!("{URL_BASE}/dbg?id={fetch_url}"); + spawn_local(async move { set_object.set(Arc::new(debug_fetch(&url, auth).await)) }); + } + } > + + + + + + + +
+ obj + " " + usr +
+
+
+
+				{move || serde_json::to_string_pretty(object.get().as_ref()).unwrap_or("unserializable".to_string())}
+			
+
+ } +} + +// this is a rather weird way to fetch but i want to see the bare error text if it fails! +async fn debug_fetch(url: &str, token: Auth) -> serde_json::Value { + match Http::request::<()>(reqwest::Method::GET, url, None, token).await { + Err(e) => serde_json::Value::String(format!("[!] failed sending request: {e}")), + Ok(res) => match res.text().await { + Err(e) => serde_json::Value::String(format!("[!] invalid response body: {e}")), + Ok(x) => match serde_json::from_str(&x) { + Err(_) => serde_json::Value::String(x), + Ok(v) => v, + }, + } + } +} diff --git a/web/src/page/mod.rs b/web/src/page/mod.rs new file mode 100644 index 00000000..3e63ded7 --- /dev/null +++ b/web/src/page/mod.rs @@ -0,0 +1,23 @@ +mod about; +pub use about::AboutPage; + +mod config; +pub use config::ConfigPage; + +mod debug; +pub use debug::DebugPage; + +mod object; +pub use object::ObjectPage; + +mod register; +pub use register::RegisterPage; + +mod search; +pub use search::SearchPage; + +mod timeline; +pub use timeline::TimelinePage; + +mod user; +pub use user::UserPage; diff --git a/web/src/page/object.rs b/web/src/page/object.rs new file mode 100644 index 00000000..8e6cbac8 --- /dev/null +++ b/web/src/page/object.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use leptos::*; +use leptos_router::*; +use crate::prelude::*; + +use apb::{Base, Object}; + +#[component] +pub fn ObjectPage(tl: Timeline) -> impl IntoView { + let params = use_params_map(); + let auth = use_context::().expect("missing auth context"); + let mut uid = params.get().get("id") + .cloned() + .unwrap_or_default() + .replace("/web/objects/", "") + .replacen('+', "https://", 1) + .replace('@', "/"); + if !uid.starts_with("http") { + uid = format!("{URL_BASE}/web/objects/{uid}"); + } + let object = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), move |oid| { + async move { + match CACHE.get(&Uri::full(FetchKind::Object, &oid)) { + Some(x) => Some(x.clone()), + None => { + let obj = Http::fetch::(&Uri::api(FetchKind::Object, &oid, true), auth).await.ok()?; + let obj = Arc::new(obj); + if let Some(author) = obj.attributed_to().id() { + if let Ok(user) = Http::fetch::( + &Uri::api(FetchKind::User, &author, true), auth + ).await { + CACHE.put(Uri::full(FetchKind::User, &author), Arc::new(user)); + } + } + CACHE.put(Uri::full(FetchKind::Object, &oid), obj.clone()); + Some(obj) + } + } + } + }); + view! { +
+ + objects::view + + "\u{1f5d8}" + + +
+ {move || match object.get() { + None => view! {

loading ...

}.into_view(), + Some(None) => { + let uid = uid.clone(); + view! {

loading failed"↗"

}.into_view() + }, + Some(Some(o)) => { + let object = o.clone(); + let tl_url = format!("{}/page", Uri::api(FetchKind::Context, &o.context().id().unwrap_or_default(), false)); + if !tl.next.get().starts_with(&tl_url) { + tl.reset(tl_url); + } + view!{ + +
+ +
+ }.into_view() + }, + }} + + + } +} diff --git a/web/src/page/register.rs b/web/src/page/register.rs new file mode 100644 index 00000000..04f5c2bf --- /dev/null +++ b/web/src/page/register.rs @@ -0,0 +1,48 @@ +use leptos::*; +use crate::prelude::*; + +#[component] +pub fn RegisterPage() -> impl IntoView { + view! { +
+ register +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
username
password

display name
summary
avatar url
banner url

+
+
+ } +} diff --git a/web/src/page/search.rs b/web/src/page/search.rs new file mode 100644 index 00000000..aa6fe5ba --- /dev/null +++ b/web/src/page/search.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use leptos::*; +use leptos_router::*; +use crate::prelude::*; + +#[component] +pub fn SearchPage() -> impl IntoView { + let auth = use_context::().expect("missing auth context"); + + let user = create_local_resource( + move || use_query_map().get().get("q").cloned().unwrap_or_default(), + move |q| { + let user_fetch = Uri::api(FetchKind::User, &q, true); + async move { Some(Arc::new(Http::fetch::(&user_fetch, auth).await.ok()?)) } + } + ); + + let object = create_local_resource( + move || use_query_map().get().get("q").cloned().unwrap_or_default(), + move |q| { + let object_fetch = Uri::api(FetchKind::Object, &q, true); + async move { Some(Arc::new(Http::fetch::(&object_fetch, auth).await.ok()?)) } + } + ); + + view! { + search +
+
+ + users + +
+ {move || match user.get() { + None => view! {

searching...

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

N/A

}, + Some(Some(u)) => view! {

}, + }} +
+
+
+ +
+
+ + objects + +
+ {move || match object.get() { + None => view! {

searching...

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

N/A

}, + Some(Some(o)) => view! {

}, + }} + + + + } +} diff --git a/web/src/page/timeline.rs b/web/src/page/timeline.rs new file mode 100644 index 00000000..f64aefbf --- /dev/null +++ b/web/src/page/timeline.rs @@ -0,0 +1,27 @@ +use leptos::*; +use crate::prelude::*; + +#[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/page/user.rs b/web/src/page/user.rs new file mode 100644 index 00000000..ae59fc58 --- /dev/null +++ b/web/src/page/user.rs @@ -0,0 +1,156 @@ +use std::sync::Arc; + +use leptos::*; +use leptos_router::*; +use crate::{prelude::*, DEFAULT_AVATAR_URL}; + +use apb::{Base, Actor, ActivityMut, Object, ObjectMut}; + +fn send_follow_request(target: String) { + let auth = use_context::().expect("missing auth context"); + spawn_local(async move { + let payload = serde_json::Value::Object(serde_json::Map::default()) + .set_activity_type(Some(apb::ActivityType::Follow)) + .set_object(apb::Node::link(target.clone())) + .set_to(apb::Node::links(vec![target])); + if let Err(e) = Http::post(&auth.outbox(), &payload, auth).await { + tracing::error!("failed sending follow request: {e}"); + } + }) +} + +#[component] +pub fn UserPage(tl: Timeline) -> impl IntoView { + let params = use_params_map(); + let auth = use_context::().expect("missing auth context"); + let id = params.get() + .get("id") + .cloned() + .unwrap_or_default(); + let mut uid = id + .replace("/web/objects/", "") + .replacen('+', "https://", 1) + .replace('@', "/"); + if !uid.starts_with("http") { + uid = format!("{URL_BASE}/web/objects/{uid}"); + } + let actor = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), move |id| { + async move { + match CACHE.get(&Uri::full(FetchKind::User, &id)) { + Some(x) => Some(x.clone()), + None => { + let user : serde_json::Value = Http::fetch(&Uri::api(FetchKind::User, &id, true), auth).await.ok()?; + let user = Arc::new(user); + CACHE.put(Uri::full(FetchKind::User, &id), user.clone()); + Some(user) + }, + } + } + }); + view! { +
+ + users::view + + "\u{1f5d8}" + + +
+ {move || { + let uid = uid.clone(); + match actor.get() { + None => view! {

loading...

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

loading failed"↗"

}.into_view() + }, + Some(Some(object)) => { + let uid = object.id().unwrap_or_default().to_string(); + let avatar_url = object.icon().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into()); + let background_url = object.image().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into()); + let display_name = object.name().unwrap_or_default().to_string(); + let username = object.preferred_username().unwrap_or_default().to_string(); + let summary = object.summary().unwrap_or_default().to_string(); + let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string(); + let actor_type = object.actor_type().unwrap_or(apb::ActorType::Person); + let actor_type_tag = if actor_type == apb::ActorType::Person { None } else { + Some(view! { "["{actor_type.as_ref().to_lowercase()}"]" } ) + }; + let created = object.published(); + let following = object.following_count().unwrap_or(0); + let followers = object.followers_count().unwrap_or(0); + let statuses = object.statuses_count().unwrap_or(0); + let tl_url = format!("{}/outbox/page", Uri::api(FetchKind::User, &id.clone(), false)); + if !tl.next.get().starts_with(&tl_url) { + tl.reset(tl_url); + } + let following_me = object.following_me().unwrap_or(false); + let followed_by_me = object.followed_by_me().unwrap_or(false); + let _uid = uid.clone(); + + view! { +
+ +
+ + + + + + + + + + + + + + + +
+ + + {display_name}{actor_type_tag} + {statuses}" ""\u{1f582}"
+ {username.clone()}@{domain} + {following}" ""👥"
+ + {followers}" ""📢"
+
+ {if followed_by_me { + view! { following }.into_view() + } else { + view! { }.into_view() + }} + {if following_me { + Some(view! { follows you }) + } else { + None + }} +
+

+
+
+ + }.into_view() + }, + } + }} +
+
+ } +} diff --git a/web/src/prelude.rs b/web/src/prelude.rs index c47bfa27..3b0f844b 100644 --- a/web/src/prelude.rs +++ b/web/src/prelude.rs @@ -3,7 +3,5 @@ pub use crate::{ CACHE, URL_BASE, auth::{Auth, AuthToken}, page::*, - control::*, components::*, - auth::LoginBox, };