feat(web): big refactor, way better tl caching

this moves quite a lot of stuff around, and there's probably more work
to do (app.rs is HUGE and those routes with inline views are pretty
ugly), but it should work and most importantly it should work BETTER
than before: _every_ timeline now remembers state and scroll depth, not
just those we manually choose! no longer huge global "Timelines" object
yayyy!! i think this opens room for much more improvements, but for now
i'm content this way
This commit is contained in:
əlemi 2025-01-20 03:05:27 +01:00
parent febfbcbca7
commit 3329952a12
Signed by: alemi
GPG key ID: A4895B84D311642C
20 changed files with 498 additions and 790 deletions

View file

@ -1,6 +1,6 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::hooks::use_params; use leptos_router::hooks::use_params;
use crate::{prelude::*, timeline::any::Loadable}; use crate::prelude::*;
#[component] #[component]
pub fn FollowList(outgoing: bool) -> impl IntoView { pub fn FollowList(outgoing: bool) -> impl IntoView {

View file

@ -8,8 +8,7 @@ use apb::{ActivityMut, Actor, Base, Object, ObjectMut, Shortcuts};
pub fn ActorHeader() -> impl IntoView { pub fn ActorHeader() -> impl IntoView {
let params = use_params::<IdParam>(); let params = use_params::<IdParam>();
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 refresh = use_context::<WriteSignal<()>>().expect("missing refresh context");
let relevant_tl = use_context::<Signal<Option<Timeline>>>().expect("missing relevant timeline context");
let matched_route = use_context::<ReadSignal<crate::app::FeedRoute>>().expect("missing route context"); let matched_route = use_context::<ReadSignal<crate::app::FeedRoute>>().expect("missing route context");
let (loading, set_loading) = signal(false); let (loading, set_loading) = signal(false);
let actor = LocalResource::new( let actor = LocalResource::new(
@ -19,7 +18,7 @@ pub fn ActorHeader() -> impl IntoView {
match cache::OBJECTS.get(&Uri::full(U::Actor, &id)) { match cache::OBJECTS.get(&Uri::full(U::Actor, &id)) {
Some(x) => Some(x.clone()), Some(x) => Some(x.clone()),
None => { None => {
let user = cache::OBJECTS.resolve(&id, U::Actor, auth).await?; let user = cache::OBJECTS.fetch(&id, U::Actor, auth).await?;
Some(user) Some(user)
}, },
} }
@ -148,7 +147,7 @@ pub fn ActorHeader() -> impl IntoView {
<span style="float: right"> <span style="float: right">
<a <a
class="clean" class="clean"
on:click=move |ev| fetch_cb(ev, set_loading, uid.clone(), auth, config, relevant_tl) on:click=move |ev| fetch_cb(ev, set_loading, uid.clone(), auth, refresh)
href="#" href="#"
> >
<span class="emoji ml-2">""</span><span class="hidden-on-mobile">"fetch"</span> <span class="emoji ml-2">""</span><span class="hidden-on-mobile">"fetch"</span>
@ -208,7 +207,7 @@ fn unfollow(target: String) {
}) })
} }
fn fetch_cb(ev: leptos::ev::MouseEvent, set_loading: WriteSignal<bool>, uid: String, auth: Auth, config: Signal<crate::Config>, relevant_tl: Signal<Option<Timeline>>) { fn fetch_cb(ev: leptos::ev::MouseEvent, set_loading: WriteSignal<bool>, uid: String, auth: Auth, refresh: WriteSignal<()>) {
let api = Uri::api(U::Actor, &uid, false); let api = Uri::api(U::Actor, &uid, false);
ev.prevent_default(); ev.prevent_default();
set_loading.set(true); set_loading.set(true);
@ -217,6 +216,6 @@ fn fetch_cb(ev: leptos::ev::MouseEvent, set_loading: WriteSignal<bool>, uid: Str
tracing::error!("failed fetching outbox for {uid}: {e}"); tracing::error!("failed fetching outbox for {uid}: {e}");
} }
set_loading.set(false); set_loading.set(false);
relevant_tl.get().inspect(|x| x.refresh(auth, config)); refresh.set(());
}); });
} }

View file

@ -4,32 +4,24 @@ use crate::prelude::*;
#[component] #[component]
pub fn ActorPosts() -> impl IntoView { pub fn ActorPosts() -> impl IntoView {
let feeds = use_context::<Feeds>().expect("missing feeds context");
let params = use_params::<IdParam>(); let params = use_params::<IdParam>();
Effect::new(move |_| { let id = params.get().ok().and_then(|x| x.id).unwrap_or_default();
let id = params.get().ok().and_then(|x| x.id).unwrap_or_default();
let tl_url = format!("{}/outbox/page", Uri::api(U::Actor, &id, false));
if !feeds.user.next.get_untracked().starts_with(&tl_url) {
feeds.user.reset(Some(tl_url));
}
});
view! { view! {
<Feed tl=feeds.user /> <Loadable
base=format!("{}/outbox/page", Uri::api(U::Actor, &id, false))
element=move |item| view! { <Item item=item sep=true /> }
/>
} }
} }
#[component] #[component]
pub fn ActorLikes() -> impl IntoView { pub fn ActorLikes() -> impl IntoView {
let feeds = use_context::<Feeds>().expect("missing feeds context");
let params = use_params::<IdParam>(); let params = use_params::<IdParam>();
Effect::new(move |_| { let id = params.get().ok().and_then(|x| x.id).unwrap_or_default();
let id = params.get().ok().and_then(|x| x.id).unwrap_or_default();
let likes_url = format!("{}/likes/page", Uri::api(U::Actor, &id, false));
if !feeds.user.next.get_untracked().starts_with(&likes_url) {
feeds.user_likes.reset(Some(likes_url));
}
});
view! { view! {
<Feed tl=feeds.user_likes /> <Loadable
base=format!("{}/likes/page", Uri::api(U::Actor, &id, false))
element=move |item| view! { <Item item=item sep=true /> }
/>
} }
} }

View file

