forked from alemi/upub
feat: reworked timelines to be persistent
a lot of work on uris, fetching, concurrency and caching. now timelines persist and should behave appropriately with your logins! there are still some weird edge cases but all around i think it's pretty solid and also quite nice!!
This commit is contained in:
parent
0d2c279f93
commit
f8b135d242
5 changed files with 410 additions and 212 deletions
|
@ -21,7 +21,7 @@ serde_json = "1"
|
||||||
dashmap = "5.5"
|
dashmap = "5.5"
|
||||||
leptos = { version = "0.6", features = ["csr"] }
|
leptos = { version = "0.6", features = ["csr"] }
|
||||||
leptos_router = { version = "0.6", features = ["csr"] }
|
leptos_router = { version = "0.6", features = ["csr"] }
|
||||||
leptos-use = "0.10"
|
leptos-use = { version = "0.10", features = ["serde"] }
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
apb = { path = "../apb", features = ["unstructured"] }
|
apb = { path = "../apb", features = ["unstructured"] }
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
|
|
|
@ -31,9 +31,13 @@
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
div.boxscroll {
|
div.boxscroll {
|
||||||
max-height: calc(100vh - 8rem);
|
max-height: calc(100vh - 7rem);
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
div.tl-header {
|
||||||
|
background-color: #bf616a55;
|
||||||
|
color: #bf616a;
|
||||||
|
}
|
||||||
@media screen and (max-width: 786px) {
|
@media screen and (max-width: 786px) {
|
||||||
div.boxscroll {
|
div.boxscroll {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
|
|
@ -1,23 +1,216 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use apb::{Activity, ActivityMut, Base, Collection, CollectionPage};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use lazy_static::lazy_static;
|
use leptos::{create_signal, leptos_dom::logging::console_warn, ReadSignal, Signal, SignalGet, SignalSet, WriteSignal};
|
||||||
|
|
||||||
lazy_static! {
|
use crate::{Auth, URL_BASE};
|
||||||
pub static ref CTX: Context = Context::default();
|
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
pub static ref CACHE: ObjectCache = ObjectCache::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Context {
|
pub struct ObjectCache(pub Arc<DashMap<String, serde_json::Value>>);
|
||||||
pub cache: Arc<Cache>,
|
|
||||||
pub timelines: Arc<Timelines>,
|
impl ObjectCache {
|
||||||
|
pub fn get(&self, k: &str) -> Option<serde_json::Value> {
|
||||||
|
self.0.get(k).map(|x| x.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put(&self, k: String, v: serde_json::Value) {
|
||||||
|
self.0.insert(k, v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
pub struct Uri;
|
||||||
pub struct Cache {
|
|
||||||
pub actors: DashMap<String, serde_json::Value>,
|
impl Uri {
|
||||||
|
|
||||||
|
pub fn full(kind: &str, id: &str) -> String {
|
||||||
|
if id.starts_with('+') {
|
||||||
|
id.replace('+', "https://").replace('@', "/")
|
||||||
|
} else {
|
||||||
|
format!("{URL_BASE}/{kind}/{id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn short(url: &str) -> String {
|
||||||
|
if url.starts_with(URL_BASE) {
|
||||||
|
url.split('/').last().unwrap_or_default().to_string()
|
||||||
|
} else {
|
||||||
|
url.replace("https://", "+").replace('/', "@")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// convert url id to valid frontend view id:
|
||||||
|
/// /web/users/test
|
||||||
|
/// /web/objects/+social.alemi.dev@objects@1204kasfkl
|
||||||
|
/// accepts:
|
||||||
|
/// - https://my.domain.net/users/root
|
||||||
|
/// - https://other.domain.net/unexpected/path/root
|
||||||
|
/// - +other.domain.net@users@root
|
||||||
|
/// - root
|
||||||
|
pub fn web(kind: &str, url: &str) -> String {
|
||||||
|
format!("/web/{kind}/{}", Self::short(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// convert url id to valid backend api id
|
||||||
|
/// https://feditest.alemi.dev/users/test
|
||||||
|
/// https://feditest.alemi.dev/users/+social.alemi.dev@users@alemi
|
||||||
|
/// accepts:
|
||||||
|
/// - https://my.domain.net/users/root
|
||||||
|
/// - https://other.domain.net/unexpected/path/root
|
||||||
|
/// - +other.domain.net@users@root
|
||||||
|
/// - root
|
||||||
|
pub fn api(kind: &str, url: &str) -> String {
|
||||||
|
format!("{URL_BASE}/{kind}/{}", Self::short(url))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
// impl ObjectCache {
|
||||||
pub struct Timelines {
|
// pub async fn user(&self, id: &str, token: Option<&str>) -> Option<serde_json::Value> {
|
||||||
|
// match self.actors.get(id) {
|
||||||
|
// Some(x) => Some(x.clone()),
|
||||||
|
// None => {
|
||||||
|
// let mut req = reqwest::Client::new()
|
||||||
|
// .get(format!("{URL_BASE}/users/+?id={id}"));
|
||||||
|
// if let Some(token) = token {
|
||||||
|
// req = req.header("Authorization", format!("Bearer {token}"));
|
||||||
|
// }
|
||||||
|
// let user = req
|
||||||
|
// .send()
|
||||||
|
// .await.ok()?
|
||||||
|
// .json::<serde_json::Value>()
|
||||||
|
// .await.ok()?;
|
||||||
|
//
|
||||||
|
// self.actors.insert(id.to_string(), user.clone());
|
||||||
|
//
|
||||||
|
// Some(user)
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
pub struct Http;
|
||||||
|
|
||||||
|
impl Http {
|
||||||
|
pub async fn request<T: serde::de::DeserializeOwned>(method: reqwest::Method, url: &str, data: Option<&serde_json::Value>, token: &Signal<Option<Auth>>) -> reqwest::Result<T> {
|
||||||
|
let mut req = reqwest::Client::new()
|
||||||
|
.request(method, url);
|
||||||
|
|
||||||
|
if let Some(auth) = token.get() {
|
||||||
|
req = req.header("Authorization", format!("Bearer {}", auth.token));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(data) = data {
|
||||||
|
req = req.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<T>()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch<T: serde::de::DeserializeOwned>(url: &str, token: &Signal<Option<Auth>>) -> reqwest::Result<T> {
|
||||||
|
Self::request(reqwest::Method::GET, url, None, token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post<T: serde::de::DeserializeOwned>(url: &str, data: &serde_json::Value, token: &Signal<Option<Auth>>) -> reqwest::Result<T> {
|
||||||
|
Self::request(reqwest::Method::POST, url, Some(data), token).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Timeline {
|
||||||
|
pub(crate) feed: ReadSignal<Vec<String>>,
|
||||||
|
pub(crate) set_feed: WriteSignal<Vec<String>>,
|
||||||
|
pub(crate) next: ReadSignal<String>,
|
||||||
|
pub(crate) set_next: WriteSignal<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Timeline {
|
||||||
|
pub fn new(url: String) -> Self {
|
||||||
|
let (feed, set_feed) = create_signal(vec![]);
|
||||||
|
let (next, set_next) = create_signal(url);
|
||||||
|
Timeline { feed, set_feed, next, set_next }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn feed(&self) -> Vec<String> {
|
||||||
|
self.feed.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Auth>>) -> reqwest::Result<()> {
|
||||||
|
let feed_url = self.next();
|
||||||
|
|
||||||
|
let collection : serde_json::Value = Http::fetch(&feed_url, &auth).await?;
|
||||||
|
|
||||||
|
|
||||||
|
let activities : Vec<serde_json::Value> = collection
|
||||||
|
.ordered_items()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut out = self.feed();
|
||||||
|
let mut sub_tasks = 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() {
|
||||||
|
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() {
|
||||||
|
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() {
|
||||||
|
self.set_next(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_and_update(kind: &'static str, id: String, auth: Signal<Option<Auth>>) {
|
||||||
|
match Http::fetch(&Uri::api(kind, &id), &auth).await {
|
||||||
|
Ok(data) => CACHE.put(id, data),
|
||||||
|
Err(e) => console_warn(&format!("could not fetch '{id}': {e}")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
252
web/src/lib.rs
252
web/src/lib.rs
|
@ -1,10 +1,10 @@
|
||||||
pub mod context;
|
pub mod context;
|
||||||
|
|
||||||
use apb::{target::Addressed, Activity, ActivityMut, Actor, Base, Collection, Object, ObjectMut};
|
use apb::{target::Addressed, Activity, Actor, Base, Collection, Object, ObjectMut};
|
||||||
use context::CTX;
|
use leptos::{leptos_dom::logging::{console_error, console_log}, *};
|
||||||
use leptos::{leptos_dom::logging::console_log, *};
|
|
||||||
use leptos_router::*;
|
use leptos_router::*;
|
||||||
|
|
||||||
|
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";
|
||||||
|
@ -15,58 +15,57 @@ struct LoginForm {
|
||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// convert url id to valid frontend view id
|
|
||||||
/// accepts:
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
/// - https://my.domain.net/users/root
|
pub struct Auth {
|
||||||
/// - https://other.domain.net/unexpected/path/root
|
pub token: String,
|
||||||
/// - +other.domain.net@users@root
|
pub user: String,
|
||||||
/// - root
|
pub expires: chrono::DateTime<chrono::Utc>,
|
||||||
fn web_uri(kind: &str, url: &str) -> String {
|
|
||||||
if url.starts_with(URL_BASE) {
|
|
||||||
format!("/web/{kind}/{}", url.split('/').last().unwrap_or_default())
|
|
||||||
} else {
|
|
||||||
format!("/web/{kind}/{}", url.replace("https://", "+").replace('/', "@"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// convert url id to valid backend api id
|
pub trait MaybeToken {
|
||||||
/// accepts:
|
fn present(&self) -> bool;
|
||||||
/// - https://my.domain.net/users/root
|
fn token(&self) -> String;
|
||||||
/// - https://other.domain.net/unexpected/path/root
|
fn username(&self) -> String;
|
||||||
/// - +other.domain.net@users@root
|
|
||||||
/// - root
|
|
||||||
fn api_uri(kind: &str, url: &str) -> String {
|
|
||||||
if url.starts_with(URL_BASE) {
|
|
||||||
url.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{URL_BASE}/{kind}/{}", url.replace("https://", "+").replace('/', "@"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
impl MaybeToken for Option<Auth> {
|
||||||
struct AuthSuccess {
|
fn token(&self) -> String {
|
||||||
token: String,
|
match self {
|
||||||
user: String,
|
None => String::new(),
|
||||||
expires: chrono::DateTime<chrono::Utc>,
|
Some(x) => x.token.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn present(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
None => false,
|
||||||
|
Some(x) => !x.token.is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn username(&self) -> String {
|
||||||
|
match self {
|
||||||
|
None => "anon".to_string(),
|
||||||
|
Some(x) => x.user.split('/').last().unwrap_or_default().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LoginBox(
|
pub fn LoginBox(
|
||||||
rx: Signal<Option<String>>,
|
rx: Signal<Option<Auth>>,
|
||||||
tx: WriteSignal<Option<String>>,
|
tx: WriteSignal<Option<Auth>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let (username, username_set) = create_signal("".to_string());
|
|
||||||
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 || { rx.get().unwrap_or_default().is_empty() }>
|
<div class="w-100" class:hidden=move || !rx.get().present() >
|
||||||
"Hello "<a href={move || web_uri("users", &username.get())} >{move || username.get()}</a>
|
"Hello "<a href={move || Uri::web("users", &rx.get().username())} >{move || rx.get().username()}</a>
|
||||||
<input style="float:right" type="submit" value="logout" on:click=move |_| {
|
<input style="float:right" type="submit" value="logout" on:click=move |_| {
|
||||||
tx.set(None);
|
tx.set(None);
|
||||||
} />
|
} />
|
||||||
</div>
|
</div>
|
||||||
<div class:hidden=move || { !rx.get().unwrap_or_default().is_empty() }>
|
<div class:hidden=move || rx.get().present() >
|
||||||
<input class="w-100" type="text" node_ref=username_ref placeholder="username" />
|
<input class="w-100" type="text" node_ref=username_ref placeholder="username" />
|
||||||
<input class="w-100" type="text" node_ref=password_ref placeholder="password" />
|
<input class="w-100" type="text" node_ref=password_ref placeholder="password" />
|
||||||
<input class="w-100" type="submit" value="login" on:click=move |_| {
|
<input class="w-100" type="submit" value="login" on:click=move |_| {
|
||||||
|
@ -79,11 +78,10 @@ pub fn LoginBox(
|
||||||
.json(&LoginForm { email, password })
|
.json(&LoginForm { email, password })
|
||||||
.send()
|
.send()
|
||||||
.await.unwrap()
|
.await.unwrap()
|
||||||
.json::<AuthSuccess>()
|
.json::<Auth>()
|
||||||
.await.unwrap();
|
.await.unwrap();
|
||||||
tx.set(Some(auth.token));
|
|
||||||
username_set.set(auth.user);
|
|
||||||
console_log(&format!("logged in until {}", auth.expires));
|
console_log(&format!("logged in until {}", auth.expires));
|
||||||
|
tx.set(Some(auth));
|
||||||
});
|
});
|
||||||
} />
|
} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -92,32 +90,29 @@ pub fn LoginBox(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn PostBox(token: Signal<Option<String>>) -> impl IntoView {
|
pub fn PostBox() -> impl IntoView {
|
||||||
|
let auth = use_context::<Signal<Option<Auth>>>().expect("missing auth context");
|
||||||
let summary_ref: NodeRef<html::Input> = create_node_ref();
|
let summary_ref: NodeRef<html::Input> = create_node_ref();
|
||||||
let content_ref: NodeRef<html::Textarea> = create_node_ref();
|
let content_ref: NodeRef<html::Textarea> = create_node_ref();
|
||||||
view! {
|
view! {
|
||||||
<div class:hidden=move || { token.get().unwrap_or_default().is_empty() }>
|
<div class:hidden=move || !auth.get().present() >
|
||||||
<input class="w-100" type="text" node_ref=summary_ref placeholder="CW" />
|
<input class="w-100" type="text" node_ref=summary_ref placeholder="cw" />
|
||||||
<textarea class="w-100" node_ref=content_ref placeholder="hello world!" ></textarea>
|
<textarea class="w-100" node_ref=content_ref placeholder="leptos is kinda fun!" ></textarea>
|
||||||
<button class="w-100" type="button" on:click=move |_| {
|
<button class="w-100" type="button" on:click=move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let summary = summary_ref.get().map(|x| x.value());
|
let summary = summary_ref.get().map(|x| x.value());
|
||||||
let content = content_ref.get().map(|x| x.value()).unwrap_or("".into());
|
let content = content_ref.get().map(|x| x.value()).unwrap_or("".into());
|
||||||
reqwest::Client::new()
|
Http::post(
|
||||||
.post(format!("{URL_BASE}/users/test/outbox"))
|
&format!("{URL_BASE}/users/test/outbox"),
|
||||||
.header("Authorization", format!("Bearer {}", token.get().unwrap_or_default()))
|
|
||||||
.json(
|
|
||||||
&serde_json::Value::Object(serde_json::Map::default())
|
&serde_json::Value::Object(serde_json::Map::default())
|
||||||
.set_object_type(Some(apb::ObjectType::Note))
|
.set_object_type(Some(apb::ObjectType::Note))
|
||||||
.set_summary(summary.as_deref())
|
.set_summary(summary.as_deref())
|
||||||
.set_content(Some(&content))
|
.set_content(Some(&content))
|
||||||
.set_to(apb::Node::links(vec![apb::target::PUBLIC.to_string()]))
|
.set_to(apb::Node::links(vec![apb::target::PUBLIC.to_string()]))
|
||||||
.set_cc(apb::Node::links(vec![format!("{URL_BASE}/users/test/followers")]))
|
.set_cc(apb::Node::links(vec![format!("{URL_BASE}/users/test/followers")])),
|
||||||
|
&auth
|
||||||
)
|
)
|
||||||
.send()
|
|
||||||
.await.unwrap()
|
.await.unwrap()
|
||||||
.error_for_status()
|
|
||||||
.unwrap();
|
|
||||||
})
|
})
|
||||||
} >post</button>
|
} >post</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -153,7 +148,7 @@ pub fn ActorBanner(object: serde_json::Value) -> impl IntoView {
|
||||||
},
|
},
|
||||||
serde_json::Value::Object(_) => {
|
serde_json::Value::Object(_) => {
|
||||||
let uid = object.id().unwrap_or_default().to_string();
|
let uid = object.id().unwrap_or_default().to_string();
|
||||||
let uri = web_uri("users", &uid);
|
let uri = Uri::web("users", &uid);
|
||||||
let avatar_url = object.icon().get().map(|x| x.url().id().unwrap_or_default()).unwrap_or_default();
|
let avatar_url = object.icon().get().map(|x| x.url().id().unwrap_or_default()).unwrap_or_default();
|
||||||
let display_name = object.name().unwrap_or_default().to_string();
|
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();
|
||||||
|
@ -181,19 +176,14 @@ pub fn ActorBanner(object: serde_json::Value) -> impl IntoView {
|
||||||
#[component]
|
#[component]
|
||||||
pub fn UserPage() -> impl IntoView {
|
pub fn UserPage() -> impl IntoView {
|
||||||
let params = use_params_map();
|
let params = use_params_map();
|
||||||
let actor = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), |id| {
|
let auth = use_context::<Signal<Option<Auth>>>().expect("missing auth context");
|
||||||
|
let actor = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), move |id| {
|
||||||
async move {
|
async move {
|
||||||
let uri = web_uri("users", &id);
|
match CACHE.get(&Uri::full("users", &id)) {
|
||||||
match CTX.cache.actors.get(&uri) {
|
|
||||||
Some(x) => Some(x.clone()),
|
Some(x) => Some(x.clone()),
|
||||||
None => {
|
None => {
|
||||||
let user = reqwest::get(&uri)
|
let user : serde_json::Value = Http::fetch(&Uri::api("users", &id), &auth).await.ok()?;
|
||||||
.await
|
CACHE.put(Uri::full("users", &id), user.clone());
|
||||||
.ok()?
|
|
||||||
.json::<serde_json::Value>()
|
|
||||||
.await
|
|
||||||
.ok()?;
|
|
||||||
CTX.cache.actors.insert(uri, user.clone());
|
|
||||||
Some(user)
|
Some(user)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -230,17 +220,16 @@ pub fn UserPage() -> impl IntoView {
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ObjectPage() -> impl IntoView {
|
pub fn ObjectPage() -> impl IntoView {
|
||||||
let params = use_params_map();
|
let params = use_params_map();
|
||||||
let object = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), |oid| {
|
let auth = use_context::<Signal<Option<Auth>>>().expect("missing auth context");
|
||||||
|
let object = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), move |oid| {
|
||||||
async move {
|
async move {
|
||||||
let uid = format!("{URL_BASE}/objects/{oid}");
|
match CACHE.get(&Uri::full("objects", &oid)) {
|
||||||
match CTX.cache.actors.get(&uid) {
|
|
||||||
Some(x) => Some(x.clone()),
|
Some(x) => Some(x.clone()),
|
||||||
None => reqwest::get(uid)
|
None => {
|
||||||
.await
|
let obj = Http::fetch::<serde_json::Value>(&Uri::api("objects", &oid), &auth).await.ok()?;
|
||||||
.ok()?
|
CACHE.put(Uri::full("objects", &oid), obj.clone());
|
||||||
.json::<serde_json::Value>()
|
Some(obj)
|
||||||
.await
|
}
|
||||||
.ok()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -259,7 +248,7 @@ pub fn Object(object: serde_json::Value) -> impl IntoView {
|
||||||
let content = object.content().unwrap_or_default().to_string();
|
let content = object.content().unwrap_or_default().to_string();
|
||||||
let date = object.published().map(|x| x.to_rfc3339()).unwrap_or_default();
|
let date = object.published().map(|x| x.to_rfc3339()).unwrap_or_default();
|
||||||
let author_id = object.attributed_to().id().unwrap_or_default();
|
let author_id = object.attributed_to().id().unwrap_or_default();
|
||||||
let author = CTX.cache.actors.get(&author_id).map(|x| view! { <ActorBanner object=x.clone() /> });
|
let author = CACHE.get(&author_id).map(|x| view! { <ActorBanner object=x.clone() /> });
|
||||||
view! {
|
view! {
|
||||||
{author}
|
{author}
|
||||||
<table>
|
<table>
|
||||||
|
@ -282,7 +271,7 @@ pub fn InlineActivity(activity: serde_json::Value) -> impl IntoView {
|
||||||
serde_json::Value::String(activity.object().id().unwrap_or_default())
|
serde_json::Value::String(activity.object().id().unwrap_or_default())
|
||||||
);
|
);
|
||||||
let object_id = object.id().unwrap_or_default().to_string();
|
let object_id = object.id().unwrap_or_default().to_string();
|
||||||
let object_uri = web_uri("objects", &object_id);
|
let object_uri = Uri::web("objects", &object_id);
|
||||||
let content = dissolve::strip_html_tags(object.content().unwrap_or_default());
|
let content = dissolve::strip_html_tags(object.content().unwrap_or_default());
|
||||||
let addressed = activity.addressed();
|
let addressed = activity.addressed();
|
||||||
let audience = format!("[ {} ]", addressed.join(", "));
|
let audience = format!("[ {} ]", addressed.join(", "));
|
||||||
|
@ -322,98 +311,61 @@ pub fn InlineActivity(activity: serde_json::Value) -> impl IntoView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn About() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<p>pick a timeline to start browsing</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
struct OmgReqwestErrorIsNotClonable(String);
|
struct OmgReqwestErrorIsNotClonable(String);
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Timeline(
|
pub fn TimelineFeed(name: &'static str, tl: Timeline) -> impl IntoView {
|
||||||
token: Signal<Option<String>>,
|
let auth = use_context::<Signal<Option<Auth>>>().expect("missing auth context");
|
||||||
) -> impl IntoView {
|
|
||||||
let (timeline, set_timeline) = create_signal(format!("{URL_BASE}/inbox/page"));
|
|
||||||
let items = create_local_resource(move || timeline.get(), move |feed_url| async move {
|
|
||||||
fetch_activities_with_users(&feed_url, token).await
|
|
||||||
});
|
|
||||||
view! {
|
view! {
|
||||||
<div class="ml-1">
|
<div class="ml-1">
|
||||||
<TimelinePicker tx=set_timeline rx=timeline />
|
<div class="tl-header w-100 center mb-s" >{name}</div>
|
||||||
<div class="boxscroll" >
|
<div class="boxscroll mt-s mb-s" >
|
||||||
<ErrorBoundary fallback=move |err| view! { <p>{format!("{:?}", err.get())}</p> } >
|
<For
|
||||||
{move || items.with(|x| match x {
|
each=move || tl.feed.get()
|
||||||
None => Ok(view! { <p>loading...</p> }.into_view()),
|
key=|k| k.to_string()
|
||||||
Some(data) => match data {
|
children=move |id: String| {
|
||||||
Err(e) => Err(OmgReqwestErrorIsNotClonable(e.to_string())),
|
match CACHE.get(&id) {
|
||||||
Ok(values) => Ok(
|
Some(object) => {
|
||||||
values
|
let actor_id = object.actor().id().unwrap_or_default();
|
||||||
.iter()
|
let actor = match CACHE.get(&actor_id) {
|
||||||
.map(|object| {
|
Some(a) => a,
|
||||||
let actor = object.actor().extract().unwrap_or_else(||
|
None => serde_json::Value::String(id),
|
||||||
serde_json::Value::String(object.actor().id().unwrap_or_default())
|
};
|
||||||
);
|
|
||||||
view! {
|
view! {
|
||||||
<div class="ml-1 mr-1 mt-1">
|
<div class="ml-1 mr-1 mt-1">
|
||||||
<ActorBanner object=actor />
|
<ActorBanner object=actor />
|
||||||
<InlineActivity activity=object.clone() />
|
<InlineActivity activity=object.clone() />
|
||||||
</div>
|
</div>
|
||||||
<hr/ >
|
<hr/ >
|
||||||
|
}.into_view()
|
||||||
|
},
|
||||||
|
None => view! {
|
||||||
|
<p><code>{id}</code>" "[<a href={uri}>go</a>]</p>
|
||||||
|
}.into_view(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/ >
|
||||||
|
<div class="center" >
|
||||||
|
<button type="button"
|
||||||
|
on:click=move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Err(e) = tl.more(auth).await {
|
||||||
|
console_error(&format!("error fetching more items for timeline: {e}"));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<Fragment>>()
|
|
||||||
.into_view()
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
})}
|
>more</button>
|
||||||
</ErrorBoundary>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_activities_with_users(
|
|
||||||
feed_url: &str,
|
|
||||||
token: Signal<Option<String>>,
|
|
||||||
) -> reqwest::Result<Vec<serde_json::Value>> {
|
|
||||||
let mut req = reqwest::Client::new().get(feed_url);
|
|
||||||
|
|
||||||
if let Some(token) = token.get() {
|
|
||||||
req = req.header("Authorization", format!("Bearer {token}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let activities : Vec<serde_json::Value> = req
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.json::<serde_json::Value>()
|
|
||||||
.await?
|
|
||||||
.ordered_items()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// i could make this fancier with iterators and futures::join_all but they would run
|
|
||||||
// concurrently and make a ton of parallel request, we actually want these sequential because
|
|
||||||
// first one may fetch same user as second one
|
|
||||||
// some fancier logic may make a set of all actors and fetch uniques concurrently...
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for x in activities {
|
|
||||||
if let Some(uid) = x.actor().id() {
|
|
||||||
if let Some(actor) = CTX.cache.actors.get(&uid) {
|
|
||||||
out.push(x.set_actor(apb::Node::object(actor.clone())))
|
|
||||||
} else {
|
|
||||||
let mut req = reqwest::Client::new()
|
|
||||||
.get(api_uri("users", &uid));
|
|
||||||
|
|
||||||
if let Some(token) = token.get() {
|
|
||||||
req = req.header("Authorization", format!("Bearer {token}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO don't fail whole timeline fetch when one user fails fetching...
|
|
||||||
let actor = req.send().await?.json::<serde_json::Value>().await?;
|
|
||||||
CTX.cache.actors.insert(web_uri("users", &uid), actor.clone());
|
|
||||||
|
|
||||||
out.push(x.set_actor(apb::Node::object(actor)))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out.push(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,37 +1,46 @@
|
||||||
use leptos::*;
|
use leptos::{leptos_dom::logging::console_error, *};
|
||||||
use leptos_router::*;
|
use leptos_router::*;
|
||||||
|
|
||||||
use leptos_use::{use_cookie, utils::FromToStringCodec};
|
use leptos_use::{use_cookie, utils::JsonCodec};
|
||||||
use upub_web::{
|
use upub_web::{
|
||||||
LoginBox, ObjectPage, UserPage, PostBox, Timeline
|
URL_BASE, context::Timeline, About, Auth, LoginBox, MaybeToken, ObjectPage, PostBox, TimelineFeed, UserPage
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
_ = console_log::init_with_level(log::Level::Debug);
|
_ = console_log::init_with_level(log::Level::Debug);
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
let (cookie, set_cookie) = use_cookie::<String, FromToStringCodec>("token");
|
let (cookie, set_cookie) = use_cookie::<Auth, JsonCodec>("token");
|
||||||
|
provide_context(cookie);
|
||||||
|
|
||||||
|
let home_tl = Timeline::new(format!("{URL_BASE}/users/{}/inbox/page", cookie.get().username()));
|
||||||
|
let server_tl = Timeline::new(format!("{URL_BASE}/inbox/page"));
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Err(e) = server_tl.more(cookie).await {
|
||||||
|
console_error(&format!("error populating timeline: {e}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if cookie.get().is_some() {
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Err(e) = home_tl.more(cookie).await {
|
||||||
|
console_error(&format!("error populating timeline: {e}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
mount_to_body(
|
mount_to_body(
|
||||||
move || view! {
|
move || view! {
|
||||||
<nav class="w-100">
|
<nav class="w-100">
|
||||||
<p>
|
<p>
|
||||||
<code><a class="upub-title" href="/web" >μpub</a></code>
|
<code><a class="upub-title" href="/web/home" >μpub</a></code>
|
||||||
<small class="ml-1 mr-1" ><a class="clean" href="/web" >micro social network, federated</a></small>
|
<small class="ml-1 mr-1" ><a class="clean" href="/web/server" >micro social network, federated</a></small>
|
||||||
/* TODO kinda jank with the float but whatever, will do for now */
|
/* TODO kinda jank with the float but whatever, will do for now */
|
||||||
<small style="float: right" ><a href="https://git.alemi.dev/upub.git" >src</a></small>
|
<small style="float: right" ><a href="https://git.alemi.dev/upub.git" >src</a></small>
|
||||||
</p>
|
</p>
|
||||||
</nav>
|
</nav>
|
||||||
<hr />
|
<hr />
|
||||||
<div class="container">
|
<div class="container" >
|
||||||
<div class="two-col">
|
|
||||||
<div class="col-side">
|
|
||||||
<LoginBox
|
|
||||||
tx=set_cookie
|
|
||||||
rx=cookie
|
|
||||||
/>
|
|
||||||
<PostBox token=cookie />
|
|
||||||
</div>
|
|
||||||
<div class="col-main">
|
|
||||||
<Router // TODO maybe set base="/web" ?
|
<Router // TODO maybe set base="/web" ?
|
||||||
fallback=move || view! {
|
fallback=move || view! {
|
||||||
<div class="center">
|
<div class="center">
|
||||||
|
@ -40,18 +49,58 @@ fn main() {
|
||||||
</div>
|
</div>
|
||||||
}.into_view()
|
}.into_view()
|
||||||
>
|
>
|
||||||
|
// TODO this is kind of ugly: the whole router gets rebuilt every time we log in/out
|
||||||
|
// in a sense it's what we want: refreshing the home tl is main purpose, but also
|
||||||
|
// server tl may contain stuff we can no longer see, or otherwise we may now be
|
||||||
|
// entitled to see new posts. so while being ugly it's techically correct ig?
|
||||||
|
{move || {
|
||||||
|
view! {
|
||||||
<main>
|
<main>
|
||||||
|
<div class="two-col" >
|
||||||
|
<div class="col-side" >
|
||||||
|
<LoginBox
|
||||||
|
tx=set_cookie
|
||||||
|
rx=cookie
|
||||||
|
/>
|
||||||
|
<hr class="mt-1 mb-1" />
|
||||||
|
<a href="/web/home" >
|
||||||
|
<input class="w-100"
|
||||||
|
type="submit"
|
||||||
|
class:hidden=move || !cookie.get().present()
|
||||||
|
class:active=move || use_location().pathname.get().ends_with("/home")
|
||||||
|
value="home timeline"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<a href="/web/server" >
|
||||||
|
<input
|
||||||
|
class="w-100"
|
||||||
|
class:active=move || use_location().pathname.get().ends_with("/server")
|
||||||
|
type="submit"
|
||||||
|
value="server timeline"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<hr class="mt-1 mb-1" />
|
||||||
|
<PostBox />
|
||||||
|
</div>
|
||||||
|
<div class="col-main" >
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" view=move || view! { <Redirect path="/web" /> } />
|
<Route path="/web" view=About />
|
||||||
<Route path="/web" view=move || view! { <Timeline token=cookie /> } />
|
|
||||||
|
<Route path="/web/home" view=move || view! { <TimelineFeed name="home" tl=home_tl /> } />
|
||||||
|
<Route path="/web/server" view=move || view! { <TimelineFeed name="server" tl=server_tl /> } />
|
||||||
|
|
||||||
<Route path="/web/users/:id" view=UserPage />
|
<Route path="/web/users/:id" view=UserPage />
|
||||||
<Route path="/web/objects/:id" view=ObjectPage />
|
<Route path="/web/objects/:id" view=ObjectPage />
|
||||||
|
|
||||||
|
<Route path="/" view=move || view! { <Redirect path="/web" /> } />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
}
|
||||||
|
}}
|
||||||
</Router>
|
</Router>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue