forked from alemi/upub
feat(web): more compact, breadcrumbs, tl cleaning
This commit is contained in:
parent
9f66ee4f95
commit
06a7463af5
4 changed files with 143 additions and 89 deletions
|
@ -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;
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
Loading…
Reference in a new issue