From e183a018876ce9f8d9ff9bd5081860e568c64045 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 31 Mar 2025 22:35:11 -0400 Subject: [PATCH] add: followers/following ui --- crates/app/src/assets.rs | 4 + crates/app/src/public/css/style.css | 5 + crates/app/src/public/html/components.html | 182 +++++++------ crates/app/src/public/html/macros.html | 2 +- crates/app/src/public/html/profile/base.html | 4 +- .../src/public/html/profile/followers.html | 22 ++ .../src/public/html/profile/following.html | 22 ++ crates/app/src/routes/api/v1/auth/profile.rs | 5 +- crates/app/src/routes/pages/mod.rs | 4 +- crates/app/src/routes/pages/profile.rs | 254 +++++++++++++++++- crates/core/src/config.rs | 17 ++ crates/core/src/database/auth.rs | 4 + crates/core/src/database/userfollows.rs | 30 +++ 13 files changed, 469 insertions(+), 86 deletions(-) create mode 100644 crates/app/src/public/html/profile/followers.html create mode 100644 crates/app/src/public/html/profile/following.html diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 02458f0..36ef889 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -42,6 +42,8 @@ pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.html") pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.html"); pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.html"); pub const PROFILE_SETTINGS: &str = include_str!("./public/html/profile/settings.html"); +pub const PROFILE_FOLLOWING: &str = include_str!("./public/html/profile/following.html"); +pub const PROFILE_FOLLOWERS: &str = include_str!("./public/html/profile/followers.html"); pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.html"); pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.html"); @@ -159,6 +161,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config); write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config); write_template!(html_path->"profile/settings.html"(crate::assets::PROFILE_SETTINGS) --config=config); + write_template!(html_path->"profile/following.html"(crate::assets::PROFILE_FOLLOWING) --config=config); + write_template!(html_path->"profile/followers.html"(crate::assets::PROFILE_FOLLOWERS) --config=config); write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config); write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config); diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 5ba777c..27f936f 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -226,6 +226,10 @@ a { color: var(--color-link); } +a.flush { + color: inherit; +} + a:hover { text-decoration: underline; } @@ -633,6 +637,7 @@ nav .button:not(.inner *) { transition: opacity 0.15s, transform 0.15s; + font-size: 0.95rem; } nav button:not(.inner *):hover, diff --git a/crates/app/src/public/html/components.html b/crates/app/src/public/html/components.html index 91271ac..24fa029 100644 --- a/crates/app/src/public/html/components.html +++ b/crates/app/src/public/html/components.html @@ -87,94 +87,107 @@ community %} {% endif %} {%- endmacro %} {% macro post(post, owner, secondary=false, community=false, -show_community=true) -%} -
- - -
- {% if user %} -
+ - {% else %} -
- {% endif %} - -
+ {% endif %} +
+
+ + {{ components::avatar(username=post.owner, size="52px", + selector_type="id") }} - - {{ icon "external-link" }} - - - {% if user %} {% if (user.id == post.owner) or is_helper %} - +
+ +
+ {% if user %} +
+ {{ components::likes(id=post.id, asset_type="Post", + likes=post.likes, dislikes=post.dislikes) }} +
+ {% else %} +
+ {% endif %} + +
+ + {{ icon "message-circle" }} + {{ post.comment_count }} + + + + {{ icon "external-link" }} + + + {% if user %} {% if (user.id == post.owner) or is_helper %} + + {% endif %} {% endif %}
- {% endif %} {% endif %}
+ {% if community and show_community %}
-{%- endmacro %} {% macro notification(notification) -%} +{% endif %} {%- endmacro %} {% macro notification(notification) -%}
{% if not notification.read %} @@ -222,4 +235,15 @@ show_community=true) -%}
+{%- endmacro %} {% macro user_card(user) -%} + + {{ components::avatar(username=user.username, size="48px") }} +
+

{{ components::username(user=user) }}

+ {{ user.username }} +
+
{%- endmacro %} diff --git a/crates/app/src/public/html/macros.html b/crates/app/src/public/html/macros.html index 7744ad4..b9febff 100644 --- a/crates/app/src/public/html/macros.html +++ b/crates/app/src/public/html/macros.html @@ -65,7 +65,7 @@ show_lhs=true) -%}
{{ user.username }} - + {{ icon "circle-user-round" }} {{ text "auth:link.my_profile" }} diff --git a/crates/app/src/public/html/profile/base.html b/crates/app/src/public/html/profile/base.html index e7be6fd..8d65b06 100644 --- a/crates/app/src/public/html/profile/base.html +++ b/crates/app/src/public/html/profile/base.html @@ -34,14 +34,14 @@

