]> Untitled Git - lemmy.git/commitdiff
Adding private messaging, and matrix user ids.
authorDessalines <tyhou13@gmx.com>
Wed, 22 Jan 2020 21:35:29 +0000 (16:35 -0500)
committerDessalines <tyhou13@gmx.com>
Wed, 22 Jan 2020 21:38:16 +0000 (16:38 -0500)
- Fixes #244

34 files changed:
README.md
server/migrations/2020-01-21-001001_create_private_message/down.sql [new file with mode: 0644]
server/migrations/2020-01-21-001001_create_private_message/up.sql [new file with mode: 0644]
server/src/api/comment.rs
server/src/api/mod.rs
server/src/api/user.rs
server/src/apub/mod.rs
server/src/db/comment.rs
server/src/db/comment_view.rs
server/src/db/community.rs
server/src/db/mod.rs
server/src/db/moderator.rs
server/src/db/password_reset_request.rs
server/src/db/post.rs
server/src/db/post_view.rs
server/src/db/private_message.rs [new file with mode: 0644]
server/src/db/private_message_view.rs [new file with mode: 0644]
server/src/db/user.rs
server/src/db/user_mention.rs
server/src/db/user_view.rs
server/src/routes/index.rs
server/src/schema.rs
server/src/websocket/server.rs
ui/src/components/create-private-message.tsx [new file with mode: 0644]
ui/src/components/inbox.tsx
ui/src/components/navbar.tsx
ui/src/components/private-message-form.tsx [new file with mode: 0644]
ui/src/components/private-message.tsx [new file with mode: 0644]
ui/src/components/user.tsx
ui/src/index.tsx
ui/src/interfaces.ts
ui/src/services/WebSocketService.ts
ui/src/translations/en.ts
ui/src/utils.ts

index 51bd047692625fe492095e796667733f8d9a7eef..752e7d982423738653265e714cda4fb0bfb2c651 100644 (file)
--- a/README.md
+++ b/README.md
@@ -157,15 +157,15 @@ If you'd like to add translations, take a look a look at the [English translatio
 
 lang | done | missing
 --- | --- | ---
-de | 93% | avatar,upload_avatar,show_avatars,docs,old_password,send_notifications_to_email,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,email_already_exists 
-eo | 80% | number_of_communities,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,theme,donate_to_lemmy,donate,are_you_sure,yes,no,email_already_exists 
-es | 89% | avatar,upload_avatar,show_avatars,archive_link,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,email_already_exists 
-fr | 89% | avatar,upload_avatar,show_avatars,archive_link,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,email_already_exists 
-it | 89% | avatar,upload_avatar,show_avatars,archive_link,docs,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,email_already_exists 
-nl | 99% | donate_to_lemmy,donate,email_already_exists 
-ru | 77% | cross_posts,cross_post,number_of_communities,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,theme,donate_to_lemmy,donate,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no,email_already_exists 
-sv | 89% | avatar,upload_avatar,show_avatars,archive_link,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,email_already_exists 
-zh | 75% | cross_posts,cross_post,users,number_of_communities,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,nsfw,show_nsfw,theme,donate_to_lemmy,donate,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no,email_already_exists 
+de | 88% | create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,docs,message_sent,messages,old_password,matrix_user_id,private_message_disclaimer,send_notifications_to_email,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,from,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message 
+eo | 76% | number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,theme,donate_to_lemmy,donate,from,are_you_sure,yes,no,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message 
+es | 84% | create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,from,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message 
+fr | 84% | create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,from,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message 
+it | 85% | create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,docs,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,from,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message 
+nl | 93% | create_private_message,send_secure_message,send_message,message,message_sent,messages,matrix_user_id,private_message_disclaimer,donate_to_lemmy,donate,from,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message 
+ru | 72% | cross_posts,cross_post,number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,theme,donate_to_lemmy,donate,monero,by,to,from,transfer_community,transfer_site,are_you_sure,yes,no,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message 
+sv | 84% | create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,from,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message 
+zh | 71% | cross_posts,cross_post,users,number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,nsfw,show_nsfw,theme,donate_to_lemmy,donate,monero,by,to,from,transfer_community,transfer_site,are_you_sure,yes,no,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message 
 
 <!-- translationsstop -->
 
diff --git a/server/migrations/2020-01-21-001001_create_private_message/down.sql b/server/migrations/2020-01-21-001001_create_private_message/down.sql
new file mode 100644 (file)
index 0000000..0d951e3
--- /dev/null
@@ -0,0 +1,34 @@
+-- Drop the triggers
+drop trigger refresh_private_message on private_message;
+drop function refresh_private_message();
+
+-- Drop the view and table
+drop view private_message_view cascade;
+drop table private_message;
+
+-- Rebuild the old views
+drop view user_view cascade;
+create view user_view as 
+select 
+u.id,
+u.name,
+u.avatar,
+u.email,
+u.fedi_name,
+u.admin,
+u.banned,
+u.show_avatars,
+u.send_notifications_to_email,
+u.published,
+(select count(*) from post p where p.creator_id = u.id) as number_of_posts,
+(select coalesce(sum(score), 0) from post p, post_like pl where u.id = p.creator_id and p.id = pl.post_id) as post_score,
+(select count(*) from comment c where c.creator_id = u.id) as number_of_comments,
+(select coalesce(sum(score), 0) from comment c, comment_like cl where u.id = c.creator_id and c.id = cl.comment_id) as comment_score
+from user_ u;
+
+create materialized view user_mview as select * from user_view;
+
+create unique index idx_user_mview_id on user_mview (id);
+
+-- Drop the columns
+alter table user_ drop column matrix_user_id;
diff --git a/server/migrations/2020-01-21-001001_create_private_message/up.sql b/server/migrations/2020-01-21-001001_create_private_message/up.sql
new file mode 100644 (file)
index 0000000..48e16dd
--- /dev/null
@@ -0,0 +1,90 @@
+-- Creating private message
+create table private_message (
+  id serial primary key,
+  creator_id int references user_ on update cascade on delete cascade not null,
+  recipient_id int references user_ on update cascade on delete cascade not null,
+  content text not null,
+  deleted boolean default false not null,
+  read boolean default false not null,
+  published timestamp not null default now(),
+  updated timestamp
+);
+
+-- Create the view and materialized view which has the avatar and creator name
+create view private_message_view as 
+select        
+pm.*,
+u.name as creator_name,
+u.avatar as creator_avatar,
+u2.name as recipient_name,
+u2.avatar as recipient_avatar
+from private_message pm
+inner join user_ u on u.id = pm.creator_id
+inner join user_ u2 on u2.id = pm.recipient_id;
+
+create materialized view private_message_mview as select * from private_message_view;
+
+create unique index idx_private_message_mview_id on private_message_mview (id);
+
+-- Create the triggers
+create or replace function refresh_private_message()
+returns trigger language plpgsql
+as $$
+begin
+  refresh materialized view concurrently private_message_mview;
+  return null;
+end $$;
+
+create trigger refresh_private_message
+after insert or update or delete or truncate
+on private_message
+for each statement
+execute procedure refresh_private_message();
+
+-- Update user to include matrix id
+alter table user_ add column matrix_user_id text unique;
+
+drop view user_view cascade;
+create view user_view as 
+select 
+u.id,
+u.name,
+u.avatar,
+u.email,
+u.matrix_user_id,
+u.fedi_name,
+u.admin,
+u.banned,
+u.show_avatars,
+u.send_notifications_to_email,
+u.published,
+(select count(*) from post p where p.creator_id = u.id) as number_of_posts,
+(select coalesce(sum(score), 0) from post p, post_like pl where u.id = p.creator_id and p.id = pl.post_id) as post_score,
+(select count(*) from comment c where c.creator_id = u.id) as number_of_comments,
+(select coalesce(sum(score), 0) from comment c, comment_like cl where u.id = c.creator_id and c.id = cl.comment_id) as comment_score
+from user_ u;
+
+create materialized view user_mview as select * from user_view;
+
+create unique index idx_user_mview_id on user_mview (id);
+
+-- This is what a group pm table would look like
+-- Not going to do it now because of the complications
+-- 
+-- create table private_message (
+--   id serial primary key,
+--   creator_id int references user_ on update cascade on delete cascade not null,
+--   content text not null,
+--   deleted boolean default false not null,
+--   published timestamp not null default now(),
+--   updated timestamp
+-- );
+-- 
+-- create table private_message_recipient (
+--   id serial primary key,
+--   private_message_id int references private_message on update cascade on delete cascade not null,
+--   recipient_id int references user_ on update cascade on delete cascade not null,
+--   read boolean default false not null,
+--   published timestamp not null default now(),
+--   unique(private_message_id, recipient_id)
+-- )
index 61cc95063344c5af6618b95a3ae157cf6835bb21..382afb5b48b464d9333005aa5e9ad2740d35d29e 100644 (file)
@@ -7,7 +7,7 @@ use diesel::PgConnection;
 pub struct CreateComment {
   content: String,
   parent_id: Option<i32>,
-  edit_id: Option<i32>,
+  edit_id: Option<i32>, // TODO this isn't used
   pub post_id: i32,
   auth: String,
 }
