feat(web): better timeline, idk

cant think read the diff bad day
This commit is contained in:
əlemi 2024-04-21 18:56:25 +02:00
parent 63bde2b5e0
commit abed664f0a
Signed by: alemi
GPG key ID: A4895B84D311642C
8 changed files with 102 additions and 47 deletions

View file

@ -23,7 +23,7 @@ pub fn LoginBox(
view! { view! {
<div> <div>
<div class="w-100" class:hidden=move || !token.present() > <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> "hi "<a href={move || Uri::web(FetchKind::User, &username.get().unwrap_or_default() )} >{move || username.get().unwrap_or_default() }</a>
<input style="float:right" type="submit" value="logout" on:click=move |_| { <input style="float:right" type="submit" value="logout" on:click=move |_| {
token_tx.set(None); token_tx.set(None);
home_tl.reset(format!("{URL_BASE}/outbox/page")); home_tl.reset(format!("{URL_BASE}/outbox/page"));

View file

@ -8,13 +8,8 @@ use apb::{target::Addressed, Activity, Actor, Base, Object};
#[component] #[component]
pub fn ActivityLine(activity: serde_json::Value) -> impl IntoView { pub fn ActivityLine(activity: serde_json::Value) -> impl IntoView {
let object_id = activity.object().id().unwrap_or_default(); 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 actor_id = activity.actor().id().unwrap_or_default(); let actor_id = activity.actor().id().unwrap_or_default();
let actor = match CACHE.get(&actor_id) { let actor = CACHE.get_or(&actor_id, serde_json::Value::String(actor_id.clone()));
Some(a) => a,
None => serde_json::Value::String(actor_id.clone()),
};
let avatar = actor.icon().get().map(|x| x.url().id().unwrap_or_default()).unwrap_or_default(); let avatar = actor.icon().get().map(|x| x.url().id().unwrap_or_default()).unwrap_or_default();
let username = actor.preferred_username().unwrap_or_default().to_string(); let username = actor.preferred_username().unwrap_or_default().to_string();
let domain = actor.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string(); let domain = actor.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string();
@ -24,13 +19,13 @@ pub fn ActivityLine(activity: serde_json::Value) -> impl IntoView {
<table class="align w-100" style="table-layout: fixed"> <table class="align w-100" style="table-layout: fixed">
<tr> <tr>
<td> <td>
<a href={Uri::web("users", &actor_id)} class="clean hover"> <a href={Uri::web(FetchKind::User, &actor_id)} class="clean hover">
<img src={avatar} class="avatar-inline mr-s ml-1" /><b>{username}</b><small>@{domain}</small> <img src={avatar} class="avatar-inline mr-s ml-1" /><b>{username}</b><small>@{domain}</small>
</a> </a>
</td> </td>
<td class="rev" > <td class="rev" >
<code class="color moreinfo" title={object_id.clone()} >{kind.as_ref().to_string()}</code> <code class="color moreinfo" title={object_id.clone()} >{kind.as_ref().to_string()}</code>
<a class="hover ml-1" href={Uri::web("objects", &object_id)} > <a class="hover ml-1" href={Uri::web(FetchKind::Object, &object_id)} >
<DateTime t=activity.published() /> <DateTime t=activity.published() />
</a> </a>
<PrivacyMarker addressed=activity.addressed() /> <PrivacyMarker addressed=activity.addressed() />

View file

@ -1,7 +1,7 @@
use leptos::*; use leptos::*;
use crate::prelude::*; use crate::prelude::*;
use apb::{target::Addressed, Activity, Actor, Base, Object}; use apb::{target::Addressed, Base, Object};
#[component] #[component]
@ -10,9 +10,8 @@ pub fn Object(object: serde_json::Value) -> impl IntoView {
let in_reply_to = object.in_reply_to().id().unwrap_or_default(); let in_reply_to = object.in_reply_to().id().unwrap_or_default();
let summary = object.summary().unwrap_or_default().to_string(); let summary = object.summary().unwrap_or_default().to_string();
let content = dissolve::strip_html_tags(object.content().unwrap_or_default()); let content = dissolve::strip_html_tags(object.content().unwrap_or_default());
let date = object.published();
let author_id = object.attributed_to().id().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())); let author = CACHE.get_or(&author_id, serde_json::Value::String(author_id.clone()));
view! { view! {
<div> <div>
<table class="w-100 post-table pa-1 mb-s" > <table class="w-100 post-table pa-1 mb-s" >
@ -20,7 +19,7 @@ pub fn Object(object: serde_json::Value) -> impl IntoView {
Some(view! { Some(view! {
<tr class="post-table" > <tr class="post-table" >
<td class="post-table pa-1" colspan="2" > <td class="post-table pa-1" colspan="2" >
"in reply to "<small><a class="clean hover" href={Uri::web("objects", &in_reply_to)}>{Uri::pretty(&in_reply_to)}</a></small> "in reply to "<small><a class="clean hover" href={Uri::web(FetchKind::Object, &in_reply_to)}>{Uri::pretty(&in_reply_to)}</a></small>
</td> </td>
</tr> </tr>
}) })
@ -38,7 +37,7 @@ pub fn Object(object: serde_json::Value) -> impl IntoView {
}</td> }</td>
</tr> </tr>
<tr class="post-table" > <tr class="post-table" >
<td class="post-table pa-1" ><ActorBanner object=author tiny=true /></td> <td class="post-table pa-1" ><ActorBanner object=author /></td>
<td class="post-table pa-1 center" > <td class="post-table pa-1 center" >
<a class="clean hover" href={oid} target="_blank"> <a class="clean hover" href={oid} target="_blank">
<DateTime t=object.published() /> <DateTime t=object.published() />
@ -52,15 +51,17 @@ pub fn Object(object: serde_json::Value) -> impl IntoView {
} }
#[component] #[component]
pub fn ObjectInline(object: serde_json::Value, author: serde_json::Value) -> impl IntoView { pub fn ObjectInline(object: serde_json::Value) -> impl IntoView {
let summary = object.summary().unwrap_or_default().to_string(); let summary = object.summary().unwrap_or_default().to_string();
let content = dissolve::strip_html_tags(object.content().unwrap_or_default()); let content = dissolve::strip_html_tags(object.content().unwrap_or_default());
let author_id = object.attributed_to().id().unwrap_or_default();
let author = CACHE.get_or(&author_id, serde_json::Value::String(author_id.clone()));
view! { view! {
<table class="align w-100"> <table class="align w-100">
<tr> <tr>
<td><ActorBanner object=author /></td> <td><ActorBanner object=author /></td>
<td class="rev" > <td class="rev" >
<a class="clean hover" href={Uri::web("objects", object.id().unwrap_or_default())}> <a class="clean hover" href={Uri::web(FetchKind::Object, object.id().unwrap_or_default())}>
<DateTime t=object.published() /> <DateTime t=object.published() />
</a> </a>
<PrivacyMarker addressed=object.addressed() /> <PrivacyMarker addressed=object.addressed() />

View file

@ -1,6 +1,6 @@
use std::collections::BTreeSet; use std::{collections::BTreeSet, pin::Pin};
use apb::{Activity, Base}; use apb::{Activity, Base, Object};
use leptos::*; use leptos::*;
use crate::prelude::*; use crate::prelude::*;
@ -55,10 +55,10 @@ pub fn TimelineFeed(tl: Timeline) -> impl IntoView {
match CACHE.get(&id) { match CACHE.get(&id) {
Some(item) => match item.base_type() { Some(item) => match item.base_type() {
Some(apb::BaseType::Object(apb::ObjectType::Activity(_))) => { Some(apb::BaseType::Object(apb::ObjectType::Activity(_))) => {
let author_id = item.actor().id().unwrap_or_default();
let author = CACHE.get(&author_id).unwrap_or(serde_json::Value::String(author_id.clone()));
let object_id = item.object().id().unwrap_or_default(); let object_id = item.object().id().unwrap_or_default();
let object = CACHE.get(&object_id).map(|obj| view! { <ObjectInline object=obj author=author /> }); let object = CACHE.get(&object_id).map(|obj| {
view! { <ObjectInline object=obj /> }
});
view! { view! {
<ActivityLine activity=item /> <ActivityLine activity=item />
{object} {object}
@ -66,7 +66,7 @@ pub fn TimelineFeed(tl: Timeline) -> impl IntoView {
}.into_view() }.into_view()
}, },
Some(apb::BaseType::Object(apb::ObjectType::Note)) => view! { Some(apb::BaseType::Object(apb::ObjectType::Note)) => view! {
<Object object=item /> <Object object=item.clone() />
<hr/ > <hr/ >
}.into_view(), }.into_view(),
_ => view! { <p><code>type not implemented</code></p><hr /> }.into_view(), _ => view! { <p><code>type not implemented</code></p><hr /> }.into_view(),
@ -96,21 +96,28 @@ async fn process_activities(
auth: Signal<Option<String>>, auth: Signal<Option<String>>,
) -> Vec<String> { ) -> Vec<String> {
use apb::ActivityMut; use apb::ActivityMut;
let mut sub_tasks = Vec::new(); let mut sub_tasks : Vec<Pin<Box<dyn futures::Future<Output = ()>>>> = Vec::new();
let mut gonna_fetch = BTreeSet::new(); let mut gonna_fetch = BTreeSet::new();
let mut actors_seen = BTreeSet::new();
let mut out = Vec::new(); let mut out = Vec::new();
for activity in activities { for activity in activities {
// save embedded object if present // save embedded object if present
if let Some(object) = activity.object().get() { if let Some(object) = activity.object().get() {
// also fetch actor attributed to
if let Some(attributed_to) = object.attributed_to().id() {
actors_seen.insert(attributed_to);
}
if let Some(object_uri) = object.id() { if let Some(object_uri) = object.id() {
CACHE.put(object_uri.to_string(), object.clone()); CACHE.put(object_uri.to_string(), object.clone());
} else {
tracing::warn!("embedded object without id: {object:?}");
} }
} else { // try fetching it } else { // try fetching it
if let Some(object_id) = activity.object().id() { if let Some(object_id) = activity.object().id() {
if !gonna_fetch.contains(&object_id) { if !gonna_fetch.contains(&object_id) {
gonna_fetch.insert(object_id.clone()); gonna_fetch.insert(object_id.clone());
sub_tasks.push(fetch_and_update("objects", object_id, auth)); sub_tasks.push(Box::pin(fetch_and_update_with_user(FetchKind::Object, object_id, auth)));
} }
} }
} }
@ -128,20 +135,36 @@ async fn process_activities(
if let Some(uid) = activity.actor().id() { if let Some(uid) = activity.actor().id() {
if CACHE.get(&uid).is_none() && !gonna_fetch.contains(&uid) { if CACHE.get(&uid).is_none() && !gonna_fetch.contains(&uid) {
gonna_fetch.insert(uid.clone()); gonna_fetch.insert(uid.clone());
sub_tasks.push(fetch_and_update("users", uid, auth)); sub_tasks.push(Box::pin(fetch_and_update(FetchKind::User, uid, auth)));
} }
} }
} }
for user in actors_seen {
sub_tasks.push(Box::pin(fetch_and_update(FetchKind::User, user, auth)));
}
futures::future::join_all(sub_tasks).await; futures::future::join_all(sub_tasks).await;
out out
} }
async fn fetch_and_update(kind: &'static str, id: String, auth: Signal<Option<String>>) { async fn fetch_and_update(kind: FetchKind, id: String, auth: Signal<Option<String>>) {
match Http::fetch(&Uri::api(kind, &id, false), auth).await { match Http::fetch(&Uri::api(kind, &id, false), auth).await {
Ok(data) => CACHE.put(id, data), Ok(data) => CACHE.put(id, data),
Err(e) => console_warn(&format!("could not fetch '{id}': {e}")), Err(e) => console_warn(&format!("could not fetch '{id}': {e}")),
} }
} }
async fn fetch_and_update_with_user(kind: FetchKind, id: String, auth: Signal<Option<String>>) {
fetch_and_update(kind.clone(), id.clone(), auth).await;
if let Some(obj) = CACHE.get(&id) {
if let Some(actor_id) = match kind {
FetchKind::Object => obj.attributed_to().id(),
FetchKind::Activity => obj.actor().id(),
FetchKind::User | FetchKind::Context => None,
} {
fetch_and_update(FetchKind::User, actor_id, auth).await;
}
}
}

View file

@ -1,22 +1,18 @@
use leptos::*; use leptos::*;
use crate::prelude::*; use crate::prelude::*;
use apb::{target::Addressed, Activity, Actor, Base, Object}; use apb::{Actor, Base, Object};
#[component] #[component]
pub fn ActorBanner( pub fn ActorBanner(object: serde_json::Value) -> impl IntoView {
object: serde_json::Value,
#[prop(optional)]
tiny: bool
) -> impl IntoView {
match object { match object {
serde_json::Value::String(id) => view! { serde_json::Value::String(id) => view! {
<div><b>?</b>" "<a class="clean hover" href={Uri::web("users", &id)}>{Uri::pretty(&id)}</a></div> <div><b>?</b>" "<a class="clean hover" href={Uri::web(FetchKind::User, &id)}>{Uri::pretty(&id)}</a></div>
}, },
serde_json::Value::Object(_) => { serde_json::Value::Object(_) => {
let uid = object.id().unwrap_or_default().to_string(); let uid = object.id().unwrap_or_default().to_string();
let uri = Uri::web("users", &uid); let uri = Uri::web(FetchKind::User, &uid);
let avatar_url = object.icon().get().map(|x| x.url().id().unwrap_or_default()).unwrap_or_default(); 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 display_name = object.name().unwrap_or_default().to_string();
let username = object.preferred_username().unwrap_or_default().to_string(); let username = object.preferred_username().unwrap_or_default().to_string();

View file

@ -28,12 +28,48 @@ impl ObjectCache {
self.0.get(k).map(|x| x.clone()) self.0.get(k).map(|x| x.clone())
} }
pub fn get_or(&self, k: &str, or: serde_json::Value) -> serde_json::Value {
self.get(k).unwrap_or(or)
}
pub fn put(&self, k: String, v: serde_json::Value) { pub fn put(&self, k: String, v: serde_json::Value) {
self.0.insert(k, v); self.0.insert(k, v);
} }
pub async fn fetch(&self, k: &str, kind: FetchKind) -> reqwest::Result<serde_json::Value> {
match self.get(k) {
Some(x) => Ok(x),
None => {
let obj = reqwest::get(Uri::api(kind, k, true))
.await?
.json::<serde_json::Value>()
.await?;
self.put(k.to_string(), obj);
Ok(self.get(k).expect("not found in cache after insertion"))
}
}
}
} }
#[derive(Debug, Clone)]
pub enum FetchKind {
User,
Object,
Activity,
Context,
}
impl AsRef<str> for FetchKind {
fn as_ref(&self) -> &str {
match self {
Self::User => "users",
Self::Object => "objects",
Self::Activity => "activities",
Self::Context => "context",
}
}
}
pub struct Http; pub struct Http;
@ -79,7 +115,8 @@ impl Http {
pub struct Uri; pub struct Uri;
impl Uri { impl Uri {
pub fn full(kind: &str, id: &str) -> String { pub fn full(kind: FetchKind, id: &str) -> String {
let kind = kind.as_ref();
if id.starts_with('+') { if id.starts_with('+') {
id.replace('+', "https://").replace('@', "/") id.replace('+', "https://").replace('@', "/")
} else { } else {
@ -111,7 +148,8 @@ impl Uri {
/// - https://other.domain.net/unexpected/path/root /// - https://other.domain.net/unexpected/path/root
/// - +other.domain.net@users@root /// - +other.domain.net@users@root
/// - root /// - root
pub fn web(kind: &str, url: &str) -> String { pub fn web(kind: FetchKind, url: &str) -> String {
let kind = kind.as_ref();
format!("/web/{kind}/{}", Self::short(url)) format!("/web/{kind}/{}", Self::short(url))
} }
@ -123,7 +161,8 @@ impl Uri {
/// - https://other.domain.net/unexpected/path/root /// - https://other.domain.net/unexpected/path/root
/// - +other.domain.net@users@root /// - +other.domain.net@users@root
/// - root /// - root
pub fn api(kind: &str, url: &str, fetch: bool) -> String { pub fn api(kind: FetchKind, url: &str, fetch: bool) -> String {
let kind = kind.as_ref();
format!("{URL_BASE}/{kind}/{}{}", Self::short(url), if fetch { "?fetch=true" } else { "" }) format!("{URL_BASE}/{kind}/{}{}", Self::short(url), if fetch { "?fetch=true" } else { "" })
} }
} }

View file

@ -38,11 +38,11 @@ pub fn UserPage(tl: Timeline) -> impl IntoView {
let _id = id.clone(); // wtf triple clone??? TODO!! let _id = id.clone(); // wtf triple clone??? TODO!!
let actor = create_local_resource(move || _id.clone(), move |id| { let actor = create_local_resource(move || _id.clone(), move |id| {
async move { async move {
match CACHE.get(&Uri::full("users", &id)) { match CACHE.get(&Uri::full(FetchKind::User, &id)) {
Some(x) => Some(x.clone()), Some(x) => Some(x.clone()),
None => { None => {
let user : serde_json::Value = Http::fetch(&Uri::api("users", &id, true), auth).await.ok()?; let user : serde_json::Value = Http::fetch(&Uri::api(FetchKind::User, &id, true), auth).await.ok()?;
CACHE.put(Uri::full("users", &id), user.clone()); CACHE.put(Uri::full(FetchKind::User, &id), user.clone());
Some(user) Some(user)
}, },
} }
@ -71,7 +71,7 @@ pub fn UserPage(tl: Timeline) -> impl IntoView {
let following = object.following().get().map(|x| x.total_items().unwrap_or(0)).unwrap_or(0); let following = object.following().get().map(|x| x.total_items().unwrap_or(0)).unwrap_or(0);
let followers = object.followers().get().map(|x| x.total_items().unwrap_or(0)).unwrap_or(0); let followers = object.followers().get().map(|x| x.total_items().unwrap_or(0)).unwrap_or(0);
let statuses = object.outbox().get().map(|x| x.total_items().unwrap_or(0)).unwrap_or(0); let statuses = object.outbox().get().map(|x| x.total_items().unwrap_or(0)).unwrap_or(0);
let tl_url = format!("{}/outbox/page", Uri::api("users", &id.clone(), false)); let tl_url = format!("{}/outbox/page", Uri::api(FetchKind::User, &id.clone(), false));
if !tl.next.get().starts_with(&tl_url) { if !tl.next.get().starts_with(&tl_url) {
tl.reset(tl_url); tl.reset(tl_url);
} }
@ -136,11 +136,11 @@ pub fn ObjectPage(tl: Timeline) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context"); 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| { let object = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), move |oid| {
async move { async move {
match CACHE.get(&Uri::full("objects", &oid)) { match CACHE.get(&Uri::full(FetchKind::Object, &oid)) {
Some(x) => Some(x.clone()), Some(x) => Some(x.clone()),
None => { None => {
let obj = Http::fetch::<serde_json::Value>(&Uri::api("objects", &oid, true), auth).await.ok()?; let obj = Http::fetch::<serde_json::Value>(&Uri::api(FetchKind::Object, &oid, true), auth).await.ok()?;
CACHE.put(Uri::full("objects", &oid), obj.clone()); CACHE.put(Uri::full(FetchKind::Object, &oid), obj.clone());
Some(obj) Some(obj)
} }
} }
@ -154,12 +154,13 @@ pub fn ObjectPage(tl: Timeline) -> impl IntoView {
None => view! { <p> loading ... </p> }.into_view(), None => view! { <p> loading ... </p> }.into_view(),
Some(None) => view! { <p><code>loading failed</code></p> }.into_view(), Some(None) => view! { <p><code>loading failed</code></p> }.into_view(),
Some(Some(o)) => { Some(Some(o)) => {
let tl_url = format!("{}/page", Uri::api("context", &o.context().id().unwrap_or_default(), false)); 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) { if !tl.next.get().starts_with(&tl_url) {
tl.reset(tl_url); tl.reset(tl_url);
} }
view!{ view!{
<Object object=o.clone() /> <Object object=object />
<div class="ml-1 mr-1 mt-2"> <div class="ml-1 mr-1 mt-2">
<TimelineFeed tl=tl /> <TimelineFeed tl=tl />
</div> </div>

View file

@ -1,5 +1,5 @@
pub use crate::{ pub use crate::{
Http, Uri, Http, Uri, FetchKind,
CACHE, URL_BASE, CACHE, URL_BASE,
auth::{Auth, AuthToken}, auth::{Auth, AuthToken},
page::*, page::*,