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:
parent
febfbcbca7
commit
3329952a12
20 changed files with 498 additions and 790 deletions
web/src
|
@ -1,6 +1,6 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_params;
|
||||
use crate::{prelude::*, timeline::any::Loadable};
|
||||
use crate::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn FollowList(outgoing: bool) -> impl IntoView {
|
||||
|
|
|
@ -8,8 +8,7 @@ use apb::{ActivityMut, Actor, Base, Object, ObjectMut, Shortcuts};
|
|||
pub fn ActorHeader() -> impl IntoView {
|
||||
let params = use_params::<IdParam>();
|
||||
let auth = use_context::<Auth>().expect("missing auth context");
|
||||
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
|
||||
let relevant_tl = use_context::<Signal<Option<Timeline>>>().expect("missing relevant timeline context");
|
||||
let refresh = use_context::<WriteSignal<()>>().expect("missing refresh context");
|
||||
let matched_route = use_context::<ReadSignal<crate::app::FeedRoute>>().expect("missing route context");
|
||||
let (loading, set_loading) = signal(false);
|
||||
let actor = LocalResource::new(
|
||||
|
@ -19,7 +18,7 @@ pub fn ActorHeader() -> impl IntoView {
|
|||
match cache::OBJECTS.get(&Uri::full(U::Actor, &id)) {
|
||||
Some(x) => Some(x.clone()),
|
||||
None => {
|
||||
let user = cache::OBJECTS.resolve(&id, U::Actor, auth).await?;
|
||||
let user = cache::OBJECTS.fetch(&id, U::Actor, auth).await?;
|
||||
Some(user)
|
||||
},
|
||||
}
|
||||
|
@ -148,7 +147,7 @@ pub fn ActorHeader() -> impl IntoView {
|
|||
<span style="float: right">
|
||||
<a
|
||||
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="#"
|
||||
>
|
||||
<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);
|
||||
ev.prevent_default();
|
||||
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}");
|
||||
}
|
||||
set_loading.set(false);
|
||||
relevant_tl.get().inspect(|x| x.refresh(auth, config));
|
||||
refresh.set(());
|
||||
});
|
||||
}
|
||||
|
|
|
@ -4,32 +4,24 @@ use crate::prelude::*;
|
|||
|
||||
#[component]
|
||||
pub fn ActorPosts() -> impl IntoView {
|
||||
let feeds = use_context::<Feeds>().expect("missing feeds context");
|
||||
let params = use_params::<IdParam>();
|
||||
Effect::new(move |_| {
|
||||
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! {
|
||||
<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]
|
||||
pub fn ActorLikes() -> impl IntoView {
|
||||
let feeds = use_context::<Feeds>().expect("missing feeds context");
|
||||
let params = use_params::<IdParam>();
|
||||
Effect::new(move |_| {
|
||||
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! {
|
||||
<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 /> }
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
|
241
web/src/app.rs
241
web/src/app.rs
|
@ -1,63 +1,13 @@
|
|||
use apb::Collection;
|
||||
use apb::{Collection, Object};
|
||||
use leptos::{either::Either, prelude::*};
|
||||
use leptos_router::{components::*, hooks::use_location, path};
|
||||
use crate::{groups::GroupList, prelude::*};
|
||||
use leptos_router::{components::*, hooks::{use_location, use_params}, path};
|
||||
use crate::prelude::*;
|
||||
|
||||
use leptos_use::{
|
||||
signal_debounced, storage::use_local_storage, use_cookie_with_options, use_element_size, use_window_scroll,
|
||||
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]
|
||||
pub fn App() -> impl IntoView {
|
||||
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(config);
|
||||
provide_context(feeds);
|
||||
provide_context(privacy);
|
||||
|
||||
let reply_controls = ReplyControls::default();
|
||||
|
@ -178,20 +121,74 @@ pub fn App() -> impl IntoView {
|
|||
if auth.present() {
|
||||
view! { <Redirect path="home" /> }
|
||||
} 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!("config") view=move || view! { <ConfigPage setter=set_config /> } />
|
||||
<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?
|
||||
<Route path=path!("") view=ActorPosts />
|
||||
<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 /> } />
|
||||
</ParentRoute>
|
||||
|
||||
|
||||
// objects
|
||||
<ParentRoute path=path!("objects/:id") view=ObjectView >
|
||||
<Route path=path!("") view=ObjectContext />
|
||||
<Route path=path!("replies") view=ObjectReplies />
|
||||
<Route path=path!("likes") view=ObjectLikes />
|
||||
<Route path=path!("") 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);
|
||||
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 />
|
||||
</ParentRoute>
|
||||
|
||||
// TODO a standalone way to view activities?
|
||||
// <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>
|
||||
</Routes>
|
||||
</main>
|
||||
|
@ -228,73 +259,70 @@ pub fn App() -> impl IntoView {
|
|||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
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]
|
||||
fn Scrollable() -> impl IntoView {
|
||||
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
|
||||
// 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) = signal(FeedRoute::Home);
|
||||
let relevant_timeline = Signal::derive(move || {
|
||||
let path = location.pathname.get();
|
||||
// UPDATE it's a bit less terrible since now we just update an enum but still probs should do
|
||||
// something fancier than this string stuff... now leptos has path segments as structs,
|
||||
// maybe maybe maybe it's accessible to us??
|
||||
let (route, set_route) = signal(FeedRoute::Unknown);
|
||||
let _ = Effect::watch(
|
||||
move || location.pathname.get(),
|
||||
move |path, _path_prev, _| {
|
||||
if path.contains("/web/home") {
|
||||
set_route.set(FeedRoute::Home);
|
||||
Some(feeds.home)
|
||||
} else if path.contains("/web/global") {
|
||||
set_route.set(FeedRoute::Global);
|
||||
Some(feeds.global)
|
||||
} else if path.contains("/web/local") {
|
||||
set_route.set(FeedRoute::Server);
|
||||
Some(feeds.server)
|
||||
} else if path.starts_with("/web/notifications") {
|
||||
set_route.set(FeedRoute::Notifications);
|
||||
Some(feeds.notifications)
|
||||
} else if path.starts_with("/web/actors") {
|
||||
match path.split('/').nth(4) {
|
||||
Some("following") => {
|
||||
set_route.set(FeedRoute::Following);
|
||||
None
|
||||
},
|
||||
Some("followers") => {
|
||||
set_route.set(FeedRoute::Followers);
|
||||
None
|
||||
},
|
||||
Some("likes") => {
|
||||
set_route.set(FeedRoute::ActorLikes);
|
||||
Some(feeds.user_likes)
|
||||
},
|
||||
_ => {
|
||||
set_route.set(FeedRoute::User);
|
||||
Some(feeds.user)
|
||||
},
|
||||
}
|
||||
} else if path.starts_with("/web/objects") {
|
||||
match path.split('/').nth(4) {
|
||||
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
|
||||
set_route.set(FeedRoute::Unknown);
|
||||
}
|
||||
});
|
||||
},
|
||||
true
|
||||
);
|
||||
provide_context(route);
|
||||
provide_context(relevant_timeline);
|
||||
let breadcrumb = Signal::derive(move || {
|
||||
let path = location.pathname.get();
|
||||
let mut path_iter = path.split('/').skip(2);
|
||||
|
@ -324,14 +352,21 @@ fn Scrollable() -> impl IntoView {
|
|||
let element = NodeRef::new();
|
||||
let should_load = use_scroll_limit(element, 500.0);
|
||||
provide_context(should_load);
|
||||
let (refresh, set_refresh) = signal(());
|
||||
provide_context(refresh);
|
||||
provide_context(set_refresh);
|
||||
view! {
|
||||
<div class="mb-1" node_ref=element>
|
||||
<div class="tl-header w-100 center mb-1">
|
||||
<a class="breadcrumb mr-1" href="javascript:history.back()" ><b>"<<"</b></a>
|
||||
<b>{crate::NAME}</b>" :: "{breadcrumb}
|
||||
{move || relevant_timeline.get().map(|tl| view! {
|
||||
<a class="breadcrumb ml-1" href="#" on:click=move|_| tl.refresh(auth, config) ><b>"↺"</b></a>
|
||||
})}
|
||||
{move || if route.get().is_refreshable() {
|
||||
Some(view! {
|
||||
<a class="breadcrumb ml-1" href="#" on:click=move|_| set_refresh.set(()) ><b>"↺"</b></a>
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}}
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
@ -349,6 +384,16 @@ pub fn NotFound() -> impl IntoView {
|
|||
}
|
||||
|
||||
#[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 {
|
||||
view! {
|
||||
<div class="center mt-1 mb-1" >
|
||||
|
|
|
@ -7,19 +7,17 @@ pub fn LoginBox(
|
|||
userid_tx: WriteSignal<Option<String>>,
|
||||
) -> impl IntoView {
|
||||
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 password_ref: NodeRef<leptos::html::Input> = NodeRef::new();
|
||||
let feeds = use_context::<Feeds>().expect("missing feeds context");
|
||||
view! {
|
||||
<div>
|
||||
<div class="w-100" class:hidden=move || !auth.present() >
|
||||
"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 |_| {
|
||||
token_tx.set(None);
|
||||
feeds.reset();
|
||||
feeds.global.spawn_more(auth, config);
|
||||
feeds.server.spawn_more(auth, config);
|
||||
crate::cache::OBJECTS.clear();
|
||||
crate::cache::TIMELINES.clear();
|
||||
crate::cache::WEBFINGER.clear();
|
||||
} />
|
||||
</div>
|
||||
<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 };
|
||||
tracing::info!("logged in until {}", auth_response.expires);
|
||||
// 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));
|
||||
token_tx.set(Some(auth_response.token));
|
||||
// reset home feed and point it to our user's inbox
|
||||
feeds.home.reset(Some(format!("{URL_BASE}/actors/{username}/inbox/page")));
|
||||
feeds.home.spawn_more(auth, config);
|
||||
feeds.notifications.reset(Some(format!("{URL_BASE}/actors/{username}/notifications/page")));
|
||||
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);
|
||||
// clear caches: we may see things differently now that we're logged in!
|
||||
crate::cache::OBJECTS.clear();
|
||||
crate::cache::TIMELINES.clear();
|
||||
crate::cache::WEBFINGER.clear();
|
||||
});
|
||||
} >
|
||||
<table class="w-100 align">
|
||||
|
|
|
@ -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/config"><input class="w-100" type="submit" value="config" /></a></td>
|
||||
</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>
|
||||
</table>
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@ pub struct FiltersConfig {
|
|||
|
||||
impl FiltersConfig {
|
||||
pub fn visible(&self, item: &crate::Doc) -> bool {
|
||||
use apb::{Object, Activity};
|
||||
use apb::{Object, Base};
|
||||
use crate::Cache;
|
||||
|
||||
let type_filter = match item.object_type().unwrap_or(apb::ObjectType::Object) {
|
||||
|
@ -65,7 +65,7 @@ impl FiltersConfig {
|
|||
};
|
||||
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 obj.in_reply_to().id().is_ok() {
|
||||
reply_filter = self.replies;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use leptos::prelude::*;
|
||||
use crate::{prelude::*, timeline::any::Loadable, FALLBACK_IMAGE_URL};
|
||||
use crate::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn GroupList() -> impl IntoView {
|
||||
|
|
|
@ -61,6 +61,7 @@ pub trait Cache {
|
|||
fn lookup(&self, key: &str) -> Option<impl Deref<Target = LookupStatus<Self::Item>>>;
|
||||
fn store(&self, key: &str, value: Self::Item) -> Option<Self::Item>;
|
||||
fn invalidate(&self, key: &str);
|
||||
fn clear(&self);
|
||||
|
||||
fn get(&self, key: &str) -> Option<Self::Item> where Self::Item : Clone {
|
||||
Some(self.lookup(key)?.deref().inner()?.clone())
|
||||
|
@ -93,12 +94,16 @@ impl<T> Cache for DashmapCache<T> {
|
|||
fn invalidate(&self, key: &str) {
|
||||
self.0.remove(key);
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
self.0.clear();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
tracing::info!("resolving {key} -> {full_key}");
|
||||
tracing::debug!("resolving {key} -> {full_key}");
|
||||
match self.get(&full_key) {
|
||||
Some(x) => Some(x),
|
||||
None => {
|
||||
|
@ -118,7 +123,7 @@ impl DashmapCache<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}");
|
||||
cache::OBJECTS.store(&id, obj.clone());
|
||||
if obj.actor_type().is_ok() {
|
||||
|
@ -126,6 +131,7 @@ impl DashmapCache<Doc> {
|
|||
cache::WEBFINGER.store(&url, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(sub_obj) = obj.object().into_inner() {
|
||||
if let Ok(sub_id) = sub_obj.id() {
|
||||
tracing::debug!("storing sub object {sub_id}: {sub_obj}");
|
||||
|
@ -134,25 +140,33 @@ impl DashmapCache<Doc> {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn prefetch(&self, key: String, kind: UriClass, auth: Auth) -> Option<Doc> {
|
||||
let doc = self.resolve(&key, kind, auth).await?;
|
||||
pub async fn preload(&self, key: String, kind: UriClass, auth: Auth) -> Option<Doc> {
|
||||
let doc = self.fetch(&key, kind, auth).await?;
|
||||
let mut sub_tasks = Vec::new();
|
||||
|
||||
match kind {
|
||||
UriClass::Activity => {
|
||||
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() {
|
||||
sub_tasks.push(self.prefetch(object, UriClass::Object, auth));
|
||||
sub_tasks.push(self.preload(object, clazz, auth));
|
||||
}
|
||||
},
|
||||
UriClass::Object => {
|
||||
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() {
|
||||
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> {
|
||||
pub async fn blocking_resolve(&self, user: &str, domain: &str) -> Option<String> {
|
||||
if let Some(x) = self.resource(user, domain) { return Some(x); }
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
}
|
|
@ -314,7 +314,7 @@ pub fn ReplyButton(n: i32, target: String) -> impl IntoView {
|
|||
let _target = target.clone(); // TODO ughhhh useless clones
|
||||
view! {
|
||||
<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?
|
||||
class:emoji-btn=move || auth.present()
|
||||
class:cursor=move || auth.present()
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@ use leptos::{either::Either, ev::MouseEvent};
|
|||
use leptos::prelude::*;
|
||||
use leptos_router::components::Outlet;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
use crate::{app::FeedRoute, prelude::*, Config};
|
||||
use crate::{app::FeedRoute, prelude::*};
|
||||
|
||||
use apb::Object;
|
||||
|
||||
|
@ -11,8 +11,7 @@ pub fn ObjectView() -> impl IntoView {
|
|||
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 config = use_context::<Signal<Config>>().expect("missing config context");
|
||||
let relevant_tl = use_context::<Signal<Option<Timeline>>>().expect("missing relevant timeline context");
|
||||
let refresh = use_context::<WriteSignal<()>>().expect("missing refresh context");
|
||||
let (loading, set_loading) = signal(false);
|
||||
let id = Signal::derive(move || params.get().get("id").unwrap_or_default());
|
||||
let object = LocalResource::new(
|
||||
|
@ -20,14 +19,14 @@ pub fn ObjectView() -> impl IntoView {
|
|||
let (oid, _loading) = (id.get(), loading.get());
|
||||
async move {
|
||||
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
|
||||
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() {
|
||||
cache::OBJECTS.resolve("e, U::Object, auth).await;
|
||||
cache::OBJECTS.fetch("e, U::Object, auth).await;
|
||||
}
|
||||
|
||||
Some(obj)
|
||||
|
@ -72,7 +71,7 @@ pub fn ObjectView() -> impl IntoView {
|
|||
<span style="float: right">
|
||||
<a
|
||||
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="#"
|
||||
>
|
||||
<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);
|
||||
ev.prevent_default();
|
||||
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));
|
||||
tracing::info!("invalidated {}", Uri::full(U::Object, &oid));
|
||||
set_loading.set(false);
|
||||
relevant_tl.get().inspect(|x| x.refresh(auth, config));
|
||||
refresh.set(());
|
||||
});
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ use crate::{prelude::*, DEFAULT_COLOR};
|
|||
pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView {
|
||||
let config = use_context::<Signal<crate::Config>>().expect("missing config 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_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
|
||||
</span>
|
||||
</p>
|
||||
// <p>
|
||||
// <span title="new posts will be fetched automatically when scrolling down enough">
|
||||
// <input type="checkbox" class="mr-1"
|
||||
// prop:checked=get_cfg!(infinite_scroll)
|
||||
// on:input=set_cfg!(infinite_scroll)
|
||||
// /> infinite scroll
|
||||
// </span>
|
||||
// </p>
|
||||
<p>
|
||||
<span title="new posts will be fetched automatically when scrolling down enough">
|
||||
<input type="checkbox" class="mr-1"
|
||||
prop:checked=get_cfg!(infinite_scroll)
|
||||
on:input=set_cfg!(infinite_scroll)
|
||||
/> infinite scroll
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
accent color
|
||||
<input type="text" class="ma-1"
|
||||
|
@ -107,7 +106,7 @@ pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView {
|
|||
let mut mock = config.get();
|
||||
mock.filters.replies = event_target_checked(&ev);
|
||||
setter.set(mock);
|
||||
feeds.reset();
|
||||
cache::TIMELINES.clear();
|
||||
}/>" 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="create activities with object"><input type="checkbox" prop:checked=get_cfg!(filter creates) on:input=set_cfg!(filter creates)/>" creates"</span></li>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use apb::Collection;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_query_map;
|
||||
use crate::prelude::*;
|
||||
|
@ -34,16 +33,14 @@ pub fn SearchPage() -> impl IntoView {
|
|||
let q = use_query_map().get().get("q").unwrap_or_default();
|
||||
let search = format!("{URL_BASE}/search?q={q}");
|
||||
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(
|
||||
crate::timeline::process_activities(
|
||||
items
|
||||
.ordered_items()
|
||||
.flat()
|
||||
.into_iter()
|
||||
.filter_map(|x| x.into_inner().ok())
|
||||
.collect(),
|
||||
auth
|
||||
document,
|
||||
Vec::new(),
|
||||
true,
|
||||
uriproxy::UriClass::Object,
|
||||
auth,
|
||||
).await
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ pub use crate::{
|
|||
Http, Uri,
|
||||
IdParam,
|
||||
Cache, cache, // TODO move Cache under cache
|
||||
app::{Feeds, Loader},
|
||||
app::Loader,
|
||||
auth::Auth,
|
||||
page::*,
|
||||
components::*,
|
||||
|
@ -12,21 +12,13 @@ pub use crate::{
|
|||
follow::FollowList,
|
||||
posts::{ActorPosts, ActorLikes},
|
||||
},
|
||||
activities::{
|
||||
item::Item,
|
||||
},
|
||||
activities::item::Item,
|
||||
objects::{
|
||||
view::ObjectView,
|
||||
attachment::Attachment,
|
||||
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;
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
}
|
|
@ -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 /> }
|
||||
}
|
|
@ -1,206 +1,261 @@
|
|||
pub mod feed;
|
||||
pub mod thread;
|
||||
pub mod any;
|
||||
use std::sync::Arc;
|
||||
|
||||
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 leptos::prelude::*;
|
||||
use crate::prelude::*;
|
||||
use crate::{Auth, Cache};
|
||||
|
||||
#[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 {
|
||||
pub fn new(url: String) -> Self {
|
||||
let feed = RwSignal::new(vec![]);
|
||||
let next = RwSignal::new(url);
|
||||
let over = RwSignal::new(false);
|
||||
let loading = RwSignal::new(false);
|
||||
Timeline { feed, next, over, loading }
|
||||
}
|
||||
// TODO would be cool if "element" was passed as children() somehow
|
||||
// TODO "thread" is a bit weird, maybe better to make two distinct components?
|
||||
#[component]
|
||||
pub fn Loadable<El, V>(
|
||||
base: String,
|
||||
element: El,
|
||||
#[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 {
|
||||
self.feed.get().len()
|
||||
}
|
||||
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
|
||||
let auth = use_context::<crate::Auth>().expect("missing auth context");
|
||||
let fun = Arc::new(element);
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.feed.get().is_empty()
|
||||
}
|
||||
let (older_next, older_items) = crate::cache::TIMELINES.get(&base)
|
||||
.unwrap_or((Some(base.clone()), vec![]));
|
||||
|
||||
pub fn reset(&self, url: Option<String>) {
|
||||
self.feed.set(vec![]);
|
||||
self.over.set(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);
|
||||
}
|
||||
let (next, set_next) = signal(older_next);
|
||||
let (items, set_items) = signal(older_items);
|
||||
let (loading, set_loading) = signal(false);
|
||||
|
||||
pub fn refresh(&self, auth: Auth, config: Signal<crate::Config>) {
|
||||
self.reset(None);
|
||||
self.spawn_more(auth, config);
|
||||
}
|
||||
// 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();
|
||||
|
||||
pub fn spawn_more(&self, auth: Auth, config: Signal<crate::Config>) {
|
||||
let _self = *self;
|
||||
// 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| {
|
||||
if loading.get_untracked() { return }
|
||||
set_loading.set(true);
|
||||
leptos::task::spawn_local(async move {
|
||||
_self.more(auth, config).await
|
||||
});
|
||||
}
|
||||
|
||||
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"
|
||||
// this concurrency is rather fearful honestly
|
||||
let Some(mut url) = next.get_untracked() else {
|
||||
set_loading.set(false);
|
||||
return
|
||||
};
|
||||
}
|
||||
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();
|
||||
let mut older = process_activities(activities, auth)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|x| !feed.contains(x))
|
||||
.collect();
|
||||
feed.append(&mut older);
|
||||
self.feed.set(feed);
|
||||
|
||||
if let Ok(next) = collection.next().id() {
|
||||
self.next.set(next.to_string());
|
||||
// TODO a more elegant way to do this!!
|
||||
if !replies && !config.get_untracked().filters.replies {
|
||||
if url.contains('?') {
|
||||
url += "&replies=false";
|
||||
} else {
|
||||
self.over.set(true);
|
||||
url += "?replies=false";
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
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 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
|
||||
// which interlock and are supposed to prime the global cache with everything coming from a
|
||||
// tl. can we streamline it a bit like in our BE? maybe some traits?? maybe reuse stuff???
|
||||
#[component]
|
||||
fn FeedLinear<El, V>(items: ReadSignal<Vec<String>>, element: Arc<El>) -> impl IntoView
|
||||
where
|
||||
El: Send + Sync + Fn(crate::Doc) -> V + 'static,
|
||||
V: IntoView + 'static
|
||||
{
|
||||
view! {
|
||||
<For
|
||||
each=move || items.get()
|
||||
key=|id: &String| id.clone()
|
||||
children=move |id: String| match crate::cache::OBJECTS.get(&id) {
|
||||
Some(obj) => Either::Left(element(obj)),
|
||||
None => Either::Right(view!{ <p><code class="center color cw">{id}</code></p> }),
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
// TODO ughhh this shouldn't be here if its pub!!!
|
||||
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();
|
||||
let mut gonna_fetch = BTreeSet::new();
|
||||
let mut actors_seen = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
#[component]
|
||||
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()?)
|
||||
},
|
||||
|
||||
for activity in activities {
|
||||
let activity_type = activity.activity_type().unwrap_or(apb::ActivityType::Activity);
|
||||
// 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("e_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,
|
||||
// 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()?),
|
||||
};
|
||||
gonna_fetch.insert(object_id.clone());
|
||||
sub_tasks.push(Box::pin(deep_fetch_and_update(fetch_kind, object_id, auth)));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// 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(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 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)));
|
||||
if preload {
|
||||
sub_tasks.push(crate::cache::OBJECTS.preload(sub_id, UriClass::Object, auth));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for user in actors_seen {
|
||||
sub_tasks.push(Box::pin(deep_fetch_and_update(U::Actor, user, auth)));
|
||||
crate::cache::OBJECTS.include(Arc::new(doc));
|
||||
};
|
||||
}
|
||||
|
||||
futures::future::join_all(sub_tasks).await;
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
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("e, 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;
|
||||
}
|
||||
}
|
||||
store
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
Loading…
Add table
Reference in a new issue