feat(web): more compact, breadcrumbs, tl cleaning

This commit is contained in:
əlemi 2024-04-17 21:10:20 +02:00
parent 9f66ee4f95
commit 06a7463af5
Signed by: alemi
GPG key ID: A4895B84D311642C
4 changed files with 143 additions and 89 deletions

View file

@ -60,11 +60,17 @@
padding-bottom: 0; padding-bottom: 0;
line-height: 1rem; line-height: 1rem;
} }
main {
margin: 0em 1em;
}
blockquote { blockquote {
margin-left: 1.25em; margin-left: 1.25em;
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
} }
blockquote p {
margin: .5em 1em;
}
span.footer { span.footer {
padding: .1em; padding: .1em;
font-size: .6em; font-size: .6em;
@ -80,10 +86,15 @@
position: sticky; position: sticky;
} }
div.sticky { div.sticky {
top: 1.75rem; top: 2rem;
position: sticky; position: sticky;
background-color: var(--background); background-color: var(--background);
padding-top: .5em; }
@media screen and (max-width: 786px) {
div.sticky {
top: 1.75rem;
padding-top: .25rem;
}
} }
a.upub-title { a.upub-title {
color: var(--primary); color: var(--primary);
@ -98,6 +109,14 @@
a.hover:hover { a.hover:hover {
text-decoration: underline; text-decoration: underline;
} }
a.breadcrumb {
text-decoration: none;
color: var(--secondary);
}
a.breadcrumb:hover {
font-weight: bold;
color: var(--primary);
}
img.avatar-circle { img.avatar-circle {
display: inline; display: inline;
max-height: 2em; max-height: 2em;
@ -127,9 +146,6 @@
color: var(--background); color: var(--background);
cursor: pointer; cursor: pointer;
} }
main {
margin: 1em;
}
@media screen and (max-width: 786px) { @media screen and (max-width: 786px) {
.hidden-on-mobile { .hidden-on-mobile {
display: none; display: none;

View file

@ -2,7 +2,7 @@ use std::{collections::BTreeSet, sync::Arc};
use apb::{Activity, ActivityMut, Base, Collection, CollectionPage}; use apb::{Activity, ActivityMut, Base, Collection, CollectionPage};
use dashmap::DashMap; use dashmap::DashMap;
use leptos::{create_signal, leptos_dom::logging::console_warn, ReadSignal, Signal, SignalGet, SignalSet, WriteSignal}; use leptos::{create_rw_signal, create_signal, leptos_dom::logging::console_warn, ReadSignal, RwSignal, Signal, SignalGet, SignalSet, WriteSignal};
use crate::URL_BASE; use crate::URL_BASE;
@ -141,94 +141,88 @@ impl Http {
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct Timeline { pub struct Timeline {
pub(crate) feed: ReadSignal<Vec<String>>, pub feed: RwSignal<Vec<String>>,
pub(crate) set_feed: WriteSignal<Vec<String>>, pub next: RwSignal<String>,
pub(crate) next: ReadSignal<String>,
pub(crate) set_next: WriteSignal<String>,
} }
impl Timeline { impl Timeline {
pub fn new(url: String) -> Self { pub fn new(url: String) -> Self {
let (feed, set_feed) = create_signal(vec![]); let feed = create_rw_signal(vec![]);
let (next, set_next) = create_signal(url); let next = create_rw_signal(url);
Timeline { feed, set_feed, next, set_next } Timeline { feed, next }
} }
pub fn feed(&self) -> Vec<String> { pub fn reset(&self, url: String) {
self.feed.get() self.feed.set(vec![]);
} self.next.set(url);
pub fn set_feed(&self, feed: Vec<String>) {
self.set_feed.set(feed);
}
pub fn next(&self) -> String {
self.next.get()
}
pub fn set_next(&self, feed: String) {
self.set_next.set(feed);
} }
pub async fn more(&self, auth: Signal<Option<String>>) -> reqwest::Result<()> { pub async fn more(&self, auth: Signal<Option<String>>) -> reqwest::Result<()> {
let feed_url = self.next(); let feed_url = self.next.get();
let collection : serde_json::Value = Http::fetch(&feed_url, auth).await?; let collection : serde_json::Value = Http::fetch(&feed_url, auth).await?;
let activities : Vec<serde_json::Value> = collection let activities : Vec<serde_json::Value> = collection
.ordered_items() .ordered_items()
.collect(); .collect();
let mut out = self.feed(); let mut feed = self.feed.get();
let mut sub_tasks = Vec::new(); let mut older = process_activities(activities, auth).await;
let mut gonna_fetch = BTreeSet::new(); feed.append(&mut older);
self.feed.set(feed);
for activity in activities {
// save embedded object if present
if let Some(object) = activity.object().get() {
if let Some(object_uri) = object.id() {
CACHE.put(object_uri.to_string(), object.clone());
}
} else { // try fetching it
if let Some(object_id) = activity.object().id() {
if !gonna_fetch.contains(&object_id) {
gonna_fetch.insert(object_id.clone());
sub_tasks.push(fetch_and_update("objects", object_id, auth));
}
}
}
// save activity, removing embedded object
let object_id = activity.object().id();
if let Some(activity_id) = activity.id() {
out.push(activity_id.to_string());
CACHE.put(
activity_id.to_string(),
activity.clone().set_object(apb::Node::maybe_link(object_id))
);
}
if let Some(uid) = activity.actor().id() {
if CACHE.get(&uid).is_none() && !gonna_fetch.contains(&uid) {
gonna_fetch.insert(uid.clone());
sub_tasks.push(fetch_and_update("users", uid, auth));
}
}
}
futures::future::join_all(sub_tasks).await;
self.set_feed(out);
if let Some(next) = collection.next().id() { if let Some(next) = collection.next().id() {
self.set_next(next); self.next.set(next);
} }
Ok(()) Ok(())
} }
} }
async fn process_activities(
activities: Vec<serde_json::Value>,
auth: Signal<Option<String>>,
) -> Vec<String> {
let mut sub_tasks = Vec::new();
let mut gonna_fetch = BTreeSet::new();
let mut out = Vec::new();
for activity in activities {
// save embedded object if present
if let Some(object) = activity.object().get() {
if let Some(object_uri) = object.id() {
CACHE.put(object_uri.to_string(), object.clone());
}
} else { // try fetching it
if let Some(object_id) = activity.object().id() {
if !gonna_fetch.contains(&object_id) {
gonna_fetch.insert(object_id.clone());
sub_tasks.push(fetch_and_update("objects", object_id, auth));
}
}
}
// save activity, removing embedded object
let object_id = activity.object().id();
if let Some(activity_id) = activity.id() {
out.push(activity_id.to_string());
CACHE.put(
activity_id.to_string(),
activity.clone().set_object(apb::Node::maybe_link(object_id))
);
}
if let Some(uid) = activity.actor().id() {
if CACHE.get(&uid).is_none() && !gonna_fetch.contains(&uid) {
gonna_fetch.insert(uid.clone());
sub_tasks.push(fetch_and_update("users", uid, auth));
}
}
}
futures::future::join_all(sub_tasks).await;
out
}
async fn fetch_and_update(kind: &'static str, id: String, auth: Signal<Option<String>>) { async fn fetch_and_update(kind: &'static str, id: String, auth: Signal<Option<String>>) {
match Http::fetch(&Uri::api(kind, &id), auth).await { match Http::fetch(&Uri::api(kind, &id), auth).await {
Ok(data) => CACHE.put(id, data), Ok(data) => CACHE.put(id, data),

View file

@ -8,6 +8,7 @@ use crate::context::{Http, Timeline, Uri, CACHE};
pub const URL_BASE: &str = "https://feditest.alemi.dev"; pub const URL_BASE: &str = "https://feditest.alemi.dev";
pub const URL_PREFIX: &str = "/web"; pub const URL_PREFIX: &str = "/web";
pub const NAME: &str = "μ";
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
struct LoginForm { struct LoginForm {
@ -50,15 +51,23 @@ pub fn LoginBox(
username: Signal<Option<String>>, username: Signal<Option<String>>,
username_tx: WriteSignal<Option<String>>, username_tx: WriteSignal<Option<String>>,
home_tl: Timeline, home_tl: Timeline,
server_tl: Timeline,
) -> impl IntoView { ) -> impl IntoView {
let username_ref: NodeRef<html::Input> = create_node_ref(); let username_ref: NodeRef<html::Input> = create_node_ref();
let password_ref: NodeRef<html::Input> = create_node_ref(); let password_ref: NodeRef<html::Input> = create_node_ref();
view! { view! {
<div> <div>
<div class="w-100" class:hidden=move || !token.get().present() > <div class="w-100" class:hidden=move || !token.get().present() >
"Hello "<a href={move || Uri::web("users", &username.get().unwrap_or_default() )} >{move || username.get().unwrap_or_default() }</a> "hi "<a href={move || Uri::web("users", &username.get().unwrap_or_default() )} >{move || username.get().unwrap_or_default() }</a>
<input style="float:right" type="submit" value="logout" on:click=move |_| { <input style="float:right" type="submit" value="logout" on:click=move |_| {
token_tx.set(None); token_tx.set(None);
home_tl.reset(format!("{URL_BASE}/outbox/page"));
server_tl.reset(format!("{URL_BASE}/inbox/page"));
spawn_local(async move {
if let Err(e) = server_tl.more(token).await {
console_error(&format!("failed refreshing server timeline: {e}"));
}
});
} /> } />
</div> </div>
<div class:hidden=move || token.get().present() > <div class:hidden=move || token.get().present() >
@ -79,8 +88,19 @@ pub fn LoginBox(
console_log(&format!("logged in until {}", auth.expires)); console_log(&format!("logged in until {}", auth.expires));
let username = auth.user.split('/').last().unwrap_or_default().to_string(); let username = auth.user.split('/').last().unwrap_or_default().to_string();
// reset home feed and point it to our user's inbox // reset home feed and point it to our user's inbox
home_tl.set_feed(vec![]); home_tl.reset(format!("{URL_BASE}/users/{}/inbox/page", username));
home_tl.set_next(format!("{URL_BASE}/users/{}/inbox/page", username)); spawn_local(async move {
if let Err(e) = home_tl.more(token).await {
console_error(&format!("failed refreshing home timeline: {e}"));
}
});
// reset server feed: there may be more content now that we're authed
server_tl.reset(format!("{URL_BASE}/inbox/page"));
spawn_local(async move {
if let Err(e) = server_tl.more(token).await {
console_error(&format!("failed refreshing server timeline: {e}"));
}
});
// update our username and token cookies // update our username and token cookies
username_tx.set(Some(username)); username_tx.set(Some(username));
token_tx.set(Some(auth.token)); token_tx.set(Some(auth.token));
@ -139,7 +159,7 @@ pub fn PostBox(username: Signal<Option<String>>) -> impl IntoView {
</tr> </tr>
<tr> <tr>
<td colspan="3"> <td colspan="3">
<textarea rows="5" class="w-100" node_ref=content_ref placeholder="leptos is kinda fun!" ></textarea> <textarea rows="5" class="w-100" node_ref=content_ref title="content" ></textarea>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -257,7 +277,7 @@ pub fn UserPage() -> impl IntoView {
}); });
view! { view! {
<div> <div>
<div class="tl-header w-100 center mb-s" >view::user</div> <Breadcrumb back=true >users::view</Breadcrumb>
<div> <div>
{move || match actor.get() { {move || match actor.get() {
None => view! { <p>loading...</p> }.into_view(), None => view! { <p>loading...</p> }.into_view(),
@ -313,7 +333,7 @@ pub fn ObjectPage() -> impl IntoView {
}); });
view! { view! {
<div> <div>
<div class="tl-header w-100 center mb-s" >view::object</div> <Breadcrumb back=true >objects::view</Breadcrumb>
<div class="ma-2" > <div class="ma-2" >
{move || match object.get() { {move || match object.get() {
Some(Some(o)) => view!{ <Object object=o /> }.into_view(), Some(Some(o)) => view!{ <Object object=o /> }.into_view(),
@ -426,7 +446,7 @@ pub fn InlineActivity(activity: serde_json::Value) -> impl IntoView {
pub fn About() -> impl IntoView { pub fn About() -> impl IntoView {
view! { view! {
<div> <div>
<div class="tl-header w-100 center mb-s" >about</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>
</div> </div>
@ -434,15 +454,40 @@ pub fn About() -> impl IntoView {
} }
} }
#[derive(Debug, thiserror::Error)] #[component]
#[error("{0}")] pub fn Breadcrumb(
struct OmgReqwestErrorIsNotClonable(String); #[prop(optional)]
back: bool,
children: Children,
) -> impl IntoView {
view! {
<div class="tl-header w-100 center mb-s" >
{if back { Some(view! {
<a class="breadcrumb mr-1" href="javascript:history.back()" ><b>"<<"</b></a>
})} else { None }}
<b>{NAME}</b>" :: "{children()}
</div>
}
}
#[component] #[component]
pub fn TimelinePage(name: &'static str, tl: Timeline) -> impl IntoView { pub fn TimelinePage(name: &'static str, tl: Timeline) -> impl IntoView {
let auth = use_context::<Signal<Option<String>>>().expect("missing auth context");
view! { view! {
<div> <div>
<div class="tl-header w-100 center mb-s" >{name}</div> <Breadcrumb back=false>
{name}
<a class="clean ml-1" href="#" on:click=move |_| {
tl.reset(tl.next.get().split('?').next().unwrap_or_default().to_string());
spawn_local(async move {
if let Err(e) = tl.more(auth).await {
console_error(&format!("error fetching more items for timeline: {e}"));
}
})
}><span class="emoji">
"\u{1f5d8}"
</span></a>
</Breadcrumb>
<div class="mt-s mb-s" > <div class="mt-s mb-s" >
<TimelineFeed tl=tl /> <TimelineFeed tl=tl />
</div> </div>
@ -461,9 +506,7 @@ pub fn TimelineFeed(tl: Timeline) -> impl IntoView {
match CACHE.get(&id) { match CACHE.get(&id) {
Some(object) => { Some(object) => {
view! { view! {
<div class="ml-1 mr-1 mt-1"> <InlineActivity activity=object />
<InlineActivity activity=object />
</div>
<hr/ > <hr/ >
}.into_view() }.into_view()
}, },
@ -473,7 +516,7 @@ pub fn TimelineFeed(tl: Timeline) -> impl IntoView {
} }
} }
/ > / >
<div class="center" > <div class="center mt-1 mb-1" >
<button type="button" <button type="button"
on:click=move |_| { on:click=move |_| {
spawn_local(async move { spawn_local(async move {

View file

@ -4,7 +4,7 @@ use leptos_router::*;
use leptos_use::{use_cookie, utils::FromToStringCodec}; use leptos_use::{use_cookie, utils::FromToStringCodec};
use upub_web::{ use upub_web::{
URL_BASE, context::Timeline, About, LoginBox, MaybeToken, ObjectPage, PostBox, URL_BASE, context::Timeline, About, LoginBox, MaybeToken, ObjectPage, PostBox,
TimelinePage, Navigator, UserPage TimelinePage, Navigator, UserPage, Breadcrumb
}; };
fn main() { fn main() {
@ -53,6 +53,7 @@ fn main() {
username_tx=set_username username_tx=set_username
username=username username=username
home_tl=home_tl home_tl=home_tl
server_tl=server_tl
/> />
<hr class="mt-1 mb-1" /> <hr class="mt-1 mb-1" />
<Navigator /> <Navigator />