]> Untitled Git - lemmy.git/commitdiff
Preferred usernames, banners and icons. (#1055)
authorDessalines <dessalines@users.noreply.github.com>
Wed, 5 Aug 2020 16:03:46 +0000 (12:03 -0400)
committerGitHub <noreply@github.com>
Wed, 5 Aug 2020 16:03:46 +0000 (12:03 -0400)
* Re-organizing federation tests. #746 #1040

* Adding federation support for user bios. Fixes #992

* Adding icons, banners, and preferred usernames.

- Added optional community icons, and community banners.
- Added user banners.
- Added Site icon and banner, with custom favicon.
- Set up preferred usernames. Fixes #1017
- Added an additional post sort: Active
  - Hot rank now uses the published time.
  - Active uses the most recent comment time, and is default.
- DB Migration was required to add all these fields to the views.
- Added transfercommunity helper function.
- Removed title column from communities page.
- Abstracted an image-upload-form.tsx, and a banner-icon-header.tsx
- Fixes #899

* Some navbar fixes.

* Fixing css

* Some fixes.

- Showing correct user icon and banner after save without page reload.
- Abstracting diesel update overwrite.
- Adding some docs.

* Adding @ when a user doesn't have a preferred username.

58 files changed:
docs/src/about_ranking.md
docs/src/contributing_websocket_http_api.md
server/lemmy_db/src/activity.rs
server/lemmy_db/src/comment.rs
server/lemmy_db/src/comment_view.rs
server/lemmy_db/src/community.rs
server/lemmy_db/src/community_view.rs
server/lemmy_db/src/lib.rs
server/lemmy_db/src/moderator.rs
server/lemmy_db/src/password_reset_request.rs
server/lemmy_db/src/post.rs
server/lemmy_db/src/post_view.rs
server/lemmy_db/src/private_message.rs
server/lemmy_db/src/private_message_view.rs
server/lemmy_db/src/schema.rs
server/lemmy_db/src/site.rs
server/lemmy_db/src/site_view.rs
server/lemmy_db/src/user.rs
server/lemmy_db/src/user_mention.rs
server/lemmy_db/src/user_mention_view.rs
server/lemmy_db/src/user_view.rs
server/migrations/2020-08-03-000110_add_preferred_usernames_banners_and_icons/down.sql [new file with mode: 0644]
server/migrations/2020-08-03-000110_add_preferred_usernames_banners_and_icons/up.sql [new file with mode: 0644]
server/src/api/community.rs
server/src/api/site.rs
server/src/api/user.rs
server/src/apub/community.rs
server/src/apub/inbox/activities/delete.rs
server/src/apub/inbox/activities/remove.rs
server/src/apub/inbox/activities/undo.rs
server/src/apub/user.rs
server/src/code_migrations.rs
ui/assets/css/main.css
ui/src/api_tests/shared.ts
ui/src/components/admin-settings.tsx
ui/src/components/banner-icon-header.tsx [new file with mode: 0644]
ui/src/components/comment-node.tsx
ui/src/components/communities.tsx
ui/src/components/community-form.tsx
ui/src/components/community-link.tsx
ui/src/components/community.tsx
ui/src/components/image-upload-form.tsx [new file with mode: 0644]
ui/src/components/main.tsx
ui/src/components/navbar.tsx
ui/src/components/post-listing.tsx
ui/src/components/post.tsx
ui/src/components/private-message-form.tsx
ui/src/components/private-message.tsx
ui/src/components/search.tsx
ui/src/components/sidebar.tsx
ui/src/components/site-form.tsx
ui/src/components/sort-select.tsx
ui/src/components/symbols.tsx
ui/src/components/user-listing.tsx
ui/src/components/user.tsx
ui/src/interfaces.ts
ui/src/utils.ts
ui/translations/en.json

index f1ed9b3867cf9360d2d94760c9c358e30cf81cf7..0f91b7e3936c378697466c52b5f9e77c5f6748a2 100644 (file)
@@ -18,7 +18,9 @@ Score = Upvotes - Downvotes
 Time = time since submission (in hours)
 Gravity = Decay gravity, 1.8 is default
 ```
-- For posts, in order to bring up active posts, it uses the latest comment time (limited to a max creation age of a month ago)
+- Lemmy uses the same `Rank` algorithm above, in two sorts: `Active`, and `Hot`.
+  - `Active` uses the post votes, and latest comment time (limited to two days).
+  - `Hot` uses the post votes, and the post published time.
 - Use Max(1, score) to make sure all comments are affected by time decay.
 - Add 3 to the score, so that everything that has less than 3 downvotes will seem new. Otherwise all new comments would stay at zero, near the bottom.
 - The sign and abs of the score are necessary for dealing with the log of negative scores.
index 7953bc9a2279348e662840bf6902cdf999da8a3c..fa241d162b92fedb8f7ff11d0a14756267c939a8 100644 (file)
@@ -330,7 +330,8 @@ curl -i -H \
 
 These go wherever there is a `sort` field. The available sort types are:
 
-- `Hot` - the hottest posts/communities, depending on votes, views, comments and publish date
+- `Active` - the hottest posts/communities, depending on votes, and newest comment publish date.
+- `Hot` - the hottest posts/communities, depending on votes and publish date.
 - `New` - the newest posts/communities
 - `TopDay` - the most upvoted posts/communities of the current day.
 - `TopWeek` - the most upvoted posts/communities of the current week.
@@ -482,7 +483,19 @@ These expire after 10 minutes.
     theme: String, // Default 'darkly'
     default_sort_type: i16, // The Sort types from above, zero indexed as a number
     default_listing_type: i16, // Post listing types are `All, Subscribed, Community`
-    auth: String
+    lang: String,
+    avatar: Option<String>,
+    banner: Option<String>,
+    preferred_username: Option<String>,
+    email: Option<String>,
+    bio: Option<String>,
+    matrix_user_id: Option<String>,
+    new_password: Option<String>,
+    new_password_verify: Option<String>,
+    old_password: Option<String>,
+    show_avatars: bool,
+    send_notifications_to_email: bool,
+    auth: String,
   }
 }
 ```
@@ -924,6 +937,8 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
   data: {
     name: String,
     description: Option<String>,
+    icon: Option<String>,
+    banner: Option<String>,
     auth: String
   }
 }
@@ -950,6 +965,8 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
   data: {
     name: String,
     description: Option<String>,
+    icon: Option<String>,
+    banner: Option<String>,
     auth: String
   }
 }
@@ -1105,6 +1122,8 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
     name: String,
     title: String,
     description: Option<String>,
+    icon: Option<String>,
+    banner: Option<String>,
     category_id: i32 ,
     auth: String
   }
@@ -1215,6 +1234,8 @@ Only mods can edit a community.
     edit_id: i32,
     title: String,
     description: Option<String>,
+    icon: Option<String>,
+    banner: Option<String>,
     category_id: i32,
     auth: String
   }
index 557eb9e9ef1e9ea45b8c6ac53640087a1f01504c..3f7fd1d5c3d16a6ba321d01771599fce786ecb3c 100644 (file)
@@ -107,6 +107,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
index 99efde8d7d1faaadb5430c12ba6c7e46f862da67..354922e85eb5433148155abc3fa0a3c1d1a7bc0c 100644 (file)
@@ -116,10 +116,7 @@ impl Comment {
   ) -> Result<Self, Error> {
     use crate::schema::comment::dsl::*;
     diesel::update(comment.find(comment_id))
-      .set((
-        deleted.eq(new_deleted),
-        updated.eq(naive_now())
-      ))
+      .set((deleted.eq(new_deleted), updated.eq(naive_now())))
       .get_result::<Self>(conn)
   }
 
@@ -130,10 +127,7 @@ impl Comment {
   ) -> Result<Self, Error> {
     use crate::schema::comment::dsl::*;
     diesel::update(comment.find(comment_id))
-      .set((
-        removed.eq(new_removed),
-        updated.eq(naive_now())
-      ))
+      .set((removed.eq(new_removed), updated.eq(naive_now())))
       .get_result::<Self>(conn)
   }
 
@@ -261,6 +255,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
@@ -297,6 +292,8 @@ mod tests {
       public_key: None,
       last_refreshed_at: None,
       published: None,
+      banner: None,
+      icon: None,
     };
 
     let inserted_community = Community::create(&conn, &new_community).unwrap();
