forked from alemi/upub
chore(web): refactor
very much past due time...
This commit is contained in:
parent
06a7463af5
commit
75db004a20
10 changed files with 850 additions and 851 deletions
109
web/src/app.rs
Normal file
109
web/src/app.rs
Normal file
|
@ -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::<String, FromToStringCodec>("token");
|
||||||
|
let (username, set_username) = use_cookie::<String, FromToStringCodec>("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! {
|
||||||
|
<nav class="w-100 mt-1 mb-1 pb-s">
|
||||||
|
<code class="color ml-3" ><a class="upub-title" href=move || if token.present() { "/web/home" } else { "/web/server" } >μpub</a></code>
|
||||||
|
<small class="ml-1 mr-1 hidden-on-tiny" ><a class="clean" href="/web/server" >micro social network, federated</a></small>
|
||||||
|
/* TODO kinda jank with the float but whatever, will do for now */
|
||||||
|
<input type="submit" class="mr-2 rev" on:click=move |_| set_menu.set(!menu.get()) value="menu" style="float: right" />
|
||||||
|
</nav>
|
||||||
|
<hr class="sep" />
|
||||||
|
<div class="container mt-2 pt-2" >
|
||||||
|
<div class="two-col" >
|
||||||
|
<div class="col-side sticky" class:hidden=move || menu.get() >
|
||||||
|
<LoginBox
|
||||||
|
token_tx=set_token
|
||||||
|
token=token
|
||||||
|
username_tx=set_username
|
||||||
|
username=username
|
||||||
|
home_tl=home_tl
|
||||||
|
server_tl=server_tl
|
||||||
|
/>
|
||||||
|
<hr class="mt-1 mb-1" />
|
||||||
|
<Navigator />
|
||||||
|
<hr class="mt-1 mb-1" />
|
||||||
|
<PostBox username=username />
|
||||||
|
</div>
|
||||||
|
<div class="col-main" class:w-100=move || menu.get() >
|
||||||
|
<Router // TODO maybe set base="/web" ?
|
||||||
|
trailing_slash=TrailingSlash::Redirect
|
||||||
|
fallback=move || view! {
|
||||||
|
<div class="center">
|
||||||
|
<h3>nothing to see here!</h3>
|
||||||
|
<p><a href="/web"><button type="button">back to root</button></a></p>
|
||||||
|
</div>
|
||||||
|
}.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! {
|
||||||
|
<main>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/web" view=move ||
|
||||||
|
if token.get().is_some() {
|
||||||
|
view! { <Redirect path="/web/home" /> }
|
||||||
|
} else {
|
||||||
|
view! { <Redirect path="/web/server" /> }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="/web/home" view=move || view! { <TimelinePage name="home" tl=home_tl /> } />
|
||||||
|
<Route path="/web/server" view=move || view! { <TimelinePage name="server" tl=server_tl /> } />
|
||||||
|
|
||||||
|
<Route path="/web/about" view=AboutPage />
|
||||||
|
|
||||||
|
<Route path="/web/users/:id" view=UserPage />
|
||||||
|
<Route path="/web/objects/:id" view=ObjectPage />
|
||||||
|
|
||||||
|
<Route path="/" view=move || view! { <Redirect path="/web" /> } />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Router>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<div>
|
||||||
|
<hr class="sep" />
|
||||||
|
<span class="footer" >"\u{26fc} woven under moonlight :: "<a href="https://git.alemi.dev/upub.git" target="_blank" >src</a>" :: wip by alemi "</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
}
|
||||||
|
}
|
106
web/src/auth.rs
Normal file
106
web/src/auth.rs
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
use leptos::*;
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LoginBox(
|
||||||
|
token_tx: WriteSignal<Option<String>>,
|
||||||
|
token: Signal<Option<String>>,
|
||||||
|
username: Signal<Option<String>>,
|
||||||
|
username_tx: WriteSignal<Option<String>>,
|
||||||
|
home_tl: Timeline,
|
||||||
|
server_tl: Timeline,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let username_ref: NodeRef<html::Input> = create_node_ref();
|
||||||
|
let password_ref: NodeRef<html::Input> = create_node_ref();
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
<div class="w-100" class:hidden=move || !token.present() >
|
||||||
|
"hi "<a href={move || Uri::web("users", &username.get().unwrap_or_default() )} >{move || username.get().unwrap_or_default() }</a>
|
||||||
|
<input style="float:right" type="submit" value="logout" on:click=move |_| {
|
||||||
|
token_tx.set(None);
|
||||||
|
home_tl.reset(format!("{URL_BASE}/outbox/page"));
|
||||||
|
server_tl.reset(format!("{URL_BASE}/inbox/page"));
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Err(e) = server_tl.more(token).await {
|
||||||
|
logging::error!("failed refreshing server timeline: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} />
|
||||||
|
</div>
|
||||||
|
<div class:hidden=move || token.present() >
|
||||||
|
<input class="w-100" type="text" node_ref=username_ref placeholder="username" />
|
||||||
|
<input class="w-100" type="text" node_ref=password_ref placeholder="password" />
|
||||||
|
<input class="w-100" type="submit" value="login" on:click=move |_| {
|
||||||
|
logging::log!("logging in...");
|
||||||
|
let email = username_ref.get().map(|x| x.value()).unwrap_or("".into());
|
||||||
|
let password = password_ref.get().map(|x| x.value()).unwrap_or("".into());
|
||||||
|
spawn_local(async move {
|
||||||
|
let auth = reqwest::Client::new()
|
||||||
|
.post(format!("{URL_BASE}/auth"))
|
||||||
|
.json(&LoginForm { email, password })
|
||||||
|
.send()
|
||||||
|
.await.unwrap()
|
||||||
|
.json::<AuthResponse>()
|
||||||
|
.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));
|
||||||
|
});
|
||||||
|
} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
struct LoginForm {
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
struct AuthResponse {
|
||||||
|
token: String,
|
||||||
|
user: String,
|
||||||
|
expires: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Auth = Signal<Option<String>>;
|
||||||
|
pub trait AuthToken {
|
||||||
|
fn present(&self) -> bool;
|
||||||
|
fn token(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthToken for Signal<Option<String>> {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<DashMap<String, serde_json::Value>>);
|
|
||||||
|
|
||||||
impl ObjectCache {
|
|
||||||
pub fn get(&self, k: &str) -> Option<serde_json::Value> {
|
|
||||||
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<serde_json::Value> {
|
|
||||||
// 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::<serde_json::Value>()
|
|
||||||
// .await.ok()?;
|
|
||||||
//
|
|
||||||
// self.actors.insert(id.to_string(), user.clone());
|
|
||||||
//
|
|
||||||
// Some(user)
|
|
||||||
// },
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
pub struct Http;
|
|
||||||
|
|
||||||
impl Http {
|
|
||||||
pub async fn request<T: serde::ser::Serialize>(
|
|
||||||
method: reqwest::Method,
|
|
||||||
url: &str,
|
|
||||||
data: Option<&T>,
|
|
||||||
token: Signal<Option<String>>
|
|
||||||
) -> reqwest::Result<reqwest::Response> {
|
|
||||||
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<T: serde::de::DeserializeOwned>(url: &str, token: Signal<Option<String>>) -> reqwest::Result<T> {
|
|
||||||
Self::request::<()>(reqwest::Method::GET, url, None, token)
|
|
||||||
.await?
|
|
||||||
.json::<T>()
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn post<T: serde::ser::Serialize>(url: &str, data: &T, token: Signal<Option<String>>) -> reqwest::Result<()> {
|
|
||||||
Self::request(reqwest::Method::POST, url, Some(data), token)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct Timeline {
|
|
||||||
pub feed: RwSignal<Vec<String>>,
|
|
||||||
pub next: RwSignal<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Option<String>>) -> reqwest::Result<()> {
|
|
||||||
let feed_url = self.next.get();
|
|
||||||
let collection : serde_json::Value = Http::fetch(&feed_url, auth).await?;
|
|
||||||
let activities : Vec<serde_json::Value> = 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<serde_json::Value>,
|
|
||||||
auth: Signal<Option<String>>,
|
|
||||||
) -> Vec<String> {
|
|
||||||
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<Option<String>>) {
|
|
||||||
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}")),
|
|
||||||
}
|
|
||||||
}
|
|
114
web/src/control.rs
Normal file
114
web/src/control.rs
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
use apb::ObjectMut;
|
||||||
|
|
||||||
|
use leptos::*;
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Navigator() -> impl IntoView {
|
||||||
|
let auth = use_context::<Auth>().expect("missing auth context");
|
||||||
|
view! {
|
||||||
|
<a href="/web/home" >
|
||||||
|
<input class="w-100"
|
||||||
|
type="submit"
|
||||||
|
class:hidden=move || !auth.present()
|
||||||
|
// class:active=move || use_location().pathname.get().ends_with("/home")
|
||||||
|
value="home timeline"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<a href="/web/server" >
|
||||||
|
<input
|
||||||
|
class="w-100"
|
||||||
|
// class:active=move || use_location().pathname.get().ends_with("/server")
|
||||||
|
type="submit"
|
||||||
|
value="server timeline"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<a href="/web/about" >
|
||||||
|
<input
|
||||||
|
class="w-100"
|
||||||
|
// class:active=move || use_location().pathname.get().ends_with("/server")
|
||||||
|
type="submit"
|
||||||
|
value="about"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn PostBox(username: Signal<Option<String>>) -> impl IntoView {
|
||||||
|
let auth = use_context::<Auth>().expect("missing auth context");
|
||||||
|
let summary_ref: NodeRef<html::Input> = create_node_ref();
|
||||||
|
let content_ref: NodeRef<html::Textarea> = create_node_ref();
|
||||||
|
let public_ref: NodeRef<html::Input> = create_node_ref();
|
||||||
|
let followers_ref: NodeRef<html::Input> = create_node_ref();
|
||||||
|
view! {
|
||||||
|
<div class:hidden=move || !auth.present() >
|
||||||
|
<table class="align w-100">
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" title="public" value="public" node_ref=public_ref /></td>
|
||||||
|
<td class="w-100"><input class="w-100" type="text" node_ref=summary_ref title="summary" /></td>
|
||||||
|
<td><input type="checkbox" title="followers" value="followers" node_ref=followers_ref checked /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="3">
|
||||||
|
<textarea rows="5" class="w-100" node_ref=content_ref title="content" ></textarea>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="3">
|
||||||
|
<button class="w-100" type="button" on:click=move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
let summary = summary_ref.get().map(|x| x.value());
|
||||||
|
let content = content_ref.get().map(|x| x.value()).unwrap_or_default();
|
||||||
|
let public = public_ref.get().map(|x| x.checked()).unwrap_or_default();
|
||||||
|
let followers = followers_ref.get().map(|x| x.checked()).unwrap_or_default();
|
||||||
|
match Http::post(
|
||||||
|
&format!("{URL_BASE}/users/test/outbox"),
|
||||||
|
&serde_json::Value::Object(serde_json::Map::default())
|
||||||
|
.set_object_type(Some(apb::ObjectType::Note))
|
||||||
|
.set_summary(summary.as_deref())
|
||||||
|
.set_content(Some(&content))
|
||||||
|
.set_to(
|
||||||
|
if public {
|
||||||
|
apb::Node::links(vec![apb::target::PUBLIC.to_string()])
|
||||||
|
} else { apb::Node::Empty }
|
||||||
|
)
|
||||||
|
.set_cc(
|
||||||
|
if followers {
|
||||||
|
apb::Node::links(vec![format!("{URL_BASE}/users/{}/followers", username.get().unwrap_or_default())])
|
||||||
|
} else { apb::Node::Empty }
|
||||||
|
),
|
||||||
|
auth
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Err(e) => tracing::error!("error posting note: {e}"),
|
||||||
|
Ok(()) => {
|
||||||
|
if let Some(x) = summary_ref.get() { x.set_value("") }
|
||||||
|
if let Some(x) = content_ref.get() { x.set_value("") }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} >post</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Breadcrumb(
|
||||||
|
#[prop(optional)]
|
||||||
|
back: bool,
|
||||||
|
children: Children,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="tl-header w-100 center mb-s" >
|
||||||
|
{if back { Some(view! {
|
||||||
|
<a class="breadcrumb mr-1" href="javascript:history.back()" ><b>"<<"</b></a>
|
||||||
|
})} else { None }}
|
||||||
|
<b>{crate::NAME}</b>" :: "{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
623
web/src/lib.rs
623
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};
|
pub use app::App;
|
||||||
use leptos::{leptos_dom::logging::{console_error, console_log}, *};
|
pub use timeline::Timeline;
|
||||||
use leptos_router::*;
|
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_BASE: &str = "https://feditest.alemi.dev";
|
||||||
pub const URL_PREFIX: &str = "/web";
|
pub const URL_PREFIX: &str = "/web";
|
||||||
pub const NAME: &str = "μ";
|
pub const NAME: &str = "μ";
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
use std::sync::Arc;
|
||||||
struct LoginForm {
|
|
||||||
email: String,
|
|
||||||
password: String,
|
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
pub static ref CACHE: ObjectCache = ObjectCache::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ObjectCache(pub Arc<dashmap::DashMap<String, serde_json::Value>>);
|
||||||
|
|
||||||
|
impl ObjectCache {
|
||||||
|
pub fn get(&self, k: &str) -> Option<serde_json::Value> {
|
||||||
|
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 struct Http;
|
||||||
pub token: String,
|
|
||||||
pub user: String,
|
impl Http {
|
||||||
pub expires: chrono::DateTime<chrono::Utc>,
|
pub async fn request<T: serde::ser::Serialize>(
|
||||||
|
method: reqwest::Method,
|
||||||
|
url: &str,
|
||||||
|
data: Option<&T>,
|
||||||
|
token: leptos::Signal<Option<String>>
|
||||||
|
) -> reqwest::Result<reqwest::Response> {
|
||||||
|
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<T: serde::de::DeserializeOwned>(url: &str, token: leptos::Signal<Option<String>>) -> reqwest::Result<T> {
|
||||||
|
Self::request::<()>(reqwest::Method::GET, url, None, token)
|
||||||
|
.await?
|
||||||
|
.json::<T>()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post<T: serde::ser::Serialize>(url: &str, data: &T, token: leptos::Signal<Option<String>>) -> reqwest::Result<()> {
|
||||||
|
Self::request(reqwest::Method::POST, url, Some(data), token)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait MaybeToken {
|
pub struct Uri;
|
||||||
fn present(&self) -> bool;
|
|
||||||
fn token(&self) -> String;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MaybeToken for Option<String> {
|
impl Uri {
|
||||||
fn token(&self) -> String {
|
pub fn full(kind: &str, id: &str) -> String {
|
||||||
match self {
|
if id.starts_with('+') {
|
||||||
None => String::new(),
|
id.replace('+', "https://").replace('@', "/")
|
||||||
Some(x) => x.clone(),
|
} else {
|
||||||
|
format!("{URL_BASE}/{kind}/{id}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn present(&self) -> bool {
|
|
||||||
match self {
|
pub fn pretty(url: &str) -> String {
|
||||||
None => false,
|
if url.len() < 50 {
|
||||||
Some(x) => !x.is_empty(),
|
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]
|
/// convert url id to valid frontend view id:
|
||||||
pub fn LoginBox(
|
/// /web/users/test
|
||||||
token_tx: WriteSignal<Option<String>>,
|
/// /web/objects/+social.alemi.dev@objects@1204kasfkl
|
||||||
token: Signal<Option<String>>,
|
/// accepts:
|
||||||
username: Signal<Option<String>>,
|
/// - https://my.domain.net/users/root
|
||||||
username_tx: WriteSignal<Option<String>>,
|
/// - https://other.domain.net/unexpected/path/root
|
||||||
home_tl: Timeline,
|
/// - +other.domain.net@users@root
|
||||||
server_tl: Timeline,
|
/// - root
|
||||||
) -> impl IntoView {
|
pub fn web(kind: &str, url: &str) -> String {
|
||||||
let username_ref: NodeRef<html::Input> = create_node_ref();
|
format!("/web/{kind}/{}", Self::short(url))
|
||||||
let password_ref: NodeRef<html::Input> = create_node_ref();
|
}
|
||||||
view! {
|
|
||||||
<div>
|
/// convert url id to valid backend api id
|
||||||
<div class="w-100" class:hidden=move || !token.get().present() >
|
/// https://feditest.alemi.dev/users/test
|
||||||
"hi "<a href={move || Uri::web("users", &username.get().unwrap_or_default() )} >{move || username.get().unwrap_or_default() }</a>
|
/// https://feditest.alemi.dev/users/+social.alemi.dev@users@alemi
|
||||||
<input style="float:right" type="submit" value="logout" on:click=move |_| {
|
/// accepts:
|
||||||
token_tx.set(None);
|
/// - https://my.domain.net/users/root
|
||||||
home_tl.reset(format!("{URL_BASE}/outbox/page"));
|
/// - https://other.domain.net/unexpected/path/root
|
||||||
server_tl.reset(format!("{URL_BASE}/inbox/page"));
|
/// - +other.domain.net@users@root
|
||||||
spawn_local(async move {
|
/// - root
|
||||||
if let Err(e) = server_tl.more(token).await {
|
pub fn api(kind: &str, url: &str) -> String {
|
||||||
console_error(&format!("failed refreshing server timeline: {e}"));
|
format!("{URL_BASE}/{kind}/{}", Self::short(url))
|
||||||
}
|
|
||||||
});
|
|
||||||
} />
|
|
||||||
</div>
|
|
||||||
<div class:hidden=move || token.get().present() >
|
|
||||||
<input class="w-100" type="text" node_ref=username_ref placeholder="username" />
|
|
||||||
<input class="w-100" type="text" node_ref=password_ref placeholder="password" />
|
|
||||||
<input class="w-100" type="submit" value="login" on:click=move |_| {
|
|
||||||
console_log("logging in...");
|
|
||||||
let email = username_ref.get().map(|x| x.value()).unwrap_or("".into());
|
|
||||||
let password = password_ref.get().map(|x| x.value()).unwrap_or("".into());
|
|
||||||
spawn_local(async move {
|
|
||||||
let auth = reqwest::Client::new()
|
|
||||||
.post(format!("{URL_BASE}/auth"))
|
|
||||||
.json(&LoginForm { email, password })
|
|
||||||
.send()
|
|
||||||
.await.unwrap()
|
|
||||||
.json::<Auth>()
|
|
||||||
.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));
|
|
||||||
});
|
|
||||||
} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Navigator() -> impl IntoView {
|
|
||||||
let auth = use_context::<Signal<Option<String>>>().expect("missing auth context");
|
|
||||||
view! {
|
|
||||||
<a href="/web/home" >
|
|
||||||
<input class="w-100"
|
|
||||||
type="submit"
|
|
||||||
class:hidden=move || !auth.get().present()
|
|
||||||
// class:active=move || use_location().pathname.get().ends_with("/home")
|
|
||||||
value="home timeline"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<a href="/web/server" >
|
|
||||||
<input
|
|
||||||
class="w-100"
|
|
||||||
// class:active=move || use_location().pathname.get().ends_with("/server")
|
|
||||||
type="submit"
|
|
||||||
value="server timeline"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<a href="/web/about" >
|
|
||||||
<input
|
|
||||||
class="w-100"
|
|
||||||
// class:active=move || use_location().pathname.get().ends_with("/server")
|
|
||||||
type="submit"
|
|
||||||
value="about"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn PostBox(username: Signal<Option<String>>) -> impl IntoView {
|
|
||||||
let auth = use_context::<Signal<Option<String>>>().expect("missing auth context");
|
|
||||||
let summary_ref: NodeRef<html::Input> = create_node_ref();
|
|
||||||
let content_ref: NodeRef<html::Textarea> = create_node_ref();
|
|
||||||
let public_ref: NodeRef<html::Input> = create_node_ref();
|
|
||||||
let followers_ref: NodeRef<html::Input> = create_node_ref();
|
|
||||||
view! {
|
|
||||||
<div class:hidden=move || !auth.get().present() >
|
|
||||||
<table class="align w-100">
|
|
||||||
<tr>
|
|
||||||
<td><input type="checkbox" title="public" value="public" node_ref=public_ref /></td>
|
|
||||||
<td class="w-100"><input class="w-100" type="text" node_ref=summary_ref title="summary" /></td>
|
|
||||||
<td><input type="checkbox" title="followers" value="followers" node_ref=followers_ref checked /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="3">
|
|
||||||
<textarea rows="5" class="w-100" node_ref=content_ref title="content" ></textarea>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="3">
|
|
||||||
<button class="w-100" type="button" on:click=move |_| {
|
|
||||||
spawn_local(async move {
|
|
||||||
let summary = summary_ref.get().map(|x| x.value());
|
|
||||||
let content = content_ref.get().map(|x| x.value()).unwrap_or_default();
|
|
||||||
let public = public_ref.get().map(|x| x.checked()).unwrap_or_default();
|
|
||||||
let followers = followers_ref.get().map(|x| x.checked()).unwrap_or_default();
|
|
||||||
match Http::post(
|
|
||||||
&format!("{URL_BASE}/users/test/outbox"),
|
|
||||||
&serde_json::Value::Object(serde_json::Map::default())
|
|
||||||
.set_object_type(Some(apb::ObjectType::Note))
|
|
||||||
.set_summary(summary.as_deref())
|
|
||||||
.set_content(Some(&content))
|
|
||||||
.set_to(
|
|
||||||
if public {
|
|
||||||
apb::Node::links(vec![apb::target::PUBLIC.to_string()])
|
|
||||||
} else { apb::Node::Empty }
|
|
||||||
)
|
|
||||||
.set_cc(
|
|
||||||
if followers {
|
|
||||||
apb::Node::links(vec![format!("{URL_BASE}/users/{}/followers", username.get().unwrap_or_default())])
|
|
||||||
} else { apb::Node::Empty }
|
|
||||||
),
|
|
||||||
auth
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Err(e) => console_error(&format!("error posting note: {e}")),
|
|
||||||
Ok(()) => {
|
|
||||||
if let Some(x) = summary_ref.get() { x.set_value("") }
|
|
||||||
if let Some(x) = content_ref.get() { x.set_value("") }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} >post</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn TimelinePicker(
|
|
||||||
tx: WriteSignal<String>,
|
|
||||||
rx: ReadSignal<String>,
|
|
||||||
) -> 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! {
|
|
||||||
<input type="submit" class:active=move || rx.get() == my_in_ on:click=move |_| tx.set(my_in.clone()) value="my inbox" />
|
|
||||||
<input type="submit" class:active=move || rx.get() == my_out_ on:click=move |_| tx.set(my_out.clone()) value="my outbox" />
|
|
||||||
<input type="submit" class:active=move || rx.get() == our_in_ on:click=move |_| tx.set(our_in.clone()) value="global inbox" />
|
|
||||||
<input type="submit" class:active=move || rx.get() == our_out_ on:click=move |_| tx.set(our_out.clone()) value="global outbox" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ActorBanner(object: serde_json::Value) -> impl IntoView {
|
|
||||||
match object {
|
|
||||||
serde_json::Value::String(id) => view! {
|
|
||||||
<div><b>{id}</b></div>
|
|
||||||
},
|
|
||||||
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! {
|
|
||||||
<div>
|
|
||||||
<table class="align" >
|
|
||||||
<tr>
|
|
||||||
<td rowspan="2" ><a href={uri.clone()} ><img class="avatar-circle" src={avatar_url} /></a></td>
|
|
||||||
<td><b>{display_name}</b></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="top" ><a class="hover" href={uri} ><small>{username}@{domain}</small></a></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => view! {
|
|
||||||
<div><b>invalid actor</b></div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn UserPage() -> impl IntoView {
|
|
||||||
let params = use_params_map();
|
|
||||||
let auth = use_context::<Signal<Option<String>>>().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! {
|
|
||||||
<div>
|
|
||||||
<Breadcrumb back=true >users::view</Breadcrumb>
|
|
||||||
<div>
|
|
||||||
{move || match actor.get() {
|
|
||||||
None => view! { <p>loading...</p> }.into_view(),
|
|
||||||
Some(None) => view! { <p><code>error loading</code></p> }.into_view(),
|
|
||||||
Some(Some(x)) => view! {
|
|
||||||
<div class="ml-3 mr-3 mt-3">
|
|
||||||
<ActorBanner object=x.clone() />
|
|
||||||
<p
|
|
||||||
class="pb-2 pt-2 pr-2 pl-2"
|
|
||||||
style={format!(
|
|
||||||
"background-image: url({}); background-size: cover;",
|
|
||||||
x.image().get().map(|x| x.url().id().unwrap_or_default()).unwrap_or_default()
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
dissolve::strip_html_tags(x.summary().unwrap_or(""))
|
|
||||||
.into_iter()
|
|
||||||
.map(|x| view! { <p>{x}</p> })
|
|
||||||
.collect_view()
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li><code>type</code>" "<b>{x.actor_type().unwrap_or(apb::ActorType::Person).as_ref().to_string()}</b></li>
|
|
||||||
<li><code>following</code>" "<b>{x.following().get().map(|x| x.total_items().unwrap_or(0))}</b></li>
|
|
||||||
<li><code>followers</code>" "<b>{x.followers().get().map(|x| x.total_items().unwrap_or(0))}</b></li>
|
|
||||||
<li><code>created</code>" "{x.published().map(|x| x.to_rfc3339())}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<TimelineFeed tl=Timeline::new(format!("{}/outbox/page", Uri::api("users", x.id().unwrap_or_default()))) />
|
|
||||||
}.into_view(),
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ObjectPage() -> impl IntoView {
|
|
||||||
let params = use_params_map();
|
|
||||||
let auth = use_context::<Signal<Option<String>>>().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::<serde_json::Value>(&Uri::api("objects", &oid), auth).await.ok()?;
|
|
||||||
CACHE.put(Uri::full("objects", &oid), obj.clone());
|
|
||||||
Some(obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
view! {
|
|
||||||
<div>
|
|
||||||
<Breadcrumb back=true >objects::view</Breadcrumb>
|
|
||||||
<div class="ma-2" >
|
|
||||||
{move || match object.get() {
|
|
||||||
Some(Some(o)) => view!{ <Object object=o /> }.into_view(),
|
|
||||||
Some(None) => view! { <p><code>loading failed</code></p> }.into_view(),
|
|
||||||
None => view! { <p> loading ... </p> }.into_view(),
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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! {
|
|
||||||
<div>
|
|
||||||
<table class="w-100 post-table pa-1 mb-s" >
|
|
||||||
{move || if !summary.is_empty() {
|
|
||||||
view! {
|
|
||||||
<tr class="post-table" >
|
|
||||||
<td class="post-table pa-1" colspan="2" >{summary.clone()}</td>
|
|
||||||
</tr>
|
|
||||||
}.into_view()
|
|
||||||
} else {
|
|
||||||
view! { }.into_view()
|
|
||||||
}}
|
|
||||||
<tr class="post-table" >
|
|
||||||
<td class="post-table pa-1" colspan="2" >{
|
|
||||||
content.into_iter().map(|x| view! { <p>{x}</p> }).collect_view()
|
|
||||||
}</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="post-table" >
|
|
||||||
<td class="post-table pa-1" ><ActorBanner object=author /></td>
|
|
||||||
<td class="post-table pa-1 center" ><small title={date_rfc} >{date}</small></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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! { <code class="color">{summary}</code> })}}
|
|
||||||
<blockquote>
|
|
||||||
{content.into_iter().map(|x| view! { <p>{x}</p> }).collect_view()}
|
|
||||||
</blockquote>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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! {
|
|
||||||
<div>
|
|
||||||
<table class="align w-100" >
|
|
||||||
<tr>
|
|
||||||
<td rowspan="2" >
|
|
||||||
<ActorBanner object=actor />
|
|
||||||
</td>
|
|
||||||
<td class="rev" >
|
|
||||||
<code class="color moreinfo" title={object_id.clone()} >{kind.as_ref().to_string()}</code>
|
|
||||||
<span class="emoji ml-s moreinfo" title={audience} >{privacy}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="rev">
|
|
||||||
<a class="hover" href={Uri::web("objects", &object_id)} >
|
|
||||||
<small>{date}</small>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{match kind {
|
|
||||||
// post
|
|
||||||
apb::ActivityType::Create => view! { <ObjectInline object=object /> }.into_view(),
|
|
||||||
_ => view! {}.into_view(),
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn About() -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<div>
|
|
||||||
<Breadcrumb>about</Breadcrumb>
|
|
||||||
<div class="mt-s mb-s" >
|
|
||||||
<p><code>μpub</code>" is a micro social network powered by "<a href="">ActivityPub</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Breadcrumb(
|
|
||||||
#[prop(optional)]
|
|
||||||
back: bool,
|
|
||||||
children: Children,
|
|
||||||
) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<div class="tl-header w-100 center mb-s" >
|
|
||||||
{if back { Some(view! {
|
|
||||||
<a class="breadcrumb mr-1" href="javascript:history.back()" ><b>"<<"</b></a>
|
|
||||||
})} else { None }}
|
|
||||||
<b>{NAME}</b>" :: "{children()}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn TimelinePage(name: &'static str, tl: Timeline) -> impl IntoView {
|
|
||||||
let auth = use_context::<Signal<Option<String>>>().expect("missing auth context");
|
|
||||||
view! {
|
|
||||||
<div>
|
|
||||||
<Breadcrumb back=false>
|
|
||||||
{name}
|
|
||||||
<a class="clean ml-1" href="#" on:click=move |_| {
|
|
||||||
tl.reset(tl.next.get().split('?').next().unwrap_or_default().to_string());
|
|
||||||
spawn_local(async move {
|
|
||||||
if let Err(e) = tl.more(auth).await {
|
|
||||||
console_error(&format!("error fetching more items for timeline: {e}"));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}><span class="emoji">
|
|
||||||
"\u{1f5d8}"
|
|
||||||
</span></a>
|
|
||||||
</Breadcrumb>
|
|
||||||
<div class="mt-s mb-s" >
|
|
||||||
<TimelineFeed tl=tl />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn TimelineFeed(tl: Timeline) -> impl IntoView {
|
|
||||||
let auth = use_context::<Signal<Option<String>>>().expect("missing auth context");
|
|
||||||
view! {
|
|
||||||
<For
|
|
||||||
each=move || tl.feed.get()
|
|
||||||
key=|k| k.to_string()
|
|
||||||
children=move |id: String| {
|
|
||||||
match CACHE.get(&id) {
|
|
||||||
Some(object) => {
|
|
||||||
view! {
|
|
||||||
<InlineActivity activity=object />
|
|
||||||
<hr/ >
|
|
||||||
}.into_view()
|
|
||||||
},
|
|
||||||
None => view! {
|
|
||||||
<p><code>{id}</code>" "[<a href={uri}>go</a>]</p>
|
|
||||||
}.into_view(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/ >
|
|
||||||
<div class="center mt-1 mb-1" >
|
|
||||||
<button type="button"
|
|
||||||
on:click=move |_| {
|
|
||||||
spawn_local(async move {
|
|
||||||
if let Err(e) = tl.more(auth).await {
|
|
||||||
console_error(&format!("error fetching more items for timeline: {e}"));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>more</button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
110
web/src/main.rs
110
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() {
|
fn main() {
|
||||||
_ = console_log::init_with_level(log::Level::Info);
|
_ = console_log::init_with_level(log::Level::Info);
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
let (token, set_token) = use_cookie::<String, FromToStringCodec>("token");
|
|
||||||
let (username, set_username) = use_cookie::<String, FromToStringCodec>("username");
|
|
||||||
provide_context(token);
|
|
||||||
|
|
||||||
let home_tl = Timeline::new(format!("{URL_BASE}/users/{}/inbox/page", username.get().unwrap_or_default()));
|
leptos::mount_to_body(upub_web::App);
|
||||||
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! {
|
|
||||||
<nav class="w-100 mt-1 mb-1 pb-s">
|
|
||||||
<code class="color ml-3" ><a class="upub-title" href=move || if token.get().present() { "/web/home" } else { "/web/server" } >μpub</a></code>
|
|
||||||
<small class="ml-1 mr-1 hidden-on-tiny" ><a class="clean" href="/web/server" >micro social network, federated</a></small>
|
|
||||||
/* TODO kinda jank with the float but whatever, will do for now */
|
|
||||||
<input type="submit" class="mr-2 rev" on:click=move |_| set_menu.set(!menu.get()) value="menu" style="float: right" />
|
|
||||||
</nav>
|
|
||||||
<hr class="sep" />
|
|
||||||
<div class="container mt-2 pt-2" >
|
|
||||||
<div class="two-col" >
|
|
||||||
<div class="col-side sticky" class:hidden=move || menu.get() >
|
|
||||||
<LoginBox
|
|
||||||
token_tx=set_token
|
|
||||||
token=token
|
|
||||||
username_tx=set_username
|
|
||||||
username=username
|
|
||||||
home_tl=home_tl
|
|
||||||
server_tl=server_tl
|
|
||||||
/>
|
|
||||||
<hr class="mt-1 mb-1" />
|
|
||||||
<Navigator />
|
|
||||||
<hr class="mt-1 mb-1" />
|
|
||||||
<PostBox username=username />
|
|
||||||
</div>
|
|
||||||
<div class="col-main" class:w-100=move || menu.get() >
|
|
||||||
<Router // TODO maybe set base="/web" ?
|
|
||||||
trailing_slash=TrailingSlash::Redirect
|
|
||||||
fallback=move || view! {
|
|
||||||
<div class="center">
|
|
||||||
<h3>nothing to see here!</h3>
|
|
||||||
<p><a href="/web"><button type="button">back to root</button></a></p>
|
|
||||||
</div>
|
|
||||||
}.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! {
|
|
||||||
<main>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/web" view=move ||
|
|
||||||
if token.get().is_some() {
|
|
||||||
view! { <Redirect path="/web/home" /> }
|
|
||||||
} else {
|
|
||||||
view! { <Redirect path="/web/server" /> }
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="/web/home" view=move || view! { <TimelinePage name="home" tl=home_tl /> } />
|
|
||||||
<Route path="/web/server" view=move || view! { <TimelinePage name="server" tl=server_tl /> } />
|
|
||||||
|
|
||||||
<Route path="/web/about" view=About />
|
|
||||||
|
|
||||||
<Route path="/web/users/:id" view=UserPage />
|
|
||||||
<Route path="/web/objects/:id" view=ObjectPage />
|
|
||||||
|
|
||||||
<Route path="/" view=move || view! { <Redirect path="/web" /> } />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
</Router>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer>
|
|
||||||
<div>
|
|
||||||
<hr class="sep" />
|
|
||||||
<span class="footer" >"\u{26fc} woven under moonlight :: "<a href="https://git.alemi.dev/upub.git" target="_blank" >src</a>" :: wip by alemi "</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
128
web/src/page.rs
Normal file
128
web/src/page.rs
Normal file
|
@ -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! {
|
||||||
|
<div>
|
||||||
|
<Breadcrumb>about</Breadcrumb>
|
||||||
|
<div class="mt-s mb-s" >
|
||||||
|
<p><code>μpub</code>" is a micro social network powered by "<a href="">ActivityPub</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn UserPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let auth = use_context::<Auth>().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! {
|
||||||
|
<div>
|
||||||
|
<Breadcrumb back=true >users::view</Breadcrumb>
|
||||||
|
<div>
|
||||||
|
{move || match actor.get() {
|
||||||
|
None => view! { <p>loading...</p> }.into_view(),
|
||||||
|
Some(None) => view! { <p><code>error loading</code></p> }.into_view(),
|
||||||
|
Some(Some(x)) => view! {
|
||||||
|
<div class="ml-3 mr-3 mt-3">
|
||||||
|
<ActorBanner object=x.clone() />
|
||||||
|
<p
|
||||||
|
class="pb-2 pt-2 pr-2 pl-2"
|
||||||
|
style={format!(
|
||||||
|
"background-image: url({}); background-size: cover;",
|
||||||
|
x.image().get().map(|x| x.url().id().unwrap_or_default()).unwrap_or_default()
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
dissolve::strip_html_tags(x.summary().unwrap_or(""))
|
||||||
|
.into_iter()
|
||||||
|
.map(|x| view! { <p>{x}</p> })
|
||||||
|
.collect_view()
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>type</code>" "<b>{x.actor_type().unwrap_or(apb::ActorType::Person).as_ref().to_string()}</b></li>
|
||||||
|
<li><code>following</code>" "<b>{x.following().get().map(|x| x.total_items().unwrap_or(0))}</b></li>
|
||||||
|
<li><code>followers</code>" "<b>{x.followers().get().map(|x| x.total_items().unwrap_or(0))}</b></li>
|
||||||
|
<li><code>created</code>" "{x.published().map(|x| x.to_rfc3339())}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<TimelineFeed tl=Timeline::new(format!("{}/outbox/page", Uri::api("users", x.id().unwrap_or_default()))) />
|
||||||
|
}.into_view(),
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ObjectPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let auth = use_context::<Auth>().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::<serde_json::Value>(&Uri::api("objects", &oid), auth).await.ok()?;
|
||||||
|
CACHE.put(Uri::full("objects", &oid), obj.clone());
|
||||||
|
Some(obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
<Breadcrumb back=true >objects::view</Breadcrumb>
|
||||||
|
<div class="ma-2" >
|
||||||
|
{move || match object.get() {
|
||||||
|
Some(Some(o)) => view!{ <Object object=o /> }.into_view(),
|
||||||
|
Some(None) => view! { <p><code>loading failed</code></p> }.into_view(),
|
||||||
|
None => view! { <p> loading ... </p> }.into_view(),
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TimelinePage(name: &'static str, tl: Timeline) -> impl IntoView {
|
||||||
|
let auth = use_context::<Auth>().expect("missing auth context");
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
<Breadcrumb back=false>
|
||||||
|
{name}
|
||||||
|
<a class="clean ml-1" href="#" on:click=move |_| {
|
||||||
|
tl.reset(tl.next.get().split('?').next().unwrap_or_default().to_string());
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Err(e) = tl.more(auth).await {
|
||||||
|
tracing::error!("error fetching more items for timeline: {e}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}><span class="emoji">
|
||||||
|
"\u{1f5d8}"
|
||||||
|
</span></a>
|
||||||
|
</Breadcrumb>
|
||||||
|
<div class="mt-s mb-s" >
|
||||||
|
<TimelineFeed tl=tl />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
11
web/src/prelude.rs
Normal file
11
web/src/prelude.rs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
pub use crate::{
|
||||||
|
AuthToken,
|
||||||
|
Auth, Timeline, Http, Uri,
|
||||||
|
CACHE, URL_BASE,
|
||||||
|
page::*,
|
||||||
|
control::*,
|
||||||
|
view::*,
|
||||||
|
app::App,
|
||||||
|
auth::LoginBox,
|
||||||
|
timeline::TimelineFeed,
|
||||||
|
};
|
134
web/src/timeline.rs
Normal file
134
web/src/timeline.rs
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use leptos::*;
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Timeline {
|
||||||
|
pub feed: RwSignal<Vec<String>>,
|
||||||
|
pub next: RwSignal<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<String>>) -> 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<serde_json::Value> = 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::<Auth>().expect("missing auth context");
|
||||||
|
view! {
|
||||||
|
<For
|
||||||
|
each=move || tl.feed.get()
|
||||||
|
key=|k| k.to_string()
|
||||||
|
children=move |id: String| {
|
||||||
|
match CACHE.get(&id) {
|
||||||
|
Some(object) => {
|
||||||
|
view! {
|
||||||
|
<InlineActivity activity=object />
|
||||||
|
<hr/ >
|
||||||
|
}.into_view()
|
||||||
|
},
|
||||||
|
None => view! {
|
||||||
|
<p><code>{id}</code>" "[<a href={uri}>go</a>]</p>
|
||||||
|
}.into_view(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/ >
|
||||||
|
<div class="center mt-1 mb-1" >
|
||||||
|
<button type="button"
|
||||||
|
on:click=move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Err(e) = tl.more(auth).await {
|
||||||
|
tracing::error!("error fetching more items for timeline: {e}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>more</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_activities(
|
||||||
|
activities: Vec<serde_json::Value>,
|
||||||
|
auth: Signal<Option<String>>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
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<Option<String>>) {
|
||||||
|
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}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
135
web/src/view.rs
Normal file
135
web/src/view.rs
Normal file
|
@ -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! {
|
||||||
|
<div>
|
||||||
|
<table class="align w-100" >
|
||||||
|
<tr>
|
||||||
|
<td rowspan="2" >
|
||||||
|
<ActorBanner object=actor />
|
||||||
|
</td>
|
||||||
|
<td class="rev" >
|
||||||
|
<code class="color moreinfo" title={object_id.clone()} >{kind.as_ref().to_string()}</code>
|
||||||
|
<span class="emoji ml-s moreinfo" title={audience} >{privacy}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rev">
|
||||||
|
<a class="hover" href={Uri::web("objects", &object_id)} >
|
||||||
|
<small>{date}</small>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{match kind {
|
||||||
|
// post
|
||||||
|
apb::ActivityType::Create => view! { <ObjectInline object=object /> }.into_view(),
|
||||||
|
_ => view! {}.into_view(),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ActorBanner(object: serde_json::Value) -> impl IntoView {
|
||||||
|
match object {
|
||||||
|
serde_json::Value::String(id) => view! {
|
||||||
|
<div><b>{id}</b></div>
|
||||||
|
},
|
||||||
|
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! {
|
||||||
|
<div>
|
||||||
|
<table class="align" >
|
||||||
|
<tr>
|
||||||
|
<td rowspan="2" ><a href={uri.clone()} ><img class="avatar-circle" src={avatar_url} /></a></td>
|
||||||
|
<td><b>{display_name}</b></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="top" ><a class="hover" href={uri} ><small>{username}@{domain}</small></a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => view! {
|
||||||
|
<div><b>invalid actor</b></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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! {
|
||||||
|
<div>
|
||||||
|
<table class="w-100 post-table pa-1 mb-s" >
|
||||||
|
{move || if !summary.is_empty() {
|
||||||
|
view! {
|
||||||
|
<tr class="post-table" >
|
||||||
|
<td class="post-table pa-1" colspan="2" >{summary.clone()}</td>
|
||||||
|
</tr>
|
||||||
|
}.into_view()
|
||||||
|
} else {
|
||||||
|
view! { }.into_view()
|
||||||
|
}}
|
||||||
|
<tr class="post-table" >
|
||||||
|
<td class="post-table pa-1" colspan="2" >{
|
||||||
|
content.into_iter().map(|x| view! { <p>{x}</p> }).collect_view()
|
||||||
|
}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="post-table" >
|
||||||
|
<td class="post-table pa-1" ><ActorBanner object=author /></td>
|
||||||
|
<td class="post-table pa-1 center" ><small title={date_rfc} >{date}</small></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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! { <code class="color">{summary}</code> })}}
|
||||||
|
<blockquote>
|
||||||
|
{content.into_iter().map(|x| view! { <p>{x}</p> }).collect_view()}
|
||||||
|
</blockquote>
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue