diff --git a/Cargo.toml b/Cargo.toml index b32c6f5..04367f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["apb"] +members = ["apb", "web"] [package] name = "upub" diff --git a/web/Cargo.toml b/web/Cargo.toml new file mode 100644 index 0000000..fa5eb0f --- /dev/null +++ b/web/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "upub-web" +version = "0.1.0" +edition = "2021" +authors = [ "alemi " ] +description = "web frontend for upub" +license = "AGPL-3.0" +keywords = ["activitypub", "upub", "json", "web", "wasm"] +repository = "https://git.alemi.dev/upub.git" +#readme = "README.md" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +log = "0.4" +console_log = "1.0" +console_error_panic_hook = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dashmap = "5.5" +web-sys = { version = "0.3", features = ["Window", "Storage", "HtmlDocument"] } +leptos = { version = "0.6", features = ["csr"] } +leptos-use = { version = "0.10", features = [] } +reqwest = { version = "0.12", features = ["json"] } +apb = { path = "../apb", features = ["unstructured"] } +futures = "0.3.30" +dissolve = "0.2" # TODO strip html without this crate diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..23f42cd --- /dev/null +++ b/web/index.html @@ -0,0 +1,29 @@ + + + + upub + + + + + + diff --git a/web/src/lib.rs b/web/src/lib.rs new file mode 100644 index 0000000..1d301b5 --- /dev/null +++ b/web/src/lib.rs @@ -0,0 +1,262 @@ +use std::sync::Arc; + +use apb::{target::Addressed, Activity, ActivityMut, Actor, Base, Collection, Object, ObjectMut}; +use dashmap::DashMap; +use leptos::{leptos_dom::logging::console_log, *}; + +pub const BASE_URL: &str = "https://feditest.alemi.dev"; + +#[derive(Debug, serde::Serialize)] +struct LoginForm { + email: String, + password: String, +} + +#[component] +pub fn LoginBox( + rx: Signal>, + tx: WriteSignal>, +) -> impl IntoView { + let username_ref: NodeRef = create_node_ref(); + let password_ref: NodeRef = create_node_ref(); + view! { +
+
+ +
+
+

+ + + () + .await.unwrap(); + tx.set(Some(auth)); + }); + } /> +

+
+
+ } +} + +#[component] +pub fn PostBox(token: Signal>) -> impl IntoView { + let summary_ref: NodeRef = create_node_ref(); + let content_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 Actor(object: serde_json::Value) -> impl IntoView { + match object { + serde_json::Value::String(id) => view! { +
{id}
+ }, + serde_json::Value::Object(_) => { + 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 Activity(activity: serde_json::Value) -> impl IntoView { + let object = activity.clone().object().extract().unwrap_or_else(|| + serde_json::Value::String(activity.object().id().unwrap_or_default()) + ); + let object_id = object.id().unwrap_or_default().to_string(); + let content = dissolve::strip_html_tags(object.content().unwrap_or_default()); + let addressed = activity.addressed(); + let audience = format!("[ {} ]", addressed.join(", ")); + let privacy = if addressed.iter().any(|x| x == apb::target::PUBLIC) { + "[public]" + } else if addressed.iter().any(|x| x.ends_with("/followers")) { + "[followers]" + } else { + "[private]" + }; + let title = object.summary().unwrap_or_default().to_string(); + let date = object.published().map(|x| x.to_rfc3339()).unwrap_or_default(); + let kind = activity.activity_type().unwrap_or(apb::ActivityType::Activity); + view! { + {match kind { + // post + apb::ActivityType::Create => view! { +
+

{title}

+ {x}

} + /> +
+ }, + kind => view! { +
+ {kind.as_ref().to_string()}" >> "{object_id} +
+ }, + }} + {privacy}" "{date} + } +} + +#[component] +pub fn Timeline( + feed: ReadSignal, + token: Signal>, +) -> impl IntoView { + let users : Arc> = Arc::new(DashMap::new()); + let _users = users.clone(); // TODO i think there is syntactic sugar i forgot? + let items = create_resource(move || feed.get(), move |feed_url| { + let __users = _users.clone(); // TODO lmao this is meme tier + async move { + 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.unwrap() + .json::() + .await.unwrap() + .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) = __users.get(&uid) { + out.push(x.set_actor(apb::Node::object(actor.clone()))) + } else { + let mut req = reqwest::Client::new() + .get(format!("https://feditest.alemi.dev/users/+?id={uid}")); + + if let Some(token) = token.get() { + req = req.header("Authorization", format!("Bearer {token}")); + } + + let actor = req.send().await.unwrap().json::().await.unwrap(); + __users.insert(uid, actor.clone()); + + out.push(x.set_actor(apb::Node::object(actor))) + } + } else { + out.push(x) + } + } + + out + } + }); + move || match items.get() { + None => view! {

loading...

}.into_view(), + Some(data) => { + view! { +
+ + + +
+
+ } + } + /> + + }.into_view() + }, + } +} diff --git a/web/src/main.rs b/web/src/main.rs new file mode 100644 index 0000000..67e8fb7 --- /dev/null +++ b/web/src/main.rs @@ -0,0 +1,46 @@ +use leptos::*; + +use leptos_use::{use_cookie, utils::FromToStringCodec}; +use upub_web::{ + LoginBox, PostBox, TimelinePicker, Timeline +}; + +fn main() { + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + let (cookie, set_cookie) = use_cookie::("token"); + let (timeline, set_timeline) = create_signal("https://feditest.alemi.dev/users/test/inbox/page".to_string()); + mount_to_body( + move || view! { + +
+
+
+
+ +
+ +
+
+
+ + +
+
+
+ } + ); +}