index 7f99ba4ade9b704eb863883a6db45e8260507244..9400e32089437f5fbed5f18b1996a58db7dcee0b 100644 (file)
@@ -23,17 +23,20 @@ table! {
     community_actor_id -> Text,
     community_local -> Bool,
     community_name -> Varchar,
+    community_icon -> Nullable<Text>,
     banned -> Bool,
     banned_from_community -> Bool,
     creator_actor_id -> Text,
     creator_local -> Bool,
     creator_name -> Varchar,
+    creator_preferred_username -> Nullable<Varchar>,
     creator_published -> Timestamp,
     creator_avatar -> Nullable<Text>,
     score -> BigInt,
     upvotes -> BigInt,
     downvotes -> BigInt,
     hot_rank -> Int4,
+    hot_rank_active -> Int4,
     user_id -> Nullable<Int4>,
     my_vote -> Nullable<Int4>,
     subscribed -> Nullable<Bool>,
@@ -60,17 +63,20 @@ table! {
     community_actor_id -> Text,
     community_local -> Bool,
     community_name -> Varchar,
+    community_icon -> Nullable<Text>,
     banned -> Bool,
     banned_from_community -> Bool,
     creator_actor_id -> Text,
     creator_local -> Bool,
     creator_name -> Varchar,
+    creator_preferred_username -> Nullable<Varchar>,
     creator_published -> Timestamp,
     creator_avatar -> Nullable<Text>,
     score -> BigInt,
     upvotes -> BigInt,
     downvotes -> BigInt,
     hot_rank -> Int4,
+    hot_rank_active -> Int4,
     user_id -> Nullable<Int4>,
     my_vote -> Nullable<Int4>,
     subscribed -> Nullable<Bool>,
@@ -100,17 +106,20 @@ pub struct CommentView {
   pub community_actor_id: String,
   pub community_local: bool,
   pub community_name: String,
+  pub community_icon: Option<String>,
   pub banned: bool,
   pub banned_from_community: bool,
   pub creator_actor_id: String,
   pub creator_local: bool,
   pub creator_name: String,
+  pub creator_preferred_username: Option<String>,
   pub creator_published: chrono::NaiveDateTime,
   pub creator_avatar: Option<String>,
   pub score: i64,
   pub upvotes: i64,
   pub downvotes: i64,
   pub hot_rank: i32,
+  pub hot_rank_active: i32,
   pub user_id: Option<i32>,
   pub my_vote: Option<i32>,
   pub subscribed: Option<bool>,
@@ -244,6 +253,9 @@ impl<'a> CommentQueryBuilder<'a> {
       SortType::Hot => query
         .order_by(hot_rank.desc())
         .then_order_by(published.desc()),
+      SortType::Active => query
+        .order_by(hot_rank_active.desc())
+        .then_order_by(published.desc()),
       SortType::New => query.order_by(published.desc()),
       SortType::TopAll => query.order_by(score.desc()),
       SortType::TopYear => query
@@ -315,17 +327,20 @@ table! {
     community_actor_id -> Text,
     community_local -> Bool,
     community_name -> Varchar,
+    community_icon -> Nullable<Varchar>,
     banned -> Bool,
     banned_from_community -> Bool,
     creator_actor_id -> Text,
     creator_local -> Bool,
     creator_name -> Varchar,
+    creator_preferred_username -> Nullable<Varchar>,
     creator_avatar -> Nullable<Text>,
     creator_published -> Timestamp,
     score -> BigInt,
     upvotes -> BigInt,
     downvotes -> BigInt,
     hot_rank -> Int4,
+    hot_rank_active -> Int4,
     user_id -> Nullable<Int4>,
     my_vote -> Nullable<Int4>,
     subscribed -> Nullable<Bool>,
@@ -356,17 +371,20 @@ pub struct ReplyView {
   pub community_actor_id: String,
   pub community_local: bool,
   pub community_name: String,
+  pub community_icon: Option<String>,
   pub banned: bool,
   pub banned_from_community: bool,
   pub creator_actor_id: String,
   pub creator_local: bool,
   pub creator_name: String,
+  pub creator_preferred_username: Option<String>,
   pub creator_avatar: Option<String>,
   pub creator_published: chrono::NaiveDateTime,
   pub score: i64,
   pub upvotes: i64,
   pub downvotes: i64,
   pub hot_rank: i32,
+  pub hot_rank_active: i32,
   pub user_id: Option<i32>,
   pub my_vote: Option<i32>,
   pub subscribed: Option<bool>,
@@ -437,7 +455,7 @@ impl<'a> ReplyQueryBuilder<'a> {
     }
 
     query = match self.sort {
-      // SortType::Hot => query.order_by(hot_rank.desc()),
+      // SortType::Hot => query.order_by(hot_rank.desc()), // TODO why is this commented
       SortType::New => query.order_by(published.desc()),
       SortType::TopAll => query.order_by(score.desc()),
       SortType::TopYear => query
@@ -488,6 +506,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
@@ -524,6 +543,8 @@ mod tests {
       public_key: None,
       last_refreshed_at: None,
       published: None,
+      icon: None,
+      banner: None,
     };
 
     let inserted_community = Community::create(&conn, &new_community).unwrap();
@@ -584,6 +605,7 @@ mod tests {
       post_name: inserted_post.name.to_owned(),
       community_id: inserted_community.id,
       community_name: inserted_community.name.to_owned(),
+      community_icon: None,
       parent_id: None,
       removed: false,
       deleted: false,
@@ -593,11 +615,13 @@ mod tests {
       published: inserted_comment.published,
       updated: None,
       creator_name: inserted_user.name.to_owned(),
+      creator_preferred_username: None,
       creator_published: inserted_user.published,
       creator_avatar: None,
       score: 1,
       downvotes: 0,
       hot_rank: 0,
+      hot_rank_active: 0,
       upvotes: 1,
       user_id: None,
       my_vote: None,
@@ -619,6 +643,7 @@ mod tests {
       post_name: inserted_post.name.to_owned(),
       community_id: inserted_community.id,
       community_name: inserted_community.name.to_owned(),
+      community_icon: None,
       parent_id: None,
       removed: false,
       deleted: false,
@@ -628,11 +653,13 @@ mod tests {
       published: inserted_comment.published,
       updated: None,
       creator_name: inserted_user.name.to_owned(),
+      creator_preferred_username: None,
       creator_published: inserted_user.published,
       creator_avatar: None,
       score: 1,
       downvotes: 0,
       hot_rank: 0,
+      hot_rank_active: 0,
       upvotes: 1,
       user_id: Some(inserted_user.id),
       my_vote: Some(1),
@@ -651,6 +678,7 @@ mod tests {
       .list()
       .unwrap();
     read_comment_views_no_user[0].hot_rank = 0;
+    read_comment_views_no_user[0].hot_rank_active = 0;
 
     let mut read_comment_views_with_user = CommentQueryBuilder::create(&conn)
       .for_post_id(inserted_post.id)
@@ -658,6 +686,7 @@ mod tests {
       .list()
       .unwrap();
     read_comment_views_with_user[0].hot_rank = 0;
+    read_comment_views_with_user[0].hot_rank_active = 0;
 
     let like_removed = CommentLike::remove(&conn, &comment_like_form).unwrap();
     let num_deleted = Comment::delete(&conn, inserted_comment.id).unwrap();
index 2c86f1e755f0031160fca1eb57437ad55d70cfe6..b4200b2d9edad2f064221ac2d2ceee174414d011 100644 (file)
@@ -28,6 +28,8 @@ pub struct Community {
   pub private_key: Option<String>,
   pub public_key: Option<String>,
   pub last_refreshed_at: chrono::NaiveDateTime,
+  pub icon: Option<String>,
+  pub banner: Option<String>,
 }
 
 #[derive(Insertable, AsChangeset, Clone, Serialize, Deserialize, Debug)]
@@ -48,6 +50,8 @@ pub struct CommunityForm {
   pub private_key: Option<String>,
   pub public_key: Option<String>,
   pub last_refreshed_at: Option<chrono::NaiveDateTime>,
+  pub icon: Option<Option<String>>,
+  pub banner: Option<Option<String>>,
 }
 
 impl Crud<CommunityForm> for Community {
@@ -107,10 +111,7 @@ impl Community {
   ) -> Result<Self, Error> {
     use crate::schema::community::dsl::*;
     diesel::update(community.find(community_id))
-      .set((
-        deleted.eq(new_deleted),
-        updated.eq(naive_now())
-      ))
+      .set((deleted.eq(new_deleted), updated.eq(naive_now())))
       .get_result::<Self>(conn)
   }
 
@@ -121,10 +122,7 @@ impl Community {
   ) -> Result<Self, Error> {
     use crate::schema::community::dsl::*;
     diesel::update(community.find(community_id))
-      .set((
-        removed.eq(new_removed),
-        updated.eq(naive_now())
-      ))
+      .set((removed.eq(new_removed), updated.eq(naive_now())))
       .get_result::<Self>(conn)
   }
 
@@ -305,6 +303,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
@@ -341,6 +340,8 @@ mod tests {
       public_key: None,
       last_refreshed_at: None,
       published: None,
+      icon: None,
+      banner: None,
     };
 
     let inserted_community = Community::create(&conn, &new_community).unwrap();
@@ -362,6 +363,8 @@ mod tests {
       private_key: None,
       public_key: None,
       last_refreshed_at: inserted_community.published,
+      icon: None,
+      banner: None,
     };
 
     let community_follower_form = CommunityFollowerForm {
index 880c945591f5fa815b61c3a6ecea8489dd76acad..540841f22b584a7a562c5e0c23c43acd78630223 100644 (file)
@@ -8,6 +8,8 @@ table! {
     id -> Int4,
     name -> Varchar,
     title -> Varchar,
+    icon -> Nullable<Text>,
+    banner -> Nullable<Text>,
     description -> Nullable<Text>,
     category_id -> Int4,
     creator_id -> Int4,
@@ -22,6 +24,7 @@ table! {
     creator_actor_id -> Text,
     creator_local -> Bool,
     creator_name -> Varchar,
+    creator_preferred_username -> Nullable<Varchar>,
     creator_avatar -> Nullable<Text>,
     category_name -> Varchar,
     number_of_subscribers -> BigInt,
@@ -38,6 +41,8 @@ table! {
     id -> Int4,
     name -> Varchar,
     title -> Varchar,
+    icon -> Nullable<Text>,
+    banner -> Nullable<Text>,
     description -> Nullable<Text>,
     category_id -> Int4,
     creator_id -> Int4,
@@ -52,6 +57,7 @@ table! {
     creator_actor_id -> Text,
     creator_local -> Bool,
     creator_name -> Varchar,
+    creator_preferred_username -> Nullable<Varchar>,
     creator_avatar -> Nullable<Text>,
     category_name -> Varchar,
     number_of_subscribers -> BigInt,
@@ -72,10 +78,12 @@ table! {
     user_actor_id -> Text,
     user_local -> Bool,
     user_name -> Varchar,
+    user_preferred_username -> Nullable<Varchar>,
     avatar -> Nullable<Text>,
     community_actor_id -> Text,
     community_local -> Bool,
     community_name -> Varchar,
+    community_icon -> Nullable<Text>,
   }
 }
 
@@ -88,10 +96,12 @@ table! {
     user_actor_id -> Text,
     user_local -> Bool,
     user_name -> Varchar,
+    user_preferred_username -> Nullable<Varchar>,
     avatar -> Nullable<Text>,
     community_actor_id -> Text,
     community_local -> Bool,
     community_name -> Varchar,
+    community_icon -> Nullable<Text>,
   }
 }
 
@@ -104,10 +114,12 @@ table! {
     user_actor_id -> Text,
     user_local -> Bool,
     user_name -> Varchar,
+    user_preferred_username -> Nullable<Varchar>,
     avatar -> Nullable<Text>,
     community_actor_id -> Text,
     community_local -> Bool,
     community_name -> Varchar,
+    community_icon -> Nullable<Text>,
   }
 }
 
@@ -119,6 +131,8 @@ pub struct CommunityView {
   pub id: i32,
   pub name: String,
   pub title: String,
+  pub icon: Option<String>,
+  pub banner: Option<String>,
   pub description: Option<String>,
   pub category_id: i32,
   pub creator_id: i32,
@@ -133,6 +147,7 @@ pub struct CommunityView {
   pub creator_actor_id: String,
   pub creator_local: bool,
   pub creator_name: String,
+  pub creator_preferred_username: Option<String>,
   pub creator_avatar: Option<String>,
   pub category_name: String,
   pub number_of_subscribers: i64,
@@ -288,10 +303,12 @@ pub struct CommunityModeratorView {
   pub user_actor_id: String,
   pub user_local: bool,
   pub user_name: String,
+  pub user_preferred_username: Option<String>,
   pub avatar: Option<String>,
   pub community_actor_id: String,
   pub community_local: bool,
   pub community_name: String,
+  pub community_icon: Option<String>,
 }
 
 impl CommunityModeratorView {
@@ -324,10 +341,12 @@ pub struct CommunityFollowerView {
   pub user_actor_id: String,
   pub user_local: bool,
   pub user_name: String,
+  pub user_preferred_username: Option<String>,
   pub avatar: Option<String>,
   pub community_actor_id: String,
   pub community_local: bool,
   pub community_name: String,
+  pub community_icon: Option<String>,
 }
 
 impl CommunityFollowerView {
@@ -358,10 +377,12 @@ pub struct CommunityUserBanView {
   pub user_actor_id: String,
   pub user_local: bool,
   pub user_name: String,
+  pub user_preferred_username: Option<String>,
   pub avatar: Option<String>,
   pub community_actor_id: String,
   pub community_local: bool,
   pub community_name: String,
+  pub community_icon: Option<String>,
 }
 
 impl CommunityUserBanView {
index cca2994b8cc62467340798494fa9e48712f3dcf4..edfc26468e10e097b46de8cbd83620e7976781e6 100644 (file)
@@ -134,6 +134,7 @@ pub fn get_database_url_from_env() -> Result<String, VarError> {
 
 #[derive(EnumString, ToString, Debug, Serialize, Deserialize)]
 pub enum SortType {
+  Active,
   Hot,
   New,
   TopDay,
@@ -180,6 +181,20 @@ pub fn is_email_regex(test: &str) -> bool {
   EMAIL_REGEX.is_match(test)
 }
 
+pub fn diesel_option_overwrite(opt: &Option<String>) -> Option<Option<String>> {
+  match opt {
+    // An empty string is an erase
+    Some(unwrapped) => {
+      if !unwrapped.eq("") {
+        Some(Some(unwrapped.to_owned()))
+      } else {
+        Some(None)
+      }
+    }
+    None => None,
+  }
+}
+
 lazy_static! {
   static ref EMAIL_REGEX: Regex =
     Regex::new(r"^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$").unwrap();
index 0992197b1df80e4c13b555b3e00342001dcabda8..33e1063c500185f1c7fa2c9ff3f2792e663bb410 100644 (file)
@@ -460,6 +460,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
@@ -487,6 +488,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
@@ -523,6 +525,8 @@ mod tests {
       public_key: None,
       last_refreshed_at: None,
       published: None,
+      icon: None,
+      banner: None,
     };
 
     let inserted_community = Community::create(&conn, &new_community).unwrap();
index 2529ba67a989595761c52827ec6685d08c961893..038450bcb10dac5e302831078e84c20c01f6fb61 100644 (file)
@@ -95,6 +95,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
index 56ff7474bdbb6e191b52ef56932235d873804e07..591b4cbb04343b89b38438d32540b6a021e108a1 100644 (file)
@@ -119,10 +119,7 @@ impl Post {
   ) -> Result<Self, Error> {
     use crate::schema::post::dsl::*;
     diesel::update(post.find(post_id))
-      .set((
-        deleted.eq(new_deleted),
-        updated.eq(naive_now())
-      ))
+      .set((deleted.eq(new_deleted), updated.eq(naive_now())))
       .get_result::<Self>(conn)
   }
 
@@ -133,10 +130,7 @@ impl Post {
   ) -> Result<Self, Error> {
     use crate::schema::post::dsl::*;
     diesel::update(post.find(post_id))
-      .set((
-        removed.eq(new_removed),
-        updated.eq(naive_now())
-      ))
+      .set((removed.eq(new_removed), updated.eq(naive_now())))
       .get_result::<Self>(conn)
   }
 
@@ -322,6 +316,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
@@ -358,6 +353,8 @@ mod tests {
       public_key: None,
       last_refreshed_at: None,
       published: None,
+      icon: None,
+      banner: None,
     };
 
     let inserted_community = Community::create(&conn, &new_community).unwrap();
index ffc8afebd1b47290890fde4e7cf066d4904f37af..9878807a4cfc6c4815d4f870bb99d4c0521e5d8b 100644 (file)
@@ -28,6 +28,7 @@ table! {
     creator_actor_id -> Text,
     creator_local -> Bool,
     creator_name -> Varchar,
+    creator_preferred_username -> Nullable<Varchar>,
     creator_published -> Timestamp,
     creator_avatar -> Nullable<Text>,
     banned -> Bool,
@@ -35,6 +36,7 @@ table! {
     community_actor_id -> Text,
     community_local -> Bool,
     community_name -> Varchar,
+    community_icon -> Nullable<Text>,
     community_removed -> Bool,
     community_deleted -> Bool,
     community_nsfw -> Bool,
@@ -43,6 +45,7 @@ table! {
     upvotes -> BigInt,
     downvotes -> BigInt,
     hot_rank -> Int4,
+    hot_rank_active -> Int4,
     newest_activity_time -> Timestamp,
     user_id -> Nullable<Int4>,
     my_vote -> Nullable<Int4>,
@@ -76,6 +79,7 @@ table! {
     creator_actor_id -> Text,
     creator_local -> Bool,
     creator_name -> Varchar,
+    creator_preferred_username -> Nullable<Varchar>,
     creator_published -> Timestamp,
     creator_avatar -> Nullable<Text>,
     banned -> Bool,
@@ -83,6 +87,7 @@ table! {
     community_actor_id -> Text,
     community_local -> Bool,
     community_name -> Varchar,
+    community_icon -> Nullable<Text>,
     community_removed -> Bool,
     community_deleted -> Bool,
     community_nsfw -> Bool,
@@ -91,6 +96,7 @@ table! {
     upvotes -> BigInt,
     downvotes -> BigInt,
     hot_rank -> Int4,
+    hot_rank_active -> Int4,
     newest_activity_time -> Timestamp,
     user_id -> Nullable<Int4>,
     my_vote -> Nullable<Int4>,
@@ -127,6 +133,7 @@ pub struct PostView {
   pub creator_actor_id: String,
   pub creator_local: bool,
   pub creator_name: String,
+  pub creator_preferred_username: Option<String>,
   pub creator_published: chrono::NaiveDateTime,
   pub creator_avatar: Option<String>,
   pub banned: bool,
@@ -134,6 +141,7 @@ pub struct PostView {
   pub community_actor_id: String,
   pub community_local: bool,
   pub community_name: String,
+  pub community_icon: Option<String>,
   pub community_removed: bool,
   pub community_deleted: bool,
   pub community_nsfw: bool,
@@ -142,6 +150,7 @@ pub struct PostView {
   pub upvotes: i64,
   pub downvotes: i64,
   pub hot_rank: i32,
+  pub hot_rank_active: i32,
   pub newest_activity_time: chrono::NaiveDateTime,
   pub user_id: Option<i32>,
   pub my_vote: Option<i32>,
@@ -289,6 +298,9 @@ impl<'a> PostQueryBuilder<'a> {
     }
 
     query = match self.sort {
+      SortType::Active => query
+        .then_order_by(hot_rank_active.desc())
+        .then_order_by(published.desc()),
       SortType::Hot => query
         .then_order_by(hot_rank.desc())
         .then_order_by(published.desc()),
@@ -405,6 +417,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       updated: None,
       admin: false,
       banned: false,
@@ -441,6 +454,8 @@ mod tests {
       public_key: None,
       last_refreshed_at: None,
       published: None,
+      icon: None,
+      banner: None,
     };
 
     let inserted_community = Community::create(&conn, &new_community).unwrap();
@@ -519,6 +534,7 @@ mod tests {
       body: None,
       creator_id: inserted_user.id,
       creator_name: user_name.to_owned(),
+      creator_preferred_username: None,
       creator_published: inserted_user.published,
       creator_avatar: None,
       banned: false,
@@ -529,6 +545,7 @@ mod tests {
       locked: false,
       stickied: false,
       community_name: community_name.to_owned(),
+      community_icon: None,
       community_removed: false,
       community_deleted: false,
       community_nsfw: false,
@@ -537,6 +554,7 @@ mod tests {
       upvotes: 1,
       downvotes: 0,
       hot_rank: read_post_listing_no_user.hot_rank,
+      hot_rank_active: read_post_listing_no_user.hot_rank_active,
       published: inserted_post.published,
       newest_activity_time: inserted_post.published,
       updated: None,
@@ -569,12 +587,14 @@ mod tests {
       stickied: false,
       creator_id: inserted_user.id,
       creator_name: user_name,
+      creator_preferred_username: None,
       creator_published: inserted_user.published,
       creator_avatar: None,
       banned: false,
       banned_from_community: false,
       community_id: inserted_community.id,
       community_name,
+      community_icon: None,
       community_removed: false,
       community_deleted: false,
       community_nsfw: false,
@@ -583,6 +603,7 @@ mod tests {
       upvotes: 1,
       downvotes: 0,
       hot_rank: read_post_listing_with_user.hot_rank,
+      hot_rank_active: read_post_listing_with_user.hot_rank_active,
       published: inserted_post.published,
       newest_activity_time: inserted_post.published,
       updated: None,
index 3486cf545e4d327b406735f8df3cfd91153eedf5..d9dc047b27f4f9d1b77b22e36cb88ae607520d6b 100644 (file)
@@ -147,6 +147,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
@@ -174,6 +175,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
index dfb11c444c2bf0003b87fa75805c3508606025b5..c9b4249b6e3bfa9271ac70c5b37745619f9a70e6 100644 (file)
@@ -16,10 +16,12 @@ table! {
     ap_id -> Text,
     local -> Bool,
     creator_name -> Varchar,
+    creator_preferred_username -> Nullable<Varchar>,
     creator_avatar -> Nullable<Text>,
     creator_actor_id -> Text,
     creator_local -> Bool,
     recipient_name -> Varchar,
+    recipient_preferred_username -> Nullable<Varchar>,
     recipient_avatar -> Nullable<Text>,
     recipient_actor_id -> Text,
     recipient_local -> Bool,
@@ -42,10 +44,12 @@ pub struct PrivateMessageView {
   pub ap_id: String,
   pub local: bool,
   pub creator_name: String,
+  pub creator_preferred_username: Option<String>,
   pub creator_avatar: Option<String>,
   pub creator_actor_id: String,
   pub creator_local: bool,
   pub recipient_name: String,
+  pub recipient_preferred_username: Option<String>,
   pub recipient_avatar: Option<String>,
   pub recipient_actor_id: String,
   pub recipient_local: bool,
index 9608fb7d423260a360c40cc4d95a1b701b784319..c446edd9f27e72f2add810f181ef57b093a0c36d 100644 (file)
@@ -52,17 +52,20 @@ table! {
         community_actor_id -> Nullable<Varchar>,
         community_local -> Nullable<Bool>,
         community_name -> Nullable<Varchar>,
+        community_icon -> Nullable<Text>,
         banned -> Nullable<Bool>,
         banned_from_community -> Nullable<Bool>,
         creator_actor_id -> Nullable<Varchar>,
         creator_local -> Nullable<Bool>,
         creator_name -> Nullable<Varchar>,
+        creator_preferred_username -> Nullable<Varchar>,
         creator_published -> Nullable<Timestamp>,
         creator_avatar -> Nullable<Text>,
         score -> Nullable<Int8>,
         upvotes -> Nullable<Int8>,
         downvotes -> Nullable<Int8>,
         hot_rank -> Nullable<Int4>,
+        hot_rank_active -> Nullable<Int4>,
     }
 }
 
@@ -104,6 +107,8 @@ table! {
         private_key -> Nullable<Text>,
         public_key -> Nullable<Text>,
         last_refreshed_at -> Timestamp,
+        icon -> Nullable<Text>,
+        banner -> Nullable<Text>,
     }
 }
 
@@ -112,6 +117,8 @@ table! {
         id -> Int4,
         name -> Nullable<Varchar>,
         title -> Nullable<Varchar>,
+        icon -> Nullable<Text>,
+        banner -> Nullable<Text>,
         description -> Nullable<Text>,
         category_id -> Nullable<Int4>,
         creator_id -> Nullable<Int4>,
@@ -126,6 +133,7 @@ table! {
         creator_actor_id -> Nullable<Varchar>,
         creator_local -> Nullable<Bool>,
         creator_name -> Nullable<Varchar>,
+        creator_preferred_username -> Nullable<Varchar>,
         creator_avatar -> Nullable<Text>,
         category_name -> Nullable<Varchar>,
         number_of_subscribers -> Nullable<Int8>,
@@ -319,6 +327,7 @@ table! {
         creator_actor_id -> Nullable<Varchar>,
         creator_local -> Nullable<Bool>,
         creator_name -> Nullable<Varchar>,
+        creator_preferred_username -> Nullable<Varchar>,
         creator_published -> Nullable<Timestamp>,
         creator_avatar -> Nullable<Text>,
         banned -> Nullable<Bool>,
@@ -326,6 +335,7 @@ table! {
         community_actor_id -> Nullable<Varchar>,
         community_local -> Nullable<Bool>,
         community_name -> Nullable<Varchar>,
+        community_icon -> Nullable<Text>,
         community_removed -> Nullable<Bool>,
         community_deleted -> Nullable<Bool>,
         community_nsfw -> Nullable<Bool>,
@@ -334,6 +344,7 @@ table! {
         upvotes -> Nullable<Int8>,
         downvotes -> Nullable<Int8>,
         hot_rank -> Nullable<Int4>,
+        hot_rank_active -> Nullable<Int4>,
         newest_activity_time -> Nullable<Timestamp>,
     }
 }
@@ -392,6 +403,8 @@ table! {
         enable_downvotes -> Bool,
         open_registration -> Bool,
         enable_nsfw -> Bool,
+        icon -> Nullable<Text>,
+        banner -> Nullable<Text>,
     }
 }
 
@@ -421,6 +434,7 @@ table! {
         private_key -> Nullable<Text>,
         public_key -> Nullable<Text>,
         last_refreshed_at -> Timestamp,
+        banner -> Nullable<Text>,
     }
 }
 
@@ -437,7 +451,9 @@ table! {
         id -> Int4,
         actor_id -> Nullable<Varchar>,
         name -> Nullable<Varchar>,
+        preferred_username -> Nullable<Varchar>,
         avatar -> Nullable<Text>,
+        banner -> Nullable<Text>,
         email -> Nullable<Text>,
         matrix_user_id -> Nullable<Text>,
         bio -> Nullable<Text>,
index 066ae0b1a68f152d3f51f82db864ff9072dc02be..51699d657b884676a1b76dd0064f7d79b9143b4f 100644 (file)
@@ -1,4 +1,4 @@
-use crate::{schema::site, Crud};
+use crate::{naive_now, schema::site, Crud};
 use diesel::{dsl::*, result::Error, *};
 use serde::{Deserialize, Serialize};
 
@@ -14,6 +14,8 @@ pub struct Site {
   pub enable_downvotes: bool,
   pub open_registration: bool,
   pub enable_nsfw: bool,
+  pub icon: Option<String>,
+  pub banner: Option<String>,
 }
 
 #[derive(Insertable, AsChangeset, Clone, Serialize, Deserialize)]
@@ -26,6 +28,9 @@ pub struct SiteForm {
   pub enable_downvotes: bool,
   pub open_registration: bool,
   pub enable_nsfw: bool,
+  // when you want to null out a column, you have to send Some(None)), since sending None means you just don't want to update that column.
+  pub icon: Option<Option<String>>,
+  pub banner: Option<Option<String>>,
 }
 
 impl Crud<SiteForm> for Site {
@@ -51,3 +56,12 @@ impl Crud<SiteForm> for Site {
       .get_result::<Self>(conn)
   }
 }
+
+impl Site {
+  pub fn transfer(conn: &PgConnection, new_creator_id: i32) -> Result<Self, Error> {
+    use crate::schema::site::dsl::*;
+    diesel::update(site.find(1))
+      .set((creator_id.eq(new_creator_id), updated.eq(naive_now())))
+      .get_result::<Self>(conn)
+  }
+}
index bb9b54aa611825eba581ae9f02ae9ea23d636285..75cb29cb7e8414190d5598df5e3521cc9ee9aab1 100644 (file)
@@ -12,7 +12,10 @@ table! {
     enable_downvotes -> Bool,
     open_registration -> Bool,
     enable_nsfw -> Bool,
+    icon -> Nullable<Text>,
+    banner -> Nullable<Text>,
     creator_name -> Varchar,
+    creator_preferred_username -> Nullable<Varchar>,
     creator_avatar -> Nullable<Text>,
     number_of_users -> BigInt,
     number_of_posts -> BigInt,
@@ -35,7 +38,10 @@ pub struct SiteView {
   pub enable_downvotes: bool,
   pub open_registration: bool,
   pub enable_nsfw: bool,
+  pub icon: Option<String>,
+  pub banner: Option<String>,
   pub creator_name: String,
+  pub creator_preferred_username: Option<String>,
   pub creator_avatar: Option<String>,
   pub number_of_users: i64,
   pub number_of_posts: i64,
index ca454c5f241f616a0730e0b35a245f7d277fd84d..d30609bc3cac7ea7b6998c65417952db4de61ae3 100644 (file)
@@ -35,6 +35,7 @@ pub struct User_ {
   pub private_key: Option<String>,
   pub public_key: Option<String>,
   pub last_refreshed_at: chrono::NaiveDateTime,
+  pub banner: Option<String>,
 }
 
 #[derive(Insertable, AsChangeset, Clone, Debug)]
@@ -46,7 +47,7 @@ pub struct UserForm {
   pub admin: bool,
   pub banned: bool,
   pub email: Option<String>,
-  pub avatar: Option<String>,
+  pub avatar: Option<Option<String>>,
   pub updated: Option<chrono::NaiveDateTime>,
   pub show_nsfw: bool,
   pub theme: String,
@@ -62,6 +63,7 @@ pub struct UserForm {
   pub private_key: Option<String>,
   pub public_key: Option<String>,
   pub last_refreshed_at: Option<chrono::NaiveDateTime>,
+  pub banner: Option<Option<String>>,
 }
 
 impl Crud<UserForm> for User_ {
@@ -167,6 +169,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
@@ -195,6 +198,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       published: inserted_user.published,
index f32318e0acf648a41877b802a62a27f4517fc441..e8bfa73d6ca36513af35aeedea082c8a34203182 100644 (file)
@@ -100,6 +100,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
@@ -127,6 +128,7 @@ mod tests {
       email: None,
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       admin: false,
       banned: false,
       updated: None,
@@ -163,6 +165,8 @@ mod tests {
       public_key: None,
       last_refreshed_at: None,
       published: None,
+      icon: None,
+      banner: None,
     };
 
     let inserted_community = Community::create(&conn, &new_community).unwrap();
index 359f166d67acee569563e70b8036c8cb0a95bffd..f74adc31b74cf43041dcb4c0e035e30754a4413e 100644 (file)
@@ -23,14 +23,17 @@ table! {
     community_actor_id -> Text,
     community_local -> Bool,
     community_name -> Varchar,
+    community_icon -> Nullable<Text>,
     banned -> Bool,
     banned_from_community -> Bool,
     creator_name -> Varchar,
+    creator_preferred_username -> Nullable<Varchar>,
     creator_avatar -> Nullable<Text>,
     score -> BigInt,
     upvotes -> BigInt,
     downvotes -> BigInt,
     hot_rank -> Int4,
+    hot_rank_active -> Int4,
     user_id -> Nullable<Int4>,
     my_vote -> Nullable<Int4>,
     saved -> Nullable<Bool>,
@@ -60,14 +63,17 @@ table! {
     community_actor_id -> Text,
     community_local -> Bool,
     community_name -> Varchar,
+    community_icon -> Nullable<Text>,
     banned -> Bool,
     banned_from_community -> Bool,
     creator_name -> Varchar,
+    creator_preferred_username -> Nullable<Varchar>,
     creator_avatar -> Nullable<Text>,
     score -> BigInt,
     upvotes -> BigInt,
     downvotes -> BigInt,
     hot_rank -> Int4,
+    hot_rank_active -> Int4,
     user_id -> Nullable<Int4>,
     my_vote -> Nullable<Int4>,
     saved -> Nullable<Bool>,
@@ -100,14 +106,17 @@ pub struct UserMentionView {
   pub community_actor_id: String,
   pub community_local: bool,
   pub community_name: String,
+  pub community_icon: Option<String>,
   pub banned: bool,
   pub banned_from_community: bool,
   pub creator_name: String,
+  pub creator_preferred_username: Option<String>,
   pub creator_avatar: Option<String>,
   pub score: i64,
   pub upvotes: i64,
   pub downvotes: i64,
   pub hot_rank: i32,
+  pub hot_rank_active: i32,
   pub user_id: Option<i32>,
   pub my_vote: Option<i32>,
   pub saved: Option<bool>,
@@ -180,6 +189,9 @@ impl<'a> UserMentionQueryBuilder<'a> {
       SortType::Hot => query
         .order_by(hot_rank.desc())
         .then_order_by(published.desc()),
+      SortType::Active => query
+        .order_by(hot_rank_active.desc())
+        .then_order_by(published.desc()),
       SortType::New => query.order_by(published.desc()),
       SortType::TopAll => query.order_by(score.desc()),
       SortType::TopYear => query
index 5e1eb2d4adf62326a72749b8f6dab582aba5945a..ce75ef4d22ea5b2584baf30bef2cea32ffeb7af0 100644 (file)
@@ -8,7 +8,9 @@ table! {
     id -> Int4,
     actor_id -> Text,
     name -> Varchar,
+    preferred_username -> Nullable<Varchar>,
     avatar -> Nullable<Text>,
+    banner -> Nullable<Text>,
     email -> Nullable<Text>,
     matrix_user_id -> Nullable<Text>,
     bio -> Nullable<Text>,
@@ -30,7 +32,9 @@ table! {
     id -> Int4,
     actor_id -> Text,
     name -> Varchar,
+    preferred_username -> Nullable<Varchar>,
     avatar -> Nullable<Text>,
+    banner -> Nullable<Text>,
     email -> Nullable<Text>,
     matrix_user_id -> Nullable<Text>,
     bio -> Nullable<Text>,
@@ -55,7 +59,9 @@ pub struct UserView {
   pub id: i32,
   pub actor_id: String,
   pub name: String,
+  pub preferred_username: Option<String>,
   pub avatar: Option<String>,
+  pub banner: Option<String>,
   pub email: Option<String>, // TODO this shouldn't be in this view
   pub matrix_user_id: Option<String>,
   pub bio: Option<String>,
@@ -126,6 +132,9 @@ impl<'a> UserQueryBuilder<'a> {
       SortType::Hot => query
         .order_by(comment_score.desc())
         .then_order_by(published.desc()),
+      SortType::Active => query
+        .order_by(comment_score.desc())
+        .then_order_by(published.desc()),
       SortType::New => query.order_by(published.desc()),
       SortType::TopAll => query.order_by(comment_score.desc()),
       SortType::TopYear => query
@@ -164,7 +173,9 @@ impl UserView {
         id,
         actor_id,
         name,
+        preferred_username,
         avatar,
+        banner,
         "".into_sql::<Nullable<Text>>(),
         matrix_user_id,
         bio,
@@ -192,7 +203,9 @@ impl UserView {
         id,
         actor_id,
         name,
+        preferred_username,
         avatar,
+        banner,
         "".into_sql::<Nullable<Text>>(),
         matrix_user_id,
         bio,
diff --git a/server/migrations/2020-08-03-000110_add_preferred_usernames_banners_and_icons/down.sql b/server/migrations/2020-08-03-000110_add_preferred_usernames_banners_and_icons/down.sql
new file mode 100644 (file)
index 0000000..8ac1a99
--- /dev/null
@@ -0,0 +1,704 @@
+-- Drops first
+drop view site_view;
+drop table user_fast;
+drop view user_view;
+drop view post_fast_view;
+drop table post_aggregates_fast;
+drop view post_view;
+drop view post_aggregates_view;
+drop view community_moderator_view;
+drop view community_follower_view;
+drop view community_user_ban_view;
+drop view community_view;
+drop view community_aggregates_view;
+drop view community_fast_view;
+drop table community_aggregates_fast;
+drop view private_message_view;
+drop view user_mention_view;
+drop view reply_fast_view;
+drop view comment_fast_view;
+drop view comment_view;
+drop view user_mention_fast_view;
+drop table comment_aggregates_fast;
+drop view comment_aggregates_view;
+
+alter table site 
+  drop column icon,
+  drop column banner;
+
+alter table community 
+  drop column icon,
+  drop column banner;
+
+alter table user_ drop column banner;
+
+-- Site
+create view site_view as 
+select *,
+(select name from user_ u where s.creator_id = u.id) as creator_name,
+(select avatar from user_ u where s.creator_id = u.id) as creator_avatar,
+(select count(*) from user_) as number_of_users,
+(select count(*) from post) as number_of_posts,
+(select count(*) from comment) as number_of_comments,
+(select count(*) from community) as number_of_communities
+from site s;
+
+-- User
+create view user_view as
+select 
+       u.id,
+  u.actor_id,
+       u.name,
+       u.avatar,
+       u.email,
+       u.matrix_user_id,
+  u.bio,
+  u.local,
+       u.admin,
+       u.banned,
+       u.show_avatars,
+       u.send_notifications_to_email,
+       u.published,
+       coalesce(pd.posts, 0) as number_of_posts,
+       coalesce(pd.score, 0) as post_score,
+       coalesce(cd.comments, 0) as number_of_comments,
+       coalesce(cd.score, 0) as comment_score
+from user_ u
+left join (
+    select
+        p.creator_id as creator_id,
+        count(distinct p.id) as posts,
+        sum(pl.score) as score
+    from post p
+    join post_like pl on p.id = pl.post_id
+    group by p.creator_id
+) pd on u.id = pd.creator_id
+left join (
+    select
+        c.creator_id,
+        count(distinct c.id) as comments,
+        sum(cl.score) as score
+    from comment c
+    join comment_like cl on c.id = cl.comment_id
+    group by c.creator_id
+) cd on u.id = cd.creator_id;
+
+create table user_fast as select * from user_view;
+alter table user_fast add primary key (id);
+
+-- Post fast
+
+create view post_aggregates_view as
+select
+       p.*,
+       -- creator details
+       u.actor_id as creator_actor_id,
+       u."local" as creator_local,
+       u."name" as creator_name,
+  u.published as creator_published,
+       u.avatar as creator_avatar,
+  u.banned as banned,
+  cb.id::bool as banned_from_community,
+       -- community details
+       c.actor_id as community_actor_id,
+       c."local" as community_local,
+       c."name" as community_name,
+       c.removed as community_removed,
+       c.deleted as community_deleted,
+       c.nsfw as community_nsfw,
+       -- post score data/comment count
+       coalesce(ct.comments, 0) as number_of_comments,
+       coalesce(pl.score, 0) as score,
+       coalesce(pl.upvotes, 0) as upvotes,
+       coalesce(pl.downvotes, 0) as downvotes,
+       hot_rank(
+               coalesce(pl.score , 0), (
+                       case
+                               when (p.published < ('now'::timestamp - '1 month'::interval))
+                               then p.published
+                               else greatest(ct.recent_comment_time, p.published)
+                       end
+               )
+       ) as hot_rank,
+       (
+               case
+                       when (p.published < ('now'::timestamp - '1 month'::interval))
+                       then p.published
+                       else greatest(ct.recent_comment_time, p.published)
+               end
+       ) as newest_activity_time
+from post p
+left join user_ u on p.creator_id = u.id
+left join community_user_ban cb on p.creator_id = cb.user_id and p.community_id = cb.community_id
+left join community c on p.community_id = c.id
+left join (
+       select
+               post_id,
+               count(*) as comments,
+               max(published) as recent_comment_time
+       from comment
+       group by post_id
+) ct on ct.post_id = p.id
+left join (
+       select
+               post_id,
+               sum(score) as score,
+               sum(score) filter (where score = 1) as upvotes,
+               -sum(score) filter (where score = -1) as downvotes
+       from post_like
+       group by post_id
+) pl on pl.post_id = p.id
+order by p.id;
+
+create view post_view as
+select
+       pav.*,
+       us.id as user_id,
+       us.user_vote as my_vote,
+       us.is_subbed::bool as subscribed,
+       us.is_read::bool as read,
+       us.is_saved::bool as saved
+from post_aggregates_view pav
+cross join lateral (
+       select
+               u.id,
+               coalesce(cf.community_id, 0) as is_subbed,
+               coalesce(pr.post_id, 0) as is_read,
+               coalesce(ps.post_id, 0) as is_saved,
+               coalesce(pl.score, 0) as user_vote
+       from user_ u
+       left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
+       left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
+       left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
+       left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
+       left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
+) as us
+
+union all
+
+select 
+pav.*,
+null as user_id,
+null as my_vote,
+null as subscribed,
+null as read,
+null as saved
+from post_aggregates_view pav;
+
+create table post_aggregates_fast as select * from post_aggregates_view;
+alter table post_aggregates_fast add primary key (id);
+
+create view post_fast_view as 
+select
+       pav.*,
+       us.id as user_id,
+       us.user_vote as my_vote,
+       us.is_subbed::bool as subscribed,
+       us.is_read::bool as read,
+       us.is_saved::bool as saved
+from post_aggregates_fast pav
+cross join lateral (
+       select
+               u.id,
+               coalesce(cf.community_id, 0) as is_subbed,
+               coalesce(pr.post_id, 0) as is_read,
+               coalesce(ps.post_id, 0) as is_saved,
+               coalesce(pl.score, 0) as user_vote
+       from user_ u
+       left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
+       left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
+       left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
+       left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
+       left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
+) as us
+
+union all
+
+select 
+pav.*,
+null as user_id,
+null as my_vote,
+null as subscribed,
+null as read,
+null as saved
+from post_aggregates_fast pav;
+
+-- Community
+create view community_aggregates_view as
+select 
+    c.id,
+    c.name,
+    c.title,
+    c.description,
+    c.category_id,
+    c.creator_id,
+    c.removed,
+    c.published,
+    c.updated,
+    c.deleted,
+    c.nsfw,
+    c.actor_id,
+    c.local,
+    c.last_refreshed_at,
+    u.actor_id as creator_actor_id,
+    u.local as creator_local,
+    u.name as creator_name,
+    u.avatar as creator_avatar,
+    cat.name as category_name,
+    coalesce(cf.subs, 0) as number_of_subscribers,
+    coalesce(cd.posts, 0) as number_of_posts,
+    coalesce(cd.comments, 0) as number_of_comments,
+    hot_rank(cf.subs, c.published) as hot_rank
+from community c
+left join user_ u on c.creator_id = u.id
+left join category cat on c.category_id = cat.id
+left join (
+    select
+        p.community_id,
+        count(distinct p.id) as posts,
+        count(distinct ct.id) as comments
+    from post p
+    join comment ct on p.id = ct.post_id
+    group by p.community_id
+) cd on cd.community_id = c.id
+left join (
+    select
+        community_id,
+        count(*) as subs 
+    from community_follower
+    group by community_id 
+) cf on cf.community_id = c.id;
+
+create view community_view as
+select
+    cv.*,
+    us.user as user_id,
+    us.is_subbed::bool as subscribed
+from community_aggregates_view cv
+cross join lateral (
+       select
+               u.id as user,
+               coalesce(cf.community_id, 0) as is_subbed
+       from user_ u
+       left join community_follower cf on u.id = cf.user_id and cf.community_id = cv.id
+) as us
+
+union all
+
+select 
+    cv.*,
+    null as user_id,
+    null as subscribed
+from community_aggregates_view cv;
+
+create view community_moderator_view as
+select
+    cm.*,
+    u.actor_id as user_actor_id,
+    u.local as user_local,
+    u.name as user_name,
+    u.avatar as avatar,
+    c.actor_id as community_actor_id,
+    c.local as community_local,
+    c.name as community_name
+from community_moderator cm
+left join user_ u on cm.user_id = u.id
+left join community c on cm.community_id = c.id;
+
+create view community_follower_view as
+select
+    cf.*,
+    u.actor_id as user_actor_id,
+    u.local as user_local,
+    u.name as user_name,
+    u.avatar as avatar,
+    c.actor_id as community_actor_id,
+    c.local as community_local,
+    c.name as community_name
+from community_follower cf
+left join user_ u on cf.user_id = u.id
+left join community c on cf.community_id = c.id;
+
+create view community_user_ban_view as
+select
+    cb.*,
+    u.actor_id as user_actor_id,
+    u.local as user_local,
+    u.name as user_name,
+    u.avatar as avatar,
+    c.actor_id as community_actor_id,
+    c.local as community_local,
+    c.name as community_name
+from community_user_ban cb
+left join user_ u on cb.user_id = u.id
+left join community c on cb.community_id = c.id;
+
+-- The community fast table
+
+create table community_aggregates_fast as select * from community_aggregates_view;
+alter table community_aggregates_fast add primary key (id);
+
+create view community_fast_view as
+select
+ac.*,
+u.id as user_id,
+(select cf.id::boolean from community_follower cf where u.id = cf.user_id and ac.id = cf.community_id) as subscribed
+from user_ u
+cross join (
+  select
+  ca.*
+  from community_aggregates_fast ca
+) ac
+
+union all
+
+select 
+caf.*,
+null as user_id,
+null as subscribed
+from community_aggregates_fast caf;
+
+
+-- Private message
+create view private_message_view as 
+select        
+pm.*,
+u.name as creator_name,
+u.avatar as creator_avatar,
+u.actor_id as creator_actor_id,
+u.local as creator_local,
+u2.name as recipient_name,
+u2.avatar as recipient_avatar,
+u2.actor_id as recipient_actor_id,
+u2.local as recipient_local
+from private_message pm
+inner join user_ u on u.id = pm.creator_id
+inner join user_ u2 on u2.id = pm.recipient_id;
+
+
+-- Comments, mentions, replies
+
+create view comment_aggregates_view as
+select
+       ct.*,
+       -- post details
+       p."name" as post_name,
+       p.community_id,
+       -- community details
+       c.actor_id as community_actor_id,
+       c."local" as community_local,
+       c."name" as community_name,
+       -- creator details
+       u.banned as banned,
+  coalesce(cb.id, 0)::bool as banned_from_community,
+       u.actor_id as creator_actor_id,
+       u.local as creator_local,
+       u.name as creator_name,
+  u.published as creator_published,
+       u.avatar as creator_avatar,
+       -- score details
+       coalesce(cl.total, 0) as score,
+       coalesce(cl.up, 0) as upvotes,
+       coalesce(cl.down, 0) as downvotes,
+       hot_rank(coalesce(cl.total, 0), ct.published) as hot_rank
+from comment ct
+left join post p on ct.post_id = p.id
+left join community c on p.community_id = c.id
+left join user_ u on ct.creator_id = u.id
+left join community_user_ban cb on ct.creator_id = cb.user_id and p.id = ct.post_id and p.community_id = cb.community_id
+left join (
+       select
+               l.comment_id as id,
+               sum(l.score) as total,
+               count(case when l.score = 1 then 1 else null end) as up,
+               count(case when l.score = -1 then 1 else null end) as down
+       from comment_like l
+       group by comment_id
+) as cl on cl.id = ct.id;
+
+create or replace view comment_view as (
+select
+       cav.*,
+  us.user_id as user_id,
+  us.my_vote as my_vote,
+  us.is_subbed::bool as subscribed,
+  us.is_saved::bool as saved
+from comment_aggregates_view cav
+cross join lateral (
+       select
+               u.id as user_id,
+               coalesce(cl.score, 0) as my_vote,
+    coalesce(cf.id, 0) as is_subbed,
+    coalesce(cs.id, 0) as is_saved
+       from user_ u
+       left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
+       left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
+       left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
+) as us
+
+union all
+
+select
+    cav.*,
+    null as user_id,
+    null as my_vote,
+    null as subscribed,
+    null as saved
+from comment_aggregates_view cav
+);
+
+create table comment_aggregates_fast as select * from comment_aggregates_view;
+alter table comment_aggregates_fast add primary key (id);
+
+create view comment_fast_view as
+select
+       cav.*,
+  us.user_id as user_id,
+  us.my_vote as my_vote,
+  us.is_subbed::bool as subscribed,
+  us.is_saved::bool as saved
+from comment_aggregates_fast cav
+cross join lateral (
+       select
+               u.id as user_id,
+               coalesce(cl.score, 0) as my_vote,
+    coalesce(cf.id, 0) as is_subbed,
+    coalesce(cs.id, 0) as is_saved
+       from user_ u
+       left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
+       left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
+       left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
+) as us
+
+union all
+
+select
+    cav.*,
+    null as user_id,
+    null as my_vote,
+    null as subscribed,
+    null as saved
+from comment_aggregates_fast cav;
+
+create view user_mention_view as
+select
+    c.id,
+    um.id as user_mention_id,
+    c.creator_id,
+    c.creator_actor_id,
+    c.creator_local,
+    c.post_id,
+    c.post_name,
+    c.parent_id,
+    c.content,
+    c.removed,
+    um.read,
+    c.published,
+    c.updated,
+    c.deleted,
+    c.community_id,
+    c.community_actor_id,
+    c.community_local,
+    c.community_name,
+    c.banned,
+    c.banned_from_community,
+    c.creator_name,
+    c.creator_avatar,
+    c.score,
+    c.upvotes,
+    c.downvotes,
+    c.hot_rank,
+    c.user_id,
+    c.my_vote,
+    c.saved,
+    um.recipient_id,
+    (select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
+    (select local from user_ u where u.id = um.recipient_id) as recipient_local
+from user_mention um, comment_view c
+where um.comment_id = c.id;
+
+create view user_mention_fast_view as
+select
+    ac.id,
+    um.id as user_mention_id,
+    ac.creator_id,
+    ac.creator_actor_id,
+    ac.creator_local,
+    ac.post_id,
+    ac.post_name,
+    ac.parent_id,
+    ac.content,
+    ac.removed,
+    um.read,
+    ac.published,
+    ac.updated,
+    ac.deleted,
+    ac.community_id,
+    ac.community_actor_id,
+    ac.community_local,
+    ac.community_name,
+    ac.banned,
+    ac.banned_from_community,
+    ac.creator_name,
+    ac.creator_avatar,
+    ac.score,
+    ac.upvotes,
+    ac.downvotes,
+    ac.hot_rank,
+    u.id as user_id,
+    coalesce(cl.score, 0) as my_vote,
+    (select cs.id::bool from comment_saved cs where u.id = cs.user_id and cs.comment_id = ac.id) as saved,
+    um.recipient_id,
+    (select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
+    (select local from user_ u where u.id = um.recipient_id) as recipient_local
+from user_ u
+cross join (
+  select
+  ca.*
+  from comment_aggregates_fast ca
+) ac
+left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
+left join user_mention um on um.comment_id = ac.id
+
+union all
+
+select
+    ac.id,
+    um.id as user_mention_id,
+    ac.creator_id,
+    ac.creator_actor_id,
+    ac.creator_local,
+    ac.post_id,
+    ac.post_name,
+    ac.parent_id,
+    ac.content,
+    ac.removed,
+    um.read,
+    ac.published,
+    ac.updated,
+    ac.deleted,
+    ac.community_id,
+    ac.community_actor_id,
+    ac.community_local,
+    ac.community_name,
+    ac.banned,
+    ac.banned_from_community,
+    ac.creator_name,
+    ac.creator_avatar,
+    ac.score,
+    ac.upvotes,
+    ac.downvotes,
+    ac.hot_rank,
+    null as user_id,
+    null as my_vote,
+    null as saved,
+    um.recipient_id,
+    (select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
+    (select local from user_ u where u.id = um.recipient_id) as recipient_local
+from comment_aggregates_fast ac
+left join user_mention um on um.comment_id = ac.id
+;
+
+-- Do the reply_view referencing the comment_fast_view
+create view reply_fast_view as
+with closereply as (
+    select
+    c2.id,
+    c2.creator_id as sender_id,
+    c.creator_id as recipient_id
+    from comment c
+    inner join comment c2 on c.id = c2.parent_id
+    where c2.creator_id != c.creator_id
+    -- Do union where post is null
+    union
+    select
+    c.id,
+    c.creator_id as sender_id,
+    p.creator_id as recipient_id
+    from comment c, post p
+    where c.post_id = p.id and c.parent_id is null and c.creator_id != p.creator_id
+)
+select cv.*,
+closereply.recipient_id
+from comment_fast_view cv, closereply
+where closereply.id = cv.id
+;
+
+-- redoing the triggers
+create or replace function refresh_post()
+returns trigger language plpgsql
+as $$
+begin
+  IF (TG_OP = 'DELETE') THEN
+    delete from post_aggregates_fast where id = OLD.id;
+
+    -- Update community number of posts
+    update community_aggregates_fast set number_of_posts = number_of_posts - 1 where id = OLD.community_id;
+  ELSIF (TG_OP = 'UPDATE') THEN
+    delete from post_aggregates_fast where id = OLD.id;
+    insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
+  ELSIF (TG_OP = 'INSERT') THEN
+    insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
+
+    -- Update that users number of posts, post score
+    delete from user_fast where id = NEW.creator_id;
+    insert into user_fast select * from user_view where id = NEW.creator_id;
+  
+    -- Update community number of posts
+    update community_aggregates_fast set number_of_posts = number_of_posts + 1 where id = NEW.community_id;
+
+    -- Update the hot rank on the post table
+    -- TODO this might not correctly update it, using a 1 week interval
+    update post_aggregates_fast as paf
+    set hot_rank = pav.hot_rank 
+    from post_aggregates_view as pav
+    where paf.id = pav.id  and (pav.published > ('now'::timestamp - '1 week'::interval));
+  END IF;
+
+  return null;
+end $$;
+
+create or replace function refresh_comment()
+returns trigger language plpgsql
+as $$
+begin
+  IF (TG_OP = 'DELETE') THEN
+    delete from comment_aggregates_fast where id = OLD.id;
+
+    -- Update community number of comments
+    update community_aggregates_fast as caf
+    set number_of_comments = number_of_comments - 1
+    from post as p
+    where caf.id = p.community_id and p.id = OLD.post_id;
+
+  ELSIF (TG_OP = 'UPDATE') THEN
+    delete from comment_aggregates_fast where id = OLD.id;
+    insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
+  ELSIF (TG_OP = 'INSERT') THEN
+    insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
+
+    -- Update user view due to comment count
+    update user_fast 
+    set number_of_comments = number_of_comments + 1
+    where id = NEW.creator_id;
+    
+    -- Update post view due to comment count, new comment activity time, but only on new posts
+    -- TODO this could be done more efficiently
+    delete from post_aggregates_fast where id = NEW.post_id;
+    insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.post_id;
+
+    -- Force the hot rank as zero on week-older posts
+    update post_aggregates_fast as paf
+    set hot_rank = 0
+    where paf.id = NEW.post_id and (paf.published < ('now'::timestamp - '1 week'::interval));
+
+    -- Update community number of comments
+    update community_aggregates_fast as caf
+    set number_of_comments = number_of_comments + 1 
+    from post as p
+    where caf.id = p.community_id and p.id = NEW.post_id;
+
+  END IF;
+
+  return null;
+end $$;
diff --git a/server/migrations/2020-08-03-000110_add_preferred_usernames_banners_and_icons/up.sql b/server/migrations/2020-08-03-000110_add_preferred_usernames_banners_and_icons/up.sql
new file mode 100644 (file)
index 0000000..97f35fb
--- /dev/null
@@ -0,0 +1,748 @@
+-- This adds the following columns, as well as updates the views:
+--  Site icon
+--  Site banner
+--  Community icon
+--  Community Banner
+--  User Banner (User avatar is already there)
+--  User preferred name (already in table, needs to be added to view)
+
+-- It also adds hot_rank_active to post_view
+
+alter table site 
+  add column icon text,
+  add column banner text;
+
+alter table community 
+  add column icon text,
+  add column banner text;
+
+alter table user_ add column banner text;
+
+drop view site_view;
+create view site_view as 
+select s.*,
+u.name as creator_name,
+u.preferred_username as creator_preferred_username, 
+u.avatar as creator_avatar,
+(select count(*) from user_) as number_of_users,
+(select count(*) from post) as number_of_posts,
+(select count(*) from comment) as number_of_comments,
+(select count(*) from community) as number_of_communities
+from site s
+left join user_ u on s.creator_id = u.id;
+
+-- User
+drop table user_fast;
+drop view user_view;
+create view user_view as
+select 
+       u.id,
+  u.actor_id,
+       u.name,
+  u.preferred_username,
+       u.avatar,
+  u.banner,
+       u.email,
+       u.matrix_user_id,
+  u.bio,
+  u.local,
+       u.admin,
+       u.banned,
+       u.show_avatars,
+       u.send_notifications_to_email,
+       u.published,
+       coalesce(pd.posts, 0) as number_of_posts,
+       coalesce(pd.score, 0) as post_score,
+       coalesce(cd.comments, 0) as number_of_comments,
+       coalesce(cd.score, 0) as comment_score
+from user_ u
+left join (
+    select
+        p.creator_id as creator_id,
+        count(distinct p.id) as posts,
+        sum(pl.score) as score
+    from post p
+    join post_like pl on p.id = pl.post_id
+    group by p.creator_id
+) pd on u.id = pd.creator_id
+left join (
+    select
+        c.creator_id,
+        count(distinct c.id) as comments,
+        sum(cl.score) as score
+    from comment c
+    join comment_like cl on c.id = cl.comment_id
+    group by c.creator_id
+) cd on u.id = cd.creator_id;
+
+create table user_fast as select * from user_view;
+alter table user_fast add primary key (id);
+
+-- private message
+drop view private_message_view;
+create view private_message_view as 
+select        
+pm.*,
+u.name as creator_name,
+u.preferred_username as creator_preferred_username,
+u.avatar as creator_avatar,
+u.actor_id as creator_actor_id,
+u.local as creator_local,
+u2.name as recipient_name,
+u2.preferred_username as recipient_preferred_username,
+u2.avatar as recipient_avatar,
+u2.actor_id as recipient_actor_id,
+u2.local as recipient_local
+from private_message pm
+inner join user_ u on u.id = pm.creator_id
+inner join user_ u2 on u2.id = pm.recipient_id;
+
+-- Post fast
+drop view post_fast_view;
+drop table post_aggregates_fast;
+drop view post_view;
+drop view post_aggregates_view;
+
+create view post_aggregates_view as
+select
+       p.*,
+       -- creator details
+       u.actor_id as creator_actor_id,
+       u."local" as creator_local,
+       u."name" as creator_name,
+  u."preferred_username" as creator_preferred_username,
+  u.published as creator_published,
+       u.avatar as creator_avatar,
+  u.banned as banned,
+  cb.id::bool as banned_from_community,
+       -- community details
+       c.actor_id as community_actor_id,
+       c."local" as community_local,
+       c."name" as community_name,
+  c.icon as community_icon,
+       c.removed as community_removed,
+       c.deleted as community_deleted,
+       c.nsfw as community_nsfw,
+       -- post score data/comment count
+       coalesce(ct.comments, 0) as number_of_comments,
+       coalesce(pl.score, 0) as score,
+       coalesce(pl.upvotes, 0) as upvotes,
+       coalesce(pl.downvotes, 0) as downvotes,
+       hot_rank(coalesce(pl.score, 1), p.published) as hot_rank,
+  hot_rank(coalesce(pl.score, 1), greatest(ct.recent_comment_time, p.published)) as hot_rank_active,
+       greatest(ct.recent_comment_time, p.published) as newest_activity_time
+from post p
+left join user_ u on p.creator_id = u.id
+left join community_user_ban cb on p.creator_id = cb.user_id and p.community_id = cb.community_id
+left join community c on p.community_id = c.id
+left join (
+       select
+               post_id,
+               count(*) as comments,
+               max(published) as recent_comment_time
+       from comment
+       group by post_id
+) ct on ct.post_id = p.id
+left join (
+       select
+               post_id,
+               sum(score) as score,
+               sum(score) filter (where score = 1) as upvotes,
+               -sum(score) filter (where score = -1) as downvotes
+       from post_like
+       group by post_id
+) pl on pl.post_id = p.id
+order by p.id;
+
+create view post_view as
+select
+       pav.*,
+       us.id as user_id,
+       us.user_vote as my_vote,
+       us.is_subbed::bool as subscribed,
+       us.is_read::bool as read,
+       us.is_saved::bool as saved
+from post_aggregates_view pav
+cross join lateral (
+       select
+               u.id,
+               coalesce(cf.community_id, 0) as is_subbed,
+               coalesce(pr.post_id, 0) as is_read,
+               coalesce(ps.post_id, 0) as is_saved,
+               coalesce(pl.score, 0) as user_vote
+       from user_ u
+       left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
+       left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
+       left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
+       left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
+       left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
+) as us
+
+union all
+
+select 
+pav.*,
+null as user_id,
+null as my_vote,
+null as subscribed,
+null as read,
+null as saved
+from post_aggregates_view pav;
+
+create table post_aggregates_fast as select * from post_aggregates_view;
+alter table post_aggregates_fast add primary key (id);
+
+-- For the hot rank resorting
+create index idx_post_aggregates_fast_hot_rank_published on post_aggregates_fast (hot_rank desc, published desc);
+create index idx_post_aggregates_fast_hot_rank_active_published on post_aggregates_fast (hot_rank_active desc, published desc);
+
+create view post_fast_view as 
+select
+       pav.*,
+       us.id as user_id,
+       us.user_vote as my_vote,
+       us.is_subbed::bool as subscribed,
+       us.is_read::bool as read,
+       us.is_saved::bool as saved
+from post_aggregates_fast pav
+cross join lateral (
+       select
+               u.id,
+               coalesce(cf.community_id, 0) as is_subbed,
+               coalesce(pr.post_id, 0) as is_read,
+               coalesce(ps.post_id, 0) as is_saved,
+               coalesce(pl.score, 0) as user_vote
+       from user_ u
+       left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
+       left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
+       left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
+       left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
+       left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
+) as us
+
+union all
+
+select 
+pav.*,
+null as user_id,
+null as my_vote,
+null as subscribed,
+null as read,
+null as saved
+from post_aggregates_fast pav;
+
+-- Community
+drop view community_moderator_view;
+drop view community_follower_view;
+drop view community_user_ban_view;
+drop view community_view;
+drop view community_aggregates_view;
+drop view community_fast_view;
+drop table community_aggregates_fast;
+
+create view community_aggregates_view as
+select 
+    c.id,
+    c.name,
+    c.title,
+    c.icon,
+    c.banner,
+    c.description,
+    c.category_id,
+    c.creator_id,
+    c.removed,
+    c.published,
+    c.updated,
+    c.deleted,
+    c.nsfw,
+    c.actor_id,
+    c.local,
+    c.last_refreshed_at,
+    u.actor_id as creator_actor_id,
+    u.local as creator_local,
+    u.name as creator_name,
+    u.preferred_username as creator_preferred_username,
+    u.avatar as creator_avatar,
+    cat.name as category_name,
+    coalesce(cf.subs, 0) as number_of_subscribers,
+    coalesce(cd.posts, 0) as number_of_posts,
+    coalesce(cd.comments, 0) as number_of_comments,
+    hot_rank(cf.subs, c.published) as hot_rank
+from community c
+left join user_ u on c.creator_id = u.id
+left join category cat on c.category_id = cat.id
+left join (
+    select
+        p.community_id,
+        count(distinct p.id) as posts,
+        count(distinct ct.id) as comments
+    from post p
+    join comment ct on p.id = ct.post_id
+    group by p.community_id
+) cd on cd.community_id = c.id
+left join (
+    select
+        community_id,
+        count(*) as subs 
+    from community_follower
+    group by community_id 
+) cf on cf.community_id = c.id;
+
+create view community_view as
+select
+    cv.*,
+    us.user as user_id,
+    us.is_subbed::bool as subscribed
+from community_aggregates_view cv
+cross join lateral (
+       select
+               u.id as user,
+               coalesce(cf.community_id, 0) as is_subbed
+       from user_ u
+       left join community_follower cf on u.id = cf.user_id and cf.community_id = cv.id
+) as us
+
+union all
+
+select 
+    cv.*,
+    null as user_id,
+    null as subscribed
+from community_aggregates_view cv;
+
+create view community_moderator_view as
+select
+    cm.*,
+    u.actor_id as user_actor_id,
+    u.local as user_local,
+    u.name as user_name,
+    u.preferred_username as user_preferred_username,
+    u.avatar as avatar,
+    c.actor_id as community_actor_id,
+    c.local as community_local,
+    c.name as community_name,
+    c.icon as community_icon
+from community_moderator cm
+left join user_ u on cm.user_id = u.id
+left join community c on cm.community_id = c.id;
+
+create view community_follower_view as
+select
+    cf.*,
+    u.actor_id as user_actor_id,
+    u.local as user_local,
+    u.name as user_name,
+    u.preferred_username as user_preferred_username,
+    u.avatar as avatar,
+    c.actor_id as community_actor_id,
+    c.local as community_local,
+    c.name as community_name,
+    c.icon as community_icon
+from community_follower cf
+left join user_ u on cf.user_id = u.id
+left join community c on cf.community_id = c.id;
+
+create view community_user_ban_view as
+select
+    cb.*,
+    u.actor_id as user_actor_id,
+    u.local as user_local,
+    u.name as user_name,
+    u.preferred_username as user_preferred_username,
+    u.avatar as avatar,
+    c.actor_id as community_actor_id,
+    c.local as community_local,
+    c.name as community_name,
+    c.icon as community_icon
+from community_user_ban cb
+left join user_ u on cb.user_id = u.id
+left join community c on cb.community_id = c.id;
+
+-- The community fast table
+
+create table community_aggregates_fast as select * from community_aggregates_view;
+alter table community_aggregates_fast add primary key (id);
+
+create view community_fast_view as
+select
+ac.*,
+u.id as user_id,
+(select cf.id::boolean from community_follower cf where u.id = cf.user_id and ac.id = cf.community_id) as subscribed
+from user_ u
+cross join (
+  select
+  ca.*
+  from community_aggregates_fast ca
+) ac
+
+union all
+
+select 
+caf.*,
+null as user_id,
+null as subscribed
+from community_aggregates_fast caf;
+
+-- Comments, mentions, replies
+drop view user_mention_view;
+drop view reply_fast_view;
+drop view comment_fast_view;
+drop view comment_view;
+drop view user_mention_fast_view;
+drop table comment_aggregates_fast;
+drop view comment_aggregates_view;
+
+create view comment_aggregates_view as
+select
+       ct.*,
+       -- post details
+       p."name" as post_name,
+       p.community_id,
+       -- community details
+       c.actor_id as community_actor_id,
+       c."local" as community_local,
+       c."name" as community_name,
+  c.icon as community_icon,
+       -- creator details
+       u.banned as banned,
+  coalesce(cb.id, 0)::bool as banned_from_community,
+       u.actor_id as creator_actor_id,
+       u.local as creator_local,
+       u.name as creator_name,
+  u.preferred_username as creator_preferred_username,
+  u.published as creator_published,
+       u.avatar as creator_avatar,
+       -- score details
+       coalesce(cl.total, 0) as score,
+       coalesce(cl.up, 0) as upvotes,
+       coalesce(cl.down, 0) as downvotes,
+       hot_rank(coalesce(cl.total, 1), p.published) as hot_rank,
+       hot_rank(coalesce(cl.total, 1), ct.published) as hot_rank_active
+from comment ct
+left join post p on ct.post_id = p.id
+left join community c on p.community_id = c.id
+left join user_ u on ct.creator_id = u.id
+left join community_user_ban cb on ct.creator_id = cb.user_id and p.id = ct.post_id and p.community_id = cb.community_id
+left join (
+       select
+               l.comment_id as id,
+               sum(l.score) as total,
+               count(case when l.score = 1 then 1 else null end) as up,
+               count(case when l.score = -1 then 1 else null end) as down
+       from comment_like l
+       group by comment_id
+) as cl on cl.id = ct.id;
+
+create or replace view comment_view as (
+select
+       cav.*,
+  us.user_id as user_id,
+  us.my_vote as my_vote,
+  us.is_subbed::bool as subscribed,
+  us.is_saved::bool as saved
+from comment_aggregates_view cav
+cross join lateral (
+       select
+               u.id as user_id,
+               coalesce(cl.score, 0) as my_vote,
+    coalesce(cf.id, 0) as is_subbed,
+    coalesce(cs.id, 0) as is_saved
+       from user_ u
+       left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
+       left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
+       left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
+) as us
+
+union all
+
+select
+    cav.*,
+    null as user_id,
+    null as my_vote,
+    null as subscribed,
+    null as saved
+from comment_aggregates_view cav
+);
+
+create table comment_aggregates_fast as select * from comment_aggregates_view;
+alter table comment_aggregates_fast add primary key (id);
+
+create view comment_fast_view as
+select
+       cav.*,
+  us.user_id as user_id,
+  us.my_vote as my_vote,
+  us.is_subbed::bool as subscribed,
+  us.is_saved::bool as saved
+from comment_aggregates_fast cav
+cross join lateral (
+       select
+               u.id as user_id,
+               coalesce(cl.score, 0) as my_vote,
+    coalesce(cf.id, 0) as is_subbed,
+    coalesce(cs.id, 0) as is_saved
+       from user_ u
+       left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
+       left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
+       left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
+) as us
+
+union all
+
+select
+    cav.*,
+    null as user_id,
+    null as my_vote,
+    null as subscribed,
+    null as saved
+from comment_aggregates_fast cav;
+
+create view user_mention_view as
+select
+    c.id,
+    um.id as user_mention_id,
+    c.creator_id,
+    c.creator_actor_id,
+    c.creator_local,
+    c.post_id,
+    c.post_name,
+    c.parent_id,
+    c.content,
+    c.removed,
+    um.read,
+    c.published,
+    c.updated,
+    c.deleted,
+    c.community_id,
+    c.community_actor_id,
+    c.community_local,
+    c.community_name,
+    c.community_icon,
+    c.banned,
+    c.banned_from_community,
+    c.creator_name,
+    c.creator_preferred_username,
+    c.creator_avatar,
+    c.score,
+    c.upvotes,
+    c.downvotes,
+    c.hot_rank,
+    c.hot_rank_active,
+    c.user_id,
+    c.my_vote,
+    c.saved,
+    um.recipient_id,
+    (select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
+    (select local from user_ u where u.id = um.recipient_id) as recipient_local
+from user_mention um, comment_view c
+where um.comment_id = c.id;
+
+create view user_mention_fast_view as
+select
+    ac.id,
+    um.id as user_mention_id,
+    ac.creator_id,
+    ac.creator_actor_id,
+    ac.creator_local,
+    ac.post_id,
+    ac.post_name,
+    ac.parent_id,
+    ac.content,
+    ac.removed,
+    um.read,
+    ac.published,
+    ac.updated,
+    ac.deleted,
+    ac.community_id,
+    ac.community_actor_id,
+    ac.community_local,
+    ac.community_name,
+    ac.community_icon,
+    ac.banned,
+    ac.banned_from_community,
+    ac.creator_name,
+    ac.creator_preferred_username,
+    ac.creator_avatar,
+    ac.score,
+    ac.upvotes,
+    ac.downvotes,
+    ac.hot_rank,
+    ac.hot_rank_active,
+    u.id as user_id,
+    coalesce(cl.score, 0) as my_vote,
+    (select cs.id::bool from comment_saved cs where u.id = cs.user_id and cs.comment_id = ac.id) as saved,
+    um.recipient_id,
+    (select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
+    (select local from user_ u where u.id = um.recipient_id) as recipient_local
+from user_ u
+cross join (
+  select
+  ca.*
+  from comment_aggregates_fast ca
+) ac
+left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
+left join user_mention um on um.comment_id = ac.id
+
+union all
+
+select
+    ac.id,
+    um.id as user_mention_id,
+    ac.creator_id,
+    ac.creator_actor_id,
+    ac.creator_local,
+    ac.post_id,
+    ac.post_name,
+    ac.parent_id,
+    ac.content,
+    ac.removed,
+    um.read,
+    ac.published,
+    ac.updated,
+    ac.deleted,
+    ac.community_id,
+    ac.community_actor_id,
+    ac.community_local,
+    ac.community_name,
+    ac.community_icon,
+    ac.banned,
+    ac.banned_from_community,
+    ac.creator_name,
+    ac.creator_preferred_username,
+    ac.creator_avatar,
+    ac.score,
+    ac.upvotes,
+    ac.downvotes,
+    ac.hot_rank,
+    ac.hot_rank_active,
+    null as user_id,
+    null as my_vote,
+    null as saved,
+    um.recipient_id,
+    (select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
+    (select local from user_ u where u.id = um.recipient_id) as recipient_local
+from comment_aggregates_fast ac
+left join user_mention um on um.comment_id = ac.id
+;
+
+-- Do the reply_view referencing the comment_fast_view
+create view reply_fast_view as
+with closereply as (
+    select
+    c2.id,
+    c2.creator_id as sender_id,
+    c.creator_id as recipient_id
+    from comment c
+    inner join comment c2 on c.id = c2.parent_id
+    where c2.creator_id != c.creator_id
+    -- Do union where post is null
+    union
+    select
+    c.id,
+    c.creator_id as sender_id,
+    p.creator_id as recipient_id
+    from comment c, post p
+    where c.post_id = p.id and c.parent_id is null and c.creator_id != p.creator_id
+)
+select cv.*,
+closereply.recipient_id
+from comment_fast_view cv, closereply
+where closereply.id = cv.id
+;
+
+-- Adding hot rank active to the triggers
+create or replace function refresh_post()
+returns trigger language plpgsql
+as $$
+begin
+  IF (TG_OP = 'DELETE') THEN
+    delete from post_aggregates_fast where id = OLD.id;
+
+    -- Update community number of posts
+    update community_aggregates_fast set number_of_posts = number_of_posts - 1 where id = OLD.community_id;
+  ELSIF (TG_OP = 'UPDATE') THEN
+    delete from post_aggregates_fast where id = OLD.id;
+    insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
+  ELSIF (TG_OP = 'INSERT') THEN
+    insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
+
+    -- Update that users number of posts, post score
+    delete from user_fast where id = NEW.creator_id;
+    insert into user_fast select * from user_view where id = NEW.creator_id;
+  
+    -- Update community number of posts
+    update community_aggregates_fast set number_of_posts = number_of_posts + 1 where id = NEW.community_id;
+
+    -- Update the hot rank on the post table
+    -- TODO this might not correctly update it, using a 1 week interval
+    update post_aggregates_fast as paf
+    set 
+      hot_rank = pav.hot_rank,
+      hot_rank_active = pav.hot_rank_active
+    from post_aggregates_view as pav
+    where paf.id = pav.id  and (pav.published > ('now'::timestamp - '1 week'::interval));
+  END IF;
+
+  return null;
+end $$;
+
+create or replace function refresh_comment()
+returns trigger language plpgsql
+as $$
+begin
+  IF (TG_OP = 'DELETE') THEN
+    delete from comment_aggregates_fast where id = OLD.id;
+
+    -- Update community number of comments
+    update community_aggregates_fast as caf
+    set number_of_comments = number_of_comments - 1
+    from post as p
+    where caf.id = p.community_id and p.id = OLD.post_id;
+
+  ELSIF (TG_OP = 'UPDATE') THEN
+    delete from comment_aggregates_fast where id = OLD.id;
+    insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
+  ELSIF (TG_OP = 'INSERT') THEN
+    insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
+
+    -- Update user view due to comment count
+    update user_fast 
+    set number_of_comments = number_of_comments + 1
+    where id = NEW.creator_id;
+    
+    -- Update post view due to comment count, new comment activity time, but only on new posts
+    -- TODO this could be done more efficiently
+    delete from post_aggregates_fast where id = NEW.post_id;
+    insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.post_id;
+
+    -- Update the comment hot_ranks as of last week
+    update comment_aggregates_fast as caf
+    set 
+      hot_rank = cav.hot_rank,
+      hot_rank_active = cav.hot_rank_active
+    from comment_aggregates_view as cav
+    where caf.id = cav.id and (cav.published > ('now'::timestamp - '1 week'::interval));
+
+    -- Update the post ranks
+    update post_aggregates_fast as paf
+    set 
+      hot_rank = pav.hot_rank,
+      hot_rank_active = pav.hot_rank_active
+    from post_aggregates_view as pav
+    where paf.id = pav.id  and (pav.published > ('now'::timestamp - '1 week'::interval));
+
+    -- Force the hot rank active as zero on 2 day-older posts (necro-bump)
+    update post_aggregates_fast as paf
+    set hot_rank_active = 0
+    where paf.id = NEW.post_id and (paf.published < ('now'::timestamp - '2 days'::interval));
+
+    -- Update community number of comments
+    update community_aggregates_fast as caf
+    set number_of_comments = number_of_comments + 1 
+    from post as p
+    where caf.id = p.community_id and p.id = NEW.post_id;
+
+  END IF;
+
+  return null;
+end $$;
index 904dfe53cb28f2f5a644423636ce3233b6651140..ce2851b1d6544d6ca83b6bc9ef0c0132ba39feb1 100644 (file)
@@ -10,7 +10,15 @@ use crate::{
   },
   DbPool,
 };
-use lemmy_db::{naive_now, Bannable, Crud, Followable, Joinable, SortType};
+use lemmy_db::{
+  diesel_option_overwrite,
+  naive_now,
+  Bannable,
+  Crud,
+  Followable,
+  Joinable,
+  SortType,
+};
 use lemmy_utils::{
   generate_actor_keypair,
   is_valid_community_name,
@@ -40,6 +48,8 @@ pub struct CreateCommunity {
   name: String,
   title: String,
   description: Option<String>,
+  icon: Option<String>,
+  banner: Option<String>,
   category_id: i32,
   nsfw: bool,
   auth: String,
@@ -97,6 +107,8 @@ pub struct EditCommunity {
   pub edit_id: i32,
   title: String,
   description: Option<String>,
+  icon: Option<String>,
+  banner: Option<String>,
   category_id: i32,
   nsfw: bool,
   auth: String,
@@ -251,6 +263,8 @@ impl Perform for Oper<CreateCommunity> {
       name: data.name.to_owned(),
       title: data.title.to_owned(),
       description: data.description.to_owned(),
+      icon: Some(data.icon.to_owned()),
+      banner: Some(data.banner.to_owned()),
       category_id: data.category_id,
       creator_id: user.id,
       removed: None,
@@ -332,10 +346,15 @@ impl Perform for Oper<EditCommunity> {
     let edit_id = data.edit_id;
     let read_community = blocking(pool, move |conn| Community::read(conn, edit_id)).await??;
 
+    let icon = diesel_option_overwrite(&data.icon);
+    let banner = diesel_option_overwrite(&data.banner);
+
     let community_form = CommunityForm {
       name: read_community.name,
       title: data.title.to_owned(),
       description: data.description.to_owned(),
+      icon,
+      banner,
       category_id: data.category_id.to_owned(),
       creator_id: read_community.creator_id,
       removed: Some(read_community.removed),
index 82cad9610047d904991f0b8b8571f0b63404dbca..dcbd621677bdb44cbd14d5160cd1cfbda3f08c04 100644 (file)
@@ -21,6 +21,7 @@ use lemmy_db::{
   category::*,
   comment_view::*,
   community_view::*,
+  diesel_option_overwrite,
   moderator::*,
   moderator_views::*,
   naive_now,
@@ -91,6 +92,8 @@ pub struct GetModlogResponse {
 pub struct CreateSite {
   pub name: String,
   pub description: Option<String>,
+  pub icon: Option<String>,
+  pub banner: Option<String>,
   pub enable_downvotes: bool,
   pub open_registration: bool,
   pub enable_nsfw: bool,
@@ -101,6 +104,8 @@ pub struct CreateSite {
 pub struct EditSite {
   name: String,
   description: Option<String>,
+  icon: Option<String>,
+  banner: Option<String>,
   enable_downvotes: bool,
   open_registration: bool,
   enable_nsfw: bool,
@@ -263,6 +268,8 @@ impl Perform for Oper<CreateSite> {
     let site_form = SiteForm {
       name: data.name.to_owned(),
       description: data.description.to_owned(),
+      icon: Some(data.icon.to_owned()),
+      banner: Some(data.banner.to_owned()),
       creator_id: user.id,
       enable_downvotes: data.enable_downvotes,
       open_registration: data.open_registration,
@@ -300,9 +307,14 @@ impl Perform for Oper<EditSite> {
 
     let found_site = blocking(pool, move |conn| Site::read(conn, 1)).await??;
 
+    let icon = diesel_option_overwrite(&data.icon);
+    let banner = diesel_option_overwrite(&data.banner);
+
     let site_form = SiteForm {
       name: data.name.to_owned(),
       description: data.description.to_owned(),
+      icon,
+      banner,
       creator_id: found_site.creator_id,
       updated: Some(naive_now()),
       enable_downvotes: data.enable_downvotes,
@@ -365,6 +377,8 @@ impl Perform for Oper<GetSite> {
       let create_site = CreateSite {
         name: setup.site_name.to_owned(),
         description: None,
+        icon: None,
+        banner: None,
         enable_downvotes: true,
         open_registration: true,
         enable_nsfw: true,
@@ -611,18 +625,9 @@ impl Perform for Oper<TransferSite> {
       return Err(APIError::err("not_an_admin").into());
     }
 
-    let site_form = SiteForm {
-      name: read_site.name,
-      description: read_site.description,
-      creator_id: data.user_id,
-      updated: Some(naive_now()),
-      enable_downvotes: read_site.enable_downvotes,
-      open_registration: read_site.open_registration,
-      enable_nsfw: read_site.enable_nsfw,
-    };
-
-    let update_site = move |conn: &'_ _| Site::update(conn, 1, &site_form);
-    if blocking(pool, update_site).await?.is_err() {
+    let new_creator_id = data.user_id;
+    let transfer_site = move |conn: &'_ _| Site::transfer(conn, new_creator_id);
+    if blocking(pool, transfer_site).await?.is_err() {
       return Err(APIError::err("couldnt_update_site").into());
     };
 
index d6746c1a879ebee15ea3ad8b4461fc79d0b005a6..ffdcee9a2002e0c3d6fd9094701434c37c5428b6 100644 (file)
@@ -28,6 +28,7 @@ use lemmy_db::{
   comment_view::*,
   community::*,
   community_view::*,
+  diesel_option_overwrite,
   moderator::*,
   naive_now,
   password_reset_request::*,
@@ -103,6 +104,8 @@ pub struct SaveUserSettings {
   default_listing_type: i16,
   lang: String,
   avatar: Option<String>,
+  banner: Option<String>,
+  preferred_username: Option<String>,
   email: Option<String>,
   bio: Option<String>,
   matrix_user_id: Option<String>,
@@ -395,6 +398,7 @@ impl Perform for Oper<Register> {
       email: data.email.to_owned(),
       matrix_user_id: None,
       avatar: None,
+      banner: None,
       password_encrypted: data.password.to_owned(),
       preferred_username: None,
       updated: None,
@@ -402,7 +406,7 @@ impl Perform for Oper<Register> {
       banned: false,
       show_nsfw: data.show_nsfw,
       theme: "darkly".into(),
-      default_sort_type: SortType::Hot as i16,
+      default_sort_type: SortType::Active as i16,
       default_listing_type: ListingType::Subscribed as i16,
       lang: "browser".into(),
       show_avatars: true,
@@ -454,6 +458,8 @@ impl Perform for Oper<Register> {
           public_key: Some(main_community_keypair.public_key),
           last_refreshed_at: None,
           published: None,
+          icon: None,
+          banner: None,
         };
         blocking(pool, move |conn| Community::create(conn, &community_form)).await??
       }
@@ -569,9 +575,13 @@ impl Perform for Oper<SaveUserSettings> {
       None => read_user.bio,
     };
 
-    let avatar = match &data.avatar {
-      Some(avatar) => Some(avatar.to_owned()),
-      None => read_user.avatar,
+    let avatar = diesel_option_overwrite(&data.avatar);
+    let banner = diesel_option_overwrite(&data.banner);
+
+    // The DB constraint should stop too many characters
+    let preferred_username = match &data.preferred_username {
+      Some(preferred_username) => Some(preferred_username.to_owned()),
+      None => read_user.preferred_username,
     };
 
     let password_encrypted = match &data.new_password {
@@ -612,8 +622,9 @@ impl Perform for Oper<SaveUserSettings> {
       email,
       matrix_user_id: data.matrix_user_id.to_owned(),
       avatar,
+      banner,
       password_encrypted,
-      preferred_username: read_user.preferred_username,
+      preferred_username,
       updated: Some(naive_now()),
       admin: read_user.admin,
       banned: read_user.banned,
index 96f0f84c3a4100f388b47fa7ee58bca9633b2052..549d9349f6bf67881a20eca94ae143ecf915e3f2 100644 (file)
@@ -32,7 +32,7 @@ use activitystreams::{
   base::{AnyBase, BaseExt},
   collection::{OrderedCollection, UnorderedCollection},
   context,
-  object::Tombstone,
+  object::{Image, Tombstone},
   prelude::*,
   public,
 };
@@ -335,6 +335,32 @@ impl FromApub for CommunityForm {
 
     let creator = get_or_fetch_and_upsert_user(creator_uri, client, pool).await?;
 
+    let icon = match group.icon() {
+      Some(any_image) => Some(
+        Image::from_any_base(any_image.as_one().unwrap().clone())
+          .unwrap()
+          .unwrap()
+          .url()
+          .unwrap()
+          .as_single_xsd_any_uri()
+          .map(|u| u.to_string()),
+      ),
+      None => None,
+    };
+
+    let banner = match group.image() {
+      Some(any_image) => Some(
+        Image::from_any_base(any_image.as_one().unwrap().clone())
+          .unwrap()
+          .unwrap()
+          .url()
+          .unwrap()
+          .as_single_xsd_any_uri()
+          .map(|u| u.to_string()),
+      ),
+      None => None,
+    };
+
     Ok(CommunityForm {
       name: group
         .inner
@@ -364,6 +390,8 @@ impl FromApub for CommunityForm {
       private_key: None,
       public_key: Some(group.ext_two.to_owned().public_key.public_key_pem),
       last_refreshed_at: Some(naive_now()),
+      icon,
+      banner,
     })
   }
 }
index 4fb56d321eadcf1414a9109b5723d9c2c6055a27..627d15f6a792c4d1c3be53b3cb082c025d204c57 100644 (file)
@@ -193,6 +193,8 @@ async fn receive_delete_community(
     private_key: community.private_key,
     public_key: community.public_key,
     last_refreshed_at: None,
+    icon: Some(community.icon.to_owned()),
+    banner: Some(community.banner.to_owned()),
   };
 
   let community_id = community.id;
index 485d28616bad352c961285b2962d6288261c76ed..91fca995d002f246d909d49bb903f7212e55ce42 100644 (file)
@@ -193,6 +193,8 @@ async fn receive_remove_community(
     private_key: community.private_key,
     public_key: community.public_key,
     last_refreshed_at: None,
+    icon: Some(community.icon.to_owned()),
+    banner: Some(community.banner.to_owned()),
   };
 
   let community_id = community.id;
index edfcf372ade4d51bfb6782c303c15053a4302db9..87c78a03e3196c8a081e1eb9e1dfdad9ef684510 100644 (file)
@@ -374,6 +374,8 @@ async fn receive_undo_delete_community(
     private_key: community.private_key,
     public_key: community.public_key,
     last_refreshed_at: None,
+    icon: Some(community.icon.to_owned()),
+    banner: Some(community.banner.to_owned()),
   };
 
   let community_id = community.id;
@@ -438,6 +440,8 @@ async fn receive_undo_remove_community(
     private_key: community.private_key,
     public_key: community.public_key,
     last_refreshed_at: None,
+    icon: Some(community.icon.to_owned()),
+    banner: Some(community.banner.to_owned()),
   };
 
   let community_id = community.id;
index 2922006d56b9c0288474c2abea9824f859066504..c20335fec8e0e03421e44672c90e9aaae3442f8b 100644 (file)
@@ -63,6 +63,12 @@ impl ToApub for User_ {
       person.set_icon(image.into_any_base()?);
     }
 
+    if let Some(banner_url) = &self.banner {
+      let mut image = Image::new();
+      image.set_url(banner_url.to_owned());
+      person.set_image(image.into_any_base()?);
+    }
+
     if let Some(bio) = &self.bio {
       person.set_summary(bio.to_owned());
     }
@@ -207,13 +213,28 @@ impl FromApub for UserForm {
   /// Parse an ActivityPub person received from another instance into a Lemmy user.
   async fn from_apub(person: &PersonExt, _: &Client, _: &DbPool) -> Result<Self, LemmyError> {
     let avatar = match person.icon() {
-      Some(any_image) => Image::from_any_base(any_image.as_one().unwrap().clone())
-        .unwrap()
-        .unwrap()
-        .url()
-        .unwrap()
-        .as_single_xsd_any_uri()
-        .map(|u| u.to_string()),
+      Some(any_image) => Some(
+        Image::from_any_base(any_image.as_one().unwrap().clone())
+          .unwrap()
+          .unwrap()
+          .url()
+          .unwrap()
+          .as_single_xsd_any_uri()
+          .map(|u| u.to_string()),
+      ),
+      None => None,
+    };
+
+    let banner = match person.image() {
+      Some(any_image) => Some(
+        Image::from_any_base(any_image.as_one().unwrap().clone())
+          .unwrap()
+          .unwrap()
+          .url()
+          .unwrap()
+          .as_single_xsd_any_uri()
+          .map(|u| u.to_string()),
+      ),
       None => None,
     };
 
@@ -232,6 +253,7 @@ impl FromApub for UserForm {
       banned: false,
       email: None,
       avatar,
+      banner,
       updated: person.updated().map(|u| u.to_owned().naive_local()),
       show_nsfw: false,
       theme: "".to_string(),
index a102faf07ea364ae3e6e57ab6346721596219982..2e70040c036db7b9548b5ac66513fbf0be62d231 100644 (file)
@@ -53,7 +53,8 @@ fn user_updates_2020_04_02(conn: &PgConnection) -> Result<(), LemmyError> {
       name: cuser.name.to_owned(),
       email: cuser.email.to_owned(),
       matrix_user_id: cuser.matrix_user_id.to_owned(),
-      avatar: cuser.avatar.to_owned(),
+      avatar: Some(cuser.avatar.to_owned()),
+      banner: Some(cuser.banner.to_owned()),
       password_encrypted: cuser.password_encrypted.to_owned(),
       preferred_username: cuser.preferred_username.to_owned(),
       updated: None,
@@ -116,6 +117,8 @@ fn community_updates_2020_04_02(conn: &PgConnection) -> Result<(), LemmyError> {
       public_key: Some(keypair.public_key),
       last_refreshed_at: Some(naive_now()),
       published: None,
+      icon: Some(ccommunity.icon.to_owned()),
+      banner: Some(ccommunity.banner.to_owned()),
     };
 
     Community::update(&conn, ccommunity.id, &form)?;
index d7e730d373bfb6f372dd7a311001d9ca2867b300..2fed68a39e8eab9c99c8e91676e6ea2a5eb3929a 100644 (file)
@@ -282,3 +282,19 @@ br.big {
   margin-top: 1rem;
 }
 
+.banner {
+  object-fit: cover;
+  width: 100%;
+  max-height: 240px;
+}
+
+.avatar-overlay {
+  width: 20%; 
+  height: 20%;
+  max-width: 120px;
+  max-height: 120px;
+}
+
+.avatar-pushup {
+  margin-top: -60px;
+}
index 3e66837888f16f5d02fd771c27027a38260209f6..31530ef7ec8fcb783bf0f6740e1dbe62ae750e7e 100644 (file)
@@ -668,7 +668,7 @@ export async function saveUserSettingsBio(
   let form: UserSettingsForm = {
     show_nsfw: true,
     theme: 'darkly',
-    default_sort_type: SortType.Hot,
+    default_sort_type: SortType.Active,
     default_listing_type: ListingType.All,
     lang: 'en',
     show_avatars: true,
index 825c11bd55a27cf39b20d7d8d78ad32ea7cd15f2..8157d4a3a0beb71f717a5936b877d59974c04fbb 100644 (file)
@@ -125,6 +125,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
               <UserListing
                 user={{
                   name: admin.name,
+                  preferred_username: admin.preferred_username,
                   avatar: admin.avatar,
                   id: admin.id,
                   local: admin.local,
@@ -148,6 +149,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
               <UserListing
                 user={{
                   name: banned.name,
+                  preferred_username: banned.preferred_username,
                   avatar: banned.avatar,
                   id: banned.id,
                   local: banned.local,
diff --git a/ui/src/components/banner-icon-header.tsx b/ui/src/components/banner-icon-header.tsx
new file mode 100644 (file)
index 0000000..8c0eedb
--- /dev/null
@@ -0,0 +1,30 @@
+import { Component } from 'inferno';
+
+interface BannerIconHeaderProps {
+  banner?: string;
+  icon?: string;
+}
+
+export class BannerIconHeader extends Component<BannerIconHeaderProps, any> {
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  render() {
+    return (
+      <div class="position-relative mb-2">
+        {this.props.banner && (
+          <img src={this.props.banner} class="banner img-fluid" />
+        )}
+        {this.props.icon && (
+          <img
+            src={this.props.icon}
+            className={`ml-2 mb-0 ${
+              this.props.banner ? 'avatar-pushup' : ''
+            } rounded-circle avatar-overlay`}
+          />
+        )}
+      </div>
+    );
+  }
+}
index 51b051617f61d36ec98ab9c69a684dcbae90088a..b9db7bb65eac725f958391e0aecc4fb7cd0f5e26 100644 (file)
@@ -158,6 +158,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                 <UserListing
                   user={{
                     name: node.comment.creator_name,
+                    preferred_username: node.comment.creator_preferred_username,
                     avatar: node.comment.creator_avatar,
                     id: node.comment.creator_id,
                     local: node.comment.creator_local,
@@ -196,6 +197,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                       id: node.comment.community_id,
                       local: node.comment.community_local,
                       actor_id: node.comment.community_actor_id,
+                      icon: node.comment.community_icon,
                     }}
                   />
                   <span class="mx-2">•</span>
index b20b42858c95a3f89bc99b5719e039ef900f15de..038e4517fdae54bb913c8423a6f57cc36442915a 100644 (file)
@@ -101,7 +101,6 @@ export class Communities extends Component<any, CommunitiesState> {
                 <thead class="pointer">
                   <tr>
                     <th>{i18n.t('name')}</th>
-                    <th class="d-none d-lg-table-cell">{i18n.t('title')}</th>
                     <th>{i18n.t('category')}</th>
                     <th class="text-right">{i18n.t('subscribers')}</th>
                     <th class="text-right d-none d-lg-table-cell">
@@ -119,7 +118,6 @@ export class Communities extends Component<any, CommunitiesState> {
                       <td>
                         <CommunityLink community={community} />
                       </td>
-                      <td class="d-none d-lg-table-cell">{community.title}</td>
                       <td>{community.category_name}</td>
                       <td class="text-right">
                         {community.number_of_subscribers}
index 4c6522db164c7a602cf7e76c8913b3fc9039ec4e..1ae96ac8b1e2687ce483ca2d68b3717c189bfcb9 100644 (file)
@@ -16,6 +16,7 @@ import { i18n } from '../i18next';
 
 import { Community } from '../interfaces';
 import { MarkdownTextArea } from './markdown-textarea';
+import { ImageUploadForm } from './image-upload-form';
 
 interface CommunityFormProps {
   community?: Community; // If a community is given, that means this is an edit
@@ -44,6 +45,8 @@ export class CommunityForm extends Component<
       title: null,
       category_id: null,
       nsfw: false,
+      icon: null,
+      banner: null,
     },
     categories: [],
     loading: false,
@@ -58,6 +61,12 @@ export class CommunityForm extends Component<
       this
     );
 
+    this.handleIconUpload = this.handleIconUpload.bind(this);
+    this.handleIconRemove = this.handleIconRemove.bind(this);
+
+    this.handleBannerUpload = this.handleBannerUpload.bind(this);
+    this.handleBannerRemove = this.handleBannerRemove.bind(this);
+
     if (this.props.community) {
       this.state.communityForm = {
         name: this.props.community.name,
@@ -66,6 +75,8 @@ export class CommunityForm extends Component<
         description: this.props.community.description,
         edit_id: this.props.community.id,
         nsfw: this.props.community.nsfw,
+        icon: this.props.community.icon,
+        banner: this.props.community.banner,
         auth: null,
       };
     }
@@ -166,6 +177,25 @@ export class CommunityForm extends Component<
               />
             </div>
           </div>
+          <div class="form-group">
+            <label>{i18n.t('icon')}</label>
+            <ImageUploadForm
+              uploadTitle={i18n.t('upload_icon')}
+              imageSrc={this.state.communityForm.icon}
+              onUpload={this.handleIconUpload}
+              onRemove={this.handleIconRemove}
+              rounded
+            />
+          </div>
+          <div class="form-group">
+            <label>{i18n.t('banner')}</label>
+            <ImageUploadForm
+              uploadTitle={i18n.t('upload_banner')}
+              imageSrc={this.state.communityForm.banner}
+              onUpload={this.handleBannerUpload}
+              onRemove={this.handleBannerRemove}
+            />
+          </div>
           <div class="form-group row">
             <label class="col-12 col-form-label" htmlFor={this.id}>
               {i18n.t('sidebar')}
@@ -286,6 +316,26 @@ export class CommunityForm extends Component<
     i.props.onCancel();
   }
 
+  handleIconUpload(url: string) {
+    this.state.communityForm.icon = url;
+    this.setState(this.state);
+  }
+
+  handleIconRemove() {
+    this.state.communityForm.icon = '';
+    this.setState(this.state);
+  }
+
+  handleBannerUpload(url: string) {
+    this.state.communityForm.banner = url;
+    this.setState(this.state);
+  }
+
+  handleBannerRemove() {
+    this.state.communityForm.banner = '';
+    this.setState(this.state);
+  }
+
   parseMessage(msg: WebSocketJsonResponse) {
     let res = wsJsonToRes(msg);
     console.log(msg);
@@ -305,9 +355,7 @@ export class CommunityForm extends Component<
       let data = res.data as CommunityResponse;
       this.state.loading = false;
       this.props.onCreate(data.community);
-    }
-    // TODO is this necessary
-    else if (res.op == UserOperation.EditCommunity) {
+    } else if (res.op == UserOperation.EditCommunity) {
       let data = res.data as CommunityResponse;
       this.state.loading = false;
       this.props.onEdit(data.community);
index eb55400e159c422b49c481a46a639a3fd558781c..293ded0464b097162d4d9bc8c773b8dd8b544f19 100644 (file)
@@ -1,11 +1,12 @@
 import { Component } from 'inferno';
 import { Link } from 'inferno-router';
 import { Community } from '../interfaces';
-import { hostname } from '../utils';
+import { hostname, pictrsAvatarThumbnail, showAvatars } from '../utils';
 
 interface CommunityOther {
   name: string;
   id?: number; // Necessary if its federated
+  icon?: string;
   local?: boolean;
   actor_id?: string;
 }
@@ -13,6 +14,9 @@ interface CommunityOther {
 interface CommunityLinkProps {
   community: Community | CommunityOther;
   realLink?: boolean;
+  useApubName?: boolean;
+  muted?: boolean;
+  hideAvatar?: boolean;
 }
 
 export class CommunityLink extends Component<CommunityLinkProps, any> {
@@ -33,6 +37,24 @@ export class CommunityLink extends Component<CommunityLinkProps, any> {
         ? `/community/${community.id}`
         : community.actor_id;
     }
-    return <Link to={link}>{name_}</Link>;
+
+    let apubName = `!${name_}`;
+    let displayName = this.props.useApubName ? apubName : name_;
+    return (
+      <Link
+        title={apubName}
+        className={`${this.props.muted ? 'text-muted' : ''}`}
+        to={link}
+      >
+        {!this.props.hideAvatar && community.icon && showAvatars() && (
+          <img
+            style="width: 2rem; height: 2rem;"
+            src={pictrsAvatarThumbnail(community.icon)}
+            class="rounded-circle mr-2"
+          />
+        )}
+        <span>{displayName}</span>
+      </Link>
+    );
   }
 }
index 579b4c196c6e372803de0c5b6e3023035a327e30..437d2cbd14afdc35cc25c1d9bcda51ff16ebb268 100644 (file)
@@ -33,6 +33,8 @@ import { CommentNodes } from './comment-nodes';
 import { SortSelect } from './sort-select';
 import { DataTypeSelect } from './data-type-select';
 import { Sidebar } from './sidebar';
+import { CommunityLink } from './community-link';
+import { BannerIconHeader } from './banner-icon-header';
 import {
   wsJsonToRes,
   fetchLimit,
@@ -47,6 +49,7 @@ import {
   editPostFindRes,
   commentsToFlatNodes,
   setupTippy,
+  favIconUrl,
 } from '../utils';
 import { i18n } from '../i18next';
 
@@ -126,6 +129,9 @@ export class Community extends Component<any, State> {
       enable_downvotes: undefined,
       open_registration: undefined,
       enable_nsfw: undefined,
+      icon: undefined,
+      banner: undefined,
+      creator_preferred_username: undefined,
     },
   };
 
@@ -183,10 +189,25 @@ export class Community extends Component<any, State> {
     }
   }
 
+  get favIcon(): string {
+    return this.state.community.icon
+      ? this.state.community.icon
+      : this.state.site.icon
+      ? this.state.site.icon
+      : favIconUrl;
+  }
+
   render() {
     return (
       <div class="container">
-        <Helmet title={this.documentTitle} />
+        <Helmet title={this.documentTitle}>
+          <link
+            id="favicon"
+            rel="icon"
+            type="image/x-icon"
+            href={this.favIcon}
+          />
+        </Helmet>
         {this.state.loading ? (
           <h5>
             <svg class="icon icon-spinner spin">
@@ -196,6 +217,7 @@ export class Community extends Component<any, State> {
         ) : (
           <div class="row">
             <div class="col-12 col-md-8">
+              {this.communityInfo()}
               {this.selects()}
               {this.listings()}
               {this.paginator()}
@@ -235,6 +257,26 @@ export class Community extends Component<any, State> {
     );
   }
 
+  communityInfo() {
+    return (
+      <div>
+        <BannerIconHeader
+          banner={this.state.community.banner}
+          icon={this.state.community.icon}
+        />
+        <h5 class="mb-0">{this.state.community.title}</h5>
+        <CommunityLink
+          community={this.state.community}
+          realLink
+          useApubName
+          muted
+          hideAvatar
+        />
+        <hr />
+      </div>
+    );
+  }
+
   selects() {
     return (
       <div class="mb-3">
diff --git a/ui/src/components/image-upload-form.tsx b/ui/src/components/image-upload-form.tsx
new file mode 100644 (file)
index 0000000..98206f1
--- /dev/null
@@ -0,0 +1,114 @@
+import { Component, linkEvent } from 'inferno';
+import { UserService } from '../services';
+import { toast, randomStr } from '../utils';
+
+interface ImageUploadFormProps {
+  uploadTitle: string;
+  imageSrc: string;
+  onUpload(url: string): any;
+  onRemove(): any;
+  rounded?: boolean;
+}
+
+interface ImageUploadFormState {
+  loading: boolean;
+}
+
+export class ImageUploadForm extends Component<
+  ImageUploadFormProps,
+  ImageUploadFormState
+> {
+  private id = `image-upload-form-${randomStr()}`;
+  private emptyState: ImageUploadFormState = {
+    loading: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+  }
+
+  render() {
+    return (
+      <form class="d-inline">
+        <label
+          htmlFor={this.id}
+          class="pointer ml-4 text-muted small font-weight-bold"
+        >
+          {!this.props.imageSrc ? (
+            <span class="btn btn-secondary">{this.props.uploadTitle}</span>
+          ) : (
+            <span class="d-inline-block position-relative">
+              <img
+                src={this.props.imageSrc}
+                height={this.props.rounded ? 60 : ''}
+                width={this.props.rounded ? 60 : ''}
+                className={`img-fluid ${
+                  this.props.rounded ? 'rounded-circle' : ''
+                }`}
+              />
+              <a onClick={linkEvent(this, this.handleRemoveImage)}>
+                <svg class="icon mini-overlay">
+                  <use xlinkHref="#icon-x"></use>
+                </svg>
+              </a>
+            </span>
+          )}
+        </label>
+        <input
+          id={this.id}
+          type="file"
+          accept="image/*,video/*"
+          name={this.id}
+          class="d-none"
+          disabled={!UserService.Instance.user}
+          onChange={linkEvent(this, this.handleImageUpload)}
+        />
+      </form>
+    );
+  }
+
+  handleImageUpload(i: ImageUploadForm, event: any) {
+    event.preventDefault();
+    let file = event.target.files[0];
+    const imageUploadUrl = `/pictrs/image`;
+    const formData = new FormData();
+    formData.append('images[]', file);
+
+    i.state.loading = true;
+    i.setState(i.state);
+
+    fetch(imageUploadUrl, {
+      method: 'POST',
+      body: formData,
+    })
+      .then(res => res.json())
+      .then(res => {
+        console.log('pictrs upload:');
+        console.log(res);
+        if (res.msg == 'ok') {
+          let hash = res.files[0].file;
+          let url = `${window.location.origin}/pictrs/image/${hash}`;
+          i.state.loading = false;
+          i.setState(i.state);
+          i.props.onUpload(url);
+        } else {
+          i.state.loading = false;
+          i.setState(i.state);
+          toast(JSON.stringify(res), 'danger');
+        }
+      })
+      .catch(error => {
+        i.state.loading = false;
+        i.setState(i.state);
+        toast(error, 'danger');
+      });
+  }
+
+  handleRemoveImage(i: ImageUploadForm, event: any) {
+    event.preventDefault();
+    i.state.loading = true;
+    i.setState(i.state);
+    i.props.onRemove();
+  }
+}
index 2466e5fae4f01c67ced7193b154eec7d62e9f177..9fbd1ae24f3f1b3dcfbf9dcd900b27034bc33b3f 100644 (file)
@@ -36,6 +36,7 @@ import { DataTypeSelect } from './data-type-select';
 import { SiteForm } from './site-form';
 import { UserListing } from './user-listing';
 import { CommunityLink } from './community-link';
+import { BannerIconHeader } from './banner-icon-header';
 import {
   wsJsonToRes,
   repoUrl,
@@ -53,6 +54,7 @@ import {
   editPostFindRes,
   commentsToFlatNodes,
   setupTippy,
+  favIconUrl,
 } from '../utils';
 import { i18n } from '../i18next';
 import { T } from 'inferno-i18next';
@@ -104,6 +106,9 @@ export class Main extends Component<any, MainState> {
         enable_downvotes: null,
         open_registration: null,
         enable_nsfw: null,
+        icon: null,
+        banner: null,
+        creator_preferred_username: null,
       },
       admins: [],
       banned: [],
@@ -186,10 +191,23 @@ export class Main extends Component<any, MainState> {
     }
   }
 
+  get favIcon(): string {
+    return this.state.siteRes.site.icon
+      ? this.state.siteRes.site.icon
+      : favIconUrl;
+  }
+
   render() {
     return (
       <div class="container">
-        <Helmet title={this.documentTitle} />
+        <Helmet title={this.documentTitle}>
+          <link
+            id="favicon"
+            rel="icon"
+            type="image/x-icon"
+            href={this.favIcon}
+          />
+        </Helmet>
         <div class="row">
           <main role="main" class="col-12 col-md-8">
             {this.posts()}
@@ -207,8 +225,11 @@ export class Main extends Component<any, MainState> {
           <div>
             <div class="card bg-transparent border-secondary mb-3">
               <div class="card-header bg-transparent border-secondary">
-                {this.siteName()}
-                {this.adminButtons()}
+                <div class="mb-2">
+                  {this.siteName()}
+                  {this.adminButtons()}
+                </div>
+                <BannerIconHeader banner={this.state.siteRes.site.banner} />
               </div>
               <div class="card-body">
                 {this.trendingCommunities()}
@@ -284,6 +305,7 @@ export class Main extends Component<any, MainState> {
                     id: community.community_id,
                     local: community.community_local,
                     actor_id: community.community_actor_id,
+                    icon: community.community_icon,
                   }}
                 />
               </li>
@@ -346,6 +368,7 @@ export class Main extends Component<any, MainState> {
             <UserListing
               user={{
                 name: admin.name,
+                preferred_username: admin.preferred_username,
                 avatar: admin.avatar,
                 local: admin.local,
                 actor_id: admin.actor_id,
index 1eb1731938b86237cbbc83a39e196b3b1b8025f8..5584b3b64df102928c0105245c04573270f1470f 100644 (file)
@@ -16,7 +16,6 @@ import {
   Comment,
   CommentResponse,
   PrivateMessage,
-  UserView,
   PrivateMessageResponse,
   WebSocketJsonResponse,
 } from '../interfaces';
@@ -41,12 +40,11 @@ interface NavbarState {
   mentions: Array<Comment>;
   messages: Array<PrivateMessage>;
   unreadCount: number;
-  siteName: string;
-  version: string;
-  admins: Array<UserView>;
   searchParam: string;
   toggleSearch: boolean;
   siteLoading: boolean;
+  siteRes: GetSiteResponse;
+  onSiteBanner?(url: string): any;
 }
 
 export class Navbar extends Component<any, NavbarState> {
@@ -61,9 +59,30 @@ export class Navbar extends Component<any, NavbarState> {
     mentions: [],
     messages: [],
     expanded: false,
-    siteName: undefined,
-    version: undefined,
-    admins: [],
+    siteRes: {
+      site: {
+        id: null,
+        name: null,
+        creator_id: null,
+        creator_name: null,
+        published: null,
+        number_of_users: null,
+        number_of_posts: null,
+        number_of_comments: null,
+        number_of_communities: null,
+        enable_downvotes: null,
+        open_registration: null,
+        enable_nsfw: null,
+        icon: null,
+        banner: null,
+        creator_preferred_username: null,
+      },
+      my_user: null,
+      admins: [],
+      banned: [],
+      online: null,
+      version: null,
+    },
     searchParam: '',
     toggleSearch: false,
     siteLoading: true,
@@ -158,12 +177,25 @@ export class Navbar extends Component<any, NavbarState> {
 
   // TODO class active corresponding to current page
   navbar() {
+    let user = UserService.Instance.user;
     return (
       <nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
         <div class="container">
           {!this.state.siteLoading ? (
-            <Link title={this.state.version} class="navbar-brand" to="/">
-              {this.state.siteName}
+            <Link
+              title={this.state.siteRes.version}
+              class="d-flex align-items-center navbar-brand mr-1"
+              to="/"
+            >
+              {this.state.siteRes.site.icon && showAvatars() && (
+                <img
+                  src={pictrsAvatarThumbnail(this.state.siteRes.site.icon)}
+                  height="32"
+                  width="32"
+                  class="rounded-circle mr-2"
+                />
+              )}
+              {this.state.siteRes.site.name}
             </Link>
           ) : (
             <div class="navbar-item">
@@ -182,14 +214,14 @@ export class Navbar extends Component<any, NavbarState> {
                 <use xlinkHref="#icon-bell"></use>
               </svg>
               {this.state.unreadCount > 0 && (
-                <span class="ml-1 badge badge-light">
+                <span class="mx-1 badge badge-light">
                   {this.state.unreadCount}
                 </span>
               )}
             </Link>
           )}
           <button
-            class="navbar-toggler border-0"
+            class="navbar-toggler border-0 p-1"
             type="button"
             aria-label="menu"
             onClick={linkEvent(this, this.expandNavbar)}
@@ -203,7 +235,7 @@ export class Navbar extends Component<any, NavbarState> {
                 !this.state.expanded && 'collapse'
               } navbar-collapse`}
             >
-              <ul class="navbar-nav my-2 mr-auto">
+              <ul class="ml-3 navbar-nav my-2 mr-auto">
                 <li class="nav-item">
                   <Link
                     class="nav-link"
@@ -246,6 +278,21 @@ export class Navbar extends Component<any, NavbarState> {
                   </Link>
                 </li>
               </ul>
+              <ul class="navbar-nav my-2">
+                {this.canAdmin && (
+                  <li className="nav-item">
+                    <Link
+                      class="nav-link"
+                      to={`/admin`}
+                      title={i18n.t('admin_settings')}
+                    >
+                      <svg class="icon">
+                        <use xlinkHref="#icon-settings"></use>
+                      </svg>
+                    </Link>
+                  </li>
+                )}
+              </ul>
               {!this.context.router.history.location.pathname.match(
                 /^\/search/
               ) && (
@@ -267,7 +314,7 @@ export class Navbar extends Component<any, NavbarState> {
                   <button
                     name="search-btn"
                     onClick={linkEvent(this, this.handleSearchBtn)}
-                    class="btn btn-link"
+                    class="px-1 btn btn-link"
                     style="color: var(--gray)"
                   >
                     <svg class="icon">
@@ -276,21 +323,6 @@ export class Navbar extends Component<any, NavbarState> {
                   </button>
                 </form>
               )}
-              <ul class="navbar-nav my-2">
-                {this.canAdmin && (
-                  <li className="nav-item">
-                    <Link
-                      class="nav-link"
-                      to={`/admin`}
-                      title={i18n.t('admin_settings')}
-                    >
-                      <svg class="icon">
-                        <use xlinkHref="#icon-settings"></use>
-                      </svg>
-                    </Link>
-                  </li>
-                )}
-              </ul>
               {this.state.isLoggedIn ? (
                 <>
                   <ul class="navbar-nav my-2">
@@ -315,22 +347,21 @@ export class Navbar extends Component<any, NavbarState> {
                     <li className="nav-item">
                       <Link
                         class="nav-link"
-                        to={`/u/${UserService.Instance.user.name}`}
+                        to={`/u/${user.name}`}
                         title={i18n.t('settings')}
                       >
                         <span>
-                          {UserService.Instance.user.avatar &&
-                            showAvatars() && (
-                              <img
-                                src={pictrsAvatarThumbnail(
-                                  UserService.Instance.user.avatar
-                                )}
-                                height="32"
-                                width="32"
-                                class="rounded-circle mr-2"
-                              />
-                            )}
-                          {UserService.Instance.user.name}
+                          {user.avatar && showAvatars() && (
+                            <img
+                              src={pictrsAvatarThumbnail(user.avatar)}
+                              height="32"
+                              width="32"
+                              class="rounded-circle mr-2"
+                            />
+                          )}
+                          {user.preferred_username
+                            ? user.preferred_username
+                            : user.name}
                         </span>
                       </Link>
                     </li>
@@ -422,11 +453,7 @@ export class Navbar extends Component<any, NavbarState> {
     } else if (res.op == UserOperation.GetSite) {
       let data = res.data as GetSiteResponse;
 
-      if (data.site && !this.state.siteName) {
-        this.state.siteName = data.site.name;
-        this.state.version = data.version;
-        this.state.admins = data.admins;
-      }
+      this.state.siteRes = data;
 
       // The login
       if (data.my_user) {
@@ -495,7 +522,9 @@ export class Navbar extends Component<any, NavbarState> {
   get canAdmin(): boolean {
     return (
       UserService.Instance.user &&
-      this.state.admins.map(a => a.id).includes(UserService.Instance.user.id)
+      this.state.siteRes.admins
+        .map(a => a.id)
+        .includes(UserService.Instance.user.id)
     );
   }
 
index a909c70203a80614a4a7bf5f1c8b7fbe45be8532..601c6b93ed4c7fd20695a0eeceb13f9007433d91 100644 (file)
@@ -440,6 +440,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                   <UserListing
                     user={{
                       name: post.creator_name,
+                      preferred_username: post.creator_preferred_username,
                       avatar: post.creator_avatar,
                       id: post.creator_id,
                       local: post.creator_local,
@@ -472,6 +473,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                           id: post.community_id,
                           local: post.community_local,
                           actor_id: post.community_actor_id,
+                          icon: post.community_icon,
                         }}
                       />
                     </span>
index 801fd90db588ea15b5e1cfb53b4e3c6e40533e30..abb603d5bd834d5db832659f5b803f6d35218a0f 100644 (file)
@@ -39,6 +39,7 @@ import {
   createPostLikeRes,
   commentsToFlatNodes,
   setupTippy,
+  favIconUrl,
 } from '../utils';
 import { PostListing } from './post-listing';
 import { Sidebar } from './sidebar';
@@ -189,10 +190,21 @@ export class Post extends Component<any, PostState> {
     }
   }
 
+  get favIcon(): string {
+    return this.state.post ? this.state.post.community_icon : favIconUrl;
+  }
+
   render() {
     return (
       <div class="container">
-        <Helmet title={this.documentTitle} />
+        <Helmet title={this.documentTitle}>
+          <link
+            id="favicon"
+            rel="icon"
+            type="image/x-icon"
+            href={this.favIcon}
+          />
+        </Helmet>
         {this.state.loading ? (
           <h5>
             <svg class="icon icon-spinner spin">
@@ -332,6 +344,7 @@ export class Post extends Component<any, PostState> {
           admins={this.state.siteRes.admins}
           online={this.state.online}
           enableNsfw={this.state.siteRes.site.enable_nsfw}
+          showIcon
         />
       </div>
     );
index eb4d49a36772bfcdb97a6c0d84f2e96e9f41545f..ff889c240c9cc55e786df1fadff4d159403a0ddc 100644 (file)
@@ -128,6 +128,8 @@ export class PrivateMessageForm extends Component<
                   <UserListing
                     user={{
                       name: this.state.recipient.name,
+                      preferred_username: this.state.recipient
+                        .preferred_username,
                       avatar: this.state.recipient.avatar,
                       id: this.state.recipient.id,
                       local: this.state.recipient.local,
index 4fa30a818a12b4735b472acd672e21f8cc0ab0ac..bb6aca4c11ae43061ae1eab6e52a686b00a56019 100644 (file)
@@ -9,6 +9,7 @@ import { WebSocketService, UserService } from '../services';
 import { mdToHtml, pictrsAvatarThumbnail, showAvatars, toast } from '../utils';
 import { MomentTime } from './moment-time';
 import { PrivateMessageForm } from './private-message-form';
+import { UserListing, UserOther } from './user-listing';
 import { i18n } from '../i18next';
 
 interface PrivateMessageState {
@@ -53,6 +54,26 @@ export class PrivateMessage extends Component<
 
   render() {
     let message = this.props.privateMessage;
+    let userOther: UserOther = this.mine
+      ? {
+          name: message.recipient_name,
+          preferred_username: message.recipient_preferred_username,
+          id: message.id,
+          avatar: message.recipient_avatar,
+          local: message.recipient_local,
+          actor_id: message.recipient_actor_id,
+          published: message.published,
+        }
+      : {
+          name: message.creator_name,
+          preferred_username: message.creator_preferred_username,
+          id: message.id,
+          avatar: message.creator_avatar,
+          local: message.creator_local,
+          actor_id: message.creator_actor_id,
+          published: message.published,
+        };
+
     return (
       <div class="border-top border-light">
         <div>
@@ -62,33 +83,7 @@ export class PrivateMessage extends Component<
               {this.mine ? i18n.t('to') : i18n.t('from')}
             </li>
             <li className="list-inline-item">
-              <Link
-                className="text-body font-weight-bold"
-                to={
-                  this.mine
-                    ? `/u/${message.recipient_name}`
-                    : `/u/${message.creator_name}`
-                }
-              >
-                {(this.mine
-                  ? message.recipient_avatar
-                  : message.creator_avatar) &&
-                  showAvatars() && (
-                    <img
-                      height="32"
-                      width="32"
-                      src={pictrsAvatarThumbnail(
-                        this.mine
-                          ? message.recipient_avatar
-                          : message.creator_avatar
-                      )}
-                      class="rounded-circle mr-1"
-                    />
-                  )}
-                <span>
-                  {this.mine ? message.recipient_name : message.creator_name}
-                </span>
-              </Link>
+              <UserListing user={userOther} />
             </li>
             <li className="list-inline-item">
               <span>
index 2162edeba2cf0d4ed57deceddf1d2425238e8be0..fc19cab9797b5389e5aa88cf44453f7bba0b6bf6 100644 (file)
@@ -315,6 +315,8 @@ export class Search extends Component<any, SearchState> {
                     <UserListing
                       user={{
                         name: (i.data as UserView).name,
+                        preferred_username: (i.data as UserView)
+                          .preferred_username,
                         avatar: (i.data as UserView).avatar,
                       }}
                     />
index ddf0bef66f781710d3bd17b9cef2969fdb76c175..61959eb07051c8585975e6399b23e6677fb967e2 100644 (file)
@@ -9,10 +9,11 @@ import {
   UserView,
 } from '../interfaces';
 import { WebSocketService, UserService } from '../services';
-import { mdToHtml, getUnixTime } from '../utils';
+import { mdToHtml, getUnixTime, pictrsAvatarThumbnail } from '../utils';
 import { CommunityForm } from './community-form';
 import { UserListing } from './user-listing';
 import { CommunityLink } from './community-link';
+import { BannerIconHeader } from './banner-icon-header';
 import { i18n } from '../i18next';
 
 interface SidebarProps {
@@ -21,6 +22,7 @@ interface SidebarProps {
   admins: Array<UserView>;
   online: number;
   enableNsfw: boolean;
+  showIcon?: boolean;
 }
 
 interface SidebarState {
@@ -86,24 +88,36 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
   communityTitle() {
     let community = this.props.community;
     return (
-      <h5 className="mb-2">
-        <span>{community.title}</span>
-        {community.removed && (
-          <small className="ml-2 text-muted font-italic">
-            {i18n.t('removed')}
-          </small>
-        )}
-        {community.deleted && (
-          <small className="ml-2 text-muted font-italic">
-            {i18n.t('deleted')}
-          </small>
-        )}
-        {community.nsfw && (
-          <small className="ml-2 text-muted font-italic">
-            {i18n.t('nsfw')}
-          </small>
-        )}
-      </h5>
+      <div>
+        <h5 className="mb-0">
+          {this.props.showIcon && (
+            <BannerIconHeader icon={community.icon} banner={community.banner} />
+          )}
+          <span>{community.title}</span>
+          {community.removed && (
+            <small className="ml-2 text-muted font-italic">
+              {i18n.t('removed')}
+            </small>
+          )}
+          {community.deleted && (
+            <small className="ml-2 text-muted font-italic">
+              {i18n.t('deleted')}
+            </small>
+          )}
+          {community.nsfw && (
+            <small className="ml-2 text-muted font-italic">
+              {i18n.t('nsfw')}
+            </small>
+          )}
+        </h5>
+        <CommunityLink
+          community={community}
+          realLink
+          useApubName
+          muted
+          hideAvatar
+        />
+      </div>
     );
   }
 
@@ -160,6 +174,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
             <UserListing
               user={{
                 name: mod.user_name,
+                preferred_username: mod.user_preferred_username,
                 avatar: mod.avatar,
                 id: mod.user_id,
                 local: mod.user_local,
index e02daabb94f27eab587986f5c4ba0c880b6ea631..98f1259b1b0f72576e2f9a38d76201279777e6b4 100644 (file)
@@ -1,6 +1,7 @@
 import { Component, linkEvent } from 'inferno';
 import { Prompt } from 'inferno-router';
 import { MarkdownTextArea } from './markdown-textarea';
+import { ImageUploadForm } from './image-upload-form';
 import { Site, SiteForm as SiteFormI } from '../interfaces';
 import { WebSocketService } from '../services';
 import { capitalizeFirstLetter, randomStr } from '../utils';
@@ -24,6 +25,8 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
       open_registration: true,
       enable_nsfw: true,
       name: null,
+      icon: null,
+      banner: null,
     },
     loading: false,
   };
@@ -36,6 +39,12 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
       this
     );
 
+    this.handleIconUpload = this.handleIconUpload.bind(this);
+    this.handleIconRemove = this.handleIconRemove.bind(this);
+
+    this.handleBannerUpload = this.handleBannerUpload.bind(this);
+    this.handleBannerRemove = this.handleBannerRemove.bind(this);
+
     if (this.props.site) {
       this.state.siteForm = {
         name: this.props.site.name,
@@ -43,6 +52,8 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
         enable_downvotes: this.props.site.enable_downvotes,
         open_registration: this.props.site.open_registration,
         enable_nsfw: this.props.site.enable_nsfw,
+        icon: this.props.site.icon,
+        banner: this.props.site.banner,
       };
     }
   }
@@ -103,6 +114,25 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
               />
             </div>
           </div>
+          <div class="form-group">
+            <label>{i18n.t('icon')}</label>
+            <ImageUploadForm
+              uploadTitle={i18n.t('upload_icon')}
+              imageSrc={this.state.siteForm.icon}
+              onUpload={this.handleIconUpload}
+              onRemove={this.handleIconRemove}
+              rounded
+            />
+          </div>
+          <div class="form-group">
+            <label>{i18n.t('banner')}</label>
+            <ImageUploadForm
+              uploadTitle={i18n.t('upload_banner')}
+              imageSrc={this.state.siteForm.banner}
+              onUpload={this.handleBannerUpload}
+              onRemove={this.handleBannerRemove}
+            />
+          </div>
           <div class="form-group row">
             <label class="col-12 col-form-label" htmlFor={this.id}>
               {i18n.t('sidebar')}
@@ -247,4 +277,24 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
   handleCancel(i: SiteForm) {
     i.props.onCancel();
   }
+
+  handleIconUpload(url: string) {
+    this.state.siteForm.icon = url;
+    this.setState(this.state);
+  }
+
+  handleIconRemove() {
+    this.state.siteForm.icon = '';
+    this.setState(this.state);
+  }
+
+  handleBannerUpload(url: string) {
+    this.state.siteForm.banner = url;
+    this.setState(this.state);
+  }
+
+  handleBannerRemove() {
+    this.state.siteForm.banner = '';
+    this.setState(this.state);
+  }
 }
index 8d0b29a3c4acefc6ff0ac1b9b376767be6b374be..778ed65ce55782051d8bc5ba4f8d05ff124e213c 100644 (file)
@@ -39,7 +39,10 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
         >
           <option disabled>{i18n.t('sort_type')}</option>
           {!this.props.hideHot && (
-            <option value={SortType.Hot}>{i18n.t('hot')}</option>
+            <>
+              <option value={SortType.Active}>{i18n.t('active')}</option>
+              <option value={SortType.Hot}>{i18n.t('hot')}</option>
+            </>
           )}
           <option value={SortType.New}>{i18n.t('new')}</option>
           <option disabled>─────</option>
index bd7021431870c7fed676ed36b80e023f2f9499fc..327a40be8ba7512a894a6466d523de36dc17748b 100644 (file)
@@ -15,6 +15,9 @@ export class Symbols extends Component<any, any> {
         xmlnsXlink="http://www.w3.org/1999/xlink"
       >
         <defs>
+          <symbol id="icon-x" viewBox="0 0 24 24">
+            <path d="M5.293 6.707l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0l5.293-5.293 5.293 5.293c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414l-5.293-5.293 5.293-5.293c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-5.293 5.293-5.293-5.293c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414z"></path>
+          </symbol>
           <symbol id="icon-refresh-cw" viewBox="0 0 24 24">
             <path d="M4.453 9.334c0.737-2.083 2.247-3.669 4.096-4.552s4.032-1.059 6.114-0.322c1.186 0.42 2.206 1.088 2.983 1.88l2.83 2.66h-3.476c-0.552 0-1 0.448-1 1s0.448 1 1 1h5.997c0.005 0 0.009 0 0.014 0 0.137-0.001 0.268-0.031 0.386-0.082 0.119-0.051 0.229-0.126 0.324-0.225 0.012-0.013 0.024-0.026 0.036-0.039 0.075-0.087 0.133-0.183 0.173-0.285s0.064-0.211 0.069-0.326c0.001-0.015 0.001-0.029 0.001-0.043v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1v3.689l-2.926-2.749c-0.992-1.010-2.271-1.843-3.743-2.364-2.603-0.921-5.335-0.699-7.643 0.402s-4.199 3.086-5.12 5.689c-0.185 0.52 0.088 1.091 0.608 1.276s1.092-0.088 1.276-0.609zM2 16.312l2.955 2.777c1.929 1.931 4.49 2.908 7.048 2.909s5.119-0.975 7.072-2.927c1.104-1.104 1.901-2.407 2.361-3.745 0.18-0.522-0.098-1.091-0.621-1.271s-1.091 0.098-1.271 0.621c-0.361 1.050-0.993 2.091-1.883 2.981-1.563 1.562-3.609 2.342-5.657 2.342s-4.094-0.782-5.679-2.366l-2.8-2.633h3.475c0.552 0 1-0.448 1-1s-0.448-1-1-1h-5.997c-0.005 0-0.009 0-0.014 0-0.137 0.001-0.268 0.031-0.386 0.082-0.119 0.051-0.229 0.126-0.324 0.225-0.012 0.013-0.024 0.026-0.036 0.039-0.075 0.087-0.133 0.183-0.173 0.285s-0.064 0.211-0.069 0.326c-0.001 0.015-0.001 0.029-0.001 0.043v6c0 0.552 0.448 1 1 1s1-0.448 1-1z"></path>
           </symbol>
index e4aa4c8937542473d4e2564ab281d2be1947146d..6ab29d0b322ace532d1642c09d47002666a8d3d1 100644 (file)
@@ -9,8 +9,9 @@ import {
 } from '../utils';
 import { CakeDay } from './cake-day';
 
-interface UserOther {
+export interface UserOther {
   name: string;
+  preferred_username?: string;
   id?: number; // Necessary if its federated
   avatar?: string;
   local?: boolean;
@@ -21,6 +22,9 @@ interface UserOther {
 interface UserListingProps {
   user: UserView | UserOther;
   realLink?: boolean;
+  useApubName?: boolean;
+  muted?: boolean;
+  hideAvatar?: boolean;
 }
 
 export class UserListing extends Component<UserListingProps, any> {
@@ -31,30 +35,40 @@ export class UserListing extends Component<UserListingProps, any> {
   render() {
     let user = this.props.user;
     let local = user.local == null ? true : user.local;
-    let name_: string, link: string;
+    let apubName: string, link: string;
 
     if (local) {
-      name_ = user.name;
+      apubName = `@${user.name}`;
       link = `/u/${user.name}`;
     } else {
-      name_ = `${user.name}@${hostname(user.actor_id)}`;
+      apubName = `@${user.name}@${hostname(user.actor_id)}`;
       link = !this.props.realLink ? `/user/${user.id}` : user.actor_id;
     }
 
+    let displayName = this.props.useApubName
+      ? apubName
+      : user.preferred_username
+      ? user.preferred_username
+      : apubName;
+
     return (
       <>
-        <Link className="text-info" to={link}>
-          {user.avatar && showAvatars() && (
+        <Link
+          title={apubName}
+          className={this.props.muted ? 'text-muted' : 'text-info'}
+          to={link}
+        >
+          {!this.props.hideAvatar && user.avatar && showAvatars() && (
             <img
               style="width: 2rem; height: 2rem;"
               src={pictrsAvatarThumbnail(user.avatar)}
               class="rounded-circle mr-2"
             />
           )}
-          <span>{name_}</span>
+          <span>{displayName}</span>
         </Link>
 
-        {isCakeDay(user.published) && <CakeDay creatorName={name_} />}
+        {isCakeDay(user.published) && <CakeDay creatorName={apubName} />}
       </>
     );
   }
index 82e5fd83526ab7e083f9ee75d06fd25a83d0f040..0d0b0143db191fc4ec6239f55bd13bfb17fa0aa0 100644 (file)
@@ -27,11 +27,12 @@ import {
   themes,
   setTheme,
   languages,
-  showAvatars,
   toast,
   setupTippy,
   getLanguage,
   mdToHtml,
+  elementUrl,
+  favIconUrl,
 } from '../utils';
 import { UserListing } from './user-listing';
 import { SortSelect } from './sort-select';
@@ -41,6 +42,8 @@ import { i18n } from '../i18next';
 import moment from 'moment';
 import { UserDetails } from './user-details';
 import { MarkdownTextArea } from './markdown-textarea';
+import { ImageUploadForm } from './image-upload-form';
+import { BannerIconHeader } from './banner-icon-header';
 
 interface UserState {
   user: UserView;
@@ -52,7 +55,6 @@ interface UserState {
   sort: SortType;
   page: number;
   loading: boolean;
-  avatarLoading: boolean;
   userSettingsForm: UserSettingsForm;
   userSettingsLoading: boolean;
   deleteAccountLoading: boolean;
@@ -98,7 +100,6 @@ export class User extends Component<any, UserState> {
     follows: [],
     moderates: [],
     loading: true,
-    avatarLoading: false,
     view: User.getViewFromProps(this.props.match.view),
     sort: User.getSortTypeFromProps(this.props.match.sort),
     page: User.getPageFromProps(this.props.match.page),
@@ -112,6 +113,7 @@ export class User extends Component<any, UserState> {
       send_notifications_to_email: null,
       auth: null,
       bio: null,
+      preferred_username: null,
     },
     userSettingsLoading: null,
     deleteAccountLoading: null,
@@ -136,6 +138,9 @@ export class User extends Component<any, UserState> {
         enable_downvotes: undefined,
         open_registration: undefined,
         enable_nsfw: undefined,
+        icon: undefined,
+        banner: undefined,
+        creator_preferred_username: undefined,
       },
       version: undefined,
     },
@@ -157,6 +162,12 @@ export class User extends Component<any, UserState> {
       this
     );
 
+    this.handleAvatarUpload = this.handleAvatarUpload.bind(this);
+    this.handleAvatarRemove = this.handleAvatarRemove.bind(this);
+
+    this.handleBannerUpload = this.handleBannerUpload.bind(this);
+    this.handleBannerRemove = this.handleBannerRemove.bind(this);
+
     this.state.user_id = Number(this.props.match.params.id) || null;
     this.state.username = this.props.match.params.username;
 
@@ -226,23 +237,27 @@ export class User extends Component<any, UserState> {
     }
   }
 
+  get favIcon(): string {
+    return this.state.user.avatar
+      ? this.state.user.avatar
+      : this.state.siteRes.site.icon
+      ? this.state.siteRes.site.icon
+      : favIconUrl;
+  }
+
   render() {
     return (
       <div class="container">
-        <Helmet title={this.documentTitle} />
+        <Helmet title={this.documentTitle}>
+          <link
+            id="favicon"
+            rel="icon"
+            type="image/x-icon"
+            href={this.favIcon}
+          />
+        </Helmet>
         <div class="row">
           <div class="col-12 col-md-8">
-            <h5>
-              {this.state.user.avatar && showAvatars() && (
-                <img
-                  height="80"
-                  width="80"
-                  src={this.state.user.avatar}
-                  class="rounded-circle mr-2"
-                />
-              )}
-              <span>@{this.state.username}</span>
-            </h5>
             {this.state.loading ? (
               <h5>
                 <svg class="icon icon-spinner spin">
@@ -250,8 +265,12 @@ export class User extends Component<any, UserState> {
                 </svg>
               </h5>
             ) : (
-              this.selects()
+              <>
+                {this.userInfo()}
+                <hr />
+              </>
             )}
+            {!this.state.loading && this.selects()}
             <UserDetails
               user_id={this.state.user_id}
               username={this.state.username}
@@ -268,7 +287,6 @@ export class User extends Component<any, UserState> {
 
           {!this.state.loading && (
             <div class="col-12 col-md-4">
-              {this.userInfo()}
               {this.isCurrentUser && this.userSettings()}
               {this.moderates()}
               {this.follows()}
@@ -365,22 +383,66 @@ export class User extends Component<any, UserState> {
 
   userInfo() {
     let user = this.state.user;
+
     return (
       <div>
-        <div class="card bg-transparent border-secondary mb-3">
-          <div class="card-body">
-            <h5>
-              <ul class="list-inline mb-0">
-                <li className="list-inline-item">
-                  <UserListing user={user} realLink />
-                </li>
-                {user.banned && (
-                  <li className="list-inline-item badge badge-danger">
-                    {i18n.t('banned')}
-                  </li>
+        <BannerIconHeader
+          banner={this.state.user.banner}
+          icon={this.state.user.avatar}
+        />
+        <div class="mb-3">
+          <div class="">
+            <div class="mb-0 d-flex flex-wrap">
+              <div>
+                {user.preferred_username && (
+                  <h5 class="mb-0">{user.preferred_username}</h5>
                 )}
-              </ul>
-            </h5>
+                <ul class="list-inline mb-2">
+                  <li className="list-inline-item">
+                    <UserListing
+                      user={user}
+                      realLink
+                      useApubName
+                      muted
+                      hideAvatar
+                    />
+                  </li>
+                  {user.banned && (
+                    <li className="list-inline-item badge badge-danger">
+                      {i18n.t('banned')}
+                    </li>
+                  )}
+                </ul>
+              </div>
+              <div className="flex-grow-1 unselectable pointer mx-2"></div>
+              {this.isCurrentUser ? (
+                <button
+                  class="d-flex align-self-start btn btn-secondary ml-2"
+                  onClick={linkEvent(this, this.handleLogoutClick)}
+                >
+                  {i18n.t('logout')}
+                </button>
+              ) : (
+                <>
+                  <a
+                    className={`d-flex align-self-start btn btn-secondary ml-2 ${
+                      !this.state.user.matrix_user_id && 'invisible'
+                    }`}
+                    target="_blank"
+                    rel="noopener"
+                    href={`https://matrix.to/#/${this.state.user.matrix_user_id}`}
+                  >
+                    {i18n.t('send_secure_message')}
+                  </a>
+                  <Link
+                    class="d-flex align-self-start btn btn-secondary ml-2"
+                    to={`/create_private_message?recipient_id=${this.state.user.id}`}
+                  >
+                    {i18n.t('send_message')}
+                  </Link>
+                </>
+              )}
+            </div>
             {user.bio && (
               <div className="d-flex align-items-center mb-2">
                 <div
@@ -389,7 +451,22 @@ export class User extends Component<any, UserState> {
                 />
               </div>
             )}
-            <div className="d-flex align-items-center mb-2">
+            <div>
+              <ul class="list-inline mb-2">
+                <li className="list-inline-item badge badge-light">
+                  {i18n.t('number_of_posts', { count: user.number_of_posts })}
+                </li>
+                <li className="list-inline-item badge badge-light">
+                  {i18n.t('number_of_comments', {
+                    count: user.number_of_comments,
+                  })}
+                </li>
+              </ul>
+            </div>
+            <div class="text-muted">
+              {i18n.t('joined')} <MomentTime data={user} showAgo />
+            </div>
+            <div className="d-flex align-items-center text-muted mb-2">
               <svg class="icon">
                 <use xlinkHref="#icon-cake"></use>
               </svg>
@@ -398,71 +475,6 @@ export class User extends Component<any, UserState> {
                 {moment.utc(user.published).local().format('MMM DD, YYYY')}
               </span>
             </div>
-            <div>
-              {i18n.t('joined')} <MomentTime data={user} showAgo />
-            </div>
-            <div class="table-responsive mt-1">
-              <table class="table table-bordered table-sm mt-2 mb-0">
-                {/*
-                <tr>
-                  <td class="text-center" colSpan={2}>
-                    {i18n.t('number_of_points', {
-                      count: user.post_score + user.comment_score,
-                    })}
-                  </td>
-                </tr>
-                */}
-                <tr>
-                  {/*
-                  <td>
-                    {i18n.t('number_of_points', { count: user.post_score })}
-                  </td>
-                  */}
-                  <td>
-                    {i18n.t('number_of_posts', { count: user.number_of_posts })}
-                  </td>
-                  {/*
-                </tr>
-                <tr>
-                  <td>
-                    {i18n.t('number_of_points', { count: user.comment_score })}
-                  </td>
-                  */}
-                  <td>
-                    {i18n.t('number_of_comments', {
-                      count: user.number_of_comments,
-                    })}
-                  </td>
-                </tr>
-              </table>
-            </div>
-            {this.isCurrentUser ? (
-              <button
-                class="btn btn-block btn-secondary mt-3"
-                onClick={linkEvent(this, this.handleLogoutClick)}
-              >
-                {i18n.t('logout')}
-              </button>
-            ) : (
-              <>
-                <a
-                  className={`btn btn-block btn-secondary mt-3 ${
-                    !this.state.user.matrix_user_id && 'disabled'
-                  }`}
-                  target="_blank"
-                  rel="noopener"
-                  href={`https://matrix.to/#/${this.state.user.matrix_user_id}`}
-                >
-                  {i18n.t('send_secure_message')}
-                </a>
-                <Link
-                  class="btn btn-block btn-secondary mt-3"
-                  to={`/create_private_message?recipient_id=${this.state.user.id}`}
-                >
-                  {i18n.t('send_message')}
-                </Link>
-              </>
-            )}
           </div>
         </div>
       </div>
@@ -478,47 +490,23 @@ export class User extends Component<any, UserState> {
             <form onSubmit={linkEvent(this, this.handleUserSettingsSubmit)}>
               <div class="form-group">
                 <label>{i18n.t('avatar')}</label>
-                <form class="d-inline">
-                  <label
-                    htmlFor="file-upload"
-                    class="pointer ml-4 text-muted small font-weight-bold"
-                  >
-                    {!this.checkSettingsAvatar ? (
-                      <span class="btn btn-secondary">
-                        {i18n.t('upload_avatar')}
-                      </span>
-                    ) : (
-                      <img
-                        height="80"
-                        width="80"
-                        src={this.state.userSettingsForm.avatar}
-                        class="rounded-circle"
-                      />
-                    )}
-                  </label>
-                  <input
-                    id="file-upload"
-                    type="file"
-                    accept="image/*,video/*"
-                    name="file"
-                    class="d-none"
-                    disabled={!UserService.Instance.user}
-                    onChange={linkEvent(this, this.handleImageUpload)}
-                  />
-                </form>
+                <ImageUploadForm
+                  uploadTitle={i18n.t('upload_avatar')}
+                  imageSrc={this.state.userSettingsForm.avatar}
+                  onUpload={this.handleAvatarUpload}
+                  onRemove={this.handleAvatarRemove}
+                  rounded
+                />
+              </div>
+              <div class="form-group">
+                <label>{i18n.t('banner')}</label>
+                <ImageUploadForm
+                  uploadTitle={i18n.t('upload_banner')}
+                  imageSrc={this.state.userSettingsForm.banner}
+                  onUpload={this.handleBannerUpload}
+                  onRemove={this.handleBannerRemove}
+                />
               </div>
-              {this.checkSettingsAvatar && (
-                <div class="form-group">
-                  <button
-                    class="btn btn-secondary btn-block"
-                    onClick={linkEvent(this, this.removeAvatar)}
-                  >
-                    {`${capitalizeFirstLetter(i18n.t('remove'))} ${i18n.t(
-                      'avatar'
-                    )}`}
-                  </button>
-                </div>
-              )}
               <div class="form-group">
                 <label>{i18n.t('language')}</label>
                 <select
@@ -566,21 +554,21 @@ export class User extends Component<any, UserState> {
                 />
               </form>
               <div class="form-group row">
-                <label class="col-lg-3 col-form-label" htmlFor="user-email">
-                  {i18n.t('email')}
+                <label class="col-lg-5 col-form-label">
+                  {i18n.t('display_name')}
                 </label>
-                <div class="col-lg-9">
+                <div class="col-lg-7">
                   <input
-                    type="email"
-                    id="user-email"
+                    type="text"
                     class="form-control"
                     placeholder={i18n.t('optional')}
-                    value={this.state.userSettingsForm.email}
+                    value={this.state.userSettingsForm.preferred_username}
                     onInput={linkEvent(
                       this,
-                      this.handleUserSettingsEmailChange
+                      this.handleUserSettingsPreferredUsernameChange
                     )}
                     minLength={3}
+                    maxLength={20}
                   />
                 </div>
               </div>
@@ -597,13 +585,28 @@ export class User extends Component<any, UserState> {
                   />
                 </div>
               </div>
+              <div class="form-group row">
+                <label class="col-lg-3 col-form-label" htmlFor="user-email">
+                  {i18n.t('email')}
+                </label>
+                <div class="col-lg-9">
+                  <input
+                    type="email"
+                    id="user-email"
+                    class="form-control"
+                    placeholder={i18n.t('optional')}
+                    value={this.state.userSettingsForm.email}
+                    onInput={linkEvent(
+                      this,
+                      this.handleUserSettingsEmailChange
+                    )}
+                    minLength={3}
+                  />
+                </div>
+              </div>
               <div class="form-group row">
                 <label class="col-lg-5 col-form-label">
-                  <a
-                    href="https://about.riot.im/"
-                    target="_blank"
-                    rel="noopener"
-                  >
+                  <a href={elementUrl} target="_blank" rel="noopener">
                     {i18n.t('matrix_user_id')}
                   </a>
                 </label>
@@ -932,6 +935,31 @@ export class User extends Component<any, UserState> {
     this.setState(this.state);
   }
 
+  handleAvatarUpload(url: string) {
+    this.state.userSettingsForm.avatar = url;
+    this.setState(this.state);
+  }
+
+  handleAvatarRemove() {
+    this.state.userSettingsForm.avatar = '';
+    this.setState(this.state);
+  }
+
+  handleBannerUpload(url: string) {
+    this.state.userSettingsForm.banner = url;
+    this.setState(this.state);
+  }
+
+  handleBannerRemove() {
+    this.state.userSettingsForm.banner = '';
+    this.setState(this.state);
+  }
+
+  handleUserSettingsPreferredUsernameChange(i: User, event: any) {
+    i.state.userSettingsForm.preferred_username = event.target.value;
+    i.setState(i.state);
+  }
+
   handleUserSettingsMatrixUserIdChange(i: User, event: any) {
     i.state.userSettingsForm.matrix_user_id = event.target.value;
     if (
@@ -967,59 +995,6 @@ export class User extends Component<any, UserState> {
     i.setState(i.state);
   }
 
-  handleImageUpload(i: User, event: any) {
-    event.preventDefault();
-    let file = event.target.files[0];
-    const imageUploadUrl = `/pictrs/image`;
-    const formData = new FormData();
-    formData.append('images[]', file);
-
-    i.state.avatarLoading = true;
-    i.setState(i.state);
-
-    fetch(imageUploadUrl, {
-      method: 'POST',
-      body: formData,
-    })
-      .then(res => res.json())
-      .then(res => {
-        console.log('pictrs upload:');
-        console.log(res);
-        if (res.msg == 'ok') {
-          let hash = res.files[0].file;
-          let url = `${window.location.origin}/pictrs/image/${hash}`;
-          i.state.userSettingsForm.avatar = url;
-          i.state.avatarLoading = false;
-          i.setState(i.state);
-        } else {
-          i.state.avatarLoading = false;
-          i.setState(i.state);
-          toast(JSON.stringify(res), 'danger');
-        }
-      })
-      .catch(error => {
-        i.state.avatarLoading = false;
-        i.setState(i.state);
-        toast(error, 'danger');
-      });
-  }
-
-  removeAvatar(i: User, event: any) {
-    event.preventDefault();
-    i.state.userSettingsLoading = true;
-    i.state.userSettingsForm.avatar = '';
-    i.setState(i.state);
-
-    WebSocketService.Instance.saveUserSettings(i.state.userSettingsForm);
-  }
-
-  get checkSettingsAvatar(): boolean {
-    return (
-      this.state.userSettingsForm.avatar &&
-      this.state.userSettingsForm.avatar != ''
-    );
-  }
-
   handleUserSettingsSubmit(i: User, event: any) {
     event.preventDefault();
     i.state.userSettingsLoading = true;
@@ -1062,7 +1037,6 @@ export class User extends Component<any, UserState> {
       }
       this.setState({
         deleteAccountLoading: false,
-        avatarLoading: false,
         userSettingsLoading: false,
       });
       return;
@@ -1088,6 +1062,9 @@ export class User extends Component<any, UserState> {
             UserService.Instance.user.default_listing_type;
           this.state.userSettingsForm.lang = UserService.Instance.user.lang;
           this.state.userSettingsForm.avatar = UserService.Instance.user.avatar;
+          this.state.userSettingsForm.banner = UserService.Instance.user.banner;
+          this.state.userSettingsForm.preferred_username =
+            UserService.Instance.user.preferred_username;
           this.state.userSettingsForm.email = this.state.user.email;
           this.state.userSettingsForm.bio = this.state.user.bio;
           this.state.userSettingsForm.send_notifications_to_email = this.state.user.send_notifications_to_email;
@@ -1102,6 +1079,9 @@ export class User extends Component<any, UserState> {
       const data = res.data as LoginResponse;
       UserService.Instance.login(data);
       this.state.user.bio = this.state.userSettingsForm.bio;
+      this.state.user.preferred_username = this.state.userSettingsForm.preferred_username;
+      this.state.user.banner = this.state.userSettingsForm.banner;
+      this.state.user.avatar = this.state.userSettingsForm.avatar;
       this.state.userSettingsLoading = false;
       this.setState(this.state);
 
index 9c0e76244eef925f74c4933f2f93caabe27d8df4..df8e3f78997d357de6ff8f4336bc0436817fb4eb 100644 (file)
@@ -83,6 +83,7 @@ export enum DataType {
 }
 
 export enum SortType {
+  Active,
   Hot,
   New,
   TopDay,
@@ -112,6 +113,7 @@ export interface User {
   preferred_username?: string;
   email?: string;
   avatar?: string;
+  banner?: string;
   admin: boolean;
   banned: boolean;
   published: string;
@@ -134,7 +136,9 @@ export interface UserView {
   id: number;
   actor_id: string;
   name: string;
+  preferred_username?: string;
   avatar?: string;
+  banner?: string;
   email?: string;
   matrix_user_id?: string;
   bio?: string;
@@ -155,11 +159,13 @@ export interface CommunityUser {
   user_actor_id: string;
   user_local: boolean;
   user_name: string;
+  user_preferred_username?: string;
   avatar?: string;
   community_id: number;
   community_actor_id: string;
   community_local: boolean;
   community_name: string;
+  community_icon?: string;
   published: string;
 }
 
@@ -169,6 +175,8 @@ export interface Community {
   local: boolean;
   name: string;
   title: string;
+  icon?: string;
+  banner?: string;
   description?: string;
   category_id: number;
   creator_id: number;
@@ -181,6 +189,7 @@ export interface Community {
   creator_local: boolean;
   last_refreshed_at: string;
   creator_name: string;
+  creator_preferred_username?: string;
   creator_avatar?: string;
   category_name: string;
   number_of_subscribers: number;
@@ -215,11 +224,13 @@ export interface Post {
   creator_actor_id: string;
   creator_local: boolean;
   creator_name: string;
+  creator_preferred_username?: string;
   creator_published: string;
   creator_avatar?: string;
   community_actor_id: string;
   community_local: boolean;
   community_name: string;
+  community_icon?: string;
   community_removed: boolean;
   community_deleted: boolean;
   community_nsfw: boolean;
@@ -228,6 +239,7 @@ export interface Post {
   upvotes: number;
   downvotes: number;
   hot_rank: number;
+  hot_rank_active: number;
   newest_activity_time: string;
   user_id?: number;
   my_vote?: number;
@@ -255,17 +267,20 @@ export interface Comment {
   community_actor_id: string;
   community_local: boolean;
   community_name: string;
+  community_icon?: string;
   banned: boolean;
   banned_from_community: boolean;
   creator_actor_id: string;
   creator_local: boolean;
   creator_name: string;
+  creator_preferred_username?: string;
   creator_avatar?: string;
   creator_published: string;
   score: number;
   upvotes: number;
   downvotes: number;
   hot_rank: number;
+  hot_rank_active: number;
   user_id?: number;
   my_vote?: number;
   subscribed?: number;
@@ -290,6 +305,7 @@ export interface Site {
   published: string;
   updated?: string;
   creator_name: string;
+  creator_preferred_username?: string;
   number_of_users: number;
   number_of_posts: number;
   number_of_comments: number;
@@ -297,6 +313,8 @@ export interface Site {
   enable_downvotes: boolean;
   open_registration: boolean;
   enable_nsfw: boolean;
+  icon?: string;
+  banner?: string;
 }
 
 export interface PrivateMessage {
@@ -311,10 +329,12 @@ export interface PrivateMessage {
   ap_id: string;
   local: boolean;
   creator_name: string;
+  creator_preferred_username?: string;
   creator_avatar?: string;
   creator_actor_id: string;
   creator_local: boolean;
   recipient_name: string;
+  recipient_preferred_username?: string;
   recipient_avatar?: string;
   recipient_actor_id: string;
   recipient_local: boolean;
@@ -596,6 +616,8 @@ export interface UserSettingsForm {
   default_listing_type: ListingType;
   lang: string;
   avatar?: string;
+  banner?: string;
+  preferred_username?: string;
   email?: string;
   bio?: string;
   matrix_user_id?: string;
@@ -612,6 +634,8 @@ export interface CommunityForm {
   edit_id?: number;
   title: string;
   description?: string;
+  icon?: string;
+  banner?: string;
   category_id: number;
   nsfw: boolean;
   auth?: string;
@@ -814,6 +838,8 @@ export interface CreatePostLikeForm {
 export interface SiteForm {
   name: string;
   description?: string;
+  icon?: string;
+  banner?: string;
   enable_downvotes: boolean;
   open_registration: boolean;
   enable_nsfw: boolean;
index 2ef1d070c0fea3d28a3aa7617656bbc3d18a409f..cdffed7e7a3cd076aacebec087204379f2f36f13 100644 (file)
@@ -58,11 +58,13 @@ import Toastify from 'toastify-js';
 import tippy from 'tippy.js';
 import moment from 'moment';
 
+export const favIconUrl = '/static/assets/favicon.svg';
 export const repoUrl = 'https://github.com/LemmyNet/lemmy';
 export const helpGuideUrl = '/docs/about_guide.html';
 export const markdownHelpUrl = `${helpGuideUrl}#markdown-guide`;
 export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
 export const archiveUrl = 'https://archive.is';
+export const elementUrl = 'https://element.io/';
 
 export const postRefetchSeconds: number = 60 * 1000;
 export const fetchLimit: number = 20;
@@ -273,6 +275,8 @@ export function routeSortTypeToEnum(sort: string): SortType {
     return SortType.New;
   } else if (sort == 'hot') {
     return SortType.Hot;
+  } else if (sort == 'active') {
+    return SortType.Active;
   } else if (sort == 'topday') {
     return SortType.TopDay;
   } else if (sort == 'topweek') {
@@ -754,7 +758,7 @@ export function getSortTypeFromProps(props: any): SortType {
     ? routeSortTypeToEnum(props.match.params.sort)
     : UserService.Instance.user
     ? UserService.Instance.user.default_sort_type
-    : SortType.Hot;
+    : SortType.Active;
 }
 
 export function getPageFromProps(props: any): number {
@@ -905,7 +909,7 @@ function convertCommentSortType(sort: SortType): CommentSortType {
     return CommentSortType.Top;
   } else if (sort == SortType.New) {
     return CommentSortType.New;
-  } else if (sort == SortType.Hot) {
+  } else if (sort == SortType.Hot || sort == SortType.Active) {
     return CommentSortType.Hot;
   } else {
     return CommentSortType.Hot;
@@ -948,6 +952,14 @@ export function postSort(
         (communityType && +b.stickied - +a.stickied) ||
         b.hot_rank - a.hot_rank
     );
+  } else if (sort == SortType.Active) {
+    posts.sort(
+      (a, b) =>
+        +a.removed - +b.removed ||
+        +a.deleted - +b.deleted ||
+        (communityType && +b.stickied - +a.stickied) ||
+        b.hot_rank_active - a.hot_rank_active
+    );
   }
 }
 
@@ -1008,3 +1020,16 @@ export function validTitle(title?: string): boolean {
 
   return regex.test(title);
 }
+
+export function siteBannerCss(banner: string): string {
+  return ` \
+    background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
+    background-attachment: fixed; \
+    background-position: top; \
+    background-repeat: no-repeat; \
+    background-size: 100% cover; \
+
+    width: 100%; \
+    max-height: 100vh; \
+    `;
+}
index f9848ea2fe5c09bc47f4f168773e5e262e1a80d5..a2865c7a3d81f298afef6ae0f2203645eab76ef7 100644 (file)
     "upload_image": "upload image",
     "avatar": "Avatar",
     "upload_avatar": "Upload Avatar",
+    "banner": "Banner",
+    "upload_banner": "Upload Banner",
+    "icon": "Icon",
+    "upload_icon": "Upload Icon",
     "show_avatars": "Show Avatars",
     "show_context": "Show context",
     "formatting_help": "formatting help",
     "sidebar": "Sidebar",
     "sort_type": "Sort type",
     "hot": "Hot",
+    "active": "Active",
     "new": "New",
     "old": "Old",
     "top_day": "Top day",
     "landing_0":
       "Lemmy is a <1>link aggregator</1> / reddit alternative, intended to work in the <2>fediverse</2>.<3></3>It's self-hostable, has live-updating comment threads, and is tiny (<4>~80kB</4>). Federation into the ActivityPub network is on the roadmap. <5></5>This is a <6>very early beta version</6>, and a lot of features are currently broken or missing. <7></7>Suggest new features or report bugs <8>here.</8><9></9>Made with <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>. <14></14> <15>Thank you to our contributors: </15> dessalines, Nutomic, asonix, zacanger, and iav.",
     "not_logged_in": "Not logged in.",
-    "bio_length_overflow": "User bio cannot exceed 300 characters!",
+    "bio_length_overflow": "User bio cannot exceed 300 characters.",
     "logged_in": "Logged in.",
     "must_login": "You must <1>log in or register</1> to comment.",
     "site_saved": "Site Saved.",
     "invalid_post_title": "Invalid post title",
     "invalid_url": "Invalid URL.",
     "play_captcha_audio": "Play Captcha Audio",
-    "bio": "Bio"
+    "bio": "Bio",
+    "display_name": "Display Name"
 }