{{ profile.follower_count }}

{{ text "auth:label.followers" }}

{{ profile.following_count }}

diff --git a/crates/app/src/public/html/profile/followers.html b/crates/app/src/public/html/profile/followers.html new file mode 100644 index 0000000..e899a34 --- /dev/null +++ b/crates/app/src/public/html/profile/followers.html @@ -0,0 +1,22 @@ +{% import "macros.html" as macros %} {% extends "profile/base.html" %} {% block +content %} +
+
+ {{ icon "users-round" }} + {{ text "auth:label.followers" }} +
+ +
+ + {% for item in list %} +
+
+ Since {{ item[0].created }} +
+ + {{ components::user_card(user=item[1]) }} +
+ {% endfor %} +
+
+{% endblock %} diff --git a/crates/app/src/public/html/profile/following.html b/crates/app/src/public/html/profile/following.html new file mode 100644 index 0000000..412336f --- /dev/null +++ b/crates/app/src/public/html/profile/following.html @@ -0,0 +1,22 @@ +{% import "macros.html" as macros %} {% extends "profile/base.html" %} {% block +content %} +
+
+ {{ icon "users-round" }} + {{ text "auth:label.following" }} +
+ +
+ + {% for item in list %} +
+
+ Since {{ item[0].created }} +
+ + {{ components::user_card(user=item[1]) }} +
+ {% endfor %} +
+
+{% endblock %} diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 8319830..7fa4a7a 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -18,14 +18,15 @@ pub async fn redirect_from_id( Extension(data): Extension, Path(id): Path, ) -> impl IntoResponse { - match (data.read().await).0 + match (data.read().await) + .0 .get_user_by_id(match id.parse::() { Ok(id) => id, Err(_) => return Redirect::to("/"), }) .await { - Ok(u) => Redirect::to(&format!("/user/{}", u.username)), + Ok(u) => Redirect::to(&format!("/@{}", u.username)), Err(_) => Redirect::to("/"), } } diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 4cdd1db..b3b1343 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -25,7 +25,9 @@ pub fn routes() -> Router { .route("/auth/login", get(auth::login_request)) // profile .route("/settings", get(profile::settings_request)) - .route("/user/{username}", get(profile::posts_request)) + .route("/@{username}", get(profile::posts_request)) + .route("/@{username}/following", get(profile::following_request)) + .route("/@{username}/followers", get(profile::followers_request)) // communities .route("/communities", get(communities::list_request)) .route("/community/{title}", get(communities::feed_request)) diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 9a9aa7e..fc74c48 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -66,7 +66,7 @@ pub fn profile_context( context.insert("is_blocking", &is_blocking); } -/// `/user/{username}` +/// `/@{username}` pub async fn posts_request( jar: CookieJar, Path(username): Path, @@ -189,3 +189,255 @@ pub async fn posts_request( // return Ok(Html(data.1.render("profile/posts.html", &context).unwrap())) } + +/// `/@{username}/following` +pub async fn following_request( + jar: CookieJar, + Path(username): Path, + Query(props): Query, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let other_user = match data.0.get_user_by_username(&username).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // check if we're blocked + if let Some(ref ua) = user { + if data + .0 + .get_userblock_by_initiator_receiver(other_user.id, ua.id) + .await + .is_ok() + { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + + // check for private profile + if other_user.settings.private_profile { + if let Some(ref ua) = user { + if ua.id != other_user.id { + if data + .0 + .get_userfollow_by_initiator_receiver(other_user.id, ua.id) + .await + .is_err() + { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + + // fetch data + let list = match data + .0 + .get_userfollows_by_initiator(other_user.id, 12, props.page) + .await + { + Ok(l) => match data.0.fill_userfollows_with_receiver(l).await { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + let communities = match data.0.get_memberships_by_owner(other_user.id).await { + Ok(m) => match data.0.fill_communities(m).await { + Ok(m) => m, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // init context + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &user).await; + + let is_self = if let Some(ref ua) = user { + ua.id == other_user.id + } else { + false + }; + + let is_following = if let Some(ref ua) = user { + data.0 + .get_userfollow_by_initiator_receiver(ua.id, other_user.id) + .await + .is_ok() + } else { + false + }; + + let is_following_you = if let Some(ref ua) = user { + data.0 + .get_userfollow_by_receiver_initiator(ua.id, other_user.id) + .await + .is_ok() + } else { + false + }; + + let is_blocking = if let Some(ref ua) = user { + data.0 + .get_userblock_by_initiator_receiver(ua.id, other_user.id) + .await + .is_ok() + } else { + false + }; + + context.insert("list", &list); + profile_context( + &mut context, + &other_user, + &communities, + is_self, + is_following, + is_following_you, + is_blocking, + ); + + // return + Ok(Html( + data.1.render("profile/following.html", &context).unwrap(), + )) +} + +/// `/@{username}/followers` +pub async fn followers_request( + jar: CookieJar, + Path(username): Path, + Query(props): Query, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let other_user = match data.0.get_user_by_username(&username).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // check if we're blocked + if let Some(ref ua) = user { + if data + .0 + .get_userblock_by_initiator_receiver(other_user.id, ua.id) + .await + .is_ok() + { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + + // check for private profile + if other_user.settings.private_profile { + if let Some(ref ua) = user { + if ua.id != other_user.id { + if data + .0 + .get_userfollow_by_initiator_receiver(other_user.id, ua.id) + .await + .is_err() + { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + + // fetch data + let list = match data + .0 + .get_userfollows_by_receiver(other_user.id, 12, props.page) + .await + { + Ok(l) => match data.0.fill_userfollows_with_initiator(l).await { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + let communities = match data.0.get_memberships_by_owner(other_user.id).await { + Ok(m) => match data.0.fill_communities(m).await { + Ok(m) => m, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // init context + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &user).await; + + let is_self = if let Some(ref ua) = user { + ua.id == other_user.id + } else { + false + }; + + let is_following = if let Some(ref ua) = user { + data.0 + .get_userfollow_by_initiator_receiver(ua.id, other_user.id) + .await + .is_ok() + } else { + false + }; + + let is_following_you = if let Some(ref ua) = user { + data.0 + .get_userfollow_by_receiver_initiator(ua.id, other_user.id) + .await + .is_ok() + } else { + false + }; + + let is_blocking = if let Some(ref ua) = user { + data.0 + .get_userblock_by_initiator_receiver(ua.id, other_user.id) + .await + .is_ok() + } else { + false + }; + + context.insert("list", &list); + profile_context( + &mut context, + &other_user, + &communities, + is_self, + is_following, + is_following_you, + is_blocking, + ); + + // return + Ok(Html( + data.1.render("profile/followers.html", &context).unwrap(), + )) +} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index e40a72c..87ec71e 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -137,6 +137,9 @@ pub struct Config { /// version built with the server binary. #[serde(default = "default_no_track")] pub no_track: Vec, + /// A list of usernames which cannot be used. + #[serde(default = "default_banned_usernames")] + pub banned_usernames: Vec, } fn default_name() -> String { @@ -170,6 +173,19 @@ fn default_no_track() -> Vec { Vec::new() } +fn default_banned_usernames() -> Vec { + vec![ + "admin".to_string(), + "owner".to_string(), + "moderator".to_string(), + "api".to_string(), + "communities".to_string(), + "notifs".to_string(), + "notification".to_string(), + "post".to_string(), + ] +} + impl Default for Config { fn default() -> Self { Self { @@ -181,6 +197,7 @@ impl Default for Config { security: default_security(), dirs: default_dirs(), no_track: default_no_track(), + banned_usernames: default_banned_usernames(), } } } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 8564650..efa69c2 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -84,6 +84,10 @@ impl DataManager { return Err(Error::DataTooShort("password".to_string())); } + if self.0.banned_usernames.contains(&data.username) { + return Err(Error::MiscError("This username cannot be used".to_string())); + } + // make sure username isn't taken if self.get_user_by_username(&data.username).await.is_ok() { return Err(Error::UsernameInUse); diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index f3d51ba..86eb462 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -145,6 +145,36 @@ impl DataManager { Ok(res.unwrap()) } + /// Complete a vector of just userfollows with their receiver as well. + pub async fn fill_userfollows_with_receiver( + &self, + userfollows: Vec, + ) -> Result> { + let mut out: Vec<(UserFollow, User)> = Vec::new(); + + for userfollow in userfollows { + let receiver = userfollow.receiver.clone(); + out.push((userfollow, self.get_user_by_id(receiver).await?)); + } + + Ok(out) + } + + /// Complete a vector of just userfollows with their initiator as well. + pub async fn fill_userfollows_with_initiator( + &self, + userfollows: Vec, + ) -> Result> { + let mut out: Vec<(UserFollow, User)> = Vec::new(); + + for userfollow in userfollows { + let initiator = userfollow.initiator.clone(); + out.push((userfollow, self.get_user_by_id(initiator).await?)); + } + + Ok(out) + } + /// Create a new user follow in the database. /// /// # Arguments