]> Untitled Git - lemmy.git/commitdiff
Adding username mentions / tagging from comments.
authorDessalines <tyhou13@gmx.com>
Sun, 20 Oct 2019 00:46:29 +0000 (17:46 -0700)
committerDessalines <tyhou13@gmx.com>
Sun, 20 Oct 2019 00:46:29 +0000 (17:46 -0700)
- Fixes #293

21 files changed:
server/migrations/2019-10-19-052737_create_user_mention/down.sql [new file with mode: 0644]
server/migrations/2019-10-19-052737_create_user_mention/up.sql [new file with mode: 0644]
server/src/api/comment.rs
server/src/api/mod.rs
server/src/api/user.rs
server/src/db/comment_view.rs
server/src/db/mod.rs
server/src/db/src/schema.rs [new file with mode: 0644]
server/src/db/user_mention.rs [new file with mode: 0644]
server/src/db/user_mention_view.rs [new file with mode: 0644]
server/src/lib.rs
server/src/schema.rs
server/src/websocket/server.rs
ui/src/components/comment-node.tsx
ui/src/components/inbox.tsx
ui/src/components/navbar.tsx
ui/src/components/search.tsx
ui/src/i18next.ts
ui/src/interfaces.ts
ui/src/services/WebSocketService.ts
ui/src/translations/en.ts

diff --git a/server/migrations/2019-10-19-052737_create_user_mention/down.sql b/server/migrations/2019-10-19-052737_create_user_mention/down.sql
new file mode 100644 (file)
index 0000000..7165bc8
--- /dev/null
@@ -0,0 +1,2 @@
+drop view user_mention_view;
+drop table user_mention;
diff --git a/server/migrations/2019-10-19-052737_create_user_mention/up.sql b/server/migrations/2019-10-19-052737_create_user_mention/up.sql
new file mode 100644 (file)
index 0000000..81fef00
--- /dev/null
@@ -0,0 +1,35 @@
+create table user_mention (
+  id serial primary key,
+  recipient_id int references user_ on update cascade on delete cascade not null,
+  comment_id int references comment on update cascade on delete cascade not null,
+  read boolean default false not null,
+  published timestamp not null default now(),
+  unique(recipient_id, comment_id)
+);
+
+create view user_mention_view as
+select 
+    c.id,
+    um.id as user_mention_id,
+    c.creator_id,
+    c.post_id,
+    c.parent_id,
+    c.content,
+    c.removed,
+    um.read,
+    c.published,
+    c.updated,
+    c.deleted,
+    c.community_id,
+    c.banned,
+    c.banned_from_community,
+    c.creator_name,
+    c.score,
+    c.upvotes,
+    c.downvotes,
+    c.user_id,
+    c.my_vote,
+    c.saved,
+    um.recipient_id
+from user_mention um, comment_view c
+where um.comment_id = c.id;
index ec010d2f5b4666a831596ec952ff65823c7fde20..a5ccd358c8055e4ff25a1c3f8f15430a72b72faa 100644 (file)
@@ -85,6 +85,35 @@ impl Perform<CommentResponse> for Oper<CreateComment> {
       Err(_e) => return Err(APIError::err(&self.op, "couldnt_create_comment"))?,
     };
 
+    // Scan the comment for user mentions, add those rows
+    let extracted_usernames = extract_usernames(&comment_form.content);
+
+    for username_mention in &extracted_usernames {
+      let mention_user = User_::read_from_name(&conn, username_mention.to_string());
+
+      if mention_user.is_ok() {
+        let mention_user_id = mention_user?.id;
+
+        // You can't mention yourself
+        // At some point, make it so you can't tag the parent creator either
+        // This can cause two notifications, one for reply and the other for mention
+        if mention_user_id != user_id {
+          let user_mention_form = UserMentionForm {
+            recipient_id: mention_user_id,
+            comment_id: inserted_comment.id,
+            read: None,
+          };
+
+          // Allow this to fail softly, since comment edits might re-update or replace it
+          // Let the uniqueness handle this fail
+          match UserMention::create(&conn, &user_mention_form) {
+            Ok(_mention) => (),
+            Err(_e) => eprintln!("{}", &_e),
+          }
+        }
+      }
+    }
+
     // You like your own comment by default
     let like_form = CommentLikeForm {
       comment_id: inserted_comment.id,
@@ -170,6 +199,35 @@ impl Perform<CommentResponse> for Oper<EditComment> {
       Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment"))?,
     };
 
+    // Scan the comment for user mentions, add those rows
+    let extracted_usernames = extract_usernames(&comment_form.content);
+
+    for username_mention in &extracted_usernames {
+      let mention_user = User_::read_from_name(&conn, username_mention.to_string());
+
+      if mention_user.is_ok() {
+        let mention_user_id = mention_user?.id;
+
+        // You can't mention yourself
+        // At some point, make it so you can't tag the parent creator either
+        // This can cause two notifications, one for reply and the other for mention
+        if mention_user_id != user_id {
+          let user_mention_form = UserMentionForm {
+            recipient_id: mention_user_id,
+            comment_id: data.edit_id,
+            read: None,
+          };
+
+          // Allow this to fail softly, since comment edits might re-update or replace it
+          // Let the uniqueness handle this fail
+          match UserMention::create(&conn, &user_mention_form) {
+            Ok(_mention) => (),
+            Err(_e) => eprintln!("{}", &_e),
+          }
+        }
+      }
+    }
+
     // Mod tables
     if let Some(removed) = data.removed.to_owned() {
       let form = ModRemoveCommentForm {
index 5ffb57d89db0b8bb02745f0b480644906e349c7e..cab8a77b576a3231b70e8c9e2a775378a6626817 100644 (file)
@@ -8,9 +8,11 @@ use crate::db::moderator_views::*;
 use crate::db::post::*;
 use crate::db::post_view::*;
 use crate::db::user::*;
+use crate::db::user_mention::*;
+use crate::db::user_mention_view::*;
 use crate::db::user_view::*;
 use crate::db::*;
-use crate::{has_slurs, naive_from_unix, naive_now, remove_slurs, Settings};
+use crate::{extract_usernames, has_slurs, naive_from_unix, naive_now, remove_slurs, Settings};
 use failure::Error;
 use serde::{Deserialize, Serialize};
 
@@ -43,6 +45,8 @@ pub enum UserOperation {
   GetFollowedCommunities,
   GetUserDetails,
   GetReplies,
+  GetUserMentions,
+  EditUserMention,
   GetModlog,
   BanFromCommunity,
   AddModToCommunity,
index 2de809055826ce33a68b0f31ad89f7450cecd6f3..563ae0a229d28766ee8eb46f91d034fc40f27313 100644 (file)
@@ -60,6 +60,12 @@ pub struct GetRepliesResponse {
   replies: Vec<ReplyView>,
 }
 
+#[derive(Serialize, Deserialize)]
+pub struct GetUserMentionsResponse {
+  op: String,
+  mentions: Vec<UserMentionView>,
+}
+
 #[derive(Serialize, Deserialize)]
 pub struct MarkAllAsRead {
   auth: String,
@@ -103,6 +109,28 @@ pub struct GetReplies {
   auth: String,
 }
 
+#[derive(Serialize, Deserialize)]
+pub struct GetUserMentions {
+  sort: String,
+  page: Option<i64>,
+  limit: Option<i64>,
+  unread_only: bool,
+  auth: String,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct EditUserMention {
+  user_mention_id: i32,
+  read: Option<bool>,
+  auth: String,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct UserMentionResponse {
+  op: String,
+  mention: UserMentionView,
+}
+
 #[derive(Serialize, Deserialize)]
 pub struct DeleteAccount {
   password: String,
@@ -299,7 +327,6 @@ impl Perform<GetUserDetailsResponse> for Oper<GetUserDetails> {
       None => false,
     };
 
-    //TODO add save
     let sort = SortType::from_str(&data.sort)?;
 
     let user_details_id = match data.user_id {
@@ -541,7 +568,6 @@ impl Perform<GetRepliesResponse> for Oper<GetReplies> {
       data.limit,
     )?;
 
-    // Return the jwt
     Ok(GetRepliesResponse {
       op: self.op.to_string(),
       replies: replies,
@@ -549,6 +575,71 @@ impl Perform<GetRepliesResponse> for Oper<GetReplies> {
   }
 }
 
+impl Perform<GetUserMentionsResponse> for Oper<GetUserMentions> {
+  fn perform(&self) -> Result<GetUserMentionsResponse, Error> {
+    let data: &GetUserMentions = &self.data;
+    let conn = establish_connection();
+
+    let claims = match Claims::decode(&data.auth) {
+      Ok(claims) => claims.claims,
+      Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
+    };
+
+    let user_id = claims.id;
+
+    let sort = SortType::from_str(&data.sort)?;
+
+    let mentions = UserMentionView::get_mentions(
+      &conn,
+      user_id,
+      &sort,
+      data.unread_only,
+      data.page,
+      data.limit,
+    )?;
+
+    Ok(GetUserMentionsResponse {
+      op: self.op.to_string(),
+      mentions: mentions,
+    })
+  }
+}
+
+impl Perform<UserMentionResponse> for Oper<EditUserMention> {
+  fn perform(&self) -> Result<UserMentionResponse, Error> {
+    let data: &EditUserMention = &self.data;
+    let conn = establish_connection();
+
+    let claims = match Claims::decode(&data.auth) {
+      Ok(claims) => claims.claims,
+      Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
+    };
+
+    let user_id = claims.id;
+
+    let user_mention = UserMention::read(&conn, data.user_mention_id)?;
+
+    let user_mention_form = UserMentionForm {
+      recipient_id: user_id,
+      comment_id: user_mention.comment_id,
+      read: data.read.to_owned(),
+    };
+
+    let _updated_user_mention =
+      match UserMention::update(&conn, user_mention.id, &user_mention_form) {
+        Ok(comment) => comment,
+        Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment"))?,
+      };
+
+    let user_mention_view = UserMentionView::read(&conn, user_mention.id, user_id)?;
+
+    Ok(UserMentionResponse {
+      op: self.op.to_string(),
+      mention: user_mention_view,
+    })
+  }
+}
+
 impl Perform<GetRepliesResponse> for Oper<MarkAllAsRead> {
   fn perform(&self) -> Result<GetRepliesResponse, Error> {
     let data: &MarkAllAsRead = &self.data;
@@ -581,11 +672,27 @@ impl Perform<GetRepliesResponse> for Oper<MarkAllAsRead> {
       };
     }
 
-    let replies = ReplyView::get_replies(&conn, user_id, &SortType::New, true, Some(1), Some(999))?;
+    // Mentions
+    let mentions =
+      UserMentionView::get_mentions(&conn, user_id, &SortType::New, true, Some(1), Some(999))?;
+
+    for mention in &mentions {
+      let mention_form = UserMentionForm {
+        recipient_id: mention.to_owned().recipient_id,
+        comment_id: mention.to_owned().id,
+        read: Some(true),
+      };
+
+      let _updated_mention =
+        match UserMention::update(&conn, mention.user_mention_id, &mention_form) {
+          Ok(mention) => mention,
+          Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment"))?,
+        };
+    }
 
     Ok(GetRepliesResponse {
       op: self.op.to_string(),
-      replies: replies,
+      replies: vec![],
     })
   }
 }
index b192e6eb5eb6df5eb14cade29be29721e82409f0..88190464c6cc01ea38e7347daab3de1cd4c18828 100644 (file)
@@ -69,7 +69,6 @@ impl CommentView {
 
     let (limit, offset) = limit_and_offset(page, limit);
 
-    // TODO no limits here?
     let mut query = comment_view.into_boxed();
 
     // The view lets you pass a null user_id, if you're not logged in
index 51a591394ab87e03904cd061e7685a7a45c440ab..ac3c3ae3356adc1bb918c295760c982fa32226f2 100644 (file)
@@ -14,6 +14,8 @@ pub mod moderator_views;
 pub mod post;
 pub mod post_view;
 pub mod user;
+pub mod user_mention;
+pub mod user_mention_view;
 pub mod user_view;
 
 pub trait Crud<T> {
diff --git a/server/src/db/src/schema.rs b/server/src/db/src/schema.rs
new file mode 100644 (file)
index 0000000..8693db2
--- /dev/null
@@ -0,0 +1,345 @@
+table! {
+    category (id) {
+        id -> Int4,
+        name -> Varchar,
+    }
+}
+
+table! {
+    comment (id) {
+        id -> Int4,
+        creator_id -> Int4,
+        post_id -> Int4,
+        parent_id -> Nullable<Int4>,
+        content -> Text,
+        removed -> Bool,
+        read -> Bool,
+        published -> Timestamp,
+        updated -> Nullable<Timestamp>,
+        deleted -> Bool,
+    }
+}
+
+table! {
+    comment_like (id) {
+        id -> Int4,
+        user_id -> Int4,
+        comment_id -> Int4,
+        post_id -> Int4,
+        score -> Int2,
+        published -> Timestamp,
+    }
+}
+
+table! {
+    comment_saved (id) {
+        id -> Int4,
+        comment_id -> Int4,
+        user_id -> Int4,
+        published -> Timestamp,
+    }
+}
+
+table! {
+    community (id) {
+        id -> Int4,
+        name -> Varchar,
+        title -> Varchar,
+        description -> Nullable<Text>,
+        category_id -> Int4,
+        creator_id -> Int4,
+        removed -> Bool,
+        published -> Timestamp,
+        updated -> Nullable<Timestamp>,
+        deleted -> Bool,
+        nsfw -> Bool,
+    }
+}
+
+table! {
+    community_follower (id) {
+        id -> Int4,
+        community_id -> Int4,
+        user_id -> Int4,
+        published -> Timestamp,
+    }
+}
+
+table! {
+    community_moderator (id) {
+        id -> Int4,
+        community_id -> Int4,
+        user_id -> Int4,
+        published -> Timestamp,
+    }
+}
+
+table! {
+    community_user_ban (id) {
+        id -> Int4,
+        community_id -> Int4,
+        user_id -> Int4,
+        published -> Timestamp,
+    }
+}
+
+table! {
+    mod_add (id) {
+        id -> Int4,
+        mod_user_id -> Int4,
+        other_user_id -> Int4,
+        removed -> Nullable<Bool>,
+        when_ -> Timestamp,
+    }
+}
+
+table! {
+    mod_add_community (id) {
+        id -> Int4,
+        mod_user_id -> Int4,
+        other_user_id -> Int4,
+        community_id -> Int4,
+        removed -> Nullable<Bool>,
+        when_ -> Timestamp,
+    }
+}
+
+table! {
+    mod_ban (id) {
+        id -> Int4,
+        mod_user_id -> Int4,
+        other_user_id -> Int4,
+        reason -> Nullable<Text>,
+        banned -> Nullable<Bool>,
+        expires -> Nullable<Timestamp>,
+        when_ -> Timestamp,
+    }
+}
+
+table! {
+    mod_ban_from_community (id) {
+        id -> Int4,
+        mod_user_id -> Int4,
+        other_user_id -> Int4,
+        community_id -> Int4,
+        reason -> Nullable<Text>,
+        banned -> Nullable<Bool>,
+        expires -> Nullable<Timestamp>,
+        when_ -> Timestamp,
+    }
+}
+
+table! {
+    mod_lock_post (id) {
+        id -> Int4,
+        mod_user_id -> Int4,
+        post_id -> Int4,
+        locked -> Nullable<Bool>,
+        when_ -> Timestamp,
+    }
+}
+
+table! {
+    mod_remove_comment (id) {
+        id -> Int4,
+        mod_user_id -> Int4,
+        comment_id -> Int4,
+        reason -> Nullable<Text>,
+        removed -> Nullable<Bool>,
+        when_ -> Timestamp,
+    }
+}
+
+table! {
+    mod_remove_community (id) {
+        id -> Int4,
+        mod_user_id -> Int4,
+        community_id -> Int4,
+        reason -> Nullable<Text>,
+        removed -> Nullable<Bool>,
+        expires -> Nullable<Timestamp>,
+        when_ -> Timestamp,
+    }
+}
+
+table! {
+    mod_remove_post (id) {
+        id -> Int4,
+        mod_user_id -> Int4,
+        post_id -> Int4,
+        reason -> Nullable<Text>,
+        removed -> Nullable<Bool>,
+        when_ -> Timestamp,
+    }
+}
+
+table! {
+    mod_sticky_post (id) {
+        id -> Int4,
+        mod_user_id -> Int4,
+        post_id -> Int4,
+        stickied -> Nullable<Bool>,
+        when_ -> Timestamp,
+    }
+}
+
+table! {
+    post (id) {
+        id -> Int4,
+        name -> Varchar,
+        url -> Nullable<Text>,
+        body -> Nullable<Text>,
+        creator_id -> Int4,
+        community_id -> Int4,
+        removed -> Bool,
+        locked -> Bool,
+        published -> Timestamp,
+        updated -> Nullable<Timestamp>,
+        deleted -> Bool,
+        nsfw -> Bool,
+        stickied -> Bool,
+    }
+}
+
+table! {
+    post_like (id) {
+        id -> Int4,
+        post_id -> Int4,
+        user_id -> Int4,
+        score -> Int2,
+        published -> Timestamp,
+    }
+}
+
+table! {
+    post_read (id) {
+        id -> Int4,
+        post_id -> Int4,
+        user_id -> Int4,
+        published -> Timestamp,
+    }
+}
+
+table! {
+    post_saved (id) {
+        id -> Int4,
+        post_id -> Int4,
+        user_id -> Int4,
+        published -> Timestamp,
+    }
+}
+
+table! {
+    site (id) {
+        id -> Int4,
+        name -> Varchar,
+        description -> Nullable<Text>,
+        creator_id -> Int4,
+        published -> Timestamp,
+        updated -> Nullable<Timestamp>,
+    }
+}
+
+table! {
+    user_ (id) {
+        id -> Int4,
+        name -> Varchar,
+        fedi_name -> Varchar,
+        preferred_username -> Nullable<Varchar>,
+        password_encrypted -> Text,
+        email -> Nullable<Text>,
+        icon -> Nullable<Bytea>,
+        admin -> Bool,
+        banned -> Bool,
+        published -> Timestamp,
+        updated -> Nullable<Timestamp>,
+        show_nsfw -> Bool,
+        theme -> Varchar,
+    }
+}
+
+table! {
+    user_ban (id) {
+        id -> Int4,
+        user_id -> Int4,
+        published -> Timestamp,
+    }
+}
+
+table! {
+    user_mention (id) {
+        id -> Int4,
+        recipient_id -> Int4,
+        comment_id -> Int4,
+        read -> Bool,
+        published -> Timestamp,
+    }
+}
+
+joinable!(comment -> post (post_id));
+joinable!(comment -> user_ (creator_id));
+joinable!(comment_like -> comment (comment_id));
+joinable!(comment_like -> post (post_id));
+joinable!(comment_like -> user_ (user_id));
+joinable!(comment_saved -> comment (comment_id));
+joinable!(comment_saved -> user_ (user_id));
+joinable!(community -> category (category_id));
+joinable!(community -> user_ (creator_id));
+joinable!(community_follower -> community (community_id));
+joinable!(community_follower -> user_ (user_id));
+joinable!(community_moderator -> community (community_id));
+joinable!(community_moderator -> user_ (user_id));
+joinable!(community_user_ban -> community (community_id));
+joinable!(community_user_ban -> user_ (user_id));
+joinable!(mod_add_community -> community (community_id));
+joinable!(mod_ban_from_community -> community (community_id));
+joinable!(mod_lock_post -> post (post_id));
+joinable!(mod_lock_post -> user_ (mod_user_id));
+joinable!(mod_remove_comment -> comment (comment_id));
+joinable!(mod_remove_comment -> user_ (mod_user_id));
+joinable!(mod_remove_community -> community (community_id));
+joinable!(mod_remove_community -> user_ (mod_user_id));
+joinable!(mod_remove_post -> post (post_id));
+joinable!(mod_remove_post -> user_ (mod_user_id));
+joinable!(mod_sticky_post -> post (post_id));
+joinable!(mod_sticky_post -> user_ (mod_user_id));
+joinable!(post -> community (community_id));
+joinable!(post -> user_ (creator_id));
+joinable!(post_like -> post (post_id));
+joinable!(post_like -> user_ (user_id));
+joinable!(post_read -> post (post_id));
+joinable!(post_read -> user_ (user_id));
+joinable!(post_saved -> post (post_id));
+joinable!(post_saved -> user_ (user_id));
+joinable!(site -> user_ (creator_id));
+joinable!(user_ban -> user_ (user_id));
+joinable!(user_mention -> comment (comment_id));
+joinable!(user_mention -> user_ (recipient_id));
+
+allow_tables_to_appear_in_same_query!(
+    category,
+    comment,
+    comment_like,
+    comment_saved,
+    community,
+    community_follower,
+    community_moderator,
+    community_user_ban,
+    mod_add,
+    mod_add_community,
+    mod_ban,
+    mod_ban_from_community,
+    mod_lock_post,
+    mod_remove_comment,
+    mod_remove_community,
+    mod_remove_post,
+    mod_sticky_post,
+    post,
+    post_like,
+    post_read,
+    post_saved,
+    site,
+    user_,
+    user_ban,
+    user_mention,
+);
diff --git a/server/src/db/user_mention.rs b/server/src/db/user_mention.rs
new file mode 100644 (file)
index 0000000..d4dc0a5
--- /dev/null
@@ -0,0 +1,169 @@
+use super::comment::Comment;
+use super::*;
+use crate::schema::user_mention;
+
+#[derive(Queryable, Associations, Identifiable, PartialEq, Debug, Serialize, Deserialize)]
+#[belongs_to(Comment)]
+#[table_name = "user_mention"]
+pub struct UserMention {
+  pub id: i32,
+  pub recipient_id: i32,
+  pub comment_id: i32,
+  pub read: bool,
+  pub published: chrono::NaiveDateTime,
+}
+
+#[derive(Insertable, AsChangeset, Clone)]
+#[table_name = "user_mention"]
+pub struct UserMentionForm {
+  pub recipient_id: i32,
+  pub comment_id: i32,
+  pub read: Option<bool>,
+}
+
+impl Crud<UserMentionForm> for UserMention {
+  fn read(conn: &PgConnection, user_mention_id: i32) -> Result<Self, Error> {
+    use crate::schema::user_mention::dsl::*;
+    user_mention.find(user_mention_id).first::<Self>(conn)
+  }
+
+  fn delete(conn: &PgConnection, user_mention_id: i32) -> Result<usize, Error> {
+    use crate::schema::user_mention::dsl::*;
+    diesel::delete(user_mention.find(user_mention_id)).execute(conn)
+  }
+
+  fn create(conn: &PgConnection, user_mention_form: &UserMentionForm) -> Result<Self, Error> {
+    use crate::schema::user_mention::dsl::*;
+    insert_into(user_mention)
+      .values(user_mention_form)
+      .get_result::<Self>(conn)
+  }
+
+  fn update(
+    conn: &PgConnection,
+    user_mention_id: i32,
+    user_mention_form: &UserMentionForm,
+  ) -> Result<Self, Error> {
+    use crate::schema::user_mention::dsl::*;
+    diesel::update(user_mention.find(user_mention_id))
+      .set(user_mention_form)
+      .get_result::<Self>(conn)
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use super::super::comment::*;
+  use super::super::community::*;
+  use super::super::post::*;
+  use super::super::user::*;
+  use super::*;
+  #[test]
+  fn test_crud() {
+    let conn = establish_connection();
+
+    let new_user = UserForm {
+      name: "terrylake".into(),
+      fedi_name: "rrf".into(),
+      preferred_username: None,
+      password_encrypted: "nope".into(),
+      email: None,
+      admin: false,
+      banned: false,
+      updated: None,
+      show_nsfw: false,
+      theme: "darkly".into(),
+    };
+
+    let inserted_user = User_::create(&conn, &new_user).unwrap();
+
+    let recipient_form = UserForm {
+      name: "terrylakes recipient".into(),
+      fedi_name: "rrf".into(),
+      preferred_username: None,
+      password_encrypted: "nope".into(),
+      email: None,
+      admin: false,
+      banned: false,
+      updated: None,
+      show_nsfw: false,
+      theme: "darkly".into(),
+    };
+
+    let inserted_recipient = User_::create(&conn, &recipient_form).unwrap();
+
+    let new_community = CommunityForm {
+      name: "test community lake".to_string(),
+      title: "nada".to_owned(),
+      description: None,
+      category_id: 1,
+      creator_id: inserted_user.id,
+      removed: None,
+      deleted: None,
+      updated: None,
+      nsfw: false,
+    };
+
+    let inserted_community = Community::create(&conn, &new_community).unwrap();
+
+    let new_post = PostForm {
+      name: "A test post".into(),
+      creator_id: inserted_user.id,
+      url: None,
+      body: None,
+      community_id: inserted_community.id,
+      removed: None,
+      deleted: None,
+      locked: None,
+      stickied: None,
+      updated: None,
+      nsfw: false,
+    };
+
+    let inserted_post = Post::create(&conn, &new_post).unwrap();
+
+    let comment_form = CommentForm {
+      content: "A test comment".into(),
+      creator_id: inserted_user.id,
+      post_id: inserted_post.id,
+      removed: None,
+      deleted: None,
+      read: None,
+      parent_id: None,
+      updated: None,
+    };
+
+    let inserted_comment = Comment::create(&conn, &comment_form).unwrap();
+
+    let user_mention_form = UserMentionForm {
+      recipient_id: inserted_recipient.id,
+      comment_id: inserted_comment.id,
+      read: None,
+    };
+
+    let inserted_mention = UserMention::create(&conn, &user_mention_form).unwrap();
+
+    let expected_mention = UserMention {
+      id: inserted_mention.id,
+      recipient_id: inserted_mention.recipient_id,
+      comment_id: inserted_mention.comment_id,
+      read: false,
+      published: inserted_mention.published,
+    };
+
+    let read_mention = UserMention::read(&conn, inserted_mention.id).unwrap();
+    let updated_mention =
+      UserMention::update(&conn, inserted_mention.id, &user_mention_form).unwrap();
+    let num_deleted = UserMention::delete(&conn, inserted_mention.id).unwrap();
+    Comment::delete(&conn, inserted_comment.id).unwrap();
+    Post::delete(&conn, inserted_post.id).unwrap();
+    Community::delete(&conn, inserted_community.id).unwrap();
+    User_::delete(&conn, inserted_user.id).unwrap();
+    User_::delete(&conn, inserted_recipient.id).unwrap();
+
+    assert_eq!(expected_mention, read_mention);
+    assert_eq!(expected_mention, inserted_mention);
+    assert_eq!(expected_mention, updated_mention);
+    assert_eq!(1, num_deleted);
+  }
+}
diff --git a/server/src/db/user_mention_view.rs b/server/src/db/user_mention_view.rs
new file mode 100644 (file)
index 0000000..6676ab9
--- /dev/null
@@ -0,0 +1,117 @@
+use super::*;
+
+// The faked schema since diesel doesn't do views
+table! {
+  user_mention_view (id) {
+    id -> Int4,
+    user_mention_id -> Int4,
+    creator_id -> Int4,
+    post_id -> Int4,
+    parent_id -> Nullable<Int4>,
+    content -> Text,
+    removed -> Bool,
+    read -> Bool,
+    published -> Timestamp,
+    updated -> Nullable<Timestamp>,
+    deleted -> Bool,
+    community_id -> Int4,
+    banned -> Bool,
+    banned_from_community -> Bool,
+    creator_name -> Varchar,
+    score -> BigInt,
+    upvotes -> BigInt,
+    downvotes -> BigInt,
+    user_id -> Nullable<Int4>,
+    my_vote -> Nullable<Int4>,
+    saved -> Nullable<Bool>,
+    recipient_id -> Int4,
+  }
+}
+
+#[derive(
+  Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize, QueryableByName, Clone,
+)]
+#[table_name = "user_mention_view"]
+pub struct UserMentionView {
+  pub id: i32,
+  pub user_mention_id: i32,
+  pub creator_id: i32,
+  pub post_id: i32,
+  pub parent_id: Option<i32>,
+  pub content: String,
+  pub removed: bool,
+  pub read: bool,
+  pub published: chrono::NaiveDateTime,
+  pub updated: Option<chrono::NaiveDateTime>,
+  pub deleted: bool,
+  pub community_id: i32,
+  pub banned: bool,
+  pub banned_from_community: bool,
+  pub creator_name: String,
+  pub score: i64,
+  pub upvotes: i64,
+  pub downvotes: i64,
+  pub user_id: Option<i32>,
+  pub my_vote: Option<i32>,
+  pub saved: Option<bool>,
+  pub recipient_id: i32,
+}
+
+impl UserMentionView {
+  pub fn get_mentions(
+    conn: &PgConnection,
+    for_user_id: i32,
+    sort: &SortType,
+    unread_only: bool,
+    page: Option<i64>,
+    limit: Option<i64>,
+  ) -> Result<Vec<Self>, Error> {
+    use super::user_mention_view::user_mention_view::dsl::*;
+
+    let (limit, offset) = limit_and_offset(page, limit);
+
+    let mut query = user_mention_view.into_boxed();
+
+    query = query
+      .filter(user_id.eq(for_user_id))
+      .filter(recipient_id.eq(for_user_id));
+
+    if unread_only {
+      query = query.filter(read.eq(false));
+    }
+
+    query = match sort {
+      // SortType::Hot => query.order_by(hot_rank.desc()),
+      SortType::New => query.order_by(published.desc()),
+      SortType::TopAll => query.order_by(score.desc()),
+      SortType::TopYear => query
+        .filter(published.gt(now - 1.years()))
+        .order_by(score.desc()),
+      SortType::TopMonth => query
+        .filter(published.gt(now - 1.months()))
+        .order_by(score.desc()),
+      SortType::TopWeek => query
+        .filter(published.gt(now - 1.weeks()))
+        .order_by(score.desc()),
+      SortType::TopDay => query
+        .filter(published.gt(now - 1.days()))
+        .order_by(score.desc()),
+      _ => query.order_by(published.desc()),
+    };
+
+    query.limit(limit).offset(offset).load::<Self>(conn)
+  }
+
+  pub fn read(
+    conn: &PgConnection,
+    from_user_mention_id: i32,
+    from_recipient_id: i32,
+  ) -> Result<Self, Error> {
+    use super::user_mention_view::user_mention_view::dsl::*;
+
+    user_mention_view
+      .filter(user_mention_id.eq(from_user_mention_id))
+      .filter(user_id.eq(from_recipient_id))
+      .first::<Self>(conn)
+  }
+}
index d75a0d18e5eee885ad7e19d6dac089fdea6fa6b8..715d9ef33ff0e20fc05579134bebf5190fb90991 100644 (file)
@@ -104,9 +104,23 @@ pub fn has_slurs(test: &str) -> bool {
   SLUR_REGEX.is_match(test)
 }
 
+pub fn extract_usernames(test: &str) -> Vec<&str> {
+  let mut matches: Vec<&str> = USERNAME_MATCHES_REGEX
+    .find_iter(test)
+    .map(|mat| mat.as_str())
+    .collect();
+
+  // Unique
+  matches.sort_unstable();
+  matches.dedup();
+
+  // Remove /u/
+  matches.iter().map(|t| &t[3..]).collect()
+}
+
 #[cfg(test)]
 mod tests {
-  use crate::{has_slurs, is_email_regex, remove_slurs, Settings};
+  use crate::{extract_usernames, has_slurs, is_email_regex, remove_slurs, Settings};
   #[test]
   fn test_api() {
     assert_eq!(Settings::get().api_endpoint(), "rrr/api/v1");
@@ -131,9 +145,17 @@ mod tests {
     assert!(has_slurs(&test));
     assert!(!has_slurs(slur_free));
   }
+
+  #[test]
+  fn test_extract_usernames() {
+    let usernames = extract_usernames("this is a user mention for [/u/testme](/u/testme) and thats all. Oh [/u/another](/u/another) user. And the first again [/u/testme](/u/testme) okay");
+    let expected = vec!["another", "testme"];
+    assert_eq!(usernames, expected);
+  }
 }
 
 lazy_static! {
   static ref EMAIL_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$").unwrap();
   static ref SLUR_REGEX: Regex = Regex::new(r"(fag(g|got|tard)?|maricos?|cock\s?sucker(s|ing)?|nig(\b|g?(a|er)?s?)\b|dindu(s?)|mudslime?s?|kikes?|mongoloids?|towel\s*heads?|\bspi(c|k)s?\b|\bchinks?|niglets?|beaners?|\bnips?\b|\bcoons?\b|jungle\s*bunn(y|ies?)|jigg?aboo?s?|\bpakis?\b|rag\s*heads?|gooks?|cunts?|bitch(es|ing|y)?|puss(y|ies?)|twats?|feminazis?|whor(es?|ing)|\bslut(s|t?y)?|\btrann?(y|ies?)|ladyboy(s?)|\b(b|re|r)tard(ed)?s?)").unwrap();
+  static ref USERNAME_MATCHES_REGEX: Regex = Regex::new(r"/u/[a-zA-Z][0-9a-zA-Z_]*").unwrap();
 }
index b4e16d1367fa8dd9eacf88ffb795df2276154552..9111c8e30504af22eae45243cffc4c93d045da56 100644 (file)
@@ -266,6 +266,16 @@ table! {
     }
 }
 
+table! {
+    user_mention (id) {
+        id -> Int4,
+        recipient_id -> Int4,
+        comment_id -> Int4,
+        read -> Bool,
+        published -> Timestamp,
+    }
+}
+
 joinable!(comment -> post (post_id));
 joinable!(comment -> user_ (creator_id));
 joinable!(comment_like -> comment (comment_id));
@@ -303,6 +313,8 @@ joinable!(post_saved -> post (post_id));
 joinable!(post_saved -> user_ (user_id));
 joinable!(site -> user_ (creator_id));
 joinable!(user_ban -> user_ (user_id));
+joinable!(user_mention -> comment (comment_id));
+joinable!(user_mention -> user_ (recipient_id));
 
 allow_tables_to_appear_in_same_query!(
   category,
@@ -329,4 +341,5 @@ allow_tables_to_appear_in_same_query!(
   site,
   user_,
   user_ban,
+  user_mention,
 );
index a00cfc3b032afbc669e5c9813390aa5ef58795f2..aeca8c0c1518c7465aedc3ec9b4fd57a452e2c01 100644 (file)
@@ -343,6 +343,16 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
       let res = Oper::new(user_operation, get_replies).perform()?;
       Ok(serde_json::to_string(&res)?)
     }
+    UserOperation::GetUserMentions => {
+      let get_user_mentions: GetUserMentions = serde_json::from_str(data)?;
+      let res = Oper::new(user_operation, get_user_mentions).perform()?;
+      Ok(serde_json::to_string(&res)?)
+    }
+    UserOperation::EditUserMention => {
+      let edit_user_mention: EditUserMention = serde_json::from_str(data)?;
+      let res = Oper::new(user_operation, edit_user_mention).perform()?;
+      Ok(serde_json::to_string(&res)?)
+    }
     UserOperation::MarkAllAsRead => {
       let mark_all_as_read: MarkAllAsRead = serde_json::from_str(data)?;
       let res = Oper::new(user_operation, mark_all_as_read).perform()?;
index 7eb5d9d4658454056bee58550c6a7a53e3e480c5..e3d821968d98d4e074ba2387b06afecc5a0a4942 100644 (file)
@@ -4,6 +4,7 @@ import {
   CommentNode as CommentNodeI,
   CommentLikeForm,
   CommentForm as CommentFormI,
+  EditUserMentionForm,
   SaveCommentForm,
   BanFromCommunityForm,
   BanUserForm,
@@ -686,16 +687,25 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
   }
 
   handleMarkRead(i: CommentNode) {
-    let form: CommentFormI = {
-      content: i.props.node.comment.content,
-      edit_id: i.props.node.comment.id,
-      creator_id: i.props.node.comment.creator_id,
-      post_id: i.props.node.comment.post_id,
-      parent_id: i.props.node.comment.parent_id,
-      read: !i.props.node.comment.read,
-      auth: null,
-    };
-    WebSocketService.Instance.editComment(form);
+    // if it has a user_mention_id field, then its a mention
+    if (i.props.node.comment.user_mention_id) {
+      let form: EditUserMentionForm = {
+        user_mention_id: i.props.node.comment.user_mention_id,
+        read: !i.props.node.comment.read,
+      };
+      WebSocketService.Instance.editUserMention(form);
+    } else {
+      let form: CommentFormI = {
+        content: i.props.node.comment.content,
+        edit_id: i.props.node.comment.id,
+        creator_id: i.props.node.comment.creator_id,
+        post_id: i.props.node.comment.post_id,
+        parent_id: i.props.node.comment.parent_id,
+        read: !i.props.node.comment.read,
+        auth: null,
+      };
+      WebSocketService.Instance.editComment(form);
+    }
   }
 
   handleModBanFromCommunityShow(i: CommentNode) {
index 9d548f8d43803e8c3abc355b2c3a18acc17896d2..6e961b17107e3694c3fa124640ef906d39751d97 100644 (file)
@@ -8,6 +8,9 @@ import {
   SortType,
   GetRepliesForm,
   GetRepliesResponse,
+  GetUserMentionsForm,
+  GetUserMentionsResponse,
+  UserMentionResponse,
   CommentResponse,
 } from '../interfaces';
 import { WebSocketService, UserService } from '../services';
@@ -16,14 +19,22 @@ import { CommentNodes } from './comment-nodes';
 import { i18n } from '../i18next';
 import { T } from 'inferno-i18next';
 
-enum UnreadType {
+enum UnreadOrAll {
   Unread,
   All,
 }
 
+enum UnreadType {
+  Both,
+  Replies,
+  Mentions,
+}
+
 interface InboxState {
+  unreadOrAll: UnreadOrAll;
   unreadType: UnreadType;
   replies: Array<Comment>;
+  mentions: Array<Comment>;
   sort: SortType;
   page: number;
 }
@@ -31,8 +42,10 @@ interface InboxState {
 export class Inbox extends Component<any, InboxState> {
   private subscription: Subscription;
   private emptyState: InboxState = {
-    unreadType: UnreadType.Unread,
+    unreadOrAll: UnreadOrAll.Unread,
+    unreadType: UnreadType.Both,
     replies: [],
+    mentions: [],
     sort: SortType.New,
     page: 1,
   };
@@ -83,8 +96,8 @@ export class Inbox extends Component<any, InboxState> {
                 </T>
               </span>
             </h5>
-            {this.state.replies.length > 0 &&
-              this.state.unreadType == UnreadType.Unread && (
+            {this.state.replies.length + this.state.mentions.length > 0 &&
+              this.state.unreadOrAll == UnreadOrAll.Unread && (
                 <ul class="list-inline mb-1 text-muted small font-weight-bold">
                   <li className="list-inline-item">
                     <span class="pointer" onClick={this.markAllAsRead}>
@@ -94,7 +107,9 @@ export class Inbox extends Component<any, InboxState> {
                 </ul>
               )}
             {this.selects()}
-            {this.replies()}
+            {this.state.unreadType == UnreadType.Both && this.both()}
+            {this.state.unreadType == UnreadType.Replies && this.replies()}
+            {this.state.unreadType == UnreadType.Mentions && this.mentions()}
             {this.paginator()}
           </div>
         </div>
@@ -106,24 +121,42 @@ export class Inbox extends Component<any, InboxState> {
     return (
       <div className="mb-2">
         <select
-          value={this.state.unreadType}
-          onChange={linkEvent(this, this.handleUnreadTypeChange)}
-          class="custom-select custom-select-sm w-auto"
+          value={this.state.unreadOrAll}
+          onChange={linkEvent(this, this.handleUnreadOrAllChange)}
+          class="custom-select custom-select-sm w-auto mr-2"
         >
           <option disabled>
             <T i18nKey="type">#</T>
           </option>
-          <option value={UnreadType.Unread}>
+          <option value={UnreadOrAll.Unread}>
             <T i18nKey="unread">#</T>
           </option>
-          <option value={UnreadType.All}>
+          <option value={UnreadOrAll.All}>
             <T i18nKey="all">#</T>
           </option>
         </select>
+        <select
+          value={this.state.unreadType}
+          onChange={linkEvent(this, this.handleUnreadTypeChange)}
+          class="custom-select custom-select-sm w-auto mr-2"
+        >
+          <option disabled>
+            <T i18nKey="type">#</T>
+          </option>
+          <option value={UnreadType.Both}>
+            <T i18nKey="both">#</T>
+          </option>
+          <option value={UnreadType.Replies}>
+            <T i18nKey="replies">#</T>
+          </option>
+          <option value={UnreadType.Mentions}>
+            <T i18nKey="mentions">#</T>
+          </option>
+        </select>
         <select
           value={this.state.sort}
           onChange={linkEvent(this, this.handleSortChange)}
-          class="custom-select custom-select-sm w-auto ml-2"
+          class="custom-select custom-select-sm w-auto"
         >
           <option disabled>
             <T i18nKey="sort_type">#</T>
@@ -151,6 +184,37 @@ export class Inbox extends Component<any, InboxState> {
     );
   }
 
+  both() {
+    let combined: Array<{
+      type_: string;
+      data: Comment;
+    }> = [];
+    let replies = this.state.replies.map(e => {
+      return { type_: 'replies', data: e };
+    });
+    let mentions = this.state.mentions.map(e => {
+      return { type_: 'mentions', data: e };
+    });
+
+    combined.push(...replies);
+    combined.push(...mentions);
+
+    // Sort it
+    if (this.state.sort == SortType.New) {
+      combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
+    } else {
+      combined.sort((a, b) => b.data.score - a.data.score);
+    }
+
+    return (
+      <div>
+        {combined.map(i => (
+          <CommentNodes nodes={[{ comment: i.data }]} noIndent markable />
+        ))}
+      </div>
+    );
+  }
+
   replies() {
     return (
       <div>
@@ -161,6 +225,16 @@ export class Inbox extends Component<any, InboxState> {
     );
   }
 
+  mentions() {
+    return (
+      <div>
+        {this.state.mentions.map(mention => (
+          <CommentNodes nodes={[{ comment: mention }]} noIndent markable />
+        ))}
+      </div>
+    );
+  }
+
   paginator() {
     return (
       <div class="mt-2">
@@ -194,6 +268,13 @@ export class Inbox extends Component<any, InboxState> {
     i.refetch();
   }
 
+  handleUnreadOrAllChange(i: Inbox, event: any) {
+    i.state.unreadOrAll = Number(event.target.value);
+    i.state.page = 1;
+    i.setState(i.state);
+    i.refetch();
+  }
+
   handleUnreadTypeChange(i: Inbox, event: any) {
     i.state.unreadType = Number(event.target.value);
     i.state.page = 1;
@@ -202,13 +283,21 @@ export class Inbox extends Component<any, InboxState> {
   }
 
   refetch() {
-    let form: GetRepliesForm = {
+    let repliesForm: GetRepliesForm = {
       sort: SortType[this.state.sort],
-      unread_only: this.state.unreadType == UnreadType.Unread,
+      unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
       page: this.state.page,
       limit: 9999,
     };
-    WebSocketService.Instance.getReplies(form);
+    WebSocketService.Instance.getReplies(repliesForm);
+
+    let userMentionsForm: GetUserMentionsForm = {
+      sort: SortType[this.state.sort],
+      unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
+      page: this.state.page,
+      limit: 9999,
+    };
+    WebSocketService.Instance.getUserMentions(userMentionsForm);
   }
 
   handleSortChange(i: Inbox, event: any) {
@@ -228,13 +317,21 @@ export class Inbox extends Component<any, InboxState> {
     if (msg.error) {
       alert(i18n.t(msg.error));
       return;
-    } else if (
-      op == UserOperation.GetReplies ||
-      op == UserOperation.MarkAllAsRead
-    ) {
+    } else if (op == UserOperation.GetReplies) {
       let res: GetRepliesResponse = msg;
       this.state.replies = res.replies;
-      this.sendRepliesCount();
+      this.sendUnreadCount();
+      window.scrollTo(0, 0);
+      this.setState(this.state);
+    } else if (op == UserOperation.GetUserMentions) {
+      let res: GetUserMentionsResponse = msg;
+      this.state.mentions = res.mentions;
+      this.sendUnreadCount();
+      window.scrollTo(0, 0);
+      this.setState(this.state);
+    } else if (op == UserOperation.MarkAllAsRead) {
+      this.state.replies = [];
+      this.state.mentions = [];
       window.scrollTo(0, 0);
       this.setState(this.state);
     } else if (op == UserOperation.EditComment) {
@@ -250,7 +347,7 @@ export class Inbox extends Component<any, InboxState> {
       found.score = res.comment.score;
 
       // If youre in the unread view, just remove it from the list
-      if (this.state.unreadType == UnreadType.Unread && res.comment.read) {
+      if (this.state.unreadOrAll == UnreadOrAll.Unread && res.comment.read) {
         this.state.replies = this.state.replies.filter(
           r => r.id !== res.comment.id
         );
@@ -258,8 +355,30 @@ export class Inbox extends Component<any, InboxState> {
         let found = this.state.replies.find(c => c.id == res.comment.id);
         found.read = res.comment.read;
       }
-      this.sendRepliesCount();
+      this.sendUnreadCount();
+      this.setState(this.state);
+    } else if (op == UserOperation.EditUserMention) {
+      let res: UserMentionResponse = msg;
 
+      let found = this.state.mentions.find(c => c.id == res.mention.id);
+      found.content = res.mention.content;
+      found.updated = res.mention.updated;
+      found.removed = res.mention.removed;
+      found.deleted = res.mention.deleted;
+      found.upvotes = res.mention.upvotes;
+      found.downvotes = res.mention.downvotes;
+      found.score = res.mention.score;
+
+      // If youre in the unread view, just remove it from the list
+      if (this.state.unreadOrAll == UnreadOrAll.Unread && res.mention.read) {
+        this.state.mentions = this.state.mentions.filter(
+          r => r.id !== res.mention.id
+        );
+      } else {
+        let found = this.state.mentions.find(c => c.id == res.mention.id);
+        found.read = res.mention.read;
+      }
+      this.sendUnreadCount();
       this.setState(this.state);
     } else if (op == UserOperation.CreateComment) {
       // let res: CommentResponse = msg;
@@ -284,10 +403,13 @@ export class Inbox extends Component<any, InboxState> {
     }
   }
 
-  sendRepliesCount() {
+  sendUnreadCount() {
+    let count =
+      this.state.replies.filter(r => !r.read).length +
+      this.state.mentions.filter(r => !r.read).length;
     UserService.Instance.sub.next({
       user: UserService.Instance.user,
-      unreadCount: this.state.replies.filter(r => !r.read).length,
+      unreadCount: count,
     });
   }
 }
index ba0dead2575eceb9b24cf5d9f3252de335f28f02..151559dfd327f7d0fe7ea96c20c7c097e6e3c72f 100644 (file)
@@ -7,6 +7,8 @@ import {
   UserOperation,
   GetRepliesForm,
   GetRepliesResponse,
+  GetUserMentionsForm,
+  GetUserMentionsResponse,
   SortType,
   GetSiteResponse,
   Comment,
@@ -21,6 +23,7 @@ interface NavbarState {
   expanded: boolean;
   expandUserDropdown: boolean;
   replies: Array<Comment>;
+  mentions: Array<Comment>;
   fetchCount: number;
   unreadCount: number;
   siteName: string;
@@ -34,6 +37,7 @@ export class Navbar extends Component<any, NavbarState> {
     unreadCount: 0,
     fetchCount: 0,
     replies: [],
+    mentions: [],
     expanded: false,
     expandUserDropdown: false,
     siteName: undefined,
@@ -44,7 +48,7 @@ export class Navbar extends Component<any, NavbarState> {
     this.state = this.emptyState;
     this.handleOverviewClick = this.handleOverviewClick.bind(this);
 
-    this.keepFetchingReplies();
+    this.keepFetchingUnreads();
 
     // Subscribe to user changes
     this.userSub = UserService.Instance.sub.subscribe(user => {
@@ -233,7 +237,22 @@ export class Navbar extends Component<any, NavbarState> {
       }
 
       this.state.replies = unreadReplies;
-      this.sendRepliesCount(res);
+      this.setState(this.state);
+      this.sendUnreadCount();
+    } else if (op == UserOperation.GetUserMentions) {
+      let res: GetUserMentionsResponse = msg;
+      let unreadMentions = res.mentions.filter(r => !r.read);
+      if (
+        unreadMentions.length > 0 &&
+        this.state.fetchCount > 1 &&
+        JSON.stringify(this.state.mentions) !== JSON.stringify(unreadMentions)
+      ) {
+        this.notify(unreadMentions);
+      }
+
+      this.state.mentions = unreadMentions;
+      this.setState(this.state);
+      this.sendUnreadCount();
     } else if (op == UserOperation.GetSite) {
       let res: GetSiteResponse = msg;
 
@@ -245,12 +264,12 @@ export class Navbar extends Component<any, NavbarState> {
     }
   }
 
-  keepFetchingReplies() {
-    this.fetchReplies();
-    setInterval(() => this.fetchReplies(), 15000);
+  keepFetchingUnreads() {
+    this.fetchUnreads();
+    setInterval(() => this.fetchUnreads(), 15000);
   }
 
-  fetchReplies() {
+  fetchUnreads() {
     if (this.state.isLoggedIn) {
       let repliesForm: GetRepliesForm = {
         sort: SortType[SortType.New],
@@ -258,8 +277,16 @@ export class Navbar extends Component<any, NavbarState> {
         page: 1,
         limit: 9999,
       };
+
+      let userMentionsForm: GetUserMentionsForm = {
+        sort: SortType[SortType.New],
+        unread_only: true,
+        page: 1,
+        limit: 9999,
+      };
       if (this.currentLocation !== '/inbox') {
         WebSocketService.Instance.getReplies(repliesForm);
+        WebSocketService.Instance.getUserMentions(userMentionsForm);
         this.state.fetchCount++;
       }
     }
@@ -269,13 +296,20 @@ export class Navbar extends Component<any, NavbarState> {
     return this.context.router.history.location.pathname;
   }
 
-  sendRepliesCount(res: GetRepliesResponse) {
+  sendUnreadCount() {
     UserService.Instance.sub.next({
       user: UserService.Instance.user,
-      unreadCount: res.replies.filter(r => !r.read).length,
+      unreadCount: this.unreadCount,
     });
   }
 
+  get unreadCount() {
+    return (
+      this.state.replies.filter(r => !r.read).length +
+      this.state.mentions.filter(r => !r.read).length
+    );
+  }
+
   requestNotificationPermission() {
     if (UserService.Instance.user) {
       document.addEventListener('DOMContentLoaded', function() {
index daf9aa2d811255baf05b8df7dfe26adb4a2e32ca..68b4ee88e2fb953426c24393a4fb9febe54a7f80 100644 (file)
@@ -396,11 +396,16 @@ export class Search extends Component<any, SearchState> {
     let res = this.state.searchResponse;
     return (
       <div>
-        {res && res.op && res.posts.length == 0 && res.comments.length == 0 && (
-          <span>
-            <T i18nKey="no_results">#</T>
-          </span>
-        )}
+        {res &&
+          res.op &&
+          res.posts.length == 0 &&
+          res.comments.length == 0 &&
+          res.communities.length == 0 &&
+          res.users.length == 0 && (
+            <span>
+              <T i18nKey="no_results">#</T>
+            </span>
+          )}
       </div>
     );
   }
@@ -420,7 +425,6 @@ export class Search extends Component<any, SearchState> {
   }
 
   search() {
-    // TODO community
     let form: SearchForm = {
       q: this.state.q,
       type_: SearchType[this.state.type_],
index 083cc7f6ad1d4c572353bad7be9c254c9e3f07c1..bfe720ff707fb21febbf065aac6bd519f74987d0 100644 (file)
@@ -11,7 +11,6 @@ import { zh } from './translations/zh';
 import { nl } from './translations/nl';
 
 // https://github.com/nimbusec-oss/inferno-i18next/blob/master/tests/T.test.js#L66
-// TODO don't forget to add moment locales for new languages.
 const resources = {
   en,
   eo,
@@ -30,7 +29,7 @@ function format(value: any, format: any, lng: any) {
 }
 
 i18n.init({
-  debug: true,
+  debug: false,
   // load: 'languageOnly',
 
   // initImmediate: false,
index fa14b23852c955a31ef70d3233598d3af41b9cba..4056e05d0f4c1256c23a569e3ff1a344adca71e5 100644 (file)
@@ -20,6 +20,8 @@ export enum UserOperation {
   GetFollowedCommunities,
   GetUserDetails,
   GetReplies,
+  GetUserMentions,
+  EditUserMention,
   GetModlog,
   BanFromCommunity,
   AddModToCommunity,
@@ -171,6 +173,8 @@ export interface Comment {
   user_id?: number;
   my_vote?: number;
   saved?: boolean;
+  user_mention_id?: number; // For mention type
+  recipient_id?: number;
 }
 
 export interface Category {
@@ -229,7 +233,7 @@ export interface UserDetailsResponse {
 }
 
 export interface GetRepliesForm {
-  sort: string; // TODO figure this one out
+  sort: string;
   page?: number;
   limit?: number;
   unread_only: boolean;
@@ -241,6 +245,30 @@ export interface GetRepliesResponse {
   replies: Array<Comment>;
 }
 
+export interface GetUserMentionsForm {
+  sort: string;
+  page?: number;
+  limit?: number;
+  unread_only: boolean;
+  auth?: string;
+}
+
+export interface GetUserMentionsResponse {
+  op: string;
+  mentions: Array<Comment>;
+}
+
+export interface EditUserMentionForm {
+  user_mention_id: number;
+  read?: boolean;
+  auth?: string;
+}
+
+export interface UserMentionResponse {
+  op: string;
+  mention: Comment;
+}
+
 export interface BanFromCommunityForm {
   community_id: number;
   user_id: number;
index f5d5b5136034eb364c2731a40d9706d1dd7c5773..ac465b221cc0bbd3dc2cb8b715ffccf324ae37a1 100644 (file)
@@ -25,6 +25,8 @@ import {
   Site,
   UserView,
   GetRepliesForm,
+  GetUserMentionsForm,
+  EditUserMentionForm,
   SearchForm,
   UserSettingsForm,
   DeleteAccountForm,
@@ -222,6 +224,16 @@ export class WebSocketService {
     this.subject.next(this.wsSendWrapper(UserOperation.GetReplies, form));
   }
 
+  public getUserMentions(form: GetUserMentionsForm) {
+    this.setAuth(form);
+    this.subject.next(this.wsSendWrapper(UserOperation.GetUserMentions, form));
+  }
+
+  public editUserMention(form: EditUserMentionForm) {
+    this.setAuth(form);
+    this.subject.next(this.wsSendWrapper(UserOperation.EditUserMention, form));
+  }
+
   public getModlog(form: GetModlogForm) {
     this.subject.next(this.wsSendWrapper(UserOperation.GetModlog, form));
   }
index 551c7ef83403b5f1dc7af5a84303a303dbbc0bf0..a971ae6e21f892e0b6e77a3de66a22ba5a2eaa4a 100644 (file)
@@ -101,6 +101,8 @@ export const en = {
     mark_all_as_read: 'mark all as read',
     type: 'Type',
     unread: 'Unread',
+    replies: 'Replies',
+    mentions: 'Mentions',
     reply_sent: 'Reply sent',
     search: 'Search',
     overview: 'Overview',