fix(web): huge refactor but basically nothing changed

... yet! this fixes the weird bug that resets timeline scroll when
coming back from users (annoying!). also slightly better spacing for
things and more consistent loading buttons. its a big refactor and its
underway but there's so much in progress that ill commit this big chunk
as is and i totally wont regret it later when i need to remember what i
was moving where aha
This commit is contained in:
əlemi 2024-06-12 06:02:36 +02:00
parent 40392aef56
commit ea655be121
Signed by: alemi
GPG key ID: A4895B84D311642C
25 changed files with 286 additions and 339 deletions

View file

@ -24,7 +24,6 @@ dashmap = "5.5"
leptos = { version = "0.6", features = ["csr", "tracing"] } leptos = { version = "0.6", features = ["csr", "tracing"] }
leptos_router = { version = "0.6", features = ["csr"] } leptos_router = { version = "0.6", features = ["csr"] }
leptos-use = { version = "0.10", features = ["serde"] } leptos-use = { version = "0.10", features = ["serde"] }
web-sys = { version = "0.3", features = ["Screen"] }
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
apb = { path = "../apb", features = ["unstructured", "activitypub-fe", "activitypub-counters", "litepub"] } apb = { path = "../apb", features = ["unstructured", "activitypub-fe", "activitypub-counters", "litepub"] }
uriproxy = { path = "../utils/uriproxy/" } uriproxy = { path = "../utils/uriproxy/" }
@ -34,3 +33,5 @@ lazy_static = "1.4"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
jrd = "0.1" jrd = "0.1"
tld = "2.35" tld = "2.35"
web-sys = { version = "0.3", features = ["Screen"] }
regex = "1.10.5"

View file