@@ -15,7 +15,7 @@ pub struct CreateComment {
 #[derive(Serialize, Deserialize)]
 pub struct EditComment {
   content: String,
-  parent_id: Option<i32>,
+  parent_id: Option<i32>, // TODO why are the parent_id, creator_id, post_id, etc fields required? They aren't going to change
   edit_id: i32,
   creator_id: i32,
   pub post_id: i32,
index e35804476850de62cc00bb2648a8afdaf0bf155d..3b2466acf8f530e60010172c862dde1f8357432c 100644 (file)
@@ -8,6 +8,8 @@ use crate::db::moderator_views::*;
 use crate::db::password_reset_request::*;
 use crate::db::post::*;
 use crate::db::post_view::*;
+use crate::db::private_message::*;
+use crate::db::private_message_view::*;
 use crate::db::site::*;
 use crate::db::site_view::*;
 use crate::db::user::*;
@@ -67,6 +69,9 @@ pub enum UserOperation {
   DeleteAccount,
   PasswordReset,
   PasswordChange,
+  CreatePrivateMessage,
+  EditPrivateMessage,
+  GetPrivateMessages,
 }
 
 #[derive(Fail, Debug)]
index ac700acad53f73bdd4affbd9fd39dfe857a7fede..046da6fb2a40e1ec13547c2b798e0506f4bd58d7 100644 (file)
@@ -30,6 +30,7 @@ pub struct SaveUserSettings {
   lang: String,
   avatar: Option<String>,
   email: Option<String>,
+  matrix_user_id: Option<String>,
   new_password: Option<String>,
   new_password_verify: Option<String>,
   old_password: Option<String>,
@@ -167,6 +168,42 @@ pub struct PasswordChange {
   password_verify: String,
 }
 
+#[derive(Serialize, Deserialize)]
+pub struct CreatePrivateMessage {
+  content: String,
+  recipient_id: i32,
+  auth: String,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct EditPrivateMessage {
+  edit_id: i32,
+  content: Option<String>,
+  deleted: Option<bool>,
+  read: Option<bool>,
+  auth: String,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct GetPrivateMessages {
+  unread_only: bool,
+  page: Option<i64>,
+  limit: Option<i64>,
+  auth: String,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct PrivateMessagesResponse {
+  op: String,
+  messages: Vec<PrivateMessageView>,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct PrivateMessageResponse {
+  op: String,
+  message: PrivateMessageView,
+}
+
 impl Perform<LoginResponse> for Oper<Login> {
   fn perform(&self, conn: &PgConnection) -> Result<LoginResponse, Error> {
     let data: &Login = &self.data;
@@ -221,6 +258,7 @@ impl Perform<LoginResponse> for Oper<Register> {
       name: data.username.to_owned(),
       fedi_name: Settings::get().hostname.to_owned(),
       email: data.email.to_owned(),
+      matrix_user_id: None,
       avatar: None,
       password_encrypted: data.password.to_owned(),
       preferred_username: None,
@@ -357,6 +395,7 @@ impl Perform<LoginResponse> for Oper<SaveUserSettings> {
       name: read_user.name,
       fedi_name: read_user.fedi_name,
       email,
+      matrix_user_id: data.matrix_user_id.to_owned(),
       avatar: data.avatar.to_owned(),
       password_encrypted,
       preferred_username: read_user.preferred_username,
@@ -504,10 +543,12 @@ impl Perform<AddAdminResponse> for Oper<AddAdmin> {
 
     let read_user = User_::read(&conn, data.user_id)?;
 
+    // TODO make addadmin easier
     let user_form = UserForm {
       name: read_user.name,
       fedi_name: read_user.fedi_name,
       email: read_user.email,
+      matrix_user_id: read_user.matrix_user_id,
       avatar: read_user.avatar,
       password_encrypted: read_user.password_encrypted,
       preferred_username: read_user.preferred_username,
@@ -568,10 +609,12 @@ impl Perform<BanUserResponse> for Oper<BanUser> {
 
     let read_user = User_::read(&conn, data.user_id)?;
 
+    // TODO make bans and addadmins easier
     let user_form = UserForm {
       name: read_user.name,
       fedi_name: read_user.fedi_name,
       email: read_user.email,
+      matrix_user_id: read_user.matrix_user_id,
       avatar: read_user.avatar,
       password_encrypted: read_user.password_encrypted,
       preferred_username: read_user.preferred_username,
@@ -762,6 +805,30 @@ impl Perform<GetRepliesResponse> for Oper<MarkAllAsRead> {
         };
     }
 
+    // messages
+    let messages = PrivateMessageQueryBuilder::create(&conn, user_id)
+      .page(1)
+      .limit(999)
+      .unread_only(true)
+      .list()?;
+
+    for message in &messages {
+      let private_message_form = PrivateMessageForm {
+        content: None,
+        creator_id: message.to_owned().creator_id,
+        recipient_id: message.to_owned().recipient_id,
+        deleted: None,
+        read: Some(true),
+        updated: None,
+      };
+
+      let _updated_message = match PrivateMessage::update(&conn, message.id, &private_message_form)
+      {
+        Ok(message) => message,
+        Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_private_message").into()),
+      };
+    }
+
     Ok(GetRepliesResponse {
       op: self.op.to_string(),
       replies: vec![],
@@ -905,3 +972,150 @@ impl Perform<LoginResponse> for Oper<PasswordChange> {
     })
   }
 }
+
+impl Perform<PrivateMessageResponse> for Oper<CreatePrivateMessage> {
+  fn perform(&self, conn: &PgConnection) -> Result<PrivateMessageResponse, Error> {
+    let data: &CreatePrivateMessage = &self.data;
+
+    let claims = match Claims::decode(&data.auth) {
+      Ok(claims) => claims.claims,
+      Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
+    };
+
+    let user_id = claims.id;
+
+    let hostname = &format!("https://{}", Settings::get().hostname);
+
+    // Check for a site ban
+    if UserView::read(&conn, user_id)?.banned {
+      return Err(APIError::err(&self.op, "site_ban").into());
+    }
+
+    let content_slurs_removed = remove_slurs(&data.content.to_owned());
+
+    let private_message_form = PrivateMessageForm {
+      content: Some(content_slurs_removed.to_owned()),
+      creator_id: user_id,
+      recipient_id: data.recipient_id,
+      deleted: None,
+      read: None,
+      updated: None,
+    };
+
+    let inserted_private_message = match PrivateMessage::create(&conn, &private_message_form) {
+      Ok(private_message) => private_message,
+      Err(_e) => {
+        return Err(APIError::err(&self.op, "couldnt_create_private_message").into());
+      }
+    };
+
+    // Send notifications to the recipient
+    let recipient_user = User_::read(&conn, data.recipient_id)?;
+    if recipient_user.send_notifications_to_email {
+      if let Some(email) = recipient_user.email {
+        let subject = &format!(
+          "{} - Private Message from {}",
+          Settings::get().hostname,
+          claims.username
+        );
+        let html = &format!(
+          "<h1>Private Message</h1><br><div>{} - {}</div><br><a href={}/inbox>inbox</a>",
+          claims.username, &content_slurs_removed, hostname
+        );
+        match send_email(subject, &email, &recipient_user.name, html) {
+          Ok(_o) => _o,
+          Err(e) => eprintln!("{}", e),
+        };
+      }
+    }
+
+    let private_message_view = PrivateMessageView::read(&conn, inserted_private_message.id)?;
+
+    Ok(PrivateMessageResponse {
+      op: self.op.to_string(),
+      message: private_message_view,
+    })
+  }
+}
+
+impl Perform<PrivateMessageResponse> for Oper<EditPrivateMessage> {
+  fn perform(&self, conn: &PgConnection) -> Result<PrivateMessageResponse, Error> {
+    let data: &EditPrivateMessage = &self.data;
+
+    let claims = match Claims::decode(&data.auth) {
+      Ok(claims) => claims.claims,
+      Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
+    };
+
+    let user_id = claims.id;
+
+    let orig_private_message = PrivateMessage::read(&conn, data.edit_id)?;
+
+    // Check for a site ban
+    if UserView::read(&conn, user_id)?.banned {
+      return Err(APIError::err(&self.op, "site_ban").into());
+    }
+
+    // Check to make sure they are the creator (or the recipient marking as read
+    if !(data.read.is_some() && orig_private_message.recipient_id.eq(&user_id)
+      || orig_private_message.creator_id.eq(&user_id))
+    {
+      return Err(APIError::err(&self.op, "no_private_message_edit_allowed").into());
+    }
+
+    let content_slurs_removed = match &data.content {
+      Some(content) => Some(remove_slurs(content)),
+      None => None,
+    };
+
+    let private_message_form = PrivateMessageForm {
+      content: content_slurs_removed,
+      creator_id: orig_private_message.creator_id,
+      recipient_id: orig_private_message.recipient_id,
+      deleted: data.deleted.to_owned(),
+      read: data.read.to_owned(),
+      updated: if data.read.is_some() {
+        orig_private_message.updated
+      } else {
+        Some(naive_now())
+      },
+    };
+
+    let _updated_private_message =
+      match PrivateMessage::update(&conn, data.edit_id, &private_message_form) {
+        Ok(private_message) => private_message,
+        Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_private_message").into()),
+      };
+
+    let private_message_view = PrivateMessageView::read(&conn, data.edit_id)?;
+
+    Ok(PrivateMessageResponse {
+      op: self.op.to_string(),
+      message: private_message_view,
+    })
+  }
+}
+
+impl Perform<PrivateMessagesResponse> for Oper<GetPrivateMessages> {
+  fn perform(&self, conn: &PgConnection) -> Result<PrivateMessagesResponse, Error> {
+    let data: &GetPrivateMessages = &self.data;
+
+    let claims = match Claims::decode(&data.auth) {
+      Ok(claims) => claims.claims,
+      Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
+    };
+
+    let user_id = claims.id;
+
+    let messages = PrivateMessageQueryBuilder::create(&conn, user_id)
+      .page(data.page)
+      .limit(data.limit)
+      .unread_only(data.unread_only)
+      .list()?;
+
+    Ok(PrivateMessagesResponse {
+      op: self.op.to_string(),
+      messages,
+    })
+  }
+}
index 2d2e5ad301c8078f972bd5e4d67e320288579795..c5a0b2f029868155d5a36f1ce28f2eb390d26bdd 100644 (file)
@@ -22,6 +22,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "here".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       published: naive_now(),
       admin: false,
index a9c7d81ddc04ad58af9401e1c678b41f0c289900..efba07a5186705700c8762acb749403d8b5fec19 100644 (file)
@@ -174,6 +174,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       admin: false,
       banned: false,
index 3b06e8e347f387636bc392f6dc156f5331a136c0..d4a65c9a9703508ee42848f0734fed1cf0871d74 100644 (file)
@@ -398,6 +398,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       admin: false,
       banned: false,
index b482ca4a91d5f78df33086688dc32c1cdc9bdf13..6350096358bd7a8f16092a0f35db1cedb90bfb53 100644 (file)
@@ -220,6 +220,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       admin: false,
       banned: false,
index fef3ffce5df79b6559bebc8df9098c142e9d9b3f..dacdb6f6a92bc2b8d72ee5366a3e1a026881cbaf 100644 (file)
@@ -15,6 +15,8 @@ pub mod moderator_views;
 pub mod password_reset_request;
 pub mod post;
 pub mod post_view;
+pub mod private_message;
+pub mod private_message_view;
 pub mod site;
 pub mod site_view;
 pub mod user;
index 3c6233cb99d43a27c79f82491feb5df127ffb356..4fd532afdbe663fdb62cb62b777acc83ee9156ec 100644 (file)
@@ -442,6 +442,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       admin: false,
       banned: false,
@@ -463,6 +464,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       admin: false,
       banned: false,
index fa060a591bfc6feb27f04787ae850b9ed75b2f60..6951fd39936912c789866bd53d12c13c2554aa6f 100644 (file)
@@ -92,6 +92,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       admin: false,
       banned: false,
index d3fba4dad0ff7e009c523f0478d16dbce194baa8..9e7a43410bd3ff61b4ce26c23acdb7a2d9a2cef6 100644 (file)
@@ -187,6 +187,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       admin: false,
       banned: false,
index f6cc274f0890bedae1b65b38c5dc8ede72ef9664..c80d16967202fc98a8faff9dbd49edc757365c69 100644 (file)
@@ -339,6 +339,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       updated: None,
       admin: false,
diff --git a/server/src/db/private_message.rs b/server/src/db/private_message.rs
new file mode 100644 (file)
index 0000000..cc073b5
--- /dev/null
@@ -0,0 +1,144 @@
+use super::*;
+use crate::schema::private_message;
+
+#[derive(Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize)]
+#[table_name = "private_message"]
+pub struct PrivateMessage {
+  pub id: i32,
+  pub creator_id: i32,
+  pub recipient_id: i32,
+  pub content: String,
+  pub deleted: bool,
+  pub read: bool,
+  pub published: chrono::NaiveDateTime,
+  pub updated: Option<chrono::NaiveDateTime>,
+}
+
+#[derive(Insertable, AsChangeset, Clone)]
+#[table_name = "private_message"]
+pub struct PrivateMessageForm {
+  pub creator_id: i32,
+  pub recipient_id: i32,
+  pub content: Option<String>,
+  pub deleted: Option<bool>,
+  pub read: Option<bool>,
+  pub updated: Option<chrono::NaiveDateTime>,
+}
+
+impl Crud<PrivateMessageForm> for PrivateMessage {
+  fn read(conn: &PgConnection, private_message_id: i32) -> Result<Self, Error> {
+    use crate::schema::private_message::dsl::*;
+    private_message.find(private_message_id).first::<Self>(conn)
+  }
+
+  fn delete(conn: &PgConnection, private_message_id: i32) -> Result<usize, Error> {
+    use crate::schema::private_message::dsl::*;
+    diesel::delete(private_message.find(private_message_id)).execute(conn)
+  }
+
+  fn create(conn: &PgConnection, private_message_form: &PrivateMessageForm) -> Result<Self, Error> {
+    use crate::schema::private_message::dsl::*;
+    insert_into(private_message)
+      .values(private_message_form)
+      .get_result::<Self>(conn)
+  }
+
+  fn update(
+    conn: &PgConnection,
+    private_message_id: i32,
+    private_message_form: &PrivateMessageForm,
+  ) -> Result<Self, Error> {
+    use crate::schema::private_message::dsl::*;
+    diesel::update(private_message.find(private_message_id))
+      .set(private_message_form)
+      .get_result::<Self>(conn)
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use super::super::user::*;
+  use super::*;
+  #[test]
+  fn test_crud() {
+    let conn = establish_unpooled_connection();
+
+    let creator_form = UserForm {
+      name: "creator_pm".into(),
+      fedi_name: "rrf".into(),
+      preferred_username: None,
+      password_encrypted: "nope".into(),
+      email: None,
+      matrix_user_id: None,
+      avatar: None,
+      admin: false,
+      banned: false,
+      updated: None,
+      show_nsfw: false,
+      theme: "darkly".into(),
+      default_sort_type: SortType::Hot as i16,
+      default_listing_type: ListingType::Subscribed as i16,
+      lang: "browser".into(),
+      show_avatars: true,
+      send_notifications_to_email: false,
+    };
+
+    let inserted_creator = User_::create(&conn, &creator_form).unwrap();
+
+    let recipient_form = UserForm {
+      name: "recipient_pm".into(),
+      fedi_name: "rrf".into(),
+      preferred_username: None,
+      password_encrypted: "nope".into(),
+      email: None,
+      matrix_user_id: None,
+      avatar: None,
+      admin: false,
+      banned: false,
+      updated: None,
+      show_nsfw: false,
+      theme: "darkly".into(),
+      default_sort_type: SortType::Hot as i16,
+      default_listing_type: ListingType::Subscribed as i16,
+      lang: "browser".into(),
+      show_avatars: true,
+      send_notifications_to_email: false,
+    };
+
+    let inserted_recipient = User_::create(&conn, &recipient_form).unwrap();
+
+    let private_message_form = PrivateMessageForm {
+      content: Some("A test private message".into()),
+      creator_id: inserted_creator.id,
+      recipient_id: inserted_recipient.id,
+      deleted: None,
+      read: None,
+      updated: None,
+    };
+
+    let inserted_private_message = PrivateMessage::create(&conn, &private_message_form).unwrap();
+
+    let expected_private_message = PrivateMessage {
+      id: inserted_private_message.id,
+      content: "A test private message".into(),
+      creator_id: inserted_creator.id,
+      recipient_id: inserted_recipient.id,
+      deleted: false,
+      read: false,
+      updated: None,
+      published: inserted_private_message.published,
+    };
+
+    let read_private_message = PrivateMessage::read(&conn, inserted_private_message.id).unwrap();
+    let updated_private_message =
+      PrivateMessage::update(&conn, inserted_private_message.id, &private_message_form).unwrap();
+    let num_deleted = PrivateMessage::delete(&conn, inserted_private_message.id).unwrap();
+    User_::delete(&conn, inserted_creator.id).unwrap();
+    User_::delete(&conn, inserted_recipient.id).unwrap();
+
+    assert_eq!(expected_private_message, read_private_message);
+    assert_eq!(expected_private_message, updated_private_message);
+    assert_eq!(expected_private_message, inserted_private_message);
+    assert_eq!(1, num_deleted);
+  }
+}
diff --git a/server/src/db/private_message_view.rs b/server/src/db/private_message_view.rs
new file mode 100644 (file)
index 0000000..59a573f
--- /dev/null
@@ -0,0 +1,140 @@
+use super::*;
+use diesel::pg::Pg;
+
+// The faked schema since diesel doesn't do views
+table! {
+  private_message_view (id) {
+    id -> Int4,
+    creator_id -> Int4,
+    recipient_id -> Int4,
+    content -> Text,
+    deleted -> Bool,
+    read -> Bool,
+    published -> Timestamp,
+    updated -> Nullable<Timestamp>,
+    creator_name -> Varchar,
+    creator_avatar -> Nullable<Text>,
+    recipient_name -> Varchar,
+    recipient_avatar -> Nullable<Text>,
+  }
+}
+
+table! {
+  private_message_mview (id) {
+    id -> Int4,
+    creator_id -> Int4,
+    recipient_id -> Int4,
+    content -> Text,
+    deleted -> Bool,
+    read -> Bool,
+    published -> Timestamp,
+    updated -> Nullable<Timestamp>,
+    creator_name -> Varchar,
+    creator_avatar -> Nullable<Text>,
+    recipient_name -> Varchar,
+    recipient_avatar -> Nullable<Text>,
+  }
+}
+
+#[derive(
+  Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize, QueryableByName, Clone,
+)]
+#[table_name = "private_message_view"]
+pub struct PrivateMessageView {
+  pub id: i32,
+  pub creator_id: i32,
+  pub recipient_id: i32,
+  pub content: String,
+  pub deleted: bool,
+  pub read: bool,
+  pub published: chrono::NaiveDateTime,
+  pub updated: Option<chrono::NaiveDateTime>,
+  pub creator_name: String,
+  pub creator_avatar: Option<String>,
+  pub recipient_name: String,
+  pub recipient_avatar: Option<String>,
+}
+
+pub struct PrivateMessageQueryBuilder<'a> {
+  conn: &'a PgConnection,
+  query: super::private_message_view::private_message_mview::BoxedQuery<'a, Pg>,
+  for_recipient_id: i32,
+  unread_only: bool,
+  page: Option<i64>,
+  limit: Option<i64>,
+}
+
+impl<'a> PrivateMessageQueryBuilder<'a> {
+  pub fn create(conn: &'a PgConnection, for_recipient_id: i32) -> Self {
+    use super::private_message_view::private_message_mview::dsl::*;
+
+    let query = private_message_mview.into_boxed();
+
+    PrivateMessageQueryBuilder {
+      conn,
+      query,
+      for_recipient_id,
+      unread_only: false,
+      page: None,
+      limit: None,
+    }
+  }
+
+  pub fn unread_only(mut self, unread_only: bool) -> Self {
+    self.unread_only = unread_only;
+    self
+  }
+
+  pub fn page<T: MaybeOptional<i64>>(mut self, page: T) -> Self {
+    self.page = page.get_optional();
+    self
+  }
+
+  pub fn limit<T: MaybeOptional<i64>>(mut self, limit: T) -> Self {
+    self.limit = limit.get_optional();
+    self
+  }
+
+  pub fn list(self) -> Result<Vec<PrivateMessageView>, Error> {
+    use super::private_message_view::private_message_mview::dsl::*;
+
+    let mut query = self.query;
+
+    // If its unread, I only want the ones to me
+    if self.unread_only {
+      query = query
+        .filter(read.eq(false))
+        .filter(recipient_id.eq(self.for_recipient_id));
+    }
+    // Otherwise, I want the ALL view to show both sent and received
+    else {
+      query = query.filter(
+        recipient_id
+          .eq(self.for_recipient_id)
+          .or(creator_id.eq(self.for_recipient_id)),
+      )
+    }
+
+    let (limit, offset) = limit_and_offset(self.page, self.limit);
+
+    query
+      .limit(limit)
+      .offset(offset)
+      .order_by(published.desc())
+      .load::<PrivateMessageView>(self.conn)
+  }
+}
+
+impl PrivateMessageView {
+  pub fn read(conn: &PgConnection, from_private_message_id: i32) -> Result<Self, Error> {
+    use super::private_message_view::private_message_view::dsl::*;
+
+    let mut query = private_message_view.into_boxed();
+
+    query = query
+      .filter(id.eq(from_private_message_id))
+      .order_by(published.desc());
+
+    query.first::<Self>(conn)
+  }
+}
index 71b63d742c8a62c0a51bb9df44fcf90b71497821..b36c07bea703f25d956e58056a32397cb36b6226 100644 (file)
@@ -26,6 +26,7 @@ pub struct User_ {
   pub lang: String,
   pub show_avatars: bool,
   pub send_notifications_to_email: bool,
+  pub matrix_user_id: Option<String>,
 }
 
 #[derive(Insertable, AsChangeset, Clone)]
@@ -47,6 +48,7 @@ pub struct UserForm {
   pub lang: String,
   pub show_avatars: bool,
   pub send_notifications_to_email: bool,
+  pub matrix_user_id: Option<String>,
 }
 
 impl Crud<UserForm> for User_ {
@@ -184,6 +186,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       admin: false,
       banned: false,
@@ -206,6 +209,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       admin: false,
       banned: false,
index 21dd1675d3591bba1addb7c21d15cac86dcdd442..3b10fd0ff42d2f0381ed298b64db48d62fe7c48b 100644 (file)
@@ -68,6 +68,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       admin: false,
       banned: false,
@@ -89,6 +90,7 @@ mod tests {
       preferred_username: None,
       password_encrypted: "nope".into(),
       email: None,
+      matrix_user_id: None,
       avatar: None,
       admin: false,
       banned: false,
index 23e47d4bef4079ede7095f3e6101d23b37f206e7..3ea506e7f9c1a0ed252414260b53f79aaf632fc0 100644 (file)
@@ -8,6 +8,7 @@ table! {
     name -> Varchar,
     avatar -> Nullable<Text>,
     email -> Nullable<Text>,
+    matrix_user_id -> Nullable<Text>,
     fedi_name -> Varchar,
     admin -> Bool,
     banned -> Bool,
@@ -27,6 +28,7 @@ table! {
     name -> Varchar,
     avatar -> Nullable<Text>,
     email -> Nullable<Text>,
+    matrix_user_id -> Nullable<Text>,
     fedi_name -> Varchar,
     admin -> Bool,
     banned -> Bool,
@@ -49,6 +51,7 @@ pub struct UserView {
   pub name: String,
   pub avatar: Option<String>,
   pub email: Option<String>,
+  pub matrix_user_id: Option<String>,
   pub fedi_name: String,
   pub admin: bool,
   pub banned: bool,
index 2453a1b24bb8e74c091963ebdb0a9b4be8cb8355..b044833efb1b88687363e97e7e3cb480a3175fa1 100644 (file)
@@ -12,6 +12,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
     .route("/login", web::get().to(index))
     .route("/create_post", web::get().to(index))
     .route("/create_community", web::get().to(index))
+    .route("/create_private_message", web::get().to(index))
     .route("/communities/page/{page}", web::get().to(index))
     .route("/communities", web::get().to(index))
     .route("/post/{id}/comment/{id2}", web::get().to(index))
index 61957067c51b2a071d69dad9cc320dc3221e9a10..5330ed070de1617376be964206d76a6e3226a18a 100644 (file)
@@ -238,6 +238,19 @@ table! {
     }
 }
 
+table! {
+    private_message (id) {
+        id -> Int4,
+        creator_id -> Int4,
+        recipient_id -> Int4,
+        content -> Text,
+        deleted -> Bool,
+        read -> Bool,
+        published -> Timestamp,
+        updated -> Nullable<Timestamp>,
+    }
+}
+
 table! {
     site (id) {
         id -> Int4,
@@ -272,6 +285,7 @@ table! {
         lang -> Varchar,
         show_avatars -> Bool,
         send_notifications_to_email -> Bool,
+        matrix_user_id -> Nullable<Text>,
     }
 }
 
@@ -357,6 +371,7 @@ allow_tables_to_appear_in_same_query!(
   post_like,
   post_read,
   post_saved,
+  private_message,
   site,
   user_,
   user_ban,
index 957c5f643296e64c8084fee161485e9dd5ce1153..5efcb7bf41fe6496c44a07ebcf219409d2bb059e 100644 (file)
@@ -547,5 +547,21 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
       let res = Oper::new(user_operation, password_change).perform(&conn)?;
       Ok(serde_json::to_string(&res)?)
     }
+    UserOperation::CreatePrivateMessage => {
+      chat.check_rate_limit_message(msg.id)?;
+      let create_private_message: CreatePrivateMessage = serde_json::from_str(data)?;
+      let res = Oper::new(user_operation, create_private_message).perform(&conn)?;
+      Ok(serde_json::to_string(&res)?)
+    }
+    UserOperation::EditPrivateMessage => {
+      let edit_private_message: EditPrivateMessage = serde_json::from_str(data)?;
+      let res = Oper::new(user_operation, edit_private_message).perform(&conn)?;
+      Ok(serde_json::to_string(&res)?)
+    }
+    UserOperation::GetPrivateMessages => {
+      let messages: GetPrivateMessages = serde_json::from_str(data)?;
+      let res = Oper::new(user_operation, messages).perform(&conn)?;
+      Ok(serde_json::to_string(&res)?)
+    }
   }
 }
diff --git a/ui/src/components/create-private-message.tsx b/ui/src/components/create-private-message.tsx
new file mode 100644 (file)
index 0000000..f74d5e9
--- /dev/null
@@ -0,0 +1,52 @@
+import { Component } from 'inferno';
+import { PrivateMessageForm } from './private-message-form';
+import { WebSocketService } from '../services';
+import { PrivateMessageFormParams } from '../interfaces';
+import { i18n } from '../i18next';
+
+export class CreatePrivateMessage extends Component<any, any> {
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.handlePrivateMessageCreate = this.handlePrivateMessageCreate.bind(
+      this
+    );
+  }
+
+  componentDidMount() {
+    document.title = `${i18n.t('create_private_message')} - ${
+      WebSocketService.Instance.site.name
+    }`;
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <div class="row">
+          <div class="col-12 col-lg-6 offset-lg-3 mb-4">
+            <h5>{i18n.t('create_private_message')}</h5>
+            <PrivateMessageForm
+              onCreate={this.handlePrivateMessageCreate}
+              params={this.params}
+            />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  get params(): PrivateMessageFormParams {
+    let urlParams = new URLSearchParams(this.props.location.search);
+    let params: PrivateMessageFormParams = {
+      recipient_id: Number(urlParams.get('recipient_id')),
+    };
+
+    return params;
+  }
+
+  handlePrivateMessageCreate() {
+    alert(i18n.t('message_sent'));
+
+    // Navigate to the front
+    this.props.history.push(`/`);
+  }
+}
index a302b8345906fd54ecb827c3e0c0ea07e4c58b1f..6a426bcc001f1a0b90e5c71f091637ea7f8a791f 100644 (file)
@@ -12,10 +12,15 @@ import {
   GetUserMentionsResponse,
   UserMentionResponse,
   CommentResponse,
+  PrivateMessage as PrivateMessageI,
+  GetPrivateMessagesForm,
+  PrivateMessagesResponse,
+  PrivateMessageResponse,
 } from '../interfaces';
 import { WebSocketService, UserService } from '../services';
-import { msgOp, fetchLimit } from '../utils';
+import { msgOp, fetchLimit, isCommentType } from '../utils';
 import { CommentNodes } from './comment-nodes';
+import { PrivateMessage } from './private-message';
 import { SortSelect } from './sort-select';
 import { i18n } from '../i18next';
 import { T } from 'inferno-i18next';
@@ -26,9 +31,10 @@ enum UnreadOrAll {
 }
 
 enum UnreadType {
-  Both,
+  All,
   Replies,
   Mentions,
+  Messages,
 }
 
 interface InboxState {
@@ -36,6 +42,7 @@ interface InboxState {
   unreadType: UnreadType;
   replies: Array<Comment>;
   mentions: Array<Comment>;
+  messages: Array<PrivateMessageI>;
   sort: SortType;
   page: number;
 }
@@ -44,9 +51,10 @@ export class Inbox extends Component<any, InboxState> {
   private subscription: Subscription;
   private emptyState: InboxState = {
     unreadOrAll: UnreadOrAll.Unread,
-    unreadType: UnreadType.Both,
+    unreadType: UnreadType.All,
     replies: [],
     mentions: [],
+    messages: [],
     sort: SortType.New,
     page: 1,
   };
@@ -103,7 +111,10 @@ export class Inbox extends Component<any, InboxState> {
                 </a>
               </small>
             </h5>
-            {this.state.replies.length + this.state.mentions.length > 0 &&
+            {this.state.replies.length +
+              this.state.mentions.length +
+              this.state.messages.length >
+              0 &&
               this.state.unreadOrAll == UnreadOrAll.Unread && (
                 <ul class="list-inline mb-1 text-muted small font-weight-bold">
                   <li className="list-inline-item">
@@ -114,9 +125,10 @@ export class Inbox extends Component<any, InboxState> {
                 </ul>
               )}
             {this.selects()}
-            {this.state.unreadType == UnreadType.Both && this.both()}
+            {this.state.unreadType == UnreadType.All && this.all()}
             {this.state.unreadType == UnreadType.Replies && this.replies()}
             {this.state.unreadType == UnreadType.Mentions && this.mentions()}
+            {this.state.unreadType == UnreadType.Messages && this.messages()}
             {this.paginator()}
           </div>
         </div>
@@ -150,8 +162,8 @@ export class Inbox extends Component<any, InboxState> {
           <option disabled>
             <T i18nKey="type">#</T>
           </option>
-          <option value={UnreadType.Both}>
-            <T i18nKey="both">#</T>
+          <option value={UnreadType.All}>
+            <T i18nKey="all">#</T>
           </option>
           <option value={UnreadType.Replies}>
             <T i18nKey="replies">#</T>
@@ -159,6 +171,9 @@ export class Inbox extends Component<any, InboxState> {
           <option value={UnreadType.Mentions}>
             <T i18nKey="mentions">#</T>
           </option>
+          <option value={UnreadType.Messages}>
+            <T i18nKey="messages">#</T>
+          </option>
         </select>
         <SortSelect
           sort={this.state.sort}
@@ -169,33 +184,29 @@ 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 };
-    });
+  all() {
+    let combined: Array<Comment | PrivateMessageI> = [];
 
-    combined.push(...replies);
-    combined.push(...mentions);
+    combined.push(...this.state.replies);
+    combined.push(...this.state.mentions);
+    combined.push(...this.state.messages);
 
     // 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);
-    }
+    combined.sort((a, b) => b.published.localeCompare(a.published));
 
     return (
       <div>
-        {combined.map(i => (
-          <CommentNodes nodes={[{ comment: i.data }]} noIndent markable />
-        ))}
+        {combined.map(i =>
+          isCommentType(i) ? (
+            <CommentNodes
+              nodes={[{ comment: i }]}
+              noIndent
+              markable
+            />
+          ) : (
+            <PrivateMessage privateMessage={i} />
+          )
+        )}
       </div>
     );
   }
@@ -220,6 +231,16 @@ export class Inbox extends Component<any, InboxState> {
     );
   }
 
+  messages() {
+    return (
+      <div>
+        {this.state.messages.map(message => (
+          <PrivateMessage privateMessage={message} />
+        ))}
+      </div>
+    );
+  }
+
   paginator() {
     return (
       <div class="mt-2">
@@ -283,6 +304,13 @@ export class Inbox extends Component<any, InboxState> {
       limit: fetchLimit,
     };
     WebSocketService.Instance.getUserMentions(userMentionsForm);
+
+    let privateMessagesForm: GetPrivateMessagesForm = {
+      unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
+      page: this.state.page,
+      limit: fetchLimit,
+    };
+    WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
   }
 
   handleSortChange(val: SortType) {
@@ -314,9 +342,37 @@ export class Inbox extends Component<any, InboxState> {
       this.sendUnreadCount();
       window.scrollTo(0, 0);
       this.setState(this.state);
+    } else if (op == UserOperation.GetPrivateMessages) {
+      let res: PrivateMessagesResponse = msg;
+      this.state.messages = res.messages;
+      this.sendUnreadCount();
+      window.scrollTo(0, 0);
+      this.setState(this.state);
+    } else if (op == UserOperation.EditPrivateMessage) {
+      let res: PrivateMessageResponse = msg;
+      let found: PrivateMessageI = this.state.messages.find(
+        m => m.id === res.message.id
+      );
+      found.content = res.message.content;
+      found.updated = res.message.updated;
+      found.deleted = res.message.deleted;
+      // If youre in the unread view, just remove it from the list
+      if (this.state.unreadOrAll == UnreadOrAll.Unread && res.message.read) {
+        this.state.messages = this.state.messages.filter(
+          r => r.id !== res.message.id
+        );
+      } else {
+        let found = this.state.messages.find(c => c.id == res.message.id);
+        found.read = res.message.read;
+      }
+      this.sendUnreadCount();
+      window.scrollTo(0, 0);
+      this.setState(this.state);
     } else if (op == UserOperation.MarkAllAsRead) {
       this.state.replies = [];
       this.state.mentions = [];
+      this.state.messages = [];
+      this.sendUnreadCount();
       window.scrollTo(0, 0);
       this.setState(this.state);
     } else if (op == UserOperation.EditComment) {
@@ -391,7 +447,10 @@ export class Inbox extends Component<any, InboxState> {
   sendUnreadCount() {
     let count =
       this.state.replies.filter(r => !r.read).length +
-      this.state.mentions.filter(r => !r.read).length;
+      this.state.mentions.filter(r => !r.read).length +
+      this.state.messages.filter(
+        r => !r.read && r.creator_id !== UserService.Instance.user.id
+      ).length;
     UserService.Instance.sub.next({
       user: UserService.Instance.user,
       unreadCount: count,
index b1dcf096505891757725703699906bca26eed714..85a54987a5b18df7a47a2160e64daeb3729277a1 100644 (file)
@@ -9,15 +9,19 @@ import {
   GetRepliesResponse,
   GetUserMentionsForm,
   GetUserMentionsResponse,
+  GetPrivateMessagesForm,
+  PrivateMessagesResponse,
   SortType,
   GetSiteResponse,
   Comment,
+  PrivateMessage,
 } from '../interfaces';
 import {
   msgOp,
   pictshareAvatarThumbnail,
   showAvatars,
   fetchLimit,
+  isCommentType,
 } from '../utils';
 import { version } from '../version';
 import { i18n } from '../i18next';
@@ -28,6 +32,7 @@ interface NavbarState {
   expanded: boolean;
   replies: Array<Comment>;
   mentions: Array<Comment>;
+  messages: Array<PrivateMessage>;
   fetchCount: number;
   unreadCount: number;
   siteName: string;
@@ -42,6 +47,7 @@ export class Navbar extends Component<any, NavbarState> {
     fetchCount: 0,
     replies: [],
     mentions: [],
+    messages: [],
     expanded: false,
     siteName: undefined,
   };
@@ -228,6 +234,20 @@ export class Navbar extends Component<any, NavbarState> {
       this.state.mentions = unreadMentions;
       this.setState(this.state);
       this.sendUnreadCount();
+    } else if (op == UserOperation.GetPrivateMessages) {
+      let res: PrivateMessagesResponse = msg;
+      let unreadMessages = res.messages.filter(r => !r.read);
+      if (
+        unreadMessages.length > 0 &&
+        this.state.fetchCount > 1 &&
+        JSON.stringify(this.state.messages) !== JSON.stringify(unreadMessages)
+      ) {
+        this.notify(unreadMessages);
+      }
+
+      this.state.messages = unreadMessages;
+      this.setState(this.state);
+      this.sendUnreadCount();
     } else if (op == UserOperation.GetSite) {
       let res: GetSiteResponse = msg;
 
@@ -259,9 +279,17 @@ export class Navbar extends Component<any, NavbarState> {
         page: 1,
         limit: fetchLimit,
       };
+
+      let privateMessagesForm: GetPrivateMessagesForm = {
+        unread_only: true,
+        page: 1,
+        limit: fetchLimit,
+      };
+
       if (this.currentLocation !== '/inbox') {
         WebSocketService.Instance.getReplies(repliesForm);
         WebSocketService.Instance.getUserMentions(userMentionsForm);
+        WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
         this.state.fetchCount++;
       }
     }
@@ -281,7 +309,8 @@ export class Navbar extends Component<any, NavbarState> {
   get unreadCount() {
     return (
       this.state.replies.filter(r => !r.read).length +
-      this.state.mentions.filter(r => !r.read).length
+      this.state.mentions.filter(r => !r.read).length +
+      this.state.messages.filter(r => !r.read).length
     );
   }
 
@@ -299,21 +328,25 @@ export class Navbar extends Component<any, NavbarState> {
     }
   }
 
-  notify(replies: Array<Comment>) {
+  notify(replies: Array<Comment | PrivateMessage>) {
     let recentReply = replies[0];
     if (Notification.permission !== 'granted') Notification.requestPermission();
     else {
       var notification = new Notification(
         `${replies.length} ${i18n.t('unread_messages')}`,
         {
-          icon: `${window.location.protocol}//${window.location.host}/static/assets/apple-touch-icon.png`,
+          icon: recentReply.creator_avatar
+            ? recentReply.creator_avatar
+            : `${window.location.protocol}//${window.location.host}/static/assets/apple-touch-icon.png`,
           body: `${recentReply.creator_name}: ${recentReply.content}`,
         }
       );
 
       notification.onclick = () => {
         this.context.router.history.push(
-          `/post/${recentReply.post_id}/comment/${recentReply.id}`
+          isCommentType(recentReply)
+            ? `/post/${recentReply.post_id}/comment/${recentReply.id}`
+            : `/inbox`
         );
       };
     }
diff --git a/ui/src/components/private-message-form.tsx b/ui/src/components/private-message-form.tsx
new file mode 100644 (file)
index 0000000..c628bf7
--- /dev/null
@@ -0,0 +1,291 @@
+import { Component, linkEvent } from 'inferno';
+import { Link } from 'inferno-router';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  PrivateMessageForm as PrivateMessageFormI,
+  EditPrivateMessageForm,
+  PrivateMessageFormParams,
+  PrivateMessage,
+  PrivateMessageResponse,
+  UserView,
+  UserOperation,
+  UserDetailsResponse,
+  GetUserDetailsForm,
+  SortType,
+} from '../interfaces';
+import { WebSocketService } from '../services';
+import {
+  msgOp,
+  capitalizeFirstLetter,
+  markdownHelpUrl,
+  mdToHtml,
+  showAvatars,
+  pictshareAvatarThumbnail,
+} from '../utils';
+import autosize from 'autosize';
+import { i18n } from '../i18next';
+import { T } from 'inferno-i18next';
+
+interface PrivateMessageFormProps {
+  privateMessage?: PrivateMessage; // If a pm is given, that means this is an edit
+  params?: PrivateMessageFormParams;
+  onCancel?(): any;
+  onCreate?(message: PrivateMessage): any;
+  onEdit?(message: PrivateMessage): any;
+}
+
+interface PrivateMessageFormState {
+  privateMessageForm: PrivateMessageFormI;
+  recipient: UserView;
+  loading: boolean;
+  previewMode: boolean;
+  showDisclaimer: boolean;
+}
+
+export class PrivateMessageForm extends Component<
+  PrivateMessageFormProps,
+  PrivateMessageFormState
+> {
+  private subscription: Subscription;
+  private emptyState: PrivateMessageFormState = {
+    privateMessageForm: {
+      content: null,
+      recipient_id: null,
+    },
+    recipient: null,
+    loading: false,
+    previewMode: false,
+    showDisclaimer: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+
+    if (this.props.privateMessage) {
+      this.state.privateMessageForm = {
+        content: this.props.privateMessage.content,
+        recipient_id: this.props.privateMessage.recipient_id,
+      };
+    }
+
+    if (this.props.params) {
+      this.state.privateMessageForm.recipient_id = this.props.params.recipient_id;
+      let form: GetUserDetailsForm = {
+        user_id: this.state.privateMessageForm.recipient_id,
+        sort: SortType[SortType.New],
+        saved_only: false,
+      };
+      WebSocketService.Instance.getUserDetails(form);
+    }
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+  }
+
+  componentDidMount() {
+    autosize(document.querySelectorAll('textarea'));
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  render() {
+    return (
+      <div>
+        <form onSubmit={linkEvent(this, this.handlePrivateMessageSubmit)}>
+          {!this.props.privateMessage && (
+            <div class="form-group row">
+              <label class="col-sm-2 col-form-label">
+                {capitalizeFirstLetter(i18n.t('to'))}
+              </label>
+
+              {this.state.recipient && (
+                <div class="col-sm-10 form-control-plaintext">
+                  <Link
+                    className="text-info"
+                    to={`/u/${this.state.recipient.name}`}
+                  >
+                    {this.state.recipient.avatar && showAvatars() && (
+                      <img
+                        height="32"
+                        width="32"
+                        src={pictshareAvatarThumbnail(
+                          this.state.recipient.avatar
+                        )}
+                        class="rounded-circle mr-1"
+                      />
+                    )}
+                    <span>{this.state.recipient.name}</span>
+                  </Link>
+                </div>
+              )}
+            </div>
+          )}
+          <div class="form-group row">
+            <label class="col-sm-2 col-form-label">{i18n.t('message')}</label>
+            <div class="col-sm-10">
+              <textarea
+                value={this.state.privateMessageForm.content}
+                onInput={linkEvent(this, this.handleContentChange)}
+                className={`form-control ${this.state.previewMode && 'd-none'}`}
+                rows={4}
+                maxLength={10000}
+              />
+              {this.state.previewMode && (
+                <div
+                  className="md-div"
+                  dangerouslySetInnerHTML={mdToHtml(
+                    this.state.privateMessageForm.content
+                  )}
+                />
+              )}
+
+              {this.state.privateMessageForm.content && (
+                <button
+                  className={`mt-1 mr-2 btn btn-sm btn-secondary ${this.state
+                    .previewMode && 'active'}`}
+                  onClick={linkEvent(this, this.handlePreviewToggle)}
+                >
+                  {i18n.t('preview')}
+                </button>
+              )}
+              <ul class="float-right list-inline mb-1 text-muted small font-weight-bold">
+                <li class="list-inline-item">
+                  <span
+                    onClick={linkEvent(this, this.handleShowDisclaimer)}
+                    class="pointer"
+                  >
+                    {i18n.t('disclaimer')}
+                  </span>
+                </li>
+                <li class="list-inline-item">
+                  <a href={markdownHelpUrl} target="_blank" class="text-muted">
+                    {i18n.t('formatting_help')}
+                  </a>
+                </li>
+              </ul>
+            </div>
+          </div>
+
+          {this.state.showDisclaimer && (
+            <div class="form-group row">
+              <div class="col-sm-10">
+                <div class="alert alert-danger" role="alert">
+                  <T i18nKey="private_message_disclaimer">
+                    #
+                    <a
+                      class="alert-link"
+                      target="_blank"
+                      href="https://about.riot.im/"
+                    >
+                      #
+                    </a>
+                  </T>
+                </div>
+              </div>
+            </div>
+          )}
+          <div class="form-group row">
+            <div class="col-sm-10">
+              <button type="submit" class="btn btn-secondary mr-2">
+                {this.state.loading ? (
+                  <svg class="icon icon-spinner spin">
+                    <use xlinkHref="#icon-spinner"></use>
+                  </svg>
+                ) : this.props.privateMessage ? (
+                  capitalizeFirstLetter(i18n.t('save'))
+                ) : (
+                  capitalizeFirstLetter(i18n.t('send_message'))
+                )}
+              </button>
+              {this.props.privateMessage && (
+                <button
+                  type="button"
+                  class="btn btn-secondary"
+                  onClick={linkEvent(this, this.handleCancel)}
+                >
+                  {i18n.t('cancel')}
+                </button>
+              )}
+            </div>
+          </div>
+        </form>
+      </div>
+    );
+  }
+
+  handlePrivateMessageSubmit(i: PrivateMessageForm, event: any) {
+    event.preventDefault();
+    if (i.props.privateMessage) {
+      let editForm: EditPrivateMessageForm = {
+        edit_id: i.props.privateMessage.id,
+        content: i.state.privateMessageForm.content,
+      };
+      WebSocketService.Instance.editPrivateMessage(editForm);
+    } else {
+      WebSocketService.Instance.createPrivateMessage(
+        i.state.privateMessageForm
+      );
+    }
+    i.state.loading = true;
+    i.setState(i.state);
+  }
+
+  handleRecipientChange(i: PrivateMessageForm, event: any) {
+    i.state.recipient = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleContentChange(i: PrivateMessageForm, event: any) {
+    i.state.privateMessageForm.content = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleCancel(i: PrivateMessageForm) {
+    i.props.onCancel();
+  }
+
+  handlePreviewToggle(i: PrivateMessageForm, event: any) {
+    event.preventDefault();
+    i.state.previewMode = !i.state.previewMode;
+    i.setState(i.state);
+  }
+
+  handleShowDisclaimer(i: PrivateMessageForm) {
+    i.state.showDisclaimer = !i.state.showDisclaimer;
+    i.setState(i.state);
+  }
+
+  parseMessage(msg: any) {
+    let op: UserOperation = msgOp(msg);
+    if (msg.error) {
+      alert(i18n.t(msg.error));
+      this.state.loading = false;
+      this.setState(this.state);
+      return;
+    } else if (op == UserOperation.EditPrivateMessage) {
+      this.state.loading = false;
+      let res: PrivateMessageResponse = msg;
+      this.props.onEdit(res.message);
+    } else if (op == UserOperation.GetUserDetails) {
+      let res: UserDetailsResponse = msg;
+      this.state.recipient = res.user;
+      this.state.privateMessageForm.recipient_id = res.user.id;
+      this.setState(this.state);
+    } else if (op == UserOperation.CreatePrivateMessage) {
+      this.state.loading = false;
+      let res: PrivateMessageResponse = msg;
+      this.props.onCreate(res.message);
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/ui/src/components/private-message.tsx b/ui/src/components/private-message.tsx
new file mode 100644 (file)
index 0000000..524b1a9
--- /dev/null
@@ -0,0 +1,249 @@
+import { Component, linkEvent } from 'inferno';
+import { Link } from 'inferno-router';
+import {
+  PrivateMessage as PrivateMessageI,
+  EditPrivateMessageForm,
+} from '../interfaces';
+import { WebSocketService, UserService } from '../services';
+import { mdToHtml, pictshareAvatarThumbnail, showAvatars } from '../utils';
+import { MomentTime } from './moment-time';
+import { PrivateMessageForm } from './private-message-form';
+import { i18n } from '../i18next';
+import { T } from 'inferno-i18next';
+
+interface PrivateMessageState {
+  showReply: boolean;
+  showEdit: boolean;
+  collapsed: boolean;
+  viewSource: boolean;
+}
+
+interface PrivateMessageProps {
+  privateMessage: PrivateMessageI;
+}
+
+export class PrivateMessage extends Component<
+  PrivateMessageProps,
+  PrivateMessageState
+> {
+  private emptyState: PrivateMessageState = {
+    showReply: false,
+    showEdit: false,
+    collapsed: false,
+    viewSource: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+    this.handleReplyCancel = this.handleReplyCancel.bind(this);
+    this.handlePrivateMessageCreate = this.handlePrivateMessageCreate.bind(
+      this
+    );
+    this.handlePrivateMessageEdit = this.handlePrivateMessageEdit.bind(this);
+  }
+
+  get mine(): boolean {
+    return UserService.Instance.user.id == this.props.privateMessage.creator_id;
+  }
+
+  render() {
+    let message = this.props.privateMessage;
+    return (
+      <div class="mb-2">
+        <div>
+          <ul class="list-inline mb-0 text-muted small">
+            <li className="list-inline-item">
+              {this.mine ? i18n.t('to') : i18n.t('from')}
+            </li>
+            <li className="list-inline-item">
+              <Link
+                className="text-info"
+                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={pictshareAvatarThumbnail(
+                        this.mine
+                          ? message.recipient_avatar
+                          : message.creator_avatar
+                      )}
+                      class="rounded-circle mr-1"
+                    />
+                  )}
+                <span>
+                  {this.mine ? message.recipient_name : message.creator_name}
+                </span>
+              </Link>
+            </li>
+            <li className="list-inline-item">
+              <span>
+                <MomentTime data={message} />
+              </span>
+            </li>
+            <li className="list-inline-item">
+              <div
+                className="pointer text-monospace"
+                onClick={linkEvent(this, this.handleMessageCollapse)}
+              >
+                {this.state.collapsed ? '[+]' : '[-]'}
+              </div>
+            </li>
+          </ul>
+          {this.state.showEdit && (
+            <PrivateMessageForm
+              privateMessage={message}
+              onEdit={this.handlePrivateMessageEdit}
+              onCancel={this.handleReplyCancel}
+            />
+          )}
+          {!this.state.showEdit && !this.state.collapsed && (
+            <div>
+              {this.state.viewSource ? (
+                <pre>{this.messageUnlessRemoved}</pre>
+              ) : (
+                <div
+                  className="md-div"
+                  dangerouslySetInnerHTML={mdToHtml(this.messageUnlessRemoved)}
+                />
+              )}
+              <ul class="list-inline mb-1 text-muted small font-weight-bold">
+                {!this.mine && (
+                  <>
+                    <li className="list-inline-item">
+                      <span
+                        class="pointer"
+                        onClick={linkEvent(this, this.handleMarkRead)}
+                      >
+                        {message.read
+                          ? i18n.t('mark_as_unread')
+                          : i18n.t('mark_as_read')}
+                      </span>
+                    </li>
+                    <li className="list-inline-item">
+                      <span
+                        class="pointer"
+                        onClick={linkEvent(this, this.handleReplyClick)}
+                      >
+                        <T i18nKey="reply">#</T>
+                      </span>
+                    </li>
+                  </>
+                )}
+                {this.mine && (
+                  <>
+                    <li className="list-inline-item">
+                      <span
+                        class="pointer"
+                        onClick={linkEvent(this, this.handleEditClick)}
+                      >
+                        <T i18nKey="edit">#</T>
+                      </span>
+                    </li>
+                    <li className="list-inline-item">
+                      <span
+                        class="pointer"
+                        onClick={linkEvent(this, this.handleDeleteClick)}
+                      >
+                        {!message.deleted
+                          ? i18n.t('delete')
+                          : i18n.t('restore')}
+                      </span>
+                    </li>
+                  </>
+                )}
+                <li className="list-inline-item">•</li>
+                <li className="list-inline-item">
+                  <span
+                    className="pointer"
+                    onClick={linkEvent(this, this.handleViewSource)}
+                  >
+                    <T i18nKey="view_source">#</T>
+                  </span>
+                </li>
+              </ul>
+            </div>
+          )}
+        </div>
+        {this.state.showReply && (
+          <PrivateMessageForm
+            params={{
+              recipient_id: this.props.privateMessage.creator_id,
+            }}
+            onCreate={this.handlePrivateMessageCreate}
+          />
+        )}
+        {/* A collapsed clearfix */}
+        {this.state.collapsed && <div class="row col-12"></div>}
+      </div>
+    );
+  }
+
+  get messageUnlessRemoved(): string {
+    let message = this.props.privateMessage;
+    return message.deleted ? `*${i18n.t('deleted')}*` : message.content;
+  }
+
+  handleReplyClick(i: PrivateMessage) {
+    i.state.showReply = true;
+    i.setState(i.state);
+  }
+
+  handleEditClick(i: PrivateMessage) {
+    i.state.showEdit = true;
+    i.setState(i.state);
+  }
+
+  handleDeleteClick(i: PrivateMessage) {
+    let form: EditPrivateMessageForm = {
+      edit_id: i.props.privateMessage.id,
+      deleted: !i.props.privateMessage.deleted,
+    };
+    WebSocketService.Instance.editPrivateMessage(form);
+  }
+
+  handleReplyCancel() {
+    this.state.showReply = false;
+    this.state.showEdit = false;
+    this.setState(this.state);
+  }
+
+  handleMarkRead(i: PrivateMessage) {
+    let form: EditPrivateMessageForm = {
+      edit_id: i.props.privateMessage.id,
+      read: !i.props.privateMessage.read,
+    };
+    WebSocketService.Instance.editPrivateMessage(form);
+  }
+
+  handleMessageCollapse(i: PrivateMessage) {
+    i.state.collapsed = !i.state.collapsed;
+    i.setState(i.state);
+  }
+
+  handleViewSource(i: PrivateMessage) {
+    i.state.viewSource = !i.state.viewSource;
+    i.setState(i.state);
+  }
+
+  handlePrivateMessageEdit() {
+    this.state.showEdit = false;
+    this.setState(this.state);
+  }
+
+  handlePrivateMessageCreate() {
+    this.state.showReply = false;
+    this.setState(this.state);
+    alert(i18n.t('message_sent'));
+  }
+}
index 206fb8ff26c9a1d81d22b221c8f2a806c5777a99..19bd5fb9054b59f5799beda4dc633857c8534fef 100644 (file)
@@ -405,13 +405,30 @@ export class User extends Component<any, UserState> {
                 </tr>
               </table>
             </div>
-            {this.isCurrentUser && (
+            {this.isCurrentUser ? (
               <button
                 class="btn btn-block btn-secondary mt-3"
                 onClick={linkEvent(this, this.handleLogoutClick)}
               >
                 <T i18nKey="logout">#</T>
               </button>
+            ) : (
+              <>
+                <a
+                  className={`btn btn-block btn-secondary mt-3 ${!this.state
+                    .user.matrix_user_id && 'disabled'}`}
+                  target="_blank"
+                  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>
@@ -539,6 +556,26 @@ export class User extends Component<any, UserState> {
                   />
                 </div>
               </div>
+              <div class="form-group row">
+                <label class="col-lg-5 col-form-label">
+                  <a href="https://about.riot.im/" target="_blank">
+                    {i18n.t('matrix_user_id')}
+                  </a>
+                </label>
+                <div class="col-lg-7">
+                  <input
+                    type="text"
+                    class="form-control"
+                    placeholder="@user:example.com"
+                    value={this.state.userSettingsForm.matrix_user_id}
+                    onInput={linkEvent(
+                      this,
+                      this.handleUserSettingsMatrixUserIdChange
+                    )}
+                    minLength={3}
+                  />
+                </div>
+              </div>
               <div class="form-group row">
                 <label class="col-lg-5 col-form-label">
                   <T i18nKey="new_password">#</T>
@@ -875,6 +912,17 @@ export class User extends Component<any, UserState> {
     i.setState(i.state);
   }
 
+  handleUserSettingsMatrixUserIdChange(i: User, event: any) {
+    i.state.userSettingsForm.matrix_user_id = event.target.value;
+    if (
+      i.state.userSettingsForm.matrix_user_id == '' &&
+      !i.state.user.matrix_user_id
+    ) {
+      i.state.userSettingsForm.matrix_user_id = undefined;
+    }
+    i.setState(i.state);
+  }
+
   handleUserSettingsNewPasswordChange(i: User, event: any) {
     i.state.userSettingsForm.new_password = event.target.value;
     if (i.state.userSettingsForm.new_password == '') {
@@ -1001,6 +1049,7 @@ export class User extends Component<any, UserState> {
         this.state.userSettingsForm.send_notifications_to_email = this.state.user.send_notifications_to_email;
         this.state.userSettingsForm.show_avatars =
           UserService.Instance.user.show_avatars;
+        this.state.userSettingsForm.matrix_user_id = this.state.user.matrix_user_id;
       }
       document.title = `/u/${this.state.user.name} - ${WebSocketService.Instance.site.name}`;
       window.scrollTo(0, 0);
index 5eeb3038b13380a561a74f79fcf0ba0820c114da..8a9aa3c387feced6c3540371775b0ec4176eaa63 100644 (file)
@@ -7,6 +7,7 @@ import { Footer } from './components/footer';
 import { Login } from './components/login';
 import { CreatePost } from './components/create-post';
 import { CreateCommunity } from './components/create-community';
+import { CreatePrivateMessage } from './components/create-private-message';
 import { PasswordChange } from './components/password_change';
 import { Post } from './components/post';
 import { Community } from './components/community';
@@ -46,6 +47,10 @@ class Index extends Component<any, any> {
               <Route path={`/login`} component={Login} />
               <Route path={`/create_post`} component={CreatePost} />
               <Route path={`/create_community`} component={CreateCommunity} />
+              <Route
+                path={`/create_private_message`}
+                component={CreatePrivateMessage}
+              />
               <Route path={`/communities/page/:page`} component={Communities} />
               <Route path={`/communities`} component={Communities} />
               <Route path={`/post/:id/comment/:comment_id`} component={Post} />
index c21d3d264983c0bd739bd7e44c5908bd4dd69923..fc7d8cb7e52f940701669ff2c039b4eb0f560daa 100644 (file)
@@ -38,6 +38,9 @@ export enum UserOperation {
   DeleteAccount,
   PasswordReset,
   PasswordChange,
+  CreatePrivateMessage,
+  EditPrivateMessage,
+  GetPrivateMessages,
 }
 
 export enum CommentSortType {
@@ -89,6 +92,7 @@ export interface UserView {
   name: string;
   avatar?: string;
   email?: string;
+  matrix_user_id?: string;
   fedi_name: string;
   published: string;
   number_of_posts: number;
@@ -218,6 +222,21 @@ export interface Site {
   enable_nsfw: boolean;
 }
 
+export interface PrivateMessage {
+  id: number;
+  creator_id: number;
+  recipient_id: number;
+  content: string;
+  deleted: boolean;
+  read: boolean;
+  published: string;
+  updated?: string;
+  creator_name: string;
+  creator_avatar?: string;
+  recipient_name: string;
+  recipient_avatar?: string;
+}
+
 export enum BanType {
   Community,
   Site,
@@ -490,6 +509,7 @@ export interface UserSettingsForm {
   lang: string;
   avatar?: string;
   email?: string;
+  matrix_user_id?: string;
   new_password?: string;
   new_password_verify?: string;
   old_password?: string;
@@ -729,3 +749,38 @@ export interface PasswordChangeForm {
   password: string;
   password_verify: string;
 }
+
+export interface PrivateMessageForm {
+  content: string;
+  recipient_id: number;
+  auth?: string;
+}
+
+export interface PrivateMessageFormParams {
+  recipient_id: number;
+}
+
+export interface EditPrivateMessageForm {
+  edit_id: number;
+  content?: string;
+  deleted?: boolean;
+  read?: boolean;
+  auth?: string;
+}
+
+export interface GetPrivateMessagesForm {
+  unread_only: boolean;
+  page?: number;
+  limit?: number;
+  auth?: string;
+}
+
+export interface PrivateMessagesResponse {
+  op: string;
+  messages: Array<PrivateMessage>;
+}
+
+export interface PrivateMessageResponse {
+  op: string;
+  message: PrivateMessage;
+}
index a70fb2deff2acd399abb3d53a05a4fb19a6729a7..146a9abf517f74cc7ec21a811ee456c7ac3cce7a 100644 (file)
@@ -32,10 +32,13 @@ import {
   DeleteAccountForm,
   PasswordResetForm,
   PasswordChangeForm,
+  PrivateMessageForm,
+  EditPrivateMessageForm,
+  GetPrivateMessagesForm,
 } from '../interfaces';
 import { webSocket } from 'rxjs/webSocket';
 import { Subject } from 'rxjs';
-import { retryWhen, delay, take } from 'rxjs/operators';
+import { retryWhen, delay } from 'rxjs/operators';
 import { UserService } from './';
 import { i18n } from '../i18next';
 
@@ -285,6 +288,27 @@ export class WebSocketService {
     this.subject.next(this.wsSendWrapper(UserOperation.PasswordChange, form));
   }
 
+  public createPrivateMessage(form: PrivateMessageForm) {
+    this.setAuth(form);
+    this.subject.next(
+      this.wsSendWrapper(UserOperation.CreatePrivateMessage, form)
+    );
+  }
+
+  public editPrivateMessage(form: EditPrivateMessageForm) {
+    this.setAuth(form);
+    this.subject.next(
+      this.wsSendWrapper(UserOperation.EditPrivateMessage, form)
+    );
+  }
+
+  public getPrivateMessages(form: GetPrivateMessagesForm) {
+    this.setAuth(form);
+    this.subject.next(
+      this.wsSendWrapper(UserOperation.GetPrivateMessages, form)
+    );
+  }
+
   private wsSendWrapper(op: UserOperation, data: any) {
     let send = { op: UserOperation[op], data: data };
     console.log(send);
index be5f9e736d0d4c4f95242e449ef24f48f37bb7cb..ecd293b5f2e1646458c7935558164d4fbbfe037d 100644 (file)
@@ -23,6 +23,10 @@ export const en = {
     list_of_communities: 'List of communities',
     number_of_communities: '{{count}} Communities',
     community_reqs: 'lowercase, underscores, and no spaces.',
+    create_private_message: 'Create Private Message',
+    send_secure_message: 'Send Secure Message',
+    send_message: 'Send Message',
+    message: 'Message',
     edit: 'edit',
     reply: 'reply',
     cancel: 'Cancel',
@@ -109,6 +113,7 @@ export const en = {
     replies: 'Replies',
     mentions: 'Mentions',
     reply_sent: 'Reply sent',
+    message_sent: 'Message sent',
     search: 'Search',
     overview: 'Overview',
     view: 'View',
@@ -119,6 +124,7 @@ export const en = {
     notifications_error:
       'Desktop notifications not available in your browser. Try Firefox or Chrome.',
     unread_messages: 'Unread Messages',
+    messages: 'Messages',
     password: 'Password',
     verify_password: 'Verify Password',
     old_password: 'Old Password',
@@ -128,6 +134,9 @@ export const en = {
     new_password: 'New Password',
     no_email_setup: "This server hasn't correctly set up email.",
     email: 'Email',
+    matrix_user_id: 'Matrix User',
+    private_message_disclaimer:
+      'Warning: Private messages in Lemmy are not secure. Please create an account on <1>Riot.im</1> for secure messaging.',
     send_notifications_to_email: 'Send notifications to Email',
     optional: 'Optional',
     expires: 'Expires',
@@ -172,6 +181,7 @@ export const en = {
     joined: 'Joined',
     by: 'by',
     to: 'to',
+    from: 'from',
     transfer_community: 'transfer community',
     transfer_site: 'transfer site',
     are_you_sure: 'are you sure?',
@@ -215,5 +225,8 @@ export const en = {
     email_already_exists: 'Email already exists.',
     couldnt_update_user: "Couldn't update user.",
     system_err_login: 'System error. Try logging out and back in.',
+    couldnt_create_private_message: "Couldn't create private message.",
+    no_private_message_edit_allowed: 'Not allowed to edit private message.',
+    couldnt_update_private_message: "Couldn't update private message.",
   },
 };
index a90afbd8bdccde0e58db833d42664d2379610c0c..dce746e2a21e4d8574632dde77018f4ee5c9b831 100644 (file)
@@ -11,6 +11,7 @@ import 'moment/locale/it';
 import {
   UserOperation,
   Comment,
+  PrivateMessage,
   User,
   SortType,
   ListingType,
@@ -361,3 +362,7 @@ export function imageThumbnailer(url: string): string {
     return url;
   }
 }
+
+export function isCommentType(item: Comment | PrivateMessage): item is Comment {
+  return (item as Comment).community_id !== undefined;
+}