feat(web): way better fetch button

it changes and automatically refreshes!!! to achieve this a lot of
refactor happened...
This commit is contained in:
əlemi 2024-11-21 02:41:19 +01:00
parent bc9f55d58c
commit d8af116667
Signed by: alemi
GPG key ID: A4895B84D311642C
7 changed files with 117 additions and 51 deletions

View file

@ -387,6 +387,10 @@
span.json-text { span.json-text {
color: var(--text); color: var(--text);
} }
span.tab-active {
color: var(--accent);
font-weight: bold;
}
pre.striped { pre.striped {
background: repeating-linear-gradient( background: repeating-linear-gradient(
135deg, 135deg,

View file

@ -105,7 +105,7 @@ pub fn App() -> impl IntoView {
// refresh notifications // refresh notifications
let (notifications, set_notifications) = create_signal(0); let (notifications, set_notifications) = create_signal(0);
let fetch_notifications = move || spawn_local(async move { let fetch_notifications = move || spawn_local(async move {
let actor_id = userid.get().unwrap_or_default(); let actor_id = userid.get_untracked().unwrap_or_default();
let notif_url = format!("{actor_id}/notifications"); let notif_url = format!("{actor_id}/notifications");
match Http::fetch::<serde_json::Value>(&notif_url, auth).await { match Http::fetch::<serde_json::Value>(&notif_url, auth).await {
Err(e) => tracing::error!("failed fetching notifications: {e}"), Err(e) => tracing::error!("failed fetching notifications: {e}"),
@ -180,7 +180,6 @@ pub fn App() -> impl IntoView {
<Route path="objects/:id" view=ObjectView > <Route path="objects/:id" view=ObjectView >
<Route path="" view=ObjectContext /> <Route path="" view=ObjectContext />
<Route path="replies" view=ObjectReplies /> <Route path="replies" view=ObjectReplies />
<Route path="context" view=ObjectContext />
// <Route path="liked" view=ObjectLiked /> // <Route path="liked" view=ObjectLiked />
// <Route path="announced" view=ObjectAnnounced /> // <Route path="announced" view=ObjectAnnounced />
</Route> </Route>
@ -204,30 +203,52 @@ pub fn App() -> impl IntoView {
} }
} }
#[derive(Debug, Clone, Copy)]
pub(crate) enum FeedRoute {
Home, Global, Server, Notifications, User, Replies, Context
}
#[component] #[component]
fn Scrollable() -> impl IntoView { fn Scrollable() -> impl IntoView {
let location = use_location(); let location = use_location();
let feeds = use_context::<Feeds>().expect("missing feeds context"); let feeds = use_context::<Feeds>().expect("missing feeds context");
let auth = use_context::<Auth>().expect("missing auth context"); let auth = use_context::<Auth>().expect("missing auth context");
let config = use_context::<Signal<crate::Config>>().expect("missing config context"); let config = use_context::<Signal<crate::Config>>().expect("missing config context");
// TODO this is terrible!! omg maybe it should receive from context current timeline?? idk this
// is awful and i patched it another time instead of doing it properly...
// at least im going to provide a route enum to use in other places
let (route, set_route) = create_signal(FeedRoute::Home);
let relevant_timeline = Signal::derive(move || { let relevant_timeline = Signal::derive(move || {
let path = location.pathname.get(); let path = location.pathname.get();
if path.contains("/web/home") { if path.contains("/web/home") {
set_route.set(FeedRoute::Home);
Some(feeds.home) Some(feeds.home)
} else if path.contains("/web/global") { } else if path.contains("/web/global") {
set_route.set(FeedRoute::Global);
Some(feeds.global) Some(feeds.global)
} else if path.contains("/web/local") { } else if path.contains("/web/local") {
set_route.set(FeedRoute::Server);
Some(feeds.server) Some(feeds.server)
} else if path.starts_with("/web/notifications") { } else if path.starts_with("/web/notifications") {
set_route.set(FeedRoute::Notifications);
Some(feeds.notifications) Some(feeds.notifications)
} else if path.starts_with("/web/actors") { } else if path.starts_with("/web/actors") {
set_route.set(FeedRoute::User);
Some(feeds.user) Some(feeds.user)
} else if path.starts_with("/web/objects") { } else if path.starts_with("/web/objects") {
Some(feeds.context) if matches!(path.split('/').nth(4), Some("replies")) {
set_route.set(FeedRoute::Replies);
Some(feeds.replies)
} else {
set_route.set(FeedRoute::Context);
Some(feeds.context)
}
} else { } else {
None None
} }
}); });
provide_context(route);
provide_context(relevant_timeline);
let breadcrumb = Signal::derive(move || { let breadcrumb = Signal::derive(move || {
let path = location.pathname.get(); let path = location.pathname.get();
let mut path_iter = path.split('/').skip(2); let mut path_iter = path.split('/').skip(2);

View file

@ -60,6 +60,7 @@ pub trait Cache {
fn lookup(&self, key: &str) -> Option<impl Deref<Target = LookupStatus<Self::Item>>>; fn lookup(&self, key: &str) -> Option<impl Deref<Target = LookupStatus<Self::Item>>>;
fn store(&self, key: &str, value: Self::Item) -> Option<Self::Item>; fn store(&self, key: &str, value: Self::Item) -> Option<Self::Item>;
fn invalidate(&self, key: &str);
fn get(&self, key: &str) -> Option<Self::Item> where Self::Item : Clone { fn get(&self, key: &str) -> Option<Self::Item> where Self::Item : Clone {
Some(self.lookup(key)?.deref().inner()?.clone()) Some(self.lookup(key)?.deref().inner()?.clone())
@ -88,11 +89,16 @@ impl<T> Cache for DashmapCache<T> {
self.0.insert(key.to_string(), LookupStatus::Found(value)) self.0.insert(key.to_string(), LookupStatus::Found(value))
.and_then(|x| if let LookupStatus::Found(x) = x { Some(x) } else { None } ) .and_then(|x| if let LookupStatus::Found(x) = x { Some(x) } else { None } )
} }
fn invalidate(&self, key: &str) {
self.0.remove(key);
}
} }
impl DashmapCache<Object> { impl DashmapCache<Object> {
pub async fn resolve(&self, key: &str, kind: UriClass, auth: Auth) -> Option<Object> { pub async fn resolve(&self, key: &str, kind: UriClass, auth: Auth) -> Option<Object> {
let full_key = Uri::full(kind, key); let full_key = Uri::full(kind, key);
tracing::info!("resolving {key} -> {full_key}");
match self.get(&full_key) { match self.get(&full_key) {
Some(x) => Some(x), Some(x) => Some(x),
None => { None => {

View file

@ -8,14 +8,12 @@ use crate::prelude::*;
pub fn ObjectContext() -> impl IntoView { pub fn ObjectContext() -> impl IntoView {
let feeds = use_context::<Feeds>().expect("missing feeds context"); let feeds = use_context::<Feeds>().expect("missing feeds context");
let params = use_params::<IdParam>(); let params = use_params::<IdParam>();
let id = Signal::derive(move || { let id = move || {
let id = params.get().ok() let id = params.with(|p| p.as_ref().ok().and_then(|x| x.id.as_ref()).cloned()).unwrap_or_default();
.and_then(|x| x.id)
.unwrap_or_default();
Uri::full(U::Object, &id) Uri::full(U::Object, &id)
}); };
let context_id = Signal::derive(move || let context_id = Signal::derive(move ||
cache::OBJECTS.get(&id.get()) cache::OBJECTS.get(&id())
.and_then(|x| x.context().id().ok()) .and_then(|x| x.context().id().ok())
.unwrap_or_default() .unwrap_or_default()
); );
@ -27,7 +25,7 @@ pub fn ObjectContext() -> impl IntoView {
}); });
view! { view! {
<div class="mr-1-r ml-1-r"> <div class="mr-1-r ml-1-r">
<Thread tl=feeds.context root=id.get() /> <Thread tl=feeds.context root=id() />
</div> </div>
} }
} }

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use leptos::*; use leptos::*;
use crate::{prelude::*, URL_SENSITIVE}; use crate::{prelude::*, URL_SENSITIVE};
use apb::{target::Addressed, ActivityMut, Base, Collection, CollectionMut, Object, ObjectMut}; use apb::{target::Addressed, ActivityMut, Base, Collection, CollectionMut, Object, ObjectMut, Shortcuts};
#[component] #[component]
pub fn Object(object: crate::Object) -> impl IntoView { pub fn Object(object: crate::Object) -> impl IntoView {
@ -20,9 +20,9 @@ pub fn Object(object: crate::Object) -> impl IntoView {
.filter_map(|x| x.into_inner().ok()) // TODO maybe show links? .filter_map(|x| x.into_inner().ok()) // TODO maybe show links?
.map(|x| view! { <Attachment object=x sensitive=sensitive /> }) .map(|x| view! { <Attachment object=x sensitive=sensitive /> })
.collect_view(); .collect_view();
let comments = object.replies().inner().and_then(|x| x.total_items()).unwrap_or_default(); let comments = object.replies_count().unwrap_or_default();
let shares = object.shares().inner().and_then(|x| x.total_items()).unwrap_or_default(); let shares = object.shares_count().unwrap_or_default();
let likes = object.likes().inner().and_then(|x| x.total_items()).unwrap_or_default(); let likes = object.likes_count().unwrap_or_default();
let already_liked = object.liked_by_me().unwrap_or(false); let already_liked = object.liked_by_me().unwrap_or(false);
let attachments_padding = if object.attachment().is_empty() { let attachments_padding = if object.attachment().is_empty() {
@ -220,7 +220,7 @@ pub fn Summary(summary: Option<String>, children: Children) -> impl IntoView {
#[component] #[component]
pub fn LikeButton( pub fn LikeButton(
n: u64, n: i32,
target: String, target: String,
liked: bool, liked: bool,
author: String, author: String,
@ -279,7 +279,7 @@ pub fn LikeButton(
} }
#[component] #[component]
pub fn ReplyButton(n: u64, target: String) -> impl IntoView { pub fn ReplyButton(n: i32, target: String) -> impl IntoView {
let reply = use_context::<ReplyControls>().expect("missing reply controls context"); let reply = use_context::<ReplyControls>().expect("missing reply controls context");
let auth = use_context::<Auth>().expect("missing auth context"); let auth = use_context::<Auth>().expect("missing auth context");
let comments = if n > 0 { let comments = if n > 0 {
@ -304,7 +304,7 @@ pub fn ReplyButton(n: u64, target: String) -> impl IntoView {
} }
#[component] #[component]
pub fn RepostButton(n: u64, target: String) -> impl IntoView { pub fn RepostButton(n: i32, target: String) -> impl IntoView {
let (count, set_count) = create_signal(n); let (count, set_count) = create_signal(n);
let (clicked, set_clicked) = create_signal(true); let (clicked, set_clicked) = create_signal(true);
let auth = use_context::<Auth>().expect("missing auth context"); let auth = use_context::<Auth>().expect("missing auth context");

View file

@ -8,7 +8,7 @@ pub fn ObjectReplies() -> impl IntoView {
let feeds = use_context::<Feeds>().expect("missing feeds context"); let feeds = use_context::<Feeds>().expect("missing feeds context");
let params = use_params::<IdParam>(); let params = use_params::<IdParam>();
let id = Signal::derive(move || let id = Signal::derive(move ||
params.get().ok().and_then(|x| x.id).unwrap_or_default() params.with(|p| p.as_ref().ok().and_then(|x| x.id.as_ref()).cloned()).unwrap_or_default()
); );
create_effect(move |_| { create_effect(move |_| {
let tl_url = format!("{}/replies/page", Uri::api(U::Object, &id.get(), false)); let tl_url = format!("{}/replies/page", Uri::api(U::Object, &id.get(), false));

View file

@ -1,16 +1,23 @@
use ev::MouseEvent;
use leptos::*; use leptos::*;
use leptos_router::*; use leptos_router::*;
use crate::prelude::*; use crate::{app::FeedRoute, prelude::*, Config};
use apb::{Base, Object}; use apb::{Base, Object};
#[component] #[component]
pub fn ObjectView() -> impl IntoView { pub fn ObjectView() -> impl IntoView {
let params = use_params_map(); let params = use_params_map();
let matched_route = use_context::<ReadSignal<crate::app::FeedRoute>>().expect("missing route context");
let auth = use_context::<Auth>().expect("missing auth context"); let auth = use_context::<Auth>().expect("missing auth context");
let config = use_context::<Signal<Config>>().expect("missing config context");
let relevant_tl = use_context::<Signal<Option<Timeline>>>().expect("missing relevant timeline context");
let (loading, set_loading) = create_signal(false);
let id = Signal::derive(move || params.get().get("id").cloned().unwrap_or_default());
let object = create_local_resource( let object = create_local_resource(
move || params.get().get("id").cloned().unwrap_or_default(), move || (id.get(), loading.get()),
move |oid| async move { move |(oid, loading)| async move {
tracing::info!("rerunning fetcher");
let obj = cache::OBJECTS.resolve(&oid, U::Object, auth).await?; let obj = cache::OBJECTS.resolve(&oid, U::Object, auth).await?;
if let Ok(author) = obj.attributed_to().id() { if let Ok(author) = obj.attributed_to().id() {
cache::OBJECTS.resolve(&author, U::Actor, auth).await; cache::OBJECTS.resolve(&author, U::Actor, auth).await;
@ -26,39 +33,69 @@ pub fn ObjectView() -> impl IntoView {
} }
); );
{move || match object.get() { view! {
None => view! { <Loader /> }.into_view(), {move || match object.get() {
Some(None) => { None => view! { <Loader /> }.into_view(),
let raw_id = params.get().get("id").cloned().unwrap_or_default(); Some(None) => {
let uid = uriproxy::uri(URL_BASE, uriproxy::UriClass::Object, &raw_id); let raw_id = params.get().get("id").cloned().unwrap_or_default();
view! { <p class="center"><code>loading failed</code><sup><small><a class="clean" href={uid} target="_blank">""</a></small></sup></p> }.into_view() let uid = uriproxy::uri(URL_BASE, uriproxy::UriClass::Object, &raw_id);
}, view! { <p class="center"><code>loading failed</code><sup><small><a class="clean" href={uid} target="_blank">""</a></small></sup></p> }.into_view()
Some(Some(o)) => { },
let object = o.clone(); Some(Some(o)) => {
let oid = o.id().unwrap_or_default(); tracing::info!("redrawing object");
let base = Uri::web(U::Object, &oid); view! { <Object object=o.clone() /> }.into_view()
let api = Uri::api(U::Object, &oid, false); },
view!{ }}
<Object object=object />
<hr class="color ma-2" /> <p>
<code class="cw color center mt-1 mb-1 ml-3 mr-3"> <span class:tab-active=move || matches!(matched_route.get(), FeedRoute::Context)><a class="clean" href=format!("/web/objects/{}", id.get())><span class="emoji ml-2">"🕸️ "</span>"context"</a></span>
<a class="clean" href=format!("{base}/context")><span class="emoji">"🕸️"</span>" "<b>context</b></a>" | "<a class="clean" href=format!("{base}/replies")><span class="emoji">"📫"</span>" "<b>replies</b></a> <span class:tab-active=move || matches!(matched_route.get(), FeedRoute::Replies)><a class="clean" href=format!("/web/objects/{}/replies", id.get())><span class="emoji ml-2">"📫 "</span>"replies"</a></span>
{if auth.present() { {move || if auth.present() {
Some(view! { if loading.get() {
" | "<a class="clean" href="#crawl" on:click=move |_| crawl(api.clone(), auth) ><span class="emoji">""</span></a> Some(view! {
}) <span style="float: right">
} else { None }} "fetching "<span class="dots"></span>
</code> </span>
<Outlet /> })
}.into_view() } else {
}, Some(view! {
}} <span style="float: right">
<a
class="clean"
on:click=move |ev| fetch_cb(ev, set_loading, id.get(), auth, config, relevant_tl)
href="#"
>
<span class="emoji ml-2">""</span>"fetch"
</a>
</span>
})
}
} else {
None
}}
</p>
<hr class="color" />
{move || if object.get().is_some() {
tracing::info!("redrawing outlet");
Some(view! { <Outlet /> })
} else {
None
}}
}
} }
fn crawl(base: String, auth: Auth) { fn fetch_cb(ev: MouseEvent, set_loading: WriteSignal<bool>, oid: String, auth: Auth, config: Signal<Config>, relevant_tl: Signal<Option<Timeline>>) {
let api = Uri::api(U::Object, &oid, false);
ev.prevent_default();
set_loading.set(true);
spawn_local(async move { spawn_local(async move {
if let Err(e) = Http::fetch::<serde_json::Value>(&format!("{base}/replies?fetch=true"), auth).await { if let Err(e) = Http::fetch::<serde_json::Value>(&format!("{api}/replies?fetch=true"), auth).await {
tracing::error!("failed crawling replies for {base}: {e}"); tracing::error!("failed crawling replies for {oid}: {e}");
} }
cache::OBJECTS.invalidate(&Uri::full(U::Object, &oid));
tracing::info!("invalidated {}", Uri::full(U::Object, &oid));
set_loading.set(false);
relevant_tl.get().inspect(|x| x.refresh(auth, config));
}); });
} }