@ -39,6 +39,10 @@
* { * {
font-family: 'Fira Code', Menlo, DejaVu Sans Mono, Monaco, Consolas, Ubuntu Mono, monospace; font-family: 'Fira Code', Menlo, DejaVu Sans Mono, Monaco, Consolas, Ubuntu Mono, monospace;
} }
html {
overflow-y: scroll;
height: 100vh;
}
body { body {
margin: 0; margin: 0;
@ -76,14 +80,23 @@
hyphens: auto; hyphens: auto;
border-left: solid 3px var(--background-secondary); border-left: solid 3px var(--background-secondary);
} }
blockquote.tl { article.tl {
color: var(--text); color: var(--text);
border-left: solid 3px var(--accent); border-left: solid 3px var(--accent);
margin-left: 1.25em;
margin-right: 1em;
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
word-wrap: break-word;
} }
blockquote.tl p { article p {
margin: 0 .5em; margin: 0 0 0 .5em;
}
b.displayname {
overflow-wrap: break-word;
}
table.align {
max-width: 100%;
} }
span.footer { span.footer {
padding: .1em; padding: .1em;
@ -257,10 +270,10 @@
color: var(--background); color: var(--background);
cursor: pointer; cursor: pointer;
} }
.ml-1-l { .ml-1-r {
margin-left: 1em; margin-left: 1em;
} }
.ml-1-r { .mr-1-r {
margin-right: 1em; margin-right: 1em;
} }
.ml-3-r { .ml-3-r {
@ -282,7 +295,7 @@
.ml-1-l { .ml-1-l {
margin-left: 0; margin-left: 0;
} }
.ml-1-r { .mr-1-r {
margin-right: 0; margin-right: 0;
} }
.ml-3-r { .ml-3-r {

View file

@ -27,7 +27,7 @@ pub fn ActorHeader() -> impl IntoView {
} }
); );
move || match actor.get() { move || match actor.get() {
None => view! { <Loader margin=true /> }.into_view(), None => view! { <Loader /> }.into_view(),
Some(Err(e)) => view! { <code class="center cw color">"could not resolve user: "{e}</code> }.into_view(), Some(Err(e)) => view! { <code class="center cw color">"could not resolve user: "{e}</code> }.into_view(),
Some(Ok(actor)) => { Some(Ok(actor)) => {
let avatar_url = actor.icon().get().map(|x| x.url().id().str().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into()); let avatar_url = actor.icon().get().map(|x| x.url().id().str().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());

View file

@ -1,6 +1,6 @@
pub mod follow; pub mod follow;
pub mod view;
pub mod posts; pub mod posts;
pub mod header;
use leptos_router::Params; // TODO can i remove this? use leptos_router::Params; // TODO can i remove this?
#[derive(Clone, leptos::Params, PartialEq)] #[derive(Clone, leptos::Params, PartialEq)]

View file

@ -7,14 +7,15 @@ pub fn ActorPosts() -> impl IntoView {
let feeds = use_context::<Feeds>().expect("missing feeds context"); let feeds = use_context::<Feeds>().expect("missing feeds context");
let params = use_params::<super::IdParam>(); let params = use_params::<super::IdParam>();
Signal::derive(move || { Signal::derive(move || {
let id = params.get().ok().and_then(|x| x.id).unwrap_or_default(); let id = params.get_untracked().ok().and_then(|x| x.id).unwrap_or_default();
let tl_url = format!("{}/outbox/page", Uri::api(U::Actor, &id, false)); let tl_url = format!("{}/outbox/page", Uri::api(U::Actor, &id, false));
if !feeds.user.next.get_untracked().starts_with(&tl_url) { if !feeds.user.next.get_untracked().starts_with(&tl_url) {
feeds.user.reset(Some(tl_url)); feeds.user.reset(Some(tl_url));
} }
id
}).track(); }).track();
view! { view! {
<code class="cw color center mt-1 mb-1 ml-3 mr-3"><span class="emoji">"🖂"</span>" "<b>posts</b></code> <code class="cw color center mt-1 mb-1 ml-3 mr-3"><span class="emoji">"🖂"</span>" "<b>posts</b></code>
<TimelineFeed tl=feeds.user /> <Feed tl=feeds.user />
} }
} }

View file

@ -2,7 +2,7 @@ use leptos::*;
use leptos_router::*; use leptos_router::*;
use crate::prelude::*; use crate::prelude::*;
use leptos_use::{storage::use_local_storage, use_cookie, utils::{FromToStringCodec, JsonCodec}}; use leptos_use::{signal_throttled, storage::use_local_storage, use_cookie, use_element_size, use_window_scroll, utils::{FromToStringCodec, JsonCodec}, UseElementSizeReturn};
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Feeds { pub struct Feeds {
@ -64,29 +64,18 @@ pub fn App() -> impl IntoView {
let reply_controls = ReplyControls::default(); let reply_controls = ReplyControls::default();
provide_context(reply_controls); provide_context(reply_controls);
let screen_width = window().screen().map(|x| x.avail_width().unwrap_or_default()).unwrap_or_default(); let screen_width = document().body().map(|x| x.client_width()).unwrap_or_default();
tracing::info!("detected width of {screen_width}");
let (menu, set_menu) = create_signal(screen_width <= 786); let (menu, set_menu) = create_signal(screen_width < 768);
let (advanced, set_advanced) = create_signal(false); let (advanced, set_advanced) = create_signal(false);
let title_target = move || if auth.present() { "/web/home" } else { "/web/server" }; let title_target = move || if auth.present() { "/web/home" } else { "/web/server" };
spawn_local(async move { // refresh token immediately and every hour
// refresh token first, or verify that we're still authed let refresh = move || spawn_local(async move { Auth::refresh(auth.token, set_token, set_userid).await; });
if Auth::refresh(auth.token, set_token, set_userid).await { refresh();
feeds.home.more(auth); // home inbox requires auth to be read set_interval(refresh, std::time::Duration::from_secs(3600));
feeds.private.more(auth);
}
feeds.global.more(auth);
feeds.public.more(auth); // server inbox may contain private posts
});
// refresh token every hour
set_interval(
move || spawn_local(async move { Auth::refresh(auth.token, set_token, set_userid).await; }),
std::time::Duration::from_secs(3600)
);
view! { view! {
<nav class="w-100 mt-1 mb-1 pb-s"> <nav class="w-100 mt-1 mb-1 pb-s">
@ -98,7 +87,7 @@ pub fn App() -> impl IntoView {
<hr class="sep sticky" /> <hr class="sep sticky" />
<div class="container mt-2 pt-2" > <div class="container mt-2 pt-2" >
<div class="two-col" > <div class="two-col" >
<div class="col-side sticky pb-s" class:hidden=move || menu.get() > <div class="col-side sticky pb-s" class:hidden=menu >
<Navigator /> <Navigator />
<hr class="mt-1 mb-1" /> <hr class="mt-1 mb-1" />
<LoginBox <LoginBox
@ -115,7 +104,7 @@ pub fn App() -> impl IntoView {
<hr class="only-on-mobile sep mb-0 pb-0" /> <hr class="only-on-mobile sep mb-0 pb-0" />
</div> </div>
</div> </div>
<div class="col-main" class:w-100=move || menu.get() > <div class="col-main" class:w-100=menu >
<Router // TODO maybe set base="/web" ? <Router // TODO maybe set base="/web" ?
trailing_slash=TrailingSlash::Redirect trailing_slash=TrailingSlash::Redirect
fallback=|| view! { <NotFound /> } fallback=|| view! { <NotFound /> }
@ -123,7 +112,7 @@ pub fn App() -> impl IntoView {
<main> <main>
<Routes> <Routes>
<Route path="/" view=move || view! { <Redirect path="/web" /> } /> <Route path="/" view=move || view! { <Redirect path="/web" /> } />
<Route path="/web" view=Navigable > <Route path="/web" view=Scrollable >
<Route path="" view=move || <Route path="" view=move ||
if auth.present() { if auth.present() {
view! { <Redirect path="home" /> } view! { <Redirect path="home" /> }
@ -131,25 +120,22 @@ pub fn App() -> impl IntoView {
view! { <Redirect path="server" /> } view! { <Redirect path="server" /> }
} }
/> />
<Route path="home" view=move || view! { <TimelinePage name="home" tl=feeds.home /> } /> <Route path="home" view=move || view! { <Feed tl=feeds.home /> } />
<Route path="server" view=move || view! { <TimelinePage name="server" tl=feeds.global /> } /> <Route path="server" view=move || view! { <Feed tl=feeds.global /> } />
<Route path="local" view=move || view! { <TimelinePage name="local" tl=feeds.server /> } /> <Route path="local" view=move || view! { <Feed tl=feeds.server /> } />
<Route path="inbox" view=move || view! { <TimelinePage name="inbox" tl=feeds.private /> } /> <Route path="inbox" view=move || view! { <Feed tl=feeds.private /> } />
<Route path="about" view=AboutPage /> <Route path="about" view=AboutPage />
<Route path="config" view=move || view! { <ConfigPage setter=set_config /> } /> <Route path="config" view=move || view! { <ConfigPage setter=set_config /> } />
<Route path="dev" view=DebugPage /> <Route path="dev" view=DebugPage />
<Route path="actors" view=Outlet > // TODO can we avoid this? <Route path="actors/:id" view=ActorHeader > // TODO can we avoid this?
<Route path=":id" view=ActorHeader >
<Route path="" view=ActorPosts /> <Route path="" view=ActorPosts />
<Route path="following" view=move || view! { <FollowList outgoing=true /> } /> <Route path="following" view=move || view! { <FollowList outgoing=true /> } />
<Route path="followers" view=move || view! { <FollowList outgoing=false /> } /> <Route path="followers" view=move || view! { <FollowList outgoing=false /> } />
</Route> </Route>
<Route path="" view=NotFound />
</Route>
<Route path="objects/:id" view=ObjectPage /> <Route path="objects/:id" view=ObjectView />
// <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="search" view=SearchPage /> <Route path="search" view=SearchPage />
@ -170,11 +156,11 @@ pub fn App() -> impl IntoView {
} }
#[component] #[component]
fn Navigable() -> impl IntoView { fn Scrollable() -> impl IntoView {
let location = use_location(); let location = use_location();
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(1); let mut path_iter = path.split('/').skip(2);
// TODO wow this breadcrumb logic really isnt nice can we make it better?? // TODO wow this breadcrumb logic really isnt nice can we make it better??
match path_iter.next() { match path_iter.next() {
Some("actors") => match path_iter.next() { Some("actors") => match path_iter.next() {
@ -197,12 +183,17 @@ fn Navigable() -> impl IntoView {
None => "?".to_string(), None => "?".to_string(),
} }
}); });
let element = create_node_ref();
let should_load = use_scroll_limit(element, 1750.0);
provide_context(should_load);
view! { view! {
<div class="tl-header w-100 center" > <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> <a class="breadcrumb mr-1" href="javascript:history.back()" ><b>"<<"</b></a>
<b>{crate::NAME}</b>" :: "{breadcrumb} <b>{crate::NAME}</b>" :: "{breadcrumb}
</div> </div>
<Outlet /> <Outlet />
</div>
} }
} }
@ -217,10 +208,32 @@ pub fn NotFound() -> impl IntoView {
} }
#[component] #[component]
pub fn Loader(#[prop(optional)] margin: bool) -> impl IntoView { pub fn Loader() -> impl IntoView {
view! { view! {
<div class="center" class:mt-1={margin}> <div class="center mt-1 mb-1" >
<button type="button" disabled>"loading "<span class="dots"></span></button> <button type="button" disabled>"loading "<span class="dots"></span></button>
</div> </div>
} }
} }
pub fn use_scroll_limit<El, T>(el: El, offset: f64) -> Signal<bool>
where
El: Into<leptos_use::core::ElementMaybeSignal<T, web_sys::Element>> + Clone + 'static,
T: Into<web_sys::Element> + Clone + 'static,
{
let (load, set_load) = create_signal(false);
let (_x, y) = use_window_scroll();
let UseElementSizeReturn { height, .. } = use_element_size(el);
let scroll_state = Signal::derive(move || (y.get(), height.get()));
let scroll_state_throttled = signal_throttled(scroll_state, 200.);
let _ = watch(
move || scroll_state_throttled.get(),
move |(y, height), _, _| {
let before = load.get();
let after = y + offset >= *height;
if after != before { set_load.set(after) };
},
false,
);
load.into()
}

View file

@ -17,8 +17,8 @@ pub fn LoginBox(
<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(); feeds.reset();
feeds.global.more(auth); feeds.global.spawn_more(auth);
feeds.public.more(auth); feeds.public.spawn_more(auth);
} /> } />
</div> </div>
<div class:hidden=move || auth.present() > <div class:hidden=move || auth.present() >
@ -45,14 +45,14 @@ pub fn LoginBox(
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 // reset home feed and point it to our user's inbox
feeds.home.reset(Some(format!("{URL_BASE}/actors/{username}/feed/page"))); feeds.home.reset(Some(format!("{URL_BASE}/actors/{username}/feed/page")));
feeds.home.more(auth); feeds.home.spawn_more(auth);
feeds.private.reset(Some(format!("{URL_BASE}/actors/{username}/inbox/page"))); feeds.private.reset(Some(format!("{URL_BASE}/actors/{username}/inbox/page")));
feeds.private.more(auth); feeds.private.spawn_more(auth);
// reset server feed: there may be more content now that we're authed // reset server feed: there may be more content now that we're authed
feeds.global.reset(Some(format!("{URL_BASE}/feed/page"))); feeds.global.reset(Some(format!("{URL_BASE}/feed/page")));
feeds.global.more(auth); feeds.global.spawn_more(auth);
feeds.server.reset(Some(format!("{URL_BASE}/inbox/page"))); feeds.server.reset(Some(format!("{URL_BASE}/inbox/page")));
feeds.server.more(auth); feeds.server.spawn_more(auth);
}); });
} > } >
<table class="w-100 align"> <table class="w-100 align">

View file

@ -16,9 +16,6 @@ pub use user::*;
mod post; mod post;
pub use post::*; pub use post::*;
mod timeline;
pub use timeline::*;
use leptos::*; use leptos::*;
#[component] #[component]

View file

@ -157,7 +157,7 @@ pub fn Object(object: crate::Object) -> impl IntoView {
let post = match object.object_type() { let post = match object.object_type() {
// mastodon, pleroma, misskey // mastodon, pleroma, misskey
Ok(apb::ObjectType::Note) => view! { Ok(apb::ObjectType::Note) => view! {
<blockquote class="tl">{post_inner}</blockquote> <article class="tl">{post_inner}</article>
}.into_view(), }.into_view(),
// lemmy with Page, peertube with Video // lemmy with Page, peertube with Video
Ok(apb::ObjectType::Document(t)) => view! { Ok(apb::ObjectType::Document(t)) => view! {

View file

@ -26,15 +26,15 @@ pub fn ActorBanner(object: crate::Object) -> impl IntoView {
let uid = object.id().unwrap_or_default().to_string(); let uid = object.id().unwrap_or_default().to_string();
let uri = Uri::web(U::Actor, &uid); let uri = Uri::web(U::Actor, &uid);
let avatar_url = object.icon().get().map(|x| x.url().id().str().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into()); let avatar_url = object.icon().get().map(|x| x.url().id().str().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
let display_name = object.name().unwrap_or_default().to_string();
let username = object.preferred_username().unwrap_or_default().to_string(); let username = object.preferred_username().unwrap_or_default().to_string();
let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string(); let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string();
let display_name = object.name().unwrap_or_default().to_string();
view! { view! {
<div> <div>
<table class="align" > <table class="align" >
<tr> <tr>
<td rowspan="2" ><a href={uri.clone()} ><img class="avatar avatar-actor" src={avatar_url} /></a></td> <td rowspan="2" ><a href={uri.clone()} ><img class="avatar avatar-actor" src={avatar_url} /></a></td>
<td><b>{display_name}</b></td> <td><b class="displayname"><DisplayName name=display_name /></b></td>
</tr> </tr>
<tr> <tr>
<td class="top" ><a class="hover" href={uri} ><small>{username}@{domain}</small></a></td> <td class="top" ><a class="hover" href={uri} ><small>{username}@{domain}</small></a></td>
@ -49,6 +49,18 @@ pub fn ActorBanner(object: crate::Object) -> impl IntoView {
} }
} }
#[component]
fn DisplayName(mut name: String) -> impl IntoView {
let custom_emoji_regex = regex::Regex::new(r":\w+:").expect("failed compiling regex pattern");
for m in custom_emoji_regex.find_iter(&name.clone()) {
// TODO this is a clear unmitigated unsanitized html injection ahahahahaha but accounts
// with many custom emojis in their names mess with my frontend and i dont want to
// deal with it rn
name = name.replace(m.as_str(), &format!("<u class=\"moreinfo\" title=\"{}\">[::]</u>", m.as_str()));
}
view! { <span inner_html=name></span> }
}
#[component] #[component]
pub fn FollowRequestButtons(activity_id: String, actor_id: String) -> impl IntoView { pub fn FollowRequestButtons(activity_id: String, actor_id: String) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context"); let auth = use_context::<Auth>().expect("missing auth context");
@ -104,79 +116,6 @@ async fn send_follow_response(kind: apb::ActivityType, target: String, to: Strin
} }
} }
#[component]
pub fn ActorHeader(object: crate::Object) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
let avatar_url = object.icon().get().map(|x| x.url().id().str().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
let background_url = object.image().get().map(|x| x.url().id().str().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
let display_name = object.name().unwrap_or_default().to_string();
let username = object.preferred_username().unwrap_or_default().to_string();
let created = object.published().ok();
let following = object.following_count().unwrap_or(0);
let followers = object.followers_count().unwrap_or(0);
let statuses = object.statuses_count().unwrap_or(0);
let following_me = object.following_me().unwrap_or(false);
let followed_by_me = object.followed_by_me().unwrap_or(false);
let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string();
let actor_type = object.actor_type().unwrap_or(apb::ActorType::Person);
let actor_type_tag = if actor_type == apb::ActorType::Person { None } else {
Some(view! { <sup class="ml-s"><small>"["{actor_type.as_ref().to_lowercase()}"]"</small></sup> } )
};
let uid = object.id().unwrap_or_default().to_string();
let web_path = Uri::web(U::Actor, &uid);
let _uid = uid.clone();
view! {
<div
class="banner"
style={format!("background: center / cover url({background_url});")}
>
<div style="height: 10em"></div> // TODO bad way to have it fixed height ewwww
</div>
<div class="overlap">
<table class="pl-2 pr-2 align w-100" style="table-layout: fixed">
<tr>
<td rowspan=4 style="width: 8em">
<img class="avatar avatar-border mr-s" src={avatar_url} style="height: 7em; width: 7em"/>
</td>
<td rowspan=2 class="bottom">
<b class="big">{display_name}</b>{actor_type_tag}
</td>
<td rowspan=2 class="bottom rev" title="statuses">{statuses}" "<span class="emoji">"\u{1f582}"</span></td>
</tr>
<tr></tr>
<tr>
<td class="top">
<small><a class="clean hover" href={uid.clone()} target="_blank">{username.clone()}@{domain}</a></small>
</td>
<td class="rev" title="following">
<a class="clean" href={format!("{web_path}/following")}>{following}" "<span class="emoji">"👥"</span></a>
</td>
</tr>
<tr>
<td>
<DateTime t=created />
</td>
<td class="rev" title="followers">
<a class="clean" href={format!("{web_path}/followers")}>{followers}" "<span class="emoji">"📢"</span></a>
</td>
</tr>
</table>
<div class="rev mr-1" class:hidden=move || !auth.present() || auth.user_id() == uid>
{if followed_by_me {
view! { <code class="color">following</code> }.into_view()
} else {
view! { <input type="submit" value="follow" on:click=move |_| send_follow_request(_uid.clone()) /> }.into_view()
}}
{if following_me {
Some(view! { <code class="ml-1 color">follows you</code> })
} else {
None
}}
</div>
</div>
}.into_view()
}
fn send_follow_request(target: String) { fn send_follow_request(target: String) {
let auth = use_context::<Auth>().expect("missing auth context"); let auth = use_context::<Auth>().expect("missing auth context");
spawn_local(async move { spawn_local(async move {

11
web/src/getters.rs Normal file
View file

@ -0,0 +1,11 @@
pub trait Getter<T: Default> {
/// .ok().unwrap_or_default()
fn want(self) -> T;
}
impl<T: Default> Getter<T> for apb::Field<T> {
fn want(self) -> T {
self.ok().unwrap_or_default()
}
}

View file

@ -3,8 +3,12 @@ mod app;
mod components; mod components;
mod page; mod page;
mod config; mod config;
mod objects;
mod actors;
mod getters;
mod timeline;
pub mod actors;
pub use app::App; pub use app::App;
pub use config::Config; pub use config::Config;
pub use auth::Auth; pub use auth::Auth;

1
web/src/objects/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod view;

View file

@ -7,7 +7,7 @@ use crate::prelude::*;
use apb::{Base, Object}; use apb::{Base, Object};
#[component] #[component]
pub fn ObjectPage() -> impl IntoView { pub fn ObjectView() -> impl IntoView {
let params = use_params_map(); let params = use_params_map();
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 feeds = use_context::<Feeds>().expect("missing feeds context");
@ -45,23 +45,9 @@ pub fn ObjectPage() -> impl IntoView {
Some(obj) Some(obj)
} }
); );
view! {
<div>
<Breadcrumb back=true >
objects::view
<a
class="clean ml-1" href="#"
class:hidden=move || feeds.context.is_empty()
on:click=move |_| {
feeds.context.reset(Some(feeds.context.next.get().split('?').next().unwrap_or_default().to_string()));
feeds.context.more(auth);
}><span class="emoji">
"\u{1f5d8}"
</span></a>
</Breadcrumb>
<div class="ma-2" >
{move || match object.get() { {move || match object.get() {
None => view! { <p class="center"> loading ... </p> }.into_view(), None => view! { <Loader /> }.into_view(),
Some(None) => { Some(None) => {
let raw_id = params.get().get("id").cloned().unwrap_or_default(); let raw_id = params.get().get("id").cloned().unwrap_or_default();
let uid = uriproxy::uri(URL_BASE, uriproxy::UriClass::Object, &raw_id); let uid = uriproxy::uri(URL_BASE, uriproxy::UriClass::Object, &raw_id);
@ -71,13 +57,11 @@ pub fn ObjectPage() -> impl IntoView {
let object = o.clone(); let object = o.clone();
view!{ view!{
<Object object=object /> <Object object=object />
<div class="ml-1 mr-1 mt-2"> <hr class="color ma-2" />
<TimelineReplies tl=feeds.context root=o.id().unwrap_or_default().to_string() /> <div class="mr-1-r ml-1-r">
<Thread tl=feeds.context root=o.id().unwrap_or_default().to_string() />
</div> </div>
}.into_view() }.into_view()
}, },
}} }}
</div>
</div>
}
} }

View file

@ -5,7 +5,6 @@ use crate::prelude::*;
pub fn AboutPage() -> impl IntoView { pub fn AboutPage() -> impl IntoView {
view! { view! {
<div> <div>
<Breadcrumb>about</Breadcrumb>
<div class="mt-s mb-s" > <div class="mt-s mb-s" >
<p><code>μpub</code>" is a micro social network powered by "<a href="">ActivityPub</a></p> <p><code>μpub</code>" is a micro social network powered by "<a href="">ActivityPub</a></p>
<p><i>"the "<a href="https://en.wikipedia.org/wiki/Fediverse">fediverse</a>" is an ensemble of social networks, which, while independently hosted, can communicate with each other"</i></p> <p><i>"the "<a href="https://en.wikipedia.org/wiki/Fediverse">fediverse</a>" is an ensemble of social networks, which, while independently hosted, can communicate with each other"</i></p>

View file

@ -53,7 +53,6 @@ pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView {
view! { view! {
<div> <div>
<Breadcrumb>config</Breadcrumb>
<p class="center mt-0"><small>config is saved in your browser local storage</small></p> <p class="center mt-0"><small>config is saved in your browser local storage</small></p>
<p> <p>
<span title="embedded video attachments will loop like gifs if this option is enabled"> <span title="embedded video attachments will loop like gifs if this option is enabled">

View file

@ -40,7 +40,6 @@ pub fn DebugPage() -> impl IntoView {
view! { view! {
<div> <div>
<Breadcrumb back=true>devtools</Breadcrumb>
<div class="mt-1" > <div class="mt-1" >
<form on:submit=move|ev| { <form on:submit=move|ev| {
ev.prevent_default(); ev.prevent_default();

View file

@ -7,14 +7,8 @@ pub use config::ConfigPage;
mod debug; mod debug;
pub use debug::DebugPage; pub use debug::DebugPage;
mod object;
pub use object::ObjectPage;
mod register; mod register;
pub use register::RegisterPage; pub use register::RegisterPage;
mod search; mod search;
pub use search::SearchPage; pub use search::SearchPage;
mod timeline;
pub use timeline::TimelinePage;

View file

@ -14,7 +14,6 @@ pub fn RegisterPage() -> impl IntoView {
let (error, set_error) = create_signal(None); let (error, set_error) = create_signal(None);
view! { view! {
<div class="two-col"> <div class="two-col">
<Breadcrumb>register</Breadcrumb>
<div class="border ma-2 pa-1"> <div class="border ma-2 pa-1">
<form on:submit=move|ev| { <form on:submit=move|ev| {
ev.prevent_default(); ev.prevent_default();

View file

@ -25,7 +25,6 @@ pub fn SearchPage() -> impl IntoView {
); );
view! { view! {
<Breadcrumb>search</Breadcrumb>
<blockquote class="mt-3 mb-3"> <blockquote class="mt-3 mb-3">
<details open> <details open>
<summary class="mb-2"> <summary class="mb-2">

View file

@ -1,23 +0,0 @@
use leptos::*;
use crate::prelude::*;
#[component]
pub fn TimelinePage(name: &'static str, tl: Timeline) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
view! {
<div>
<Breadcrumb back=false>
{name}
<a class="clean ml-1" href="#" on:click=move |_| {
tl.reset(Some(tl.next.get().split('?').next().unwrap_or_default().to_string()));
tl.more(auth);
}><span class="emoji">
"\u{1f5d8}"
</span></a>
</Breadcrumb>
<div class="mt-s mb-s" >
<TimelineFeed tl=tl />
</div>
</div>
}
}

View file

@ -1,14 +1,22 @@
pub use crate::{ pub use crate::{
Http, Uri, Http, Uri,
CACHE, URL_BASE, CACHE, URL_BASE,
app::Feeds, app::{Feeds, Loader},
auth::Auth, auth::Auth,
page::*, page::*,
components::*, components::*,
actors::{ actors::{
view::ActorHeader, header::ActorHeader,
follow::FollowList, follow::FollowList,
posts::ActorPosts, posts::ActorPosts,
},
timeline::{
Timeline,
feed::Feed,
thread::Thread,
},
objects::{
view::ObjectView,
} }
}; };

42
web/src/timeline/feed.rs Normal file
View file

@ -0,0 +1,42 @@
use leptos::*;
use crate::prelude::*;
use super::Timeline;
#[component]
pub fn Feed(tl: Timeline) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
if let Some(auto_scroll) = use_context::<Signal<bool>>() {
let _ = leptos::watch(
move || auto_scroll.get(),
move |new, old, _| {
match old {
None => tl.spawn_more(auth), // always do it first time
Some(old) => if *new && new != old {
tl.spawn_more(auth);
},
}
},
true,
);
}
view! {
<div>
<For
each=move || tl.feed.get()
key=|k| k.to_string()
let:id
>
{match CACHE.get(&id) {
Some(i) => view! {
<Item item=i sep=true />
}.into_view(),
None => view! {
<p><code>{id}</code>" "[<a href={uri}>go</a>]</p>
<hr />
}.into_view(),
}}
</For>
</div>
{move || if tl.loading.get() { Some(view! { <Loader /> }) } else { None }}
}
}

View file

@ -1,8 +1,10 @@
pub mod feed;
pub mod thread;
use std::{collections::BTreeSet, pin::Pin, sync::Arc}; use std::{collections::BTreeSet, pin::Pin, sync::Arc};
use apb::{field::OptionalString, Activity, ActivityMut, Base, Object}; use apb::{field::OptionalString, Activity, ActivityMut, Base, Object};
use leptos::*; use leptos::*;
use leptos_use::{signal_throttled, use_element_size, use_window_scroll, UseElementSizeReturn};
use crate::prelude::*; use crate::prelude::*;
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@ -38,18 +40,26 @@ impl Timeline {
} }
} }
pub fn more(&self, auth: Auth) { pub fn spawn_more(&self, auth: Auth) {
if self.loading.get_untracked() { return }
if self.over.get_untracked() { return }
let _self = *self; let _self = *self;
spawn_local(async move { spawn_local(async move {
_self.loading.set(true); _self.more(auth).await
let res = _self.load_more(auth).await; });
_self.loading.set(false); }
pub fn loading(&self) -> bool {
self.loading.get_untracked()
}
pub async fn more(&self, auth: Auth) {
if self.loading.get_untracked() { return }
if self.over.get_untracked() { return }
self.loading.set(true);
let res = self.load_more(auth).await;
self.loading.set(false);
if let Err(e) = res { if let Err(e) = res {
tracing::error!("failed loading posts for timeline: {e}"); tracing::error!("failed loading posts for timeline: {e}");
} }
});
} }
pub async fn load_more(&self, auth: Auth) -> reqwest::Result<()> { pub async fn load_more(&self, auth: Auth) -> reqwest::Result<()> {
@ -80,124 +90,6 @@ impl Timeline {
} }
} }
#[component]
pub fn TimelineRepliesRecursive(tl: Timeline, root: String) -> impl IntoView {
let root_values = move || tl.feed
.get()
.into_iter()
.filter_map(|x| {
let document = CACHE.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.get(document.object().id().ok()?)?;
(object.id().str()?, object.in_reply_to().id().str()?)
},
// if it's a raw note, directly check if it replies to root
apb::ObjectType::Note => (document.id().str()?, document.in_reply_to().id().str()?),
// if it's anything else, check if it relates to root, maybe like or announce?
_ => (document.id().str()?, document.object().id().str()?),
};
if reply == root {
Some((oid, document))
} else {
None
}
})
.collect::<Vec<(String, crate::Object)>>();
view! {
<For
each=root_values
key=|(id, _obj)| id.clone()
children=move |(id, obj)|
view! {
<div class="context depth-r">
<Item item=obj replies=true />
<div class="depth-r">
<TimelineRepliesRecursive tl=tl root=id />
</div>
</div>
}
/ >
}
}
#[component]
pub fn TimelineReplies(tl: Timeline, root: String) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
view! {
<div>
<TimelineRepliesRecursive tl=tl root=root />
</div>
<div class="center mt-1 mb-1" class:hidden=tl.over >
<button type="button"
prop:disabled=tl.loading
on:click=move |_| tl.more(auth)
>
{move || if tl.loading.get() {
view! { "loading"<span class="dots"></span> }.into_view()
} else { "more".into_view() }}
</button>
</div>
}
}
#[component]
pub fn TimelineFeed(tl: Timeline) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
// double view height: preload when 1 screen away
let view_height = 2.0 * window()
.inner_height()
.map_or(500.0, |v| v.as_f64().unwrap_or_default());
let scroll_ref = create_node_ref();
let UseElementSizeReturn { width: _w, height } = use_element_size(scroll_ref);
let (_x, scroll) = use_window_scroll();
let scroll_debounced = signal_throttled(scroll, 500.0);
let _auto_loader = create_local_resource(
move || (scroll_debounced.get(), height.get()),
move |(s, h)| async move {
if !config.get_untracked().infinite_scroll { return }
if h - s < view_height {
tl.more(auth);
}
},
);
view! {
<div ref=scroll_ref>
<For
each=move || tl.feed.get()
key=|k| k.to_string()
children=move |id: String| {
match CACHE.get(&id) {
Some(i) => view! {
<Item item=i sep=true />
}.into_view(),
None => view! {
<p><code>{id}</code>" "[<a href={uri}>go</a>]</p>
<hr />
}.into_view(),
}
}
/ >
</div>
<div class="center mt-1 mb-1" class:hidden=tl.over >
<button type="button"
prop:disabled=tl.loading
on:click=move |_| tl.more(auth)
>
{move || if tl.loading.get() {
view! { "loading "<span class="dots"></span> }.into_view()
} else { "more".into_view() }}
</button>
</div>
}
}
async fn process_activities(activities: Vec<serde_json::Value>, auth: Auth) -> Vec<String> { 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 sub_tasks : Vec<Pin<Box<dyn futures::Future<Output = ()>>>> = Vec::new();
let mut gonna_fetch = BTreeSet::new(); let mut gonna_fetch = BTreeSet::new();

View file

@ -0,0 +1,75 @@
use apb::{field::OptionalString, Activity, Base, Object};
use leptos::*;
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");
if let Some(auto_scroll) = use_context::<Signal<bool>>() {
let _ = leptos::watch(
move || auto_scroll.get(),
move |new, old, _| {
match old {
None => tl.spawn_more(auth), // always do it first time
Some(old) => if *new && new != old {
tl.spawn_more(auth);
},
}
},
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.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.get(document.object().id().ok()?)?;
(object.id().str()?, object.in_reply_to().id().str()?)
},
// if it's a raw note, directly check if it replies to root
apb::ObjectType::Note => (document.id().str()?, document.in_reply_to().id().str()?),
// if it's anything else, check if it relates to root, maybe like or announce?
_ => (document.id().str()?, document.object().id().str()?),
};
if reply == root {
Some((oid, document))
} else {
None
}
})
.collect::<Vec<(String, crate::Object)>>();
view! {
<For
each=root_values
key=|(id, _obj)| id.clone()
children=move |(id, obj)|
view! {
<div class="context depth-r">
<Item item=obj replies=true />
<div class="depth-r">
<FeedRecursive tl=tl root=id />
</div>
</div>
}
/ >
}
}