@ -1,63 +1,13 @@
use apb::Collection; use apb::{Collection, Object};
use leptos::{either::Either, prelude::*}; use leptos::{either::Either, prelude::*};
use leptos_router::{components::*, hooks::use_location, path}; use leptos_router::{components::*, hooks::{use_location, use_params}, path};
use crate::{groups::GroupList, prelude::*}; use crate::prelude::*;
use leptos_use::{ use leptos_use::{
signal_debounced, storage::use_local_storage, use_cookie_with_options, use_element_size, use_window_scroll, signal_debounced, storage::use_local_storage, use_cookie_with_options, use_element_size, use_window_scroll,
UseCookieOptions, UseElementSizeReturn UseCookieOptions, UseElementSizeReturn
}; };
// TODO this is getting out of hand
// when we will add lists there will have to potentially be multiple timelines (one per list)
// per user, which doesn't scale with this model. we should either take the "go back to where
// you were" into our own hands (maybe with timeline "segments"? would also solve slow load,
// but infinite-scroll upwards too may be hard to do) or understand how it works (with page
// stacks?) and keep timelines local to views.
#[derive(Clone, Copy)]
pub struct Feeds {
pub home: Timeline,
pub global: Timeline,
pub notifications: Timeline,
// exploration feeds
pub user: Timeline,
pub user_likes: Timeline,
pub server: Timeline,
pub context: Timeline,
pub replies: Timeline,
pub object_likes: Timeline,
pub tag: Timeline,
}
impl Feeds {
pub fn new(username: &str) -> Self {
Feeds {
home: Timeline::new(format!("{URL_BASE}/actors/{username}/inbox/page")),
notifications: Timeline::new(format!("{URL_BASE}/actors/{username}/notifications/page")),
global: Timeline::new(format!("{URL_BASE}/inbox/page")),
user: Timeline::new(format!("{URL_BASE}/actors/{username}/outbox/page")),
user_likes: Timeline::new(format!("{URL_BASE}/actors/{username}/likes")),
server: Timeline::new(format!("{URL_BASE}/outbox/page")),
tag: Timeline::new(format!("{URL_BASE}/tags/upub/page")),
context: Timeline::new(format!("{URL_BASE}/outbox/page")), // TODO ehhh
replies: Timeline::new(format!("{URL_BASE}/outbox/page")), // TODO ehhh
object_likes: Timeline::new(format!("{URL_BASE}/outbox/page")), // TODO ehhh
}
}
pub fn reset(&self) {
self.home.reset(None);
self.notifications.reset(None);
self.global.reset(None);
self.user.reset(None);
self.user_likes.reset(None);
self.server.reset(None);
self.context.reset(None);
self.replies.reset(None);
self.tag.reset(None);
}
}
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
let (token, set_token) = use_cookie_with_options::<String, codee::string::FromToStringCodec>( let (token, set_token) = use_cookie_with_options::<String, codee::string::FromToStringCodec>(
@ -96,15 +46,8 @@ pub fn App() -> impl IntoView {
} }
}); });
let username = auth.userid.get_untracked()
.map(|x| x.split('/').last().unwrap_or_default().to_string())
.unwrap_or_default();
let feeds = Feeds::new(&username);
provide_context(auth); provide_context(auth);
provide_context(config); provide_context(config);
provide_context(feeds);
provide_context(privacy); provide_context(privacy);
let reply_controls = ReplyControls::default(); let reply_controls = ReplyControls::default();
@ -178,20 +121,74 @@ pub fn App() -> impl IntoView {
if auth.present() { if auth.present() {
view! { <Redirect path="home" /> } view! { <Redirect path="home" /> }
} else { } else {
view! { <Redirect path="global" /> } view! { <Redirect path="local" /> }
} }
/> />
<Route path=path!("home") view=move || view! { <Feed tl=feeds.home /> } />
<Route path=path!("global") view=move || view! { <Feed tl=feeds.global /> } />
<Route path=path!("local") view=move || view! { <Feed tl=feeds.server /> } />
<Route path=path!("notifications") view=move || view! { <Feed tl=feeds.notifications ignore_filters=true /> } />
<Route path=path!("tags/:id") view=move || view! { <HashtagFeed tl=feeds.tag /> } />
<Route path=path!("groups") view=GroupList />
// main timelines
<Route path=path!("home") view=move || if auth.present() {
Either::Left(view! {
<Loadable
base=format!("{URL_BASE}/actors/{}/inbox/page", auth.username())
element=move |obj| view! { <Item item=obj sep=true /> }
/>
})
} else {
Either::Right(view! { <Unauthorized /> })
} />
<Route path=path!("global") view=move || view! {
<Loadable
base=format!("{URL_BASE}/inbox/page")
element=move |obj| view! { <Item item=obj sep=true /> }
/>
} />
<Route path=path!("local") view=move || view! {
<Loadable
base=format!("{URL_BASE}/outbox/page")
element=move |obj| view! { <Item item=obj sep=true /> }
/>
} />
<Route path=path!("notifications") view=move || if auth.present() {
Either::Left(view! {
<Loadable
base=format!("{URL_BASE}/actors/{}/notifications/page", auth.username())
element=move |obj| view! { <Item item=obj sep=true always=true /> }
/>
})
} else {
Either::Right(view! { <Unauthorized /> })
} />
<Route path=path!("tags/:id") view=move || {
let params = use_params::<IdParam>();
let tag = params.get().ok().and_then(|x| x.id).unwrap_or_default();
view! {
<Loadable
base=format!("{URL_BASE}/tag/{tag}/page", )
element=move |obj| view! { <Item item=obj sep=true always=true /> }
/>
}
} />
<Route path=path!("groups") view=move || view! {
<Loadable
base=format!("{URL_BASE}/groups/page")
convert=U::Actor
element=|obj| view! { <ActorBanner object=obj /><hr/> }
/>
} />
// static pages, configs and tools
<Route path=path!("about") view=AboutPage /> <Route path=path!("about") view=AboutPage />
<Route path=path!("config") view=move || view! { <ConfigPage setter=set_config /> } /> <Route path=path!("config") view=move || view! { <ConfigPage setter=set_config /> } />
<Route path=path!("explore") view=DebugPage /> <Route path=path!("explore") view=DebugPage />
<Route path=path!("search") view=SearchPage />
<Route path=path!("register") view=RegisterPage />
// actors
<ParentRoute path=path!("actors/:id") view=ActorHeader > // TODO can we avoid this? <ParentRoute path=path!("actors/:id") view=ActorHeader > // TODO can we avoid this?
<Route path=path!("") view=ActorPosts /> <Route path=path!("") view=ActorPosts />
<Route path=path!("likes") view=ActorLikes /> <Route path=path!("likes") view=ActorLikes />
@ -199,18 +196,52 @@ pub fn App() -> impl IntoView {
<Route path=path!("followers") view=move || view! { <FollowList outgoing=false /> } /> <Route path=path!("followers") view=move || view! { <FollowList outgoing=false /> } />
</ParentRoute> </ParentRoute>
// objects
<ParentRoute path=path!("objects/:id") view=ObjectView > <ParentRoute path=path!("objects/:id") view=ObjectView >
<Route path=path!("") view=ObjectContext /> <Route path=path!("") view=move || {
<Route path=path!("replies") view=ObjectReplies /> let params = use_params::<IdParam>();
<Route path=path!("likes") view=ObjectLikes /> let id = params.get().ok().and_then(|x| x.id).unwrap_or_default();
let oid = Uri::full(U::Object, &id);
let context_id = crate::cache::OBJECTS.get(&oid)
.and_then(|obj| obj.context().id().ok())
.unwrap_or(oid.clone());
view! {
<Loadable
base=format!("{}/context/page", Uri::api(U::Object, &context_id, false))
convert=U::Object
element=move |obj| view! { <Item item=obj always=true slim=true /> }
thread=oid
/>
}
} />
<Route path=path!("replies") view=move || {
let params = use_params::<IdParam>();
let id = params.get().ok().and_then(|x| x.id).unwrap_or_default();
let oid = Uri::full(U::Object, &id);
view! {
<Loadable
base=format!("{}/replies/page", Uri::api(U::Object, &oid, false))
convert=U::Object
element=move |obj| view! { <Item item=obj always=true slim=true /> }
/>
}
} />
<Route path=path!("likes") view=move || {
let params = use_params::<IdParam>();
let id = params.get().ok().and_then(|x| x.id).unwrap_or_default();
let oid = Uri::full(U::Object, &id);
view! {
<Loadable
base=format!("{}/likes/page", Uri::api(U::Object, &oid, false))
element=move |obj| view! { <Item item=obj always=true /> }
/>
}
} />
// <Route path="announced" view=ObjectAnnounced /> // <Route path="announced" view=ObjectAnnounced />
</ParentRoute> </ParentRoute>
// TODO a standalone way to view activities?
// <Route path="/web/activities/:id" view=move || view! { <ActivityPage tl=context_tl /> } /> // <Route path="/web/activities/:id" view=move || view! { <ActivityPage tl=context_tl /> } />
<Route path=path!("search") view=SearchPage />
<Route path=path!("register") view=RegisterPage />
</ParentRoute> </ParentRoute>
</Routes> </Routes>
</main> </main>
@ -228,73 +259,70 @@ pub fn App() -> impl IntoView {
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub(crate) enum FeedRoute { pub(crate) enum FeedRoute {
Home, Global, Server, Notifications, User, Following, Followers, ActorLikes, ObjectLikes, Replies, Context Unknown, Home, Global, Server, Notifications, User, Following, Followers, ActorLikes, ObjectLikes, Replies, Context
}
impl FeedRoute {
fn is_refreshable(&self) -> bool {
!matches!(self, Self::Unknown)
}
} }
#[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 auth = use_context::<Auth>().expect("missing auth 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 // 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... // 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 // at least im going to provide a route enum to use in other places
let (route, set_route) = signal(FeedRoute::Home); // UPDATE it's a bit less terrible since now we just update an enum but still probs should do
let relevant_timeline = Signal::derive(move || { // something fancier than this string stuff... now leptos has path segments as structs,
let path = location.pathname.get(); // maybe maybe maybe it's accessible to us??
if path.contains("/web/home") { let (route, set_route) = signal(FeedRoute::Unknown);
set_route.set(FeedRoute::Home); let _ = Effect::watch(
Some(feeds.home) move || location.pathname.get(),
} else if path.contains("/web/global") { move |path, _path_prev, _| {
set_route.set(FeedRoute::Global); if path.contains("/web/home") {
Some(feeds.global) set_route.set(FeedRoute::Home);
} else if path.contains("/web/local") { } else if path.contains("/web/global") {
set_route.set(FeedRoute::Server); set_route.set(FeedRoute::Global);
Some(feeds.server) } else if path.contains("/web/local") {
} else if path.starts_with("/web/notifications") { set_route.set(FeedRoute::Server);
set_route.set(FeedRoute::Notifications); } else if path.starts_with("/web/notifications") {
Some(feeds.notifications) set_route.set(FeedRoute::Notifications);
} else if path.starts_with("/web/actors") { } else if path.starts_with("/web/actors") {
match path.split('/').nth(4) { match path.split('/').nth(4) {
Some("following") => { Some("following") => {
set_route.set(FeedRoute::Following); set_route.set(FeedRoute::Following);
None },
}, Some("followers") => {
Some("followers") => { set_route.set(FeedRoute::Followers);
set_route.set(FeedRoute::Followers); },
None Some("likes") => {
}, set_route.set(FeedRoute::ActorLikes);
Some("likes") => { },
set_route.set(FeedRoute::ActorLikes); _ => {
Some(feeds.user_likes) set_route.set(FeedRoute::User);
}, },
_ => { }
set_route.set(FeedRoute::User); } else if path.starts_with("/web/objects") {
Some(feeds.user) match path.split('/').nth(4) {
}, Some("likes") => {
set_route.set(FeedRoute::ObjectLikes);
},
Some("replies") => {
set_route.set(FeedRoute::Replies);
},
_ => {
set_route.set(FeedRoute::Context);
},
}
} else {
set_route.set(FeedRoute::Unknown);
} }
} else if path.starts_with("/web/objects") { },
match path.split('/').nth(4) { true
Some("likes") => { );
set_route.set(FeedRoute::ObjectLikes);
Some(feeds.object_likes)
},
Some("replies") => {
set_route.set(FeedRoute::Replies);
Some(feeds.replies)
},
_ => {
set_route.set(FeedRoute::Context);
Some(feeds.context)
},
}
} else {
None
}
});
provide_context(route); 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);
@ -324,14 +352,21 @@ fn Scrollable() -> impl IntoView {
let element = NodeRef::new(); let element = NodeRef::new();
let should_load = use_scroll_limit(element, 500.0); let should_load = use_scroll_limit(element, 500.0);
provide_context(should_load); provide_context(should_load);
let (refresh, set_refresh) = signal(());
provide_context(refresh);
provide_context(set_refresh);
view! { view! {
<div class="mb-1" node_ref=element> <div class="mb-1" node_ref=element>
<div class="tl-header w-100 center mb-1"> <div class="tl-header w-100 center mb-1">
<a class="breadcrumb mr-1" href="javascript:history.back()" ><b>"<<"</b></a> <a class="breadcrumb mr-1" href="javascript:history.back()" ><b>"<<"</b></a>
<b>{crate::NAME}</b>" :: "{breadcrumb} <b>{crate::NAME}</b>" :: "{breadcrumb}
{move || relevant_timeline.get().map(|tl| view! { {move || if route.get().is_refreshable() {
<a class="breadcrumb ml-1" href="#" on:click=move|_| tl.refresh(auth, config) ><b>""</b></a> Some(view! {
})} <a class="breadcrumb ml-1" href="#" on:click=move|_| set_refresh.set(()) ><b>""</b></a>
})
} else {
None
}}
</div> </div>
<Outlet /> <Outlet />
</div> </div>
@ -349,6 +384,16 @@ pub fn NotFound() -> impl IntoView {
} }
#[component] #[component]
pub fn Unauthorized() -> impl IntoView {
view! {
<p>
<code class="color center cw">please log in first</code>
</p>
}
}
#[component]
//#[deprecated = "should not be displaying this directly"]
pub fn Loader() -> impl IntoView { pub fn Loader() -> impl IntoView {
view! { view! {
<div class="center mt-1 mb-1" > <div class="center mt-1 mb-1" >

View file

@ -7,19 +7,17 @@ pub fn LoginBox(
userid_tx: WriteSignal<Option<String>>, userid_tx: WriteSignal<Option<String>>,
) -> impl IntoView { ) -> impl IntoView {
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 username_ref: NodeRef<leptos::html::Input> = NodeRef::new(); let username_ref: NodeRef<leptos::html::Input> = NodeRef::new();
let password_ref: NodeRef<leptos::html::Input> = NodeRef::new(); let password_ref: NodeRef<leptos::html::Input> = NodeRef::new();
let feeds = use_context::<Feeds>().expect("missing feeds context");
view! { view! {
<div> <div>
<div class="w-100" class:hidden=move || !auth.present() > <div class="w-100" class:hidden=move || !auth.present() >
"hi "<a href={move || Uri::web(U::Actor, &auth.username() )} >{move || auth.username() }</a> "hi "<a href={move || Uri::web(U::Actor, &auth.username() )} >{move || auth.username() }</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);
feeds.reset(); crate::cache::OBJECTS.clear();
feeds.global.spawn_more(auth, config); crate::cache::TIMELINES.clear();
feeds.server.spawn_more(auth, config); crate::cache::WEBFINGER.clear();
} /> } />
</div> </div>
<div class:hidden=move || auth.present() > <div class:hidden=move || auth.present() >
@ -41,19 +39,12 @@ pub fn LoginBox(
else { if let Some(rf) = password_ref.get() { rf.set_value("") }; return }; else { if let Some(rf) = password_ref.get() { rf.set_value("") }; return };
tracing::info!("logged in until {}", auth_response.expires); tracing::info!("logged in until {}", auth_response.expires);
// update our username and token cookies // update our username and token cookies
let username = auth_response.user.split('/').last().unwrap_or_default().to_string();
userid_tx.set(Some(auth_response.user)); userid_tx.set(Some(auth_response.user));
token_tx.set(Some(auth_response.token)); token_tx.set(Some(auth_response.token));
// reset home feed and point it to our user's inbox // clear caches: we may see things differently now that we're logged in!
feeds.home.reset(Some(format!("{URL_BASE}/actors/{username}/inbox/page"))); crate::cache::OBJECTS.clear();
feeds.home.spawn_more(auth, config); crate::cache::TIMELINES.clear();
feeds.notifications.reset(Some(format!("{URL_BASE}/actors/{username}/notifications/page"))); crate::cache::WEBFINGER.clear();
feeds.notifications.spawn_more(auth, config);
// reset server feed: there may be more content now that we're authed
feeds.global.reset(Some(format!("{URL_BASE}/inbox/page")));
feeds.global.spawn_more(auth, config);
feeds.server.reset(Some(format!("{URL_BASE}/outbox/page")));
feeds.server.spawn_more(auth, config);
}); });
} > } >
<table class="w-100 align"> <table class="w-100 align">

View file

@ -45,7 +45,7 @@ pub fn Navigator(notifications: ReadSignal<u64>) -> impl IntoView {
<td class="w-50"><a href="/web/about"><input class="w-100" type="submit" value="about" /></a></td> <td class="w-50"><a href="/web/about"><input class="w-100" type="submit" value="about" /></a></td>
<td class="w-50"><a href="/web/config"><input class="w-100" type="submit" value="config" /></a></td> <td class="w-50"><a href="/web/config"><input class="w-100" type="submit" value="config" /></a></td>
</tr> </tr>
<tr><td colspan="2"><a href="/web/groups"><input class="w-100" type="submit" value="groups" /></a></td></tr> // <tr><td colspan="2"><a href="/web/groups"><input class="w-100" type="submit" value="groups" /></a></td></tr> // still too crude, don't include in navigation
<tr><td colspan="2"><a href="/web/explore"><input class="w-100" type="submit" class:hidden=move || !auth.present() value="explore" /></a></td></tr> <tr><td colspan="2"><a href="/web/explore"><input class="w-100" type="submit" class:hidden=move || !auth.present() value="explore" /></a></td></tr>
</table> </table>
} }

View file

@ -48,7 +48,7 @@ pub struct FiltersConfig {
impl FiltersConfig { impl FiltersConfig {
pub fn visible(&self, item: &crate::Doc) -> bool { pub fn visible(&self, item: &crate::Doc) -> bool {
use apb::{Object, Activity}; use apb::{Object, Base};
use crate::Cache; use crate::Cache;
let type_filter = match item.object_type().unwrap_or(apb::ObjectType::Object) { let type_filter = match item.object_type().unwrap_or(apb::ObjectType::Object) {
@ -65,7 +65,7 @@ impl FiltersConfig {
}; };
let mut reply_filter = true; let mut reply_filter = true;
if let Ok(obj_id) = item.object().id() { if let Ok(obj_id) = item.id() {
if let Some(obj) = crate::cache::OBJECTS.get(&obj_id) { if let Some(obj) = crate::cache::OBJECTS.get(&obj_id) {
if obj.in_reply_to().id().is_ok() { if obj.in_reply_to().id().is_ok() {
reply_filter = self.replies; reply_filter = self.replies;

View file

@ -1,5 +1,5 @@
use leptos::prelude::*; use leptos::prelude::*;
use crate::{prelude::*, timeline::any::Loadable, FALLBACK_IMAGE_URL}; use crate::prelude::*;
#[component] #[component]
pub fn GroupList() -> impl IntoView { pub fn GroupList() -> impl IntoView {

View file

@ -61,6 +61,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 invalidate(&self, key: &str);
fn clear(&self);
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())
@ -93,12 +94,16 @@ impl<T> Cache for DashmapCache<T> {
fn invalidate(&self, key: &str) { fn invalidate(&self, key: &str) {
self.0.remove(key); self.0.remove(key);
} }
fn clear(&self) {
self.0.clear();
}
} }
impl DashmapCache<Doc> { impl DashmapCache<Doc> {
pub async fn resolve(&self, key: &str, kind: UriClass, auth: Auth) -> Option<Doc> { pub async fn fetch(&self, key: &str, kind: UriClass, auth: Auth) -> Option<Doc> {
let full_key = Uri::full(kind, key); let full_key = Uri::full(kind, key);
tracing::info!("resolving {key} -> {full_key}"); tracing::debug!("resolving {key} -> {full_key}");
match self.get(&full_key) { match self.get(&full_key) {
Some(x) => Some(x), Some(x) => Some(x),
None => { None => {
@ -118,12 +123,13 @@ impl DashmapCache<Doc> {
} }
pub fn include(&self, obj: Doc) { pub fn include(&self, obj: Doc) {
let Ok(id) = obj.id() else { return }; if let Ok(id) = obj.id() {
tracing::debug!("storing object {id}: {obj}"); tracing::debug!("storing object {id}: {obj}");
cache::OBJECTS.store(&id, obj.clone()); cache::OBJECTS.store(&id, obj.clone());
if obj.actor_type().is_ok() { if obj.actor_type().is_ok() {
if let Ok(url) = obj.url().id() { if let Ok(url) = obj.url().id() {
cache::WEBFINGER.store(&url, id); cache::WEBFINGER.store(&url, id);
}
} }
} }
if let Ok(sub_obj) = obj.object().into_inner() { if let Ok(sub_obj) = obj.object().into_inner() {
@ -134,25 +140,33 @@ impl DashmapCache<Doc> {
} }
} }
pub async fn prefetch(&self, key: String, kind: UriClass, auth: Auth) -> Option<Doc> { pub async fn preload(&self, key: String, kind: UriClass, auth: Auth) -> Option<Doc> {
let doc = self.resolve(&key, kind, auth).await?; let doc = self.fetch(&key, kind, auth).await?;
let mut sub_tasks = Vec::new(); let mut sub_tasks = Vec::new();
match kind { match kind {
UriClass::Activity => { UriClass::Activity => {
if let Ok(actor) = doc.actor().id() { if let Ok(actor) = doc.actor().id() {
sub_tasks.push(self.prefetch(actor, UriClass::Actor, auth)); sub_tasks.push(self.preload(actor, UriClass::Actor, auth));
} }
let clazz = match doc.activity_type().unwrap_or(apb::ActivityType::Activity) {
// TODO activities like Announce or Update may be multiple things, we can't know before
apb::ActivityType::Accept(_) => UriClass::Activity,
apb::ActivityType::Reject(_) => UriClass::Activity,
apb::ActivityType::Undo => UriClass::Activity,
apb::ActivityType::Follow => UriClass::Actor,
_ => UriClass::Object,
};
if let Ok(object) = doc.object().id() { if let Ok(object) = doc.object().id() {
sub_tasks.push(self.prefetch(object, UriClass::Object, auth)); sub_tasks.push(self.preload(object, clazz, auth));
} }
}, },
UriClass::Object => { UriClass::Object => {
if let Ok(actor) = doc.attributed_to().id() { if let Ok(actor) = doc.attributed_to().id() {
sub_tasks.push(self.prefetch(actor, UriClass::Actor, auth)); sub_tasks.push(self.preload(actor, UriClass::Actor, auth));
} }
if let Ok(quote) = doc.quote_url().id() { if let Ok(quote) = doc.quote_url().id() {
sub_tasks.push(self.prefetch(quote, UriClass::Object, auth)); sub_tasks.push(self.preload(quote, UriClass::Object, auth));
} }
}, },
_ => {}, _ => {},
@ -164,24 +178,6 @@ impl DashmapCache<Doc> {
} }
} }
// TODO would be cool unifying a bit the fetch code too
impl DashmapCache<Doc> {
pub async fn fetch(&self, k: &str, kind: UriClass) -> reqwest::Result<Doc> {
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.store(k, Arc::new(obj));
Ok(self.get(k).expect("not found in cache after insertion"))
}
}
}
}
impl DashmapCache<String> { impl DashmapCache<String> {
pub async fn blocking_resolve(&self, user: &str, domain: &str) -> Option<String> { pub async fn blocking_resolve(&self, user: &str, domain: &str) -> Option<String> {
if let Some(x) = self.resource(user, domain) { return Some(x); } if let Some(x) = self.resource(user, domain) { return Some(x); }

View file

@ -1,31 +0,0 @@
use apb::Object;
use leptos::prelude::*;
use leptos_router::hooks::use_params;
use crate::prelude::*;
#[component]
pub fn ObjectContext() -> impl IntoView {
let feeds = use_context::<Feeds>().expect("missing feeds context");
let params = use_params::<IdParam>();
let id = move || {
let id = params.with(|p| p.as_ref().ok().and_then(|x| x.id.as_ref()).cloned()).unwrap_or_default();
Uri::full(U::Object, &id)
};
let context_id = Signal::derive(move ||
cache::OBJECTS.get(&id())
.and_then(|x| x.context().id().ok())
.unwrap_or_default()
);
Effect::new(move |_| {
let tl_url = format!("{}/context/page", Uri::api(U::Object, &context_id.get(), false));
if !feeds.context.next.get_untracked().starts_with(&tl_url) {
feeds.context.reset(Some(tl_url));
}
});
view! {
<div class="mr-1-r ml-1-r">
<Thread tl=feeds.context root=id() />
</div>
}
}

View file

@ -314,7 +314,7 @@ pub fn ReplyButton(n: i32, target: String) -> impl IntoView {
let _target = target.clone(); // TODO ughhhh useless clones let _target = target.clone(); // TODO ughhhh useless clones
view! { view! {
<span <span
class:emoji=move || !reply.reply_to.get().map_or(false, |x| x == _target) class:emoji=move || !reply.reply_to.get().map(|x| x == _target).unwrap_or_default()
// TODO can we merge these two classes conditions? // TODO can we merge these two classes conditions?
class:emoji-btn=move || auth.present() class:emoji-btn=move || auth.present()
class:cursor=move || auth.present() class:cursor=move || auth.present()

View file

@ -1,44 +0,0 @@
use leptos::prelude::*;
use leptos_router::hooks::use_params;
use crate::prelude::*;
#[component]
pub fn ObjectReplies() -> impl IntoView {
let feeds = use_context::<Feeds>().expect("missing feeds context");
let params = use_params::<IdParam>();
let id = Signal::derive(move ||
params.with(|p| p.as_ref().ok().and_then(|x| x.id.as_ref()).cloned()).unwrap_or_default()
);
Effect::new(move |_| {
let tl_url = format!("{}/replies/page", Uri::api(U::Object, &id.get(), false));
if !feeds.replies.next.get_untracked().starts_with(&tl_url) {
feeds.replies.reset(Some(tl_url));
}
});
view! {
<div class="mr-1-r ml-1-r">
<Thread tl=feeds.replies root=Uri::full(U::Object, &id.get()) />
</div>
}
}
#[component]
pub fn ObjectLikes() -> impl IntoView {
let feeds = use_context::<Feeds>().expect("missing feeds context");
let params = use_params::<IdParam>();
let id = Signal::derive(move ||
params.with(|p| p.as_ref().ok().and_then(|x| x.id.as_ref()).cloned()).unwrap_or_default()
);
Effect::new(move |_| {
let tl_url = format!("{}/likes/page", Uri::api(U::Object, &id.get(), false));
if !feeds.object_likes.next.get_untracked().starts_with(&tl_url) {
feeds.object_likes.reset(Some(tl_url));
}
});
view! {
<div class="mr-1-r ml-1-r">
<Feed tl=feeds.object_likes ignore_filters=true />
</div>
}
}

View file

@ -2,7 +2,7 @@ use leptos::{either::Either, ev::MouseEvent};
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::components::Outlet; use leptos_router::components::Outlet;
use leptos_router::hooks::use_params_map; use leptos_router::hooks::use_params_map;
use crate::{app::FeedRoute, prelude::*, Config}; use crate::{app::FeedRoute, prelude::*};
use apb::Object; use apb::Object;
@ -11,8 +11,7 @@ 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 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 refresh = use_context::<WriteSignal<()>>().expect("missing refresh context");
let relevant_tl = use_context::<Signal<Option<Timeline>>>().expect("missing relevant timeline context");
let (loading, set_loading) = signal(false); let (loading, set_loading) = signal(false);
let id = Signal::derive(move || params.get().get("id").unwrap_or_default()); let id = Signal::derive(move || params.get().get("id").unwrap_or_default());
let object = LocalResource::new( let object = LocalResource::new(
@ -20,14 +19,14 @@ pub fn ObjectView() -> impl IntoView {
let (oid, _loading) = (id.get(), loading.get()); let (oid, _loading) = (id.get(), loading.get());
async move { async move {
tracing::info!("rerunning fetcher"); tracing::info!("rerunning fetcher");
let obj = cache::OBJECTS.resolve(&oid, U::Object, auth).await?; let obj = cache::OBJECTS.fetch(&oid, U::Object, auth).await?;
// TODO these two can be parallelized // TODO these two can be parallelized
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.fetch(&author, U::Actor, auth).await;
} }
if let Ok(quote) = obj.quote_url().id() { if let Ok(quote) = obj.quote_url().id() {
cache::OBJECTS.resolve(&quote, U::Object, auth).await; cache::OBJECTS.fetch(&quote, U::Object, auth).await;
} }
Some(obj) Some(obj)
@ -72,7 +71,7 @@ pub fn ObjectView() -> impl IntoView {
<span style="float: right"> <span style="float: right">
<a <a
class="clean" class="clean"
on:click=move |ev| fetch_cb(ev, set_loading, id.get(), auth, config, relevant_tl) on:click=move |ev| fetch_cb(ev, set_loading, id.get(), auth, refresh)
href="#" href="#"
> >
<span class="emoji ml-2">""</span>"fetch" <span class="emoji ml-2">""</span>"fetch"
@ -95,7 +94,7 @@ pub fn ObjectView() -> impl IntoView {
} }
} }
fn fetch_cb(ev: MouseEvent, set_loading: WriteSignal<bool>, oid: String, auth: Auth, config: Signal<Config>, relevant_tl: Signal<Option<Timeline>>) { fn fetch_cb(ev: MouseEvent, set_loading: WriteSignal<bool>, oid: String, auth: Auth, refresh: WriteSignal<()>) {
let api = Uri::api(U::Object, &oid, false); let api = Uri::api(U::Object, &oid, false);
ev.prevent_default(); ev.prevent_default();
set_loading.set(true); set_loading.set(true);
@ -106,6 +105,6 @@ fn fetch_cb(ev: MouseEvent, set_loading: WriteSignal<bool>, oid: String, auth: A
cache::OBJECTS.invalidate(&Uri::full(U::Object, &oid)); cache::OBJECTS.invalidate(&Uri::full(U::Object, &oid));
tracing::info!("invalidated {}", Uri::full(U::Object, &oid)); tracing::info!("invalidated {}", Uri::full(U::Object, &oid));
set_loading.set(false); set_loading.set(false);
relevant_tl.get().inspect(|x| x.refresh(auth, config)); refresh.set(());
}); });
} }

View file

@ -6,7 +6,6 @@ use crate::{prelude::*, DEFAULT_COLOR};
pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView { pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView {
let config = use_context::<Signal<crate::Config>>().expect("missing config context"); let config = use_context::<Signal<crate::Config>>().expect("missing config context");
let auth = use_context::<Auth>().expect("missing auth context"); let auth = use_context::<Auth>().expect("missing auth context");
let feeds = use_context::<Feeds>().expect("missing feeds context");
let (color, set_color) = leptos_use::use_css_var("--accent"); let (color, set_color) = leptos_use::use_css_var("--accent");
let (_color_rgb, set_color_rgb) = leptos_use::use_css_var("--accent-rgb"); let (_color_rgb, set_color_rgb) = leptos_use::use_css_var("--accent-rgb");
@ -76,14 +75,14 @@ pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView {
/> collapse content warnings /> collapse content warnings
</span> </span>
</p> </p>
// <p> <p>
// <span title="new posts will be fetched automatically when scrolling down enough"> <span title="new posts will be fetched automatically when scrolling down enough">
// <input type="checkbox" class="mr-1" <input type="checkbox" class="mr-1"
// prop:checked=get_cfg!(infinite_scroll) prop:checked=get_cfg!(infinite_scroll)
// on:input=set_cfg!(infinite_scroll) on:input=set_cfg!(infinite_scroll)
// /> infinite scroll /> infinite scroll
// </span> </span>
// </p> </p>
<p> <p>
accent color accent color
<input type="text" class="ma-1" <input type="text" class="ma-1"
@ -107,7 +106,7 @@ pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView {
let mut mock = config.get(); let mut mock = config.get();
mock.filters.replies = event_target_checked(&ev); mock.filters.replies = event_target_checked(&ev);
setter.set(mock); setter.set(mock);
feeds.reset(); cache::TIMELINES.clear();
}/>" replies"</span></li> }/>" replies"</span></li>
<li><span title="like activities"><input type="checkbox" prop:checked=get_cfg!(filter likes) on:input=set_cfg!(filter likes) />" likes"</span></li> <li><span title="like activities"><input type="checkbox" prop:checked=get_cfg!(filter likes) on:input=set_cfg!(filter likes) />" likes"</span></li>
<li><span title="create activities with object"><input type="checkbox" prop:checked=get_cfg!(filter creates) on:input=set_cfg!(filter creates)/>" creates"</span></li> <li><span title="create activities with object"><input type="checkbox" prop:checked=get_cfg!(filter creates) on:input=set_cfg!(filter creates)/>" creates"</span></li>

View file

@ -1,6 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use apb::Collection;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::hooks::use_query_map; use leptos_router::hooks::use_query_map;
use crate::prelude::*; use crate::prelude::*;
@ -34,16 +33,14 @@ pub fn SearchPage() -> impl IntoView {
let q = use_query_map().get().get("q").unwrap_or_default(); let q = use_query_map().get().get("q").unwrap_or_default();
let search = format!("{URL_BASE}/search?q={q}"); let search = format!("{URL_BASE}/search?q={q}");
async move { async move {
let items = Http::fetch::<serde_json::Value>(&search, auth).await.ok()?; let document = Http::fetch::<serde_json::Value>(&search, auth).await.ok()?;
Some( Some(
crate::timeline::process_activities( crate::timeline::process_activities(
items document,
.ordered_items() Vec::new(),
.flat() true,
.into_iter() uriproxy::UriClass::Object,
.filter_map(|x| x.into_inner().ok()) auth,
.collect(),
auth
).await ).await
) )
} }

View file

@ -3,7 +3,7 @@ pub use crate::{
Http, Uri, Http, Uri,
IdParam, IdParam,
Cache, cache, // TODO move Cache under cache Cache, cache, // TODO move Cache under cache
app::{Feeds, Loader}, app::Loader,
auth::Auth, auth::Auth,
page::*, page::*,
components::*, components::*,
@ -12,21 +12,13 @@ pub use crate::{
follow::FollowList, follow::FollowList,
posts::{ActorPosts, ActorLikes}, posts::{ActorPosts, ActorLikes},
}, },
activities::{ activities::item::Item,
item::Item,
},
objects::{ objects::{
view::ObjectView, view::ObjectView,
attachment::Attachment, attachment::Attachment,
item::{Object, Summary, LikeButton, RepostButton, ReplyButton}, item::{Object, Summary, LikeButton, RepostButton, ReplyButton},
context::ObjectContext,
replies::{ObjectReplies, ObjectLikes},
},
timeline::{
Timeline,
feed::{Feed, HashtagFeed},
thread::Thread,
}, },
timeline::Loadable,
}; };
pub use uriproxy::UriClass as U; pub use uriproxy::UriClass as U;

View file

@ -1,144 +0,0 @@
use apb::{Collection, CollectionPage};
use leptos::{either::Either, prelude::*};
use uriproxy::UriClass;
use crate::Cache;
#[component]
pub fn Loadable<El, V>(
base: String,
element: El,
#[prop(optional)] convert: Option<UriClass>,
#[prop(default = true)] prefetch: bool,
) -> impl IntoView
where
El: Send + Sync + Fn(crate::Doc) -> V + 'static,
V: IntoView + 'static
{
let class = convert.unwrap_or(UriClass::Object);
let auth = use_context::<crate::Auth>().expect("missing auth context");
let fun = std::sync::Arc::new(element);
let (older_next, older_items) = crate::cache::TIMELINES.get(&base)
.unwrap_or((Some(base.clone()), vec![]));
let (next, set_next) = signal(older_next);
let (items, set_items) = signal(older_items);
let (loading, set_loading) = signal(false);
// TODO having the seen set just once would be a great optimization, but then it becomes FnMut...
// let mut seen: std::collections::HashSet<String> = std::collections::HashSet::default();
// TODO it's a bit wasteful to clone the key for every load() invocation, but capturing it in the
// closure opens an industrial pipeline of worms so this will do for now
let load = move |key: String| leptos::task::spawn_local(async move {
// this concurrency is rather fearful honestly
if loading.get_untracked() { return }
set_loading.set(true);
let Some(url) = next.get_untracked() else {
set_loading.set(false);
return
};
let object = match crate::Http::fetch::<serde_json::Value>(&url, auth).await {
Ok(x) => x,
Err(e) => {
tracing::error!("could not fetch items ({url}): {e} -- {e:?}");
set_next.set(None);
set_loading.set(false);
return;
},
};
let new_next = object.next().id().ok();
set_next.set(new_next.clone());
let mut previous = items.get_untracked();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::from_iter(previous.clone());
let mut sub_tasks_a = Vec::new();
let mut sub_tasks_b = Vec::new();
for node in object.ordered_items().flat() {
let Ok(id) = node.id() else {
tracing::warn!("skipping item without id: {node:?}");
continue
};
let _id = id.clone();
match node.into_inner() {
Ok(doc) => { // we got it embedded, store it right away!
crate::cache::OBJECTS.include(std::sync::Arc::new(doc));
if prefetch {
sub_tasks_a.push(crate::cache::OBJECTS.prefetch(_id, class, auth));
}
},
Err(_) => { // we just got the id, try dereferencing it right now
let id = id.clone();
if prefetch {
sub_tasks_a.push(crate::cache::OBJECTS.prefetch(_id, class, auth));
} else {
sub_tasks_b.push(async move { crate::cache::OBJECTS.resolve(&_id, class, auth).await });
};
},
};
if !seen.contains(&id) {
seen.insert(id.clone());
previous.push(id);
}
}
futures::future::join_all(sub_tasks_a).await;
futures::future::join_all(sub_tasks_b).await;
crate::cache::TIMELINES.store(&key, (new_next, previous.clone()));
set_items.set(previous);
set_loading.set(false);
});
let _base = base.clone();
if let Some(auto_scroll) = use_context::<Signal<bool>>() {
let _ = Effect::watch(
move || auto_scroll.get(),
move |at_end, _, _| if *at_end { load(_base.clone()) },
false,
);
}
load(base.clone());
let _base = base.clone();
view! {
<div>
<For
each=move || items.get()
key=|id: &String| id.clone()
children=move |id: String| match crate::cache::OBJECTS.get(&id) {
Some(obj) => Either::Left(fun(obj)),
None => Either::Right(view!{ <p>{id}</p> }),
}
/>
{move || if loading.get() {
Some(Either::Left(view! {
<div class="center mt-1 mb-1" >
<button type="button" disabled>"loading "<span class="dots"></span></button>
</div>
}))
} else if next.with(|x| x.is_some()) {
let _base = base.clone();
Some(Either::Right(view! {
<div class="center mt-1 mb-1" >
<button type="button" on:click=move |_| load(_base.clone()) >"load more"</button>
</div>
}))
} else {
None
}}
</div>
}
}

View file

@ -1,62 +0,0 @@
use leptos::{either::Either, prelude::*};
use leptos_router::hooks::use_params;
use crate::prelude::*;
use super::Timeline;
#[component]
pub fn Feed(
tl: Timeline,
#[prop(optional)]
ignore_filters: bool,
) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
if let Some(auto_scroll) = use_context::<Signal<bool>>() {
let _ = Effect::watch(
move || auto_scroll.get(),
move |at_end, _, _| if *at_end { tl.spawn_more(auth, config) },
true,
);
}
view! {
<div>
<For
each=move || tl.feed.get()
key=|k| k.to_string()
let:id
>
{match cache::OBJECTS.get(&id) {
Some(i) => Either::Left(view! {
<Item item=i sep=true always=ignore_filters />
}),
None => Either::Right(view! {
<p><code>{id}</code>" "[<a href={uri}>go</a>]</p>
<hr />
}),
}}
</For>
</div>
{move || if tl.loading.get() { Some(view! { <Loader /> }) } else { None }}
}
}
#[component]
pub fn HashtagFeed(tl: Timeline) -> impl IntoView {
let params = use_params::<IdParam>();
Effect::new(move |_| {
let current_tag = tl.next.get_untracked()
.split('/')
.last()
.unwrap_or_default()
.split('?')
.next()
.unwrap_or_default()
.to_string();
let new_tag = params.get().ok().and_then(|x| x.id).unwrap_or_default();
if new_tag != current_tag {
tl.reset(Some(Uri::api(U::Hashtag, &format!("{new_tag}/page"), false)));
}
});
view! { <Feed tl=tl /> }
}

View file

@ -1,206 +1,261 @@
pub mod feed; use std::sync::Arc;
pub mod thread;
pub mod any;
use std::{collections::BTreeSet, pin::Pin, sync::Arc}; use apb::{Activity, Base, Collection, CollectionPage, Object};
use leptos::{either::Either, prelude::*};
use uriproxy::UriClass;
use apb::{Activity, ActivityMut, Base, Object}; use crate::{Auth, Cache};
use leptos::prelude::*;
use crate::prelude::*;
#[derive(Debug, Clone, Copy)]
pub struct Timeline {
pub feed: RwSignal<Vec<String>>,
pub next: RwSignal<String>,
pub over: RwSignal<bool>,
pub loading: RwSignal<bool>,
}
impl Timeline { // TODO would be cool if "element" was passed as children() somehow
pub fn new(url: String) -> Self { // TODO "thread" is a bit weird, maybe better to make two distinct components?
let feed = RwSignal::new(vec![]); #[component]
let next = RwSignal::new(url); pub fn Loadable<El, V>(
let over = RwSignal::new(false); base: String,
let loading = RwSignal::new(false); element: El,
Timeline { feed, next, over, loading } #[prop(default = UriClass::Activity)] convert: UriClass,
} #[prop(default = true)] preload: bool,
#[prop(default = false)] replies: bool,
#[prop(optional)] thread: Option<String>,
) -> impl IntoView
where
El: Send + Sync + Fn(crate::Doc) -> V + 'static,
V: IntoView + 'static
{
pub fn len(&self) -> usize { let config = use_context::<Signal<crate::Config>>().expect("missing config context");
self.feed.get().len() let auth = use_context::<crate::Auth>().expect("missing auth context");
} let fun = Arc::new(element);
pub fn is_empty(&self) -> bool { let (older_next, older_items) = crate::cache::TIMELINES.get(&base)
self.feed.get().is_empty() .unwrap_or((Some(base.clone()), vec![]));
}
pub fn reset(&self, url: Option<String>) { let (next, set_next) = signal(older_next);
self.feed.set(vec![]); let (items, set_items) = signal(older_items);
self.over.set(false); let (loading, set_loading) = signal(false);
let url = url.unwrap_or_else(||
self.next
.get_untracked()
.split('?')
.next()
.map(|x| x.to_string())
.unwrap_or("".to_string())
);
self.next.set(url);
}
pub fn refresh(&self, auth: Auth, config: Signal<crate::Config>) { // TODO having the seen set just once would be a great optimization, but then it becomes FnMut...
self.reset(None); // let mut seen: std::collections::HashSet<String> = std::collections::HashSet::default();
self.spawn_more(auth, config);
}
pub fn spawn_more(&self, auth: Auth, config: Signal<crate::Config>) { // TODO it's a bit wasteful to clone the key for every load() invocation, but capturing it in the
let _self = *self; // closure opens an industrial pipeline of worms so this will do for now
let load = move |key: String| {
if loading.get_untracked() { return }
set_loading.set(true);
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
_self.more(auth, config).await // this concurrency is rather fearful honestly
}); let Some(mut url) = next.get_untracked() else {
} set_loading.set(false);
return
pub fn loading(&self) -> bool {
self.loading.get_untracked()
}
pub async fn more(&self, auth: Auth, config: Signal<crate::Config>) {
if self.loading.get_untracked() { return }
if self.over.get_untracked() { return }
self.loading.set(true);
let res = self.load_more(auth, config).await;
self.loading.set(false);
if let Err(e) = res {
tracing::error!("failed loading posts for timeline: {e}");
}
}
pub async fn load_more(&self, auth: Auth, config: Signal<crate::Config>) -> reqwest::Result<()> {
use apb::{Collection, CollectionPage};
let mut feed_url = self.next.get_untracked();
if !config.get_untracked().filters.replies {
feed_url = if feed_url.contains('?') {
feed_url + "&replies=false"
} else {
feed_url + "?replies=false"
}; };
}
let collection : serde_json::Value = Http::fetch(&feed_url, auth).await?;
let activities : Vec<serde_json::Value> = collection
.ordered_items()
.flat()
.into_iter()
.filter_map(|x| x.into_inner().ok())
.collect();
let mut feed = self.feed.get_untracked(); // TODO a more elegant way to do this!!
let mut older = process_activities(activities, auth) if !replies && !config.get_untracked().filters.replies {
.await if url.contains('?') {
.into_iter() url += "&replies=false";
.filter(|x| !feed.contains(x)) } else {
.collect(); url += "?replies=false";
feed.append(&mut older); }
self.feed.set(feed); }
if let Ok(next) = collection.next().id() { let object = match crate::Http::fetch::<serde_json::Value>(&url, auth).await {
self.next.set(next.to_string()); Ok(x) => x,
} else { Err(e) => {
self.over.set(true); tracing::error!("could not fetch items ({url}): {e} -- {e:?}");
} set_next.set(None);
set_loading.set(false);
return;
},
};
Ok(()) let new_next = object.next().id().ok();
set_next.set(new_next.clone());
let store = process_activities(
object,
items.get_untracked(),
preload,
convert,
auth
).await;
crate::cache::TIMELINES.store(&key, (new_next, store.clone()));
set_items.set(store);
set_loading.set(false);
})
};
let auto_scroll = use_context::<Signal<bool>>().expect("missing auto-scroll signal");
let _base = base.clone();
let _ = Effect::watch(
move || auto_scroll.get(),
move |at_end, _, _| if *at_end && config.get_untracked().infinite_scroll {
load(_base.clone())
},
false,
);
let reload = use_context::<ReadSignal<()>>().expect("missing reload signal");
let _base = base.clone();
let _ = Effect::watch(
move || reload.get(),
move |_, _, _| {
set_items.set(vec![]);
set_next.set(Some(_base.clone()));
crate::cache::TIMELINES.invalidate(&_base);
load(_base.clone());
},
false
);
if items.get_untracked().is_empty() {
load(base.clone());
}
let _base = base.clone();
view! {
<div>
{if let Some(root) = thread {
Either::Left(view! { <FeedRecursive items=items root=root element=fun /> })
} else {
Either::Right(view! { <FeedLinear items=items element=fun /> })
}}
{move || if loading.get() {
Some(Either::Left(view! {
<div class="center mt-1 mb-1" >
<button type="button" disabled>"loading "<span class="dots"></span></button>
</div>
}))
} else if next.with(|x| x.is_some()) {
let _base = base.clone();
Some(Either::Right(view! {
<div class="center mt-1 mb-1" >
<button type="button" on:click=move |_| load(_base.clone()) >"load more"</button>
</div>
}))
} else {
None
}}
</div>
} }
} }
// TODO fetching stuff is quite centralized in upub BE but FE has this mess of three functions #[component]
// which interlock and are supposed to prime the global cache with everything coming from a fn FeedLinear<El, V>(items: ReadSignal<Vec<String>>, element: Arc<El>) -> impl IntoView
// tl. can we streamline it a bit like in our BE? maybe some traits?? maybe reuse stuff??? where
El: Send + Sync + Fn(crate::Doc) -> V + 'static,
// TODO ughhh this shouldn't be here if its pub!!! V: IntoView + 'static
pub async fn process_activities(activities: Vec<serde_json::Value>, auth: Auth) -> Vec<String> { {
let mut sub_tasks : Vec<Pin<Box<dyn futures::Future<Output = ()>>>> = Vec::new(); view! {
let mut gonna_fetch = BTreeSet::new(); <For
let mut actors_seen = BTreeSet::new(); each=move || items.get()
let mut out = Vec::new(); key=|id: &String| id.clone()
children=move |id: String| match crate::cache::OBJECTS.get(&id) {
for activity in activities { Some(obj) => Either::Left(element(obj)),
let activity_type = activity.activity_type().unwrap_or(apb::ActivityType::Activity); None => Either::Right(view!{ <p><code class="center color cw">{id}</code></p> }),
// save embedded object if present
if let Ok(object) = activity.object().inner() {
// also fetch actor attributed to
if let Ok(attributed_to) = object.attributed_to().id() {
actors_seen.insert(attributed_to);
} }
if let Ok(quote_id) = object.quote_url().id() { />
if !gonna_fetch.contains(&quote_id) {
gonna_fetch.insert(quote_id.clone());
sub_tasks.push(Box::pin(deep_fetch_and_update(U::Object, quote_id, auth)));
}
}
if let Ok(object_uri) = object.id() {
cache::OBJECTS.store(&object_uri, Arc::new(object.clone()));
} else {
tracing::warn!("embedded object without id: {object:?}");
}
} else { // try fetching it
if let Ok(object_id) = activity.object().id() {
if !gonna_fetch.contains(&object_id) {
let fetch_kind = match activity_type {
apb::ActivityType::Follow => U::Actor,
_ => U::Object,
};
gonna_fetch.insert(object_id.clone());
sub_tasks.push(Box::pin(deep_fetch_and_update(fetch_kind, object_id, auth)));
}
}
}
// save activity, removing embedded object
let object_id = activity.object().id().ok();
if let Ok(activity_id) = activity.id() {
out.push(activity_id.to_string());
cache::OBJECTS.store(
&activity_id,
Arc::new(activity.clone().set_object(apb::Node::maybe_link(object_id)))
);
} else if let Ok(object_id) = activity.object().id() {
out.push(object_id);
}
if let Ok(uid) = activity.attributed_to().id() {
if cache::OBJECTS.get(&uid).is_none() && !gonna_fetch.contains(&uid) {
gonna_fetch.insert(uid.clone());
sub_tasks.push(Box::pin(deep_fetch_and_update(U::Actor, uid, auth)));
}
}
if let Ok(uid) = activity.actor().id() {
if cache::OBJECTS.get(&uid).is_none() && !gonna_fetch.contains(&uid) {
gonna_fetch.insert(uid.clone());
sub_tasks.push(Box::pin(deep_fetch_and_update(U::Actor, uid, auth)));
}
}
} }
}
for user in actors_seen { #[component]
sub_tasks.push(Box::pin(deep_fetch_and_update(U::Actor, user, auth))); fn FeedRecursive<El, V>(items: ReadSignal<Vec<String>>, root: String, element: Arc<El>) -> impl IntoView
where
El: Send + Sync + Fn(crate::Doc) -> V + 'static,
V: IntoView + 'static
{
let root_values = move || items.get()
.into_iter()
.filter_map(|x| {
let document = crate::cache::OBJECTS.get(&x)?;
let (oid, reply) = match document.object_type().ok()? {
// if it's a create, get and check created object: does it reply to root?
apb::ObjectType::Activity(apb::ActivityType::Create) => {
let object = crate::cache::OBJECTS.get(&document.object().id().ok()?)?;
(object.id().ok()?, object.in_reply_to().id().ok()?)
},
// if it's a raw note, directly check if it replies to root
apb::ObjectType::Note => (document.id().ok()?, document.in_reply_to().id().ok()?),
// if it's anything else, check if it relates to root, maybe like or announce?
_ => (document.id().ok()?, document.object().id().ok()?),
};
if reply == root {
Some((oid, document))
} else {
None
}
})
.collect::<Vec<(String, crate::Doc)>>();
view! {
<For
each=root_values
key=|(id, _obj)| id.clone()
children=move |(id, obj)|
view! {
<div class="context depth-r">
{element(obj)}
<div class="depth-r">
<FeedRecursive items=items root=id element=element.clone() />
</div>
</div>
}
/ >
}.into_any()
}
pub async fn process_activities(
object: serde_json::Value,
mut store: Vec<String>,
preload: bool,
convert: UriClass,
auth: Auth,
) -> Vec<String> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::from_iter(store.clone());
let mut sub_tasks = Vec::new();
for node in object.ordered_items().flat() {
let mut added_something = false;
if let Ok(id) = node.id() {
added_something = true;
if !seen.contains(&id) {
seen.insert(id.clone());
store.push(id.clone());
}
if preload {
sub_tasks.push(crate::cache::OBJECTS.preload(id, convert, auth));
}
}
if let Ok(doc) = node.into_inner() {
// TODO this is weird because we manually go picking up the inner object
// worse: objects coming from fetches get stitched in timelines with empty shell
// "View" activities which don't have an id. in such cases we want the inner object to
// appear on our timelines, so we must do what we would do for the activity (but we
// didn't do) for our inner object, and also we can be pretty sure it's an object so
// override class
if let Ok(sub_doc) = doc.object().into_inner() {
if let Ok(sub_id) = sub_doc.id() {
if !added_something && !seen.contains(&sub_id) {
seen.insert(sub_id.clone());
store.push(sub_id.clone());
}
if preload {
sub_tasks.push(crate::cache::OBJECTS.preload(sub_id, UriClass::Object, auth));
}
}
}
crate::cache::OBJECTS.include(Arc::new(doc));
};
} }
futures::future::join_all(sub_tasks).await; futures::future::join_all(sub_tasks).await;
out store
}
async fn deep_fetch_and_update(kind: U, id: String, auth: Auth) {
if let Some(obj) = cache::OBJECTS.resolve(&id, kind, auth).await {
if let Ok(quote) = obj.quote_url().id() {
cache::OBJECTS.resolve(&quote, U::Object, auth).await;
}
if let Ok(actor) = obj.actor().id() {
cache::OBJECTS.resolve(&actor, U::Actor, auth).await;
}
if let Ok(attributed_to) = obj.attributed_to().id() {
cache::OBJECTS.resolve(&attributed_to, U::Actor, auth).await;
}
}
} }

View file

@ -1,76 +0,0 @@
use apb::{Activity, Base, Object};
use leptos::prelude::*;
use crate::prelude::*;
use super::Timeline;
#[component]
pub fn Thread(tl: Timeline, root: String) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
if let Some(auto_scroll) = use_context::<Signal<bool>>() {
let _ = Effect::watch(
move || auto_scroll.get(),
move |new, old, _| {
match old {
None => tl.spawn_more(auth, config), // always do it first time
Some(old) => if *new && new != old {
tl.spawn_more(auth, config);
},
}
},
true,
);
}
view! {
<div>
<FeedRecursive tl=tl root=root />
</div>
{move || if tl.loading.get() { Some(view! { <Loader /> }) } else { None }}
}
}
#[component]
fn FeedRecursive(tl: Timeline, root: String) -> impl IntoView {
let root_values = move || tl.feed
.get()
.into_iter()
.filter_map(|x| {
let document = cache::OBJECTS.get(&x)?;
let (oid, reply) = match document.object_type().ok()? {
// if it's a create, get and check created object: does it reply to root?
apb::ObjectType::Activity(apb::ActivityType::Create) => {
let object = cache::OBJECTS.get(&document.object().id().ok()?)?;
(object.id().ok()?, object.in_reply_to().id().ok()?)
},
// if it's a raw note, directly check if it replies to root
apb::ObjectType::Note => (document.id().ok()?, document.in_reply_to().id().ok()?),
// if it's anything else, check if it relates to root, maybe like or announce?
_ => (document.id().ok()?, document.object().id().ok()?),
};
if reply == root {
Some((oid, document))
} else {
None
}
})
.collect::<Vec<(String, crate::Doc)>>();
view! {
<For
each=root_values
key=|(id, _obj)| id.clone()
children=move |(id, obj)|
view! {
<div class="context depth-r">
<Item item=obj always=true slim=true />
<div class="depth-r">
<FeedRecursive tl=tl root=id />
</div>
</div>
}
/ >
}.into_any()
}