Merge branch 'private_messaging' into dev

This commit is contained in:
Dessalines 2020-01-22 20:37:08 -05:00
commit 0d2b4e6f18
34 changed files with 1560 additions and 46 deletions

18
README.md vendored
View file

@ -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 -->

View file

@ -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;

View file

@ -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)
-- )

View 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,

View 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)]

View 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,
})
}
}

View 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,

View 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,

View 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,

View 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,

View 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;

View 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,

View 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,

View 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,

View 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,

View file

@ -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);
}
}

View file

@ -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)
}
}

View 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,

View 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,

View 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,

View 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))

View 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,

View 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)?)
}
}
}

View file

@ -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(`/`);
}
}

View 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,

View 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`
);
};
}

View file

@ -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);
}
}
}

249
ui/src/components/private-message.tsx vendored Normal file
View file

@ -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'));
}
}

View 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);

5
ui/src/index.tsx vendored
View 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} />

55
ui/src/interfaces.ts vendored
View 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;
}

View 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);

View 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.",
},
};

5
ui/src/utils.ts vendored
View 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;
}