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 -->
--- /dev/null
+-- 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;
--- /dev/null
+-- 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)
+-- )
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,
}
#[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,
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::*;
DeleteAccount,
PasswordReset,
PasswordChange,
+ CreatePrivateMessage,
+ EditPrivateMessage,
+ GetPrivateMessages,
}
#[derive(Fail, Debug)]
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>,
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;
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,
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,
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,
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,
};
}
+ // 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![],
})
}
}
+
+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,
+ })
+ }
+}
preferred_username: None,
password_encrypted: "here".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
published: naive_now(),
admin: false,
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
admin: false,
banned: false,
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
admin: false,
banned: false,
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
admin: false,
banned: false,
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;
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
admin: false,
banned: false,
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
admin: false,
banned: false,
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
admin: false,
banned: false,
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
admin: false,
banned: false,
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
updated: None,
admin: false,
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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)
+ }
+}
pub lang: String,
pub show_avatars: bool,
pub send_notifications_to_email: bool,
+ pub matrix_user_id: Option<String>,
}
#[derive(Insertable, AsChangeset, Clone)]
pub lang: String,
pub show_avatars: bool,
pub send_notifications_to_email: bool,
+ pub matrix_user_id: Option<String>,
}
impl Crud<UserForm> for User_ {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
admin: false,
banned: false,
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
admin: false,
banned: false,
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
admin: false,
banned: false,
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
+ matrix_user_id: None,
avatar: None,
admin: false,
banned: false,
name -> Varchar,
avatar -> Nullable<Text>,
email -> Nullable<Text>,
+ matrix_user_id -> Nullable<Text>,
fedi_name -> Varchar,
admin -> Bool,
banned -> Bool,
name -> Varchar,
avatar -> Nullable<Text>,
email -> Nullable<Text>,
+ matrix_user_id -> Nullable<Text>,
fedi_name -> Varchar,
admin -> Bool,
banned -> Bool,
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,
.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))
}
}
+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,
lang -> Varchar,
show_avatars -> Bool,
send_notifications_to_email -> Bool,
+ matrix_user_id -> Nullable<Text>,
}
}
post_like,
post_read,
post_saved,
+ private_message,
site,
user_,
user_ban,
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)?)
+ }
}
}
--- /dev/null
+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(`/`);
+ }
+}
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';
}
enum UnreadType {
- Both,
+ All,
Replies,
Mentions,
+ Messages,
}
interface InboxState {
unreadType: UnreadType;
replies: Array<Comment>;
mentions: Array<Comment>;
+ messages: Array<PrivateMessageI>;
sort: SortType;
page: number;
}
private subscription: Subscription;
private emptyState: InboxState = {
unreadOrAll: UnreadOrAll.Unread,
- unreadType: UnreadType.Both,
+ unreadType: UnreadType.All,
replies: [],
mentions: [],
+ messages: [],
sort: SortType.New,
page: 1,
};
</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">
</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>
<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>
<option value={UnreadType.Mentions}>
<T i18nKey="mentions">#</T>
</option>
+ <option value={UnreadType.Messages}>
+ <T i18nKey="messages">#</T>
+ </option>
</select>
<SortSelect
sort={this.state.sort}
);
}
- 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>
);
}
);
}
+ messages() {
+ return (
+ <div>
+ {this.state.messages.map(message => (
+ <PrivateMessage privateMessage={message} />
+ ))}
+ </div>
+ );
+ }
+
paginator() {
return (
<div class="mt-2">
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) {
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) {
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,
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';
expanded: boolean;
replies: Array<Comment>;
mentions: Array<Comment>;
+ messages: Array<PrivateMessage>;
fetchCount: number;
unreadCount: number;
siteName: string;
fetchCount: 0,
replies: [],
mentions: [],
+ messages: [],
expanded: false,
siteName: undefined,
};
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;
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++;
}
}
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
);
}
}
}
- 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`
);
};
}
--- /dev/null
+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);
+ }
+ }
+}
--- /dev/null
+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'));
+ }
+}
</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>
/>
</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>
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 == '') {
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);
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';
<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} />
DeleteAccount,
PasswordReset,
PasswordChange,
+ CreatePrivateMessage,
+ EditPrivateMessage,
+ GetPrivateMessages,
}
export enum CommentSortType {
name: string;
avatar?: string;
email?: string;
+ matrix_user_id?: string;
fedi_name: string;
published: string;
number_of_posts: number;
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,
lang: string;
avatar?: string;
email?: string;
+ matrix_user_id?: string;
new_password?: string;
new_password_verify?: string;
old_password?: string;
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;
+}
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';
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);
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',
replies: 'Replies',
mentions: 'Mentions',
reply_sent: 'Reply sent',
+ message_sent: 'Message sent',
search: 'Search',
overview: 'Overview',
view: 'View',
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',
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',
joined: 'Joined',
by: 'by',
to: 'to',
+ from: 'from',
transfer_community: 'transfer community',
transfer_site: 'transfer site',
are_you_sure: 'are you sure?',
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.",
},
};
import {
UserOperation,
Comment,
+ PrivateMessage,
User,
SortType,
ListingType,
return url;
}
}
+
+export function isCommentType(item: Comment | PrivateMessage): item is Comment {
+ return (item as Comment).community_id !== undefined;
+}