1
0
Fork 0
mirror of https://github.com/Nutomic/ibis.git synced 2025-01-23 21:05:48 +00:00

Add instance name, topic and instance card with update time (fixes #106)

This commit is contained in:
Felix Ableitner 2025-01-23 15:12:45 +01:00
parent 218c621afb
commit cdcc992b75
22 changed files with 307 additions and 65 deletions

View file

@ -0,0 +1,2 @@
alter table instance rename topic to description;
alter table instance drop column name;

View file

@ -0,0 +1,2 @@
alter table instance rename description to topic;
alter table instance add column name text;

View file

@ -1,11 +1,18 @@
use super::empty_to_none;
use crate::{
backend::{
database::IbisContext,
database::{instance::DbInstanceUpdateForm, IbisContext},
federation::activities::follow::Follow,
utils::error::MyResult,
},
common::{
instance::{DbInstance, FollowInstanceParams, GetInstanceParams, InstanceView},
instance::{
DbInstance,
FollowInstanceParams,
GetInstanceParams,
InstanceView,
UpdateInstanceParams,
},
user::LocalUserView,
ResolveObjectParams,
SuccessResponse,
@ -25,6 +32,19 @@ pub(in crate::backend::api) async fn get_instance(
Ok(Json(local_instance))
}
pub(in crate::backend::api) async fn update_instance(
context: Data<IbisContext>,
Form(mut params): Form<UpdateInstanceParams>,
) -> MyResult<Json<DbInstance>> {
empty_to_none(&mut params.name);
empty_to_none(&mut params.topic);
let form = DbInstanceUpdateForm {
name: params.name,
topic: params.topic,
};
Ok(Json(DbInstance::update(form, &context)?))
}
/// Make the local instance follow a given remote instance, to receive activities about new and
/// updated articles.
#[debug_handler]
@ -53,9 +73,9 @@ pub(super) async fn resolve_instance(
}
#[debug_handler]
pub(in crate::backend::api) async fn list_remote_instances(
pub(in crate::backend::api) async fn list_instances(
context: Data<IbisContext>,
) -> MyResult<Json<Vec<DbInstance>>> {
let instances = DbInstance::read_remote(&context)?;
let instances = DbInstance::list(false, &context)?;
Ok(Json(instances))
}

View file

@ -36,7 +36,7 @@ use axum::{
Router,
};
use axum_macros::debug_handler;
use instance::list_remote_instances;
use instance::{list_instances, update_instance};
use user::{count_notifications, list_notifications, update_user_profile};
mod article;
@ -60,9 +60,10 @@ pub fn api_routes() -> Router<()> {
.route("/comment", post(create_comment))
.route("/comment", patch(edit_comment))
.route("/instance", get(get_instance))
.route("/instance", patch(update_instance))
.route("/instance/follow", post(follow_instance))
.route("/instance/resolve", get(resolve_instance))
.route("/instance/list", get(list_remote_instances))
.route("/instance/list", get(list_instances))
.route("/search", get(search_article))
.route("/user", get(get_user))
.route("/user/notifications/list", get(list_notifications))

View file

@ -23,6 +23,7 @@ use activitypub_federation::{
use chrono::{DateTime, Utc};
use diesel::{
insert_into,
update,
AsChangeset,
ExpressionMethods,
Insertable,
@ -37,7 +38,7 @@ use std::{fmt::Debug, ops::DerefMut};
pub struct DbInstanceForm {
pub domain: String,
pub ap_id: ObjectId<DbInstance>,
pub description: Option<String>,
pub topic: Option<String>,
pub articles_url: Option<CollectionId<DbArticleCollection>>,
pub inbox_url: String,
pub public_key: String,
@ -45,6 +46,14 @@ pub struct DbInstanceForm {
pub last_refreshed_at: DateTime<Utc>,
pub local: bool,
pub instances_url: Option<CollectionId<DbInstanceCollection>>,
pub name: Option<String>,
}
#[derive(Debug, Clone, AsChangeset)]
#[diesel(table_name = instance, check_for_backend(diesel::pg::Pg))]
pub struct DbInstanceUpdateForm {
pub topic: Option<String>,
pub name: Option<String>,
}
impl DbInstance {
@ -63,6 +72,14 @@ impl DbInstance {
Ok(instance::table.find(id).get_result(conn.deref_mut())?)
}
pub fn update(form: DbInstanceUpdateForm, context: &IbisContext) -> MyResult<Self> {
let mut conn = context.db_pool.get()?;
Ok(update(instance::table)
.filter(instance::local)
.set(form)
.get_result(conn.deref_mut())?)
}
pub fn read_from_ap_id(
ap_id: &ObjectId<DbInstance>,
context: &Data<IbisContext>,
@ -130,11 +147,13 @@ impl DbInstance {
.get_results(conn.deref_mut())?)
}
pub fn read_remote(context: &Data<IbisContext>) -> MyResult<Vec<DbInstance>> {
pub fn list(only_remote: bool, context: &Data<IbisContext>) -> MyResult<Vec<DbInstance>> {
let mut conn = context.db_pool.get()?;
Ok(instance::table
.filter(instance::local.eq(false))
.get_results(conn.deref_mut())?)
let mut query = instance::table.into_boxed();
if only_remote {
query = query.filter(instance::local.eq(false));
}
Ok(query.get_results(conn.deref_mut())?)
}
/// Read the instance where an article is hosted, based on a comment id.

View file

@ -67,7 +67,7 @@ diesel::table! {
domain -> Text,
#[max_length = 255]
ap_id -> Varchar,
description -> Nullable<Text>,
topic -> Nullable<Text>,
#[max_length = 255]
articles_url -> Nullable<Varchar>,
#[max_length = 255]
@ -78,6 +78,7 @@ diesel::table! {
local -> Bool,
#[max_length = 255]
instances_url -> Nullable<Varchar>,
name -> Nullable<Text>,
}
}

View file

@ -28,7 +28,8 @@ pub struct ApubInstance {
#[serde(rename = "type")]
kind: ServiceType,
pub id: ObjectId<DbInstance>,
content: Option<String>,
name: Option<String>,
summary: Option<String>,
articles: Option<CollectionId<DbArticleCollection>>,
instances: Option<CollectionId<DbInstanceCollection>>,
inbox: Url,
@ -89,11 +90,12 @@ impl Object for DbInstance {
Ok(ApubInstance {
kind: Default::default(),
id: self.ap_id.clone(),
content: self.description.clone(),
summary: self.topic.clone(),
articles: self.articles_url.clone(),
instances: self.instances_url.clone(),
inbox: Url::parse(&self.inbox_url)?,
public_key: self.public_key(),
name: self.name,
})
}
@ -115,7 +117,7 @@ impl Object for DbInstance {
let form = DbInstanceForm {
domain,
ap_id: json.id,
description: json.content,
topic: json.summary,
articles_url: json.articles,
instances_url: json.instances,
inbox_url: json.inbox.to_string(),
@ -123,6 +125,7 @@ impl Object for DbInstance {
private_key: None,
last_refreshed_at: Utc::now(),
local: false,
name: json.name,
};
let instance = DbInstance::create(&form, context)?;

View file

@ -48,7 +48,7 @@ impl Collection for DbInstanceCollection {
_owner: &Self::Owner,
context: &Data<Self::DataType>,
) -> Result<Self::Kind, Self::Error> {
let instances = DbInstance::read_remote(context)?;
let instances = DbInstance::list(true, context)?;
let instances = future::try_join_all(
instances
.into_iter()

View file

@ -97,7 +97,6 @@ async fn setup(context: &Data<IbisContext>) -> Result<(), Error> {
let form = DbInstanceForm {
domain: domain.to_string(),
ap_id,
description: Some("New Ibis instance".to_string()),
articles_url: Some(local_articles_url(domain)?),
instances_url: Some(linked_instances_url(domain)?),
inbox_url,
@ -105,6 +104,8 @@ async fn setup(context: &Data<IbisContext>) -> Result<(), Error> {
private_key: Some(keypair.private_key),
last_refreshed_at: Utc::now(),
local: true,
topic: None,
name: None,
};
let instance = DbInstance::create(&form, context)?;

View file

@ -28,7 +28,7 @@ pub struct DbInstance {
pub ap_id: ObjectId<DbInstance>,
#[cfg(not(feature = "ssr"))]
pub ap_id: String,
pub description: Option<String>,
pub topic: Option<String>,
#[cfg(feature = "ssr")]
pub articles_url: Option<CollectionId<DbArticleCollection>>,
#[cfg(not(feature = "ssr"))]
@ -42,6 +42,7 @@ pub struct DbInstance {
pub local: bool,
#[cfg(feature = "ssr")]
pub instances_url: Option<CollectionId<DbInstanceCollection>>,
pub name: Option<String>,
}
impl DbInstance {
@ -91,3 +92,9 @@ pub struct GetInstanceParams {
pub struct FollowInstanceParams {
pub id: InstanceId,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct UpdateInstanceParams {
pub name: Option<String>,
pub topic: Option<String>,
}

View file

@ -80,16 +80,14 @@ impl ApiClient {
&self,
data: &CreateArticleParams,
) -> Result<DbArticleView, ServerFnError> {
self.send(Method::POST, "/api/v1/article", Some(&data))
.await
self.post("/api/v1/article", Some(&data)).await
}
pub async fn edit_article_with_conflict(
&self,
params: &EditArticleParams,
) -> Result<Option<ApiConflict>, ServerFnError> {
self.send(Method::PATCH, "/api/v1/article", Some(&params))
.await
self.patch("/api/v1/article", Some(&params)).await
}
#[cfg(debug_assertions)]
@ -120,8 +118,7 @@ impl ApiClient {
&self,
params: &EditCommentParams,
) -> Result<DbCommentView, ServerFnError> {
self.send(Method::PATCH, "/api/v1/comment", Some(&params))
.await
self.patch("/api/v1/comment", Some(&params)).await
}
pub async fn notifications_list(&self) -> Option<Vec<Notification>> {
@ -169,6 +166,13 @@ impl ApiClient {
self.get("/api/v1/instance/list", None::<i32>).await
}
pub async fn update_local_instance(
&self,
params: &UpdateInstanceParams,
) -> Result<DbInstance, ServerFnError> {
self.patch("/api/v1/instance", Some(params)).await
}
pub async fn follow_instance_with_resolve(&self, follow_instance: &str) -> Option<DbInstance> {
// fetch beta instance on alpha
let params = ResolveObjectParams {
@ -272,6 +276,14 @@ impl ApiClient {
self.send(Method::POST, endpoint, query).await
}
async fn patch<T, R>(&self, endpoint: &str, query: Option<R>) -> Result<T, ServerFnError>
where
T: for<'de> Deserialize<'de>,
R: Serialize + Debug,
{
self.send(Method::PATCH, endpoint, query).await
}
#[cfg(feature = "ssr")]
async fn send<P, T>(
&self,

View file

@ -4,6 +4,7 @@ use crate::{
api::CLIENT,
components::{nav::Nav, protected_route::IbisProtectedRoute},
dark_mode::DarkMode,
instance_title,
pages::{
article::{
actions::ArticleActions,
@ -15,7 +16,7 @@ use crate::{
read::ReadArticle,
},
diff::EditDiff,
instance::{details::InstanceDetails, list::ListInstances},
instance::{details::InstanceDetails, list::ListInstances, settings::InstanceSettings},
login::Login,
notifications::Notifications,
register::Register,
@ -94,15 +95,29 @@ pub fn App() -> impl IntoView {
let darkmode = DarkMode::init();
provide_context(darkmode.clone());
// TODO: use instance name/description for title
let instance = Resource::new(
|| (),
|_| async move { CLIENT.get_local_instance().await.unwrap() },
);
view! {
<Html attr:data-theme=darkmode.theme {..} class="h-full" />
<Title formatter=|text| format!("{text} — Ibis") />
<Body {..} class="h-full max-sm:flex max-sm:flex-col" />
<>
<Stylesheet id="ibis" href="/pkg/ibis.css" />
<Stylesheet id="katex" href="/katex.min.css" />
<Router>
<Suspense>
{move || {
instance
.get()
.map(|i| {
let formatter = move |text| {
format!("{text}{}", instance_title(&i.instance))
};
view! { <Title formatter /> }
})
}}
</Suspense>
<Nav />
<main class="p-4 md:ml-64">
<Routes fallback=|| "Page not found.".into_view()>
@ -129,6 +144,7 @@ pub fn App() -> impl IntoView {
<Route path=path!("/search") view=Search />
<IbisProtectedRoute path=path!("/edit_profile") view=UserEditProfile />
<IbisProtectedRoute path=path!("/notifications") view=Notifications />
<IbisProtectedRoute path=path!("/settings") view=InstanceSettings />
</Routes>
</main>
</Router>

View file

@ -72,9 +72,9 @@ pub fn CommentView(
view! {
<CommentEditorView
article=article
parent_id=Some(comment.comment.id)
set_show_editor=Some(show_editor.1)
edit_params=Some(edit_params.clone())
parent_id=comment.comment.id
set_show_editor=show_editor.1
edit_params=edit_params.clone()
/>
}
}
@ -115,9 +115,8 @@ pub fn CommentView(
<Show when=move || show_editor.0.get() == comment.comment.id>
<CommentEditorView
article=article
parent_id=Some(comment.comment.id)
set_show_editor=Some(show_editor.1)
edit_params=None
parent_id=comment.comment.id
set_show_editor=show_editor.1
/>
</Show>
</div>

View file

@ -19,10 +19,12 @@ pub struct EditParams {
#[component]
pub fn CommentEditorView(
article: Resource<DbArticleView>,
parent_id: Option<CommentId>,
#[prop(optional)] parent_id: Option<CommentId>,
/// Set this to CommentId(-1) to hide all editors
#[prop(optional)]
set_show_editor: Option<WriteSignal<CommentId>>,
/// If this is present we are editing an existing comment
#[prop(optional)]
edit_params: Option<EditParams>,
) -> impl IntoView {
let textarea_ref = NodeRef::<Textarea>::new();

View file

@ -1,7 +1,8 @@
use crate::frontend::{
api::CLIENT,
app::{is_logged_in, site, DefaultResource},
app::{is_admin, is_logged_in, site, DefaultResource},
dark_mode::DarkMode,
instance_title,
};
use leptos::{component, prelude::*, view, IntoView, *};
use leptos_router::hooks::use_navigate;
@ -16,6 +17,10 @@ pub fn Nav() -> impl IntoView {
|| (),
move |_| async move { CLIENT.notifications_count().await.unwrap_or_default() },
);
let instance = Resource::new(
|| (),
|_| async move { CLIENT.get_local_instance().await.unwrap() },
);
let (search_query, set_search_query) = signal(String::new());
let mut dark_mode = expect_context::<DarkMode>();
@ -27,17 +32,15 @@ pub fn Nav() -> impl IntoView {
>
<h1 class="w-min font-serif text-3xl font-bold md:hidden">Ibis</h1>
<div class="flex-grow md:hidden"></div>
<button tabindex="0" class="lg:hidden btn btn-outline">
Menu
</button>
<div
tabindex="0"
class="p-2 md:h-full menu dropdown-content max-sm:rounded-box max-sm:z-[1] max-sm:shadow"
>
<button class="lg:hidden btn btn-outline">Menu</button>
<div class="md:h-full menu dropdown-content max-sm:rounded-box max-sm:z-[1] max-sm:shadow">
<Transition>
<a href="/">
<img src="/logo.png" class="m-auto max-sm:hidden" />
</a>
<h2 class="m-4 font-serif text-xl font-bold">
{move || { instance.get().map(|i| instance_title(&i.instance)) }}
</h2>
<ul>
<li>
<a href="/">"Main Page"</a>
@ -61,6 +64,11 @@ pub fn Nav() -> impl IntoView {
</a>
</li>
</Show>
<Show when=is_admin>
<li>
<a href="/settings">"Settings"</a>
</li>
</Show>
<li>
<form
class="p-1 m-0 form-control"

View file

@ -1,4 +1,9 @@
use crate::common::{article::DbArticle, user::DbPerson, utils::extract_domain};
use crate::common::{
article::DbArticle,
instance::DbInstance,
user::DbPerson,
utils::extract_domain,
};
use chrono::{DateTime, Duration, Local, Utc};
use codee::string::FromToStringCodec;
use leptos::prelude::*;
@ -103,3 +108,25 @@ fn time_ago(time: DateTime<Utc>) -> String {
let duration = std::time::Duration::from_secs(secs.try_into().unwrap_or_default());
INSTANCE.get_or_init(Formatter::new).convert(duration)
}
fn instance_title_with_domain(instance: &DbInstance) -> String {
let name = instance.name.clone();
let domain = instance.domain.clone();
if let Some(name) = name {
format!("{name} ({domain})")
} else {
domain
}
}
fn instance_title(instance: &DbInstance) -> String {
instance.name.clone().unwrap_or(instance.domain.clone())
}
fn instance_updated(instance: &DbInstance) -> String {
if instance.local {
"Local".to_string()
} else {
format!("Updated {}", time_ago(instance.last_refreshed_at))
}
}

View file

@ -21,12 +21,7 @@ pub fn ArticleDiscussion() -> impl IntoView {
view! {
<ArticleNav article=article active_tab=ActiveTab::Discussion />
<Suspense fallback=|| view! { "Loading..." }>
<CommentEditorView
article=article
parent_id=None
set_show_editor=None
edit_params=None
/>
<CommentEditorView article=article />
<div>
<For
each=move || {

View file

@ -5,6 +5,8 @@ use crate::{
article_path,
article_title,
components::instance_follow_button::InstanceFollowButton,
instance_title_with_domain,
instance_updated,
},
};
use leptos::prelude::*;
@ -41,20 +43,19 @@ pub fn InstanceDetails() -> impl IntoView {
.unwrap()
},
);
let title = instance.clone().description.unwrap_or(instance.clone().domain);
let title = instance_title_with_domain(&instance);
let instance_ = instance.clone();
view! {
<Title text=title />
<Title text=title.clone() />
<div class="grid gap-3 mt-4">
<div class="flex flex-row items-center">
<h1 class="w-full font-serif text-4xl font-bold">
{instance.domain}
</h1>
<h1 class="w-full font-serif text-4xl font-bold">{title}</h1>
{instance_updated(&instance_)}
<InstanceFollowButton instance=instance_.clone() />
</div>
<div class="divider"></div>
<div>{instance.description}</div>
<div>{instance.topic}</div>
<h2 class="font-serif text-xl font-bold">Articles</h2>
<ul class="list-none">
<Suspense>

View file

@ -1,4 +1,9 @@
use crate::frontend::{api::CLIENT, components::connect::ConnectView};
use crate::frontend::{
api::CLIENT,
components::connect::ConnectView,
instance_title_with_domain,
instance_updated,
};
use leptos::prelude::*;
use leptos_meta::Title;
@ -26,12 +31,20 @@ pub fn ListInstances() -> impl IntoView {
.map(|ref i| {
view! {
<li>
<a
class="text-lg link"
href=format!("/instance/{}", i.domain)
>
{i.domain.to_string()}
</a>
<div class="m-4 shadow card bg-base-100">
<div class="p-4 card-body">
<div class="flex">
<a
class="card-title grow"
href=format!("/instance/{}", i.domain)
>
{instance_title_with_domain(i)}
</a>
{instance_updated(i)}
</div>
<p>{i.topic.clone()}</p>
</div>
</div>
</li>
}
})

View file

@ -1,2 +1,3 @@
pub(crate) mod details;
pub(crate) mod list;
pub(crate) mod settings;

View file

@ -0,0 +1,112 @@
use crate::{common::instance::UpdateInstanceParams, frontend::api::CLIENT};
use leptos::prelude::*;
use leptos_meta::Title;
#[component]
pub fn InstanceSettings() -> impl IntoView {
let (saved, set_saved) = signal(false);
let (submit_error, set_submit_error) = signal(None::<String>);
let instance = Resource::new(
|| (),
|_| async move { CLIENT.get_local_instance().await.unwrap() },
);
let submit_action = Action::new(move |params: &UpdateInstanceParams| {
let params = params.clone();
async move {
let result = CLIENT.update_local_instance(&params).await;
match result {
Ok(_res) => {
instance.refetch();
set_saved.set(true);
set_submit_error.set(None);
}
Err(err) => {
let msg = err.to_string();
log::warn!("Unable to update profile: {msg}");
set_submit_error.set(Some(msg));
}
}
}
});
// TODO: It would make sense to use a table for the labels and inputs, but for some reason
// that completely breaks reactivity.
view! {
<Title text="Instance Settings" />
<Suspense fallback=|| {
view! { "Loading..." }
}>
{move || Suspend::new(async move {
let instance = instance.await;
let (name, set_name) = signal(instance.instance.name.unwrap_or_default());
let (topic, set_topic) = signal(instance.instance.topic.unwrap_or_default());
view! {
<h1 class="flex-auto my-6 font-serif text-4xl font-bold grow">
"Instance Settings"
</h1>
{move || {
submit_error
.get()
.map(|err| {
view! { <p class="alert alert-error">{err}</p> }
})
}}
<div class="flex flex-row mb-2">
<label class="block w-20" for="name">
Name
</label>
<input
type="text"
id="name"
class="w-80 input input-secondary input-bordered"
prop:value=name
value=name
on:change=move |ev| {
let val = event_target_value(&ev);
set_name.set(val);
}
/>
</div>
<div class="flex flex-row mb-2">
<label class="block w-20" for="topic">
"Topic"
</label>
<input
type="text"
id="name"
class="w-80 input input-secondary input-bordered"
prop:value=topic
value=topic
on:change=move |ev| {
let val = event_target_value(&ev);
set_topic.set(val);
}
/>
</div>
<button
class="btn btn-primary"
on:click=move |_| {
let form = UpdateInstanceParams {
name: Some(name.get()),
topic: Some(topic.get()),
};
submit_action.dispatch(form);
}
>
Submit
</button>
<Show when=move || saved.get()>
<div class="toast">
<div class="alert alert-info">
<span>Saved!</span>
</div>
</div>
</Show>
}
})}
</Suspense>
}
}

View file

@ -763,7 +763,7 @@ async fn test_synchronize_instances() -> Result<()> {
.await
.unwrap();
let beta_instances = beta.list_instances().await.unwrap();
assert_eq!(1, beta_instances.len());
assert_eq!(2, beta_instances.len());
// fetch beta instance on gamma
gamma
@ -777,7 +777,7 @@ async fn test_synchronize_instances() -> Result<()> {
let res = gamma.list_instances().await;
match res {
None => Err(RetryPolicy::<String>::Retry(None)),
Some(i) if i.len() < 2 => Err(RetryPolicy::Retry(None)),
Some(i) if i.len() < 3 => Err(RetryPolicy::Retry(None)),
Some(i) => Ok(i),
}
},
@ -786,7 +786,7 @@ async fn test_synchronize_instances() -> Result<()> {
.await?;
// now gamma also knows about alpha
assert_eq!(2, gamma_instances.len());
assert_eq!(3, gamma_instances.len());
assert!(gamma_instances.iter().any(|i| i.domain == alpha.hostname));
TestData::stop(alpha, beta, gamma)