Add follow instance button in article nav (fixes #31)

This commit is contained in:
Felix Ableitner 2024-03-15 13:59:30 +01:00
parent 3a8560ce37
commit 01c0175f4c
11 changed files with 92 additions and 42 deletions

View File

@ -1,17 +1,18 @@
use crate::{ use crate::{
backend::{database::IbisData, error::MyResult, federation::activities::follow::Follow}, backend::{database::IbisData, error::MyResult, federation::activities::follow::Follow},
common::{DbInstance, FollowInstance, InstanceView, LocalUserView, ResolveObject}, common::{DbInstance, FollowInstance, GetInstance, InstanceView, LocalUserView, ResolveObject},
}; };
use activitypub_federation::{config::Data, fetch::object_id::ObjectId}; use activitypub_federation::{config::Data, fetch::object_id::ObjectId};
use axum::{extract::Query, Extension, Form, Json}; use axum::{extract::Query, Extension, Form, Json};
use axum_macros::debug_handler; use axum_macros::debug_handler;
/// Retrieve the local instance info. /// Retrieve details about an instance. If no id is provided, return local instance.
#[debug_handler] #[debug_handler]
pub(in crate::backend::api) async fn get_local_instance( pub(in crate::backend::api) async fn get_instance(
data: Data<IbisData>, data: Data<IbisData>,
Form(query): Form<GetInstance>,
) -> MyResult<Json<InstanceView>> { ) -> MyResult<Json<InstanceView>> {
let local_instance = DbInstance::read_local_view(&data)?; let local_instance = DbInstance::read_view(query.id, &data)?;
Ok(Json(local_instance)) Ok(Json(local_instance))
} }

View File

@ -11,7 +11,7 @@ use crate::{
resolve_article, resolve_article,
search_article, search_article,
}, },
instance::{follow_instance, get_local_instance, resolve_instance}, instance::{follow_instance, get_instance, resolve_instance},
user::{ user::{
get_user, get_user,
login_user, login_user,
@ -56,7 +56,7 @@ pub fn api_routes() -> Router {
.route("/article/resolve", get(resolve_article)) .route("/article/resolve", get(resolve_article))
.route("/article/protect", post(protect_article)) .route("/article/protect", post(protect_article))
.route("/edit_conflicts", get(edit_conflicts)) .route("/edit_conflicts", get(edit_conflicts))
.route("/instance", get(get_local_instance)) .route("/instance", get(get_instance))
.route("/instance/follow", post(follow_instance)) .route("/instance/follow", post(follow_instance))
.route("/instance/resolve", get(resolve_instance)) .route("/instance/resolve", get(resolve_instance))
.route("/search", get(search_article)) .route("/search", get(search_article))

View File

@ -72,8 +72,11 @@ impl DbInstance {
.get_result(conn.deref_mut())?) .get_result(conn.deref_mut())?)
} }
pub fn read_local_view(data: &Data<IbisData>) -> MyResult<InstanceView> { pub fn read_view(id: Option<i32>, data: &Data<IbisData>) -> MyResult<InstanceView> {
let instance = DbInstance::read_local_instance(data)?; let instance = match id {
Some(id) => DbInstance::read(id, data),
None => DbInstance::read_local_instance(data),
}?;
let followers = DbInstance::read_followers(instance.id, data)?; let followers = DbInstance::read_followers(instance.id, data)?;
Ok(InstanceView { Ok(InstanceView {

View File

@ -210,6 +210,11 @@ pub struct ForkArticleForm {
pub new_title: String, pub new_title: String,
} }
#[derive(Deserialize, Serialize, Debug)]
pub struct GetInstance {
pub id: Option<i32>,
}
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
pub struct FollowInstance { pub struct FollowInstance {
pub id: i32, pub id: i32,

View File

@ -11,6 +11,7 @@ use crate::{
FollowInstance, FollowInstance,
ForkArticleForm, ForkArticleForm,
GetArticleForm, GetArticleForm,
GetInstance,
GetUserForm, GetUserForm,
InstanceView, InstanceView,
ListArticlesForm, ListArticlesForm,
@ -138,6 +139,10 @@ impl ApiClient {
self.get_query("/api/v1/instance", None::<i32>).await self.get_query("/api/v1/instance", None::<i32>).await
} }
pub async fn get_instance(&self, get_form: &GetInstance) -> MyResult<InstanceView> {
self.get_query("/api/v1/instance", Some(get_form)).await
}
pub async fn follow_instance_with_resolve( pub async fn follow_instance_with_resolve(
&self, &self,
follow_instance: &str, follow_instance: &str,

View File

@ -81,13 +81,13 @@ impl GlobalState {
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
provide_meta_context(); provide_meta_context();
let backend_hostname = GlobalState { let global_state = GlobalState {
api_client: ApiClient::new(Client::new(), None), api_client: ApiClient::new(Client::new(), None),
my_profile: None, my_profile: None,
}; };
// Load user profile in case we are already logged in // Load user profile in case we are already logged in
GlobalState::update_my_profile(); GlobalState::update_my_profile();
provide_context(create_rw_signal(backend_hostname)); provide_context(create_rw_signal(global_state));
view! { view! {
<> <>

View File

@ -1,31 +1,47 @@
use crate::{ use crate::{
common::{validation::can_edit_article, ArticleView}, common::{validation::can_edit_article, ArticleView, GetInstance},
frontend::{app::GlobalState, article_link}, frontend::{
app::GlobalState,
article_link,
components::instance_follow_button::InstanceFollowButton,
},
}; };
use leptos::*; use leptos::*;
use leptos_router::*; use leptos_router::*;
#[component] #[component]
pub fn ArticleNav(article: Resource<Option<String>, ArticleView>) -> impl IntoView { pub fn ArticleNav(article: Resource<Option<String>, ArticleView>) -> impl IntoView {
let global_state = use_context::<RwSignal<GlobalState>>().unwrap();
view! { view! {
<Suspense> <Suspense>
{move || article.get().map(|article| { {move || article.get().map(|article_| {
let article_link = article_link(&article.article); let instance = create_local_resource(move || article_.article.instance_id, move |instance_id| async move {
let form = GetInstance {
id: Some(instance_id)
};
GlobalState::api_client()
.get_instance(&form)
.await
.unwrap()
});
let global_state = use_context::<RwSignal<GlobalState>>().unwrap();
let article_link = article_link(&article_.article);
let article_link_ = article_link.clone(); let article_link_ = article_link.clone();
let protected = article.article.protected; let protected = article_.article.protected;
view!{ view!{
<nav class="inner"> <nav class="inner">
<A href=article_link.clone()>"Read"</A> <A href=article_link.clone()>"Read"</A>
<A href={format!("{article_link}/history")}>"History"</A> <A href={format!("{article_link}/history")}>"History"</A>
<Show when=move || global_state.with(|state| { <Show when=move || global_state.with(|state| {
let is_admin = state.my_profile.as_ref().map(|p| p.local_user.admin).unwrap_or(false); let is_admin = state.my_profile.as_ref().map(|p| p.local_user.admin).unwrap_or(false);
state.my_profile.is_some() && can_edit_article(&article.article, is_admin).is_ok() state.my_profile.is_some() && can_edit_article(&article_.article, is_admin).is_ok()
})> })>
<A href={format!("{article_link}/edit")}>"Edit"</A> <A href={format!("{article_link}/edit")}>"Edit"</A>
</Show> </Show>
<Show when=move || global_state.with(|state| state.my_profile.is_some())> <Show when=move || global_state.with(|state| state.my_profile.is_some())>
<A href={format!("{article_link_}/actions")}>"Actions"</A> <A href={format!("{article_link_}/actions")}>"Actions"</A>
{instance.get().map(|i|
view!{ <InstanceFollowButton instance=i.instance.clone() /> }
)}
</Show> </Show>
<Show when=move || protected> <Show when=move || protected>
<span title="Article can only be edited by local admins">"Protected"</span> <span title="Article can only be edited by local admins">"Protected"</span>

View File

@ -0,0 +1,39 @@
use crate::{
common::{DbInstance, FollowInstance},
frontend::app::GlobalState,
};
use leptos::{component, *};
#[component]
pub fn InstanceFollowButton(instance: DbInstance) -> impl IntoView {
let global_state = use_context::<RwSignal<GlobalState>>().unwrap();
let follow_action = create_action(move |instance_id: &i32| {
let instance_id = *instance_id;
async move {
let form = FollowInstance { id: instance_id };
GlobalState::api_client()
.follow_instance(form)
.await
.unwrap();
GlobalState::update_my_profile();
}
});
let is_following = global_state
.get_untracked()
.my_profile
.map(|p| p.following.contains(&instance))
.unwrap_or_default();
let follow_text = if is_following {
"Following instance"
} else {
"Follow instance"
};
view! {
<button on:click=move |_| follow_action.dispatch(instance.id)
prop:disabled=move || is_following
prop:hidden=move || instance.local>
{follow_text}
</button>
}
}

View File

@ -1,3 +1,4 @@
pub mod article_nav; pub mod article_nav;
pub(crate) mod credentials; pub mod credentials;
pub mod instance_follow_button;
pub mod nav; pub mod nav;

View File

@ -1,11 +1,11 @@
use crate::frontend::{ use crate::frontend::{
article_title, article_title,
components::article_nav::ArticleNav, components::article_nav::ArticleNav,
extract_domain,
pages::article_resource, pages::article_resource,
user_link, user_link,
}; };
use leptos::*; use leptos::*;
use crate::frontend::extract_domain;
#[component] #[component]
pub fn ArticleHistory() -> impl IntoView { pub fn ArticleHistory() -> impl IntoView {

View File

@ -1,6 +1,6 @@
use crate::{ use crate::{
common::{utils::http_protocol_str, DbInstance, FollowInstance}, common::{utils::http_protocol_str, DbInstance},
frontend::app::GlobalState, frontend::{app::GlobalState, components::instance_follow_button::InstanceFollowButton},
}; };
use leptos::*; use leptos::*;
use leptos_router::use_params_map; use leptos_router::use_params_map;
@ -18,36 +18,16 @@ pub fn InstanceDetails() -> impl IntoView {
.await .await
.unwrap() .unwrap()
}); });
let follow_action = create_action(move |instance_id: &i32| {
let instance_id = *instance_id;
async move {
let form = FollowInstance { id: instance_id };
GlobalState::api_client()
.follow_instance(form)
.await
.unwrap();
GlobalState::update_my_profile();
}
});
view! { view! {
<Suspense fallback=|| view! { "Loading..." }> { <Suspense fallback=|| view! { "Loading..." }> {
move || instance_profile.get().map(|instance: DbInstance| { move || instance_profile.get().map(|instance: DbInstance| {
let instance_ = instance.clone(); let instance_ = instance.clone();
let is_following = global_state.get().my_profile.map(|p| p.following.contains(&instance_)).unwrap_or_default();
let follow_text = if is_following {
"Following"
} else {
"Follow"
};
view! { view! {
<h1>{instance.domain}</h1> <h1>{instance.domain}</h1>
<Show when=move || global_state.with(|state| state.my_profile.is_some())> <Show when=move || global_state.with(|state| state.my_profile.is_some())>
<button on:click=move |_| follow_action.dispatch(instance.id) <InstanceFollowButton instance=instance_.clone() />
prop:disabled=move || is_following>
{follow_text}
</button>
</Show> </Show>
<p>Follow the instance so that new edits are federated to your instance.</p> <p>Follow the instance so that new edits are federated to your instance.</p>
<p>"TODO: show a list of articles from the instance. For now you can use the "<a href="/article/list">Article list</a>.</p> <p>"TODO: show a list of articles from the instance. For now you can use the "<a href="/article/list">Article list</a>.</p>