diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 63f229d..94c8c4d 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -58,6 +58,8 @@ pub const PROFILE_WARNING: &str = include_str!("./public/html/profile/warning.ht pub const PROFILE_PRIVATE: &str = include_str!("./public/html/profile/private.html"); pub const PROFILE_BLOCKED: &str = include_str!("./public/html/profile/blocked.html"); pub const PROFILE_BANNED: &str = include_str!("./public/html/profile/banned.html"); +pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.html"); +pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.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"); @@ -260,6 +262,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"profile/private.html"(crate::assets::PROFILE_PRIVATE) --config=config); write_template!(html_path->"profile/blocked.html"(crate::assets::PROFILE_BLOCKED) --config=config); write_template!(html_path->"profile/banned.html"(crate::assets::PROFILE_BANNED) --config=config); + write_template!(html_path->"profile/replies.html"(crate::assets::PROFILE_REPLIES) --config=config); + write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --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/langs/en-US.toml b/crates/app/src/langs/en-US.toml index d0b27e1..c635cdf 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -65,6 +65,11 @@ version = "1.0.0" "auth:label.joined_communities" = "Joined communities" "auth:label.recent_posts" = "Recent posts" "auth:label.recent_with_tag" = "Recent posts (with tag)" +"auth:label.recent_replies" = "Recent replies" +"auth:label.recent_posts_with_media" = "Recent posts (with media)" +"auth:label.posts" = "Posts" +"auth:label.replies" = "Replies" +"auth:label.media" = "Media" "auth:label.before_you_view" = "Before you view" "auth:label.private_profile" = "Private profile" "auth:label.private_profile_message" = "This profile is private, meaning you can only view it if they follow you." diff --git a/crates/app/src/public/html/macros.html b/crates/app/src/public/html/macros.html index 673f95a..f188776 100644 --- a/crates/app/src/public/html/macros.html +++ b/crates/app/src/public/html/macros.html @@ -212,4 +212,27 @@ selected="posts") -%} {% if user -%} {{ text "communities:tab.questions" }} -{%- endif %} {%- endmacro %} +{%- endif %} {%- endmacro %} {% macro profile_nav(selected="") -%} +
+ + {{ text "auth:label.posts" }} + + + + {{ text "auth:label.replies" }} + + + + {{ text "auth:label.media" }} + +
+{%- endmacro %} diff --git a/crates/app/src/public/html/profile/media.html b/crates/app/src/public/html/profile/media.html new file mode 100644 index 0000000..5429e02 --- /dev/null +++ b/crates/app/src/public/html/profile/media.html @@ -0,0 +1,42 @@ +{% extends "profile/base.html" %} {% block content %} {% if +profile.settings.enable_questions and (user or +profile.settings.allow_anonymous_questions) %} +
+ {{ components::create_question_form(receiver=profile.id, + header=profile.settings.motivational_header) }} +
+{%- endif %} {{ macros::profile_nav(selected="media") }} +
+
+
+ {{ icon "clock" }} + {{ text "auth:label.recent_posts_with_media" }} +
+ + {% if user -%} + + {{ icon "search" }} + {{ text "general:link.search" }} + + {%- endif %} +
+ +
+ + {% for post in posts %} + {% if post[2].read_access == "Everybody" -%} + {% if post[0].context.repost and post[0].context.repost.reposting -%} + {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} + {% else %} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }} + {%- endif %} + {%- endif %} + {% endfor %} + + {{ components::pagination(page=page, items=posts|length) }} +
+
+{% endblock %} diff --git a/crates/app/src/public/html/profile/posts.html b/crates/app/src/public/html/profile/posts.html index 6577a31..4b396c5 100644 --- a/crates/app/src/public/html/profile/posts.html +++ b/crates/app/src/public/html/profile/posts.html @@ -25,8 +25,7 @@ profile.settings.allow_anonymous_questions) %} {% endfor %} -{%- endif %} - +{%- endif %} {{ macros::profile_nav(selected="posts") }}
diff --git a/crates/app/src/public/html/profile/replies.html b/crates/app/src/public/html/profile/replies.html new file mode 100644 index 0000000..7b167a0 --- /dev/null +++ b/crates/app/src/public/html/profile/replies.html @@ -0,0 +1,42 @@ +{% extends "profile/base.html" %} {% block content %} {% if +profile.settings.enable_questions and (user or +profile.settings.allow_anonymous_questions) %} +
+ {{ components::create_question_form(receiver=profile.id, + header=profile.settings.motivational_header) }} +
+{%- endif %} {{ macros::profile_nav(selected="replies") }} +
+
+
+ {{ icon "clock" }} + {{ text "auth:label.recent_replies" }} +
+ + {% if user -%} + + {{ icon "search" }} + {{ text "general:link.search" }} + + {%- endif %} +
+ +
+ + {% for post in posts %} + {% if post[2].read_access == "Everybody" -%} + {% if post[0].context.repost and post[0].context.repost.reposting -%} + {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} + {% else %} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }} + {%- endif %} + {%- endif %} + {% endfor %} + + {{ components::pagination(page=page, items=posts|length) }} +
+
+{% endblock %} diff --git a/crates/app/src/public/html/timelines/search.html b/crates/app/src/public/html/timelines/search.html index cad0276..6c7af61 100644 --- a/crates/app/src/public/html/timelines/search.html +++ b/crates/app/src/public/html/timelines/search.html @@ -40,16 +40,24 @@ /> {%- endif %} - +
+ + + {% if config.manuals.search_help -%} + + {{ icon "circle-help" }} + + {%- endif %} +
- {% if config.manuals.search_help -%} - - Search help - - {%- endif %} + Search help {%- endif %} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 9922cd1..aa5956f 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -69,6 +69,8 @@ pub fn routes() -> Router { // profile .route("/settings", get(profile::settings_request)) .route("/@{username}", get(profile::posts_request)) + .route("/@{username}/media", get(profile::media_request)) + .route("/@{username}/replies", get(profile::replies_request)) .route("/@{username}/following", get(profile::following_request)) .route("/@{username}/followers", get(profile::followers_request)) // communities diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index a4b60a6..80e935c 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -369,6 +369,222 @@ pub async fn posts_request( Ok(Html(data.1.render("profile/posts.html", &context).unwrap())) } +/// `/@{username}/replies` +pub async fn replies_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_user_blocked_or_private!(user, other_user, data, jar); + + // fetch data + let ignore_users = if let Some(ref ua) = user { + data.0.get_userblocks_receivers(ua.id).await + } else { + Vec::new() + }; + + let posts = match data + .0 + .get_replies_by_user(other_user.id, 12, props.page, &user) + .await + { + Ok(p) => match data + .0 + .fill_posts_with_community( + p, + if let Some(ref ua) = user { ua.id } else { 0 }, + &ignore_users, + &user, + ) + .await + { + Ok(p) => p, + 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("posts", &posts); + context.insert("page", &props.page); + profile_context( + &mut context, + &user, + &other_user, + &communities, + is_self, + is_following, + is_following_you, + is_blocking, + ); + + // return + Ok(Html( + data.1.render("profile/replies.html", &context).unwrap(), + )) +} + +/// `/@{username}/media` +pub async fn media_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_user_blocked_or_private!(user, other_user, data, jar); + + // fetch data + let ignore_users = if let Some(ref ua) = user { + data.0.get_userblocks_receivers(ua.id).await + } else { + Vec::new() + }; + + let posts = match data + .0 + .get_media_posts_by_user(other_user.id, 12, props.page, &user) + .await + { + Ok(p) => match data + .0 + .fill_posts_with_community( + p, + if let Some(ref ua) = user { ua.id } else { 0 }, + &ignore_users, + &user, + ) + .await + { + Ok(p) => p, + 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("posts", &posts); + context.insert("page", &props.page); + profile_context( + &mut context, + &user, + &other_user, + &communities, + is_self, + is_following, + is_following_you, + is_blocking, + ); + + // return + Ok(Html(data.1.render("profile/media.html", &context).unwrap())) +} + /// `/@{username}/following` pub async fn following_request( jar: CookieJar, @@ -386,27 +602,6 @@ pub async fn following_request( check_user_blocked_or_private!(user, other_user, data, jar); - // check for private profile - if other_user.settings.private_profile { - if let Some(ref ua) = user { - if ua.id != other_user.id - && 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 diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index abfda0c..b79ef0a 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -477,6 +477,116 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all replies from the given user (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the user the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_replies_by_user( + &self, + id: usize, + batch: usize, + page: usize, + user: &Option, + ) -> Result> { + let other_user = self.get_user_by_id(id).await?; + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + // check if we should hide nsfw posts + let mut hide_nsfw: bool = true; + + if let Some(ua) = user { + if ua.id == other_user.id { + hide_nsfw = false + } + } + + if other_user.settings.private_profile { + hide_nsfw = false; + } + + // ... + let res = query_rows!( + &conn, + &format!( + "SELECT * FROM posts WHERE owner = $1 AND NOT replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean {} ORDER BY created DESC LIMIT $2 OFFSET $3", + if hide_nsfw { + "AND NOT (context::json->>'is_nsfw')::boolean" + } else { + "" + } + ), + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all posts containing media from the given user (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the user the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_media_posts_by_user( + &self, + id: usize, + batch: usize, + page: usize, + user: &Option, + ) -> Result> { + let other_user = self.get_user_by_id(id).await?; + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + // check if we should hide nsfw posts + let mut hide_nsfw: bool = true; + + if let Some(ua) = user { + if ua.id == other_user.id { + hide_nsfw = false + } + } + + if other_user.settings.private_profile { + hide_nsfw = false; + } + + // ... + let res = query_rows!( + &conn, + &format!( + "SELECT * FROM posts WHERE owner = $1 AND NOT uploads = '[]' AND NOT (context::json->>'is_profile_pinned')::boolean {} ORDER BY created DESC LIMIT $2 OFFSET $3", + if hide_nsfw { + "AND NOT (context::json->>'is_nsfw')::boolean" + } else { + "" + } + ), + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Get all posts from the given user (searched). /// /// # Arguments @@ -517,7 +627,7 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE owner = $1 AND tsvector_content @@ to_tsquery($2) AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean {} ORDER BY created DESC LIMIT $3 OFFSET $4", + "SELECT * FROM posts WHERE owner = $1 AND tsvector_content @@ to_tsquery($2) {} ORDER BY created DESC LIMIT $3 OFFSET $4", if hide_nsfw { "AND NOT (context::json->>'is_nsfw')::boolean" } else { @@ -560,7 +670,7 @@ impl DataManager { // ... let res = query_rows!( &conn, - "SELECT * FROM posts WHERE tsvector_content @@ to_tsquery($1) AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean ORDER BY created DESC LIMIT $2 OFFSET $3", + "SELECT * FROM posts WHERE tsvector_content @@ to_tsquery($1) ORDER BY created DESC LIMIT $2 OFFSET $3", params![&text_query, &(batch as i64), &((page * batch) as i64)], |x| { Self::get_post_from_row(x) } ); @@ -1493,11 +1603,7 @@ impl DataManager { } // incr user post count - let owner = self.get_user_by_id(y.owner).await?; - - if owner.post_count > 0 { - self.incr_user_post_count(y.owner).await?; - } + self.incr_user_post_count(y.owner).await?; // incr question answer count if y.context.answering != 0 {