mirror of https://github.com/LemmyNet/lemmy.git
parent
a964b4ce21
commit
253bc3e0af
|
@ -157,15 +157,15 @@ If you'd like to add translations, take a look a look at the [English translatio
|
||||||
|
|
||||||
lang | done | missing
|
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
|
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 | 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
|
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 | 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
|
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 | 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 | 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 | 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
|
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 | 99% | donate_to_lemmy,donate,email_already_exists
|
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 | 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
|
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 | 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
|
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 | 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
|
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 -->
|
<!-- translationsstop -->
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -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)
|
||||||
|
-- )
|
|
@ -7,7 +7,7 @@ use diesel::PgConnection;
|
||||||
pub struct CreateComment {
|
pub struct CreateComment {
|
||||||
content: String,
|
content: String,
|
||||||
parent_id: Option<i32>,
|
parent_id: Option<i32>,
|
||||||
edit_id: Option<i32>,
|
edit_id: Option<i32>, // TODO this isn't used
|
||||||
pub post_id: i32,
|
pub post_id: i32,
|
||||||
auth: String,
|
auth: String,
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@ pub struct CreateComment {
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct EditComment {
|
pub struct EditComment {
|
||||||
content: String,
|
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,
|
edit_id: i32,
|
||||||
creator_id: i32,
|
creator_id: i32,
|
||||||
pub post_id: i32,
|
pub post_id: i32,
|
||||||
|
|
|
@ -8,6 +8,8 @@ use crate::db::moderator_views::*;
|
||||||
use crate::db::password_reset_request::*;
|
use crate::db::password_reset_request::*;
|
||||||
use crate::db::post::*;
|
use crate::db::post::*;
|
||||||
use crate::db::post_view::*;
|
use crate::db::post_view::*;
|
||||||
|
use crate::db::private_message::*;
|
||||||
|
use crate::db::private_message_view::*;
|
||||||
use crate::db::site::*;
|
use crate::db::site::*;
|
||||||
use crate::db::site_view::*;
|
use crate::db::site_view::*;
|
||||||
use crate::db::user::*;
|
use crate::db::user::*;
|
||||||
|
@ -67,6 +69,9 @@ pub enum UserOperation {
|
||||||
DeleteAccount,
|
DeleteAccount,
|
||||||
PasswordReset,
|
PasswordReset,
|
||||||
PasswordChange,
|
PasswordChange,
|
||||||
|
CreatePrivateMessage,
|
||||||
|
EditPrivateMessage,
|
||||||
|
GetPrivateMessages,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Fail, Debug)]
|
#[derive(Fail, Debug)]
|
||||||
|
|
|
@ -30,6 +30,7 @@ pub struct SaveUserSettings {
|
||||||
lang: String,
|
lang: String,
|
||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
|
matrix_user_id: Option<String>,
|
||||||
new_password: Option<String>,
|
new_password: Option<String>,
|
||||||
new_password_verify: Option<String>,
|
new_password_verify: Option<String>,
|
||||||
old_password: Option<String>,
|
old_password: Option<String>,
|
||||||
|
@ -167,6 +168,42 @@ pub struct PasswordChange {
|
||||||
password_verify: 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> {
|
impl Perform<LoginResponse> for Oper<Login> {
|
||||||
fn perform(&self, conn: &PgConnection) -> Result<LoginResponse, Error> {
|
fn perform(&self, conn: &PgConnection) -> Result<LoginResponse, Error> {
|
||||||
let data: &Login = &self.data;
|
let data: &Login = &self.data;
|
||||||
|
@ -221,6 +258,7 @@ impl Perform<LoginResponse> for Oper<Register> {
|
||||||
name: data.username.to_owned(),
|
name: data.username.to_owned(),
|
||||||
fedi_name: Settings::get().hostname.to_owned(),
|
fedi_name: Settings::get().hostname.to_owned(),
|
||||||
email: data.email.to_owned(),
|
email: data.email.to_owned(),
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
password_encrypted: data.password.to_owned(),
|
password_encrypted: data.password.to_owned(),
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
|
@ -357,6 +395,7 @@ impl Perform<LoginResponse> for Oper<SaveUserSettings> {
|
||||||
name: read_user.name,
|
name: read_user.name,
|
||||||
fedi_name: read_user.fedi_name,
|
fedi_name: read_user.fedi_name,
|
||||||
email,
|
email,
|
||||||
|
matrix_user_id: data.matrix_user_id.to_owned(),
|
||||||
avatar: data.avatar.to_owned(),
|
avatar: data.avatar.to_owned(),
|
||||||
password_encrypted,
|
password_encrypted,
|
||||||
preferred_username: read_user.preferred_username,
|
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)?;
|
let read_user = User_::read(&conn, data.user_id)?;
|
||||||
|
|
||||||
|
// TODO make addadmin easier
|
||||||
let user_form = UserForm {
|
let user_form = UserForm {
|
||||||
name: read_user.name,
|
name: read_user.name,
|
||||||
fedi_name: read_user.fedi_name,
|
fedi_name: read_user.fedi_name,
|
||||||
email: read_user.email,
|
email: read_user.email,
|
||||||
|
matrix_user_id: read_user.matrix_user_id,
|
||||||
avatar: read_user.avatar,
|
avatar: read_user.avatar,
|
||||||
password_encrypted: read_user.password_encrypted,
|
password_encrypted: read_user.password_encrypted,
|
||||||
preferred_username: read_user.preferred_username,
|
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)?;
|
let read_user = User_::read(&conn, data.user_id)?;
|
||||||
|
|
||||||
|
// TODO make bans and addadmins easier
|
||||||
let user_form = UserForm {
|
let user_form = UserForm {
|
||||||
name: read_user.name,
|
name: read_user.name,
|
||||||
fedi_name: read_user.fedi_name,
|
fedi_name: read_user.fedi_name,
|
||||||
email: read_user.email,
|
email: read_user.email,
|
||||||
|
matrix_user_id: read_user.matrix_user_id,
|
||||||
avatar: read_user.avatar,
|
avatar: read_user.avatar,
|
||||||
password_encrypted: read_user.password_encrypted,
|
password_encrypted: read_user.password_encrypted,
|
||||||
preferred_username: read_user.preferred_username,
|
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 {
|
Ok(GetRepliesResponse {
|
||||||
op: self.op.to_string(),
|
op: self.op.to_string(),
|
||||||
replies: vec![],
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "here".into(),
|
password_encrypted: "here".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
published: naive_now(),
|
published: naive_now(),
|
||||||
admin: false,
|
admin: false,
|
||||||
|
|
|
@ -174,6 +174,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
banned: false,
|
banned: false,
|
||||||
|
|
|
@ -398,6 +398,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
banned: false,
|
banned: false,
|
||||||
|
|
|
@ -220,6 +220,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
banned: false,
|
banned: false,
|
||||||
|
|
|
@ -15,6 +15,8 @@ pub mod moderator_views;
|
||||||
pub mod password_reset_request;
|
pub mod password_reset_request;
|
||||||
pub mod post;
|
pub mod post;
|
||||||
pub mod post_view;
|
pub mod post_view;
|
||||||
|
pub mod private_message;
|
||||||
|
pub mod private_message_view;
|
||||||
pub mod site;
|
pub mod site;
|
||||||
pub mod site_view;
|
pub mod site_view;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
|
@ -442,6 +442,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
banned: false,
|
banned: false,
|
||||||
|
@ -463,6 +464,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
banned: false,
|
banned: false,
|
||||||
|
|
|
@ -92,6 +92,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
banned: false,
|
banned: false,
|
||||||
|
|
|
@ -187,6 +187,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
banned: false,
|
banned: false,
|
||||||
|
|
|
@ -339,6 +339,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
updated: None,
|
updated: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ pub struct User_ {
|
||||||
pub lang: String,
|
pub lang: String,
|
||||||
pub show_avatars: bool,
|
pub show_avatars: bool,
|
||||||
pub send_notifications_to_email: bool,
|
pub send_notifications_to_email: bool,
|
||||||
|
pub matrix_user_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Insertable, AsChangeset, Clone)]
|
#[derive(Insertable, AsChangeset, Clone)]
|
||||||
|
@ -47,6 +48,7 @@ pub struct UserForm {
|
||||||
pub lang: String,
|
pub lang: String,
|
||||||
pub show_avatars: bool,
|
pub show_avatars: bool,
|
||||||
pub send_notifications_to_email: bool,
|
pub send_notifications_to_email: bool,
|
||||||
|
pub matrix_user_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Crud<UserForm> for User_ {
|
impl Crud<UserForm> for User_ {
|
||||||
|
@ -184,6 +186,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
banned: false,
|
banned: false,
|
||||||
|
@ -206,6 +209,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
banned: false,
|
banned: false,
|
||||||
|
|
|
@ -68,6 +68,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
banned: false,
|
banned: false,
|
||||||
|
@ -89,6 +90,7 @@ mod tests {
|
||||||
preferred_username: None,
|
preferred_username: None,
|
||||||
password_encrypted: "nope".into(),
|
password_encrypted: "nope".into(),
|
||||||
email: None,
|
email: None,
|
||||||
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
banned: false,
|
banned: false,
|
||||||
|
|
|
@ -8,6 +8,7 @@ table! {
|
||||||
name -> Varchar,
|
name -> Varchar,
|
||||||
avatar -> Nullable<Text>,
|
avatar -> Nullable<Text>,
|
||||||
email -> Nullable<Text>,
|
email -> Nullable<Text>,
|
||||||
|
matrix_user_id -> Nullable<Text>,
|
||||||
fedi_name -> Varchar,
|
fedi_name -> Varchar,
|
||||||
admin -> Bool,
|
admin -> Bool,
|
||||||
banned -> Bool,
|
banned -> Bool,
|
||||||
|
@ -27,6 +28,7 @@ table! {
|
||||||
name -> Varchar,
|
name -> Varchar,
|
||||||
avatar -> Nullable<Text>,
|
avatar -> Nullable<Text>,
|
||||||
email -> Nullable<Text>,
|
email -> Nullable<Text>,
|
||||||
|
matrix_user_id -> Nullable<Text>,
|
||||||
fedi_name -> Varchar,
|
fedi_name -> Varchar,
|
||||||
admin -> Bool,
|
admin -> Bool,
|
||||||
banned -> Bool,
|
banned -> Bool,
|
||||||
|
@ -49,6 +51,7 @@ pub struct UserView {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub avatar: Option<String>,
|
pub avatar: Option<String>,
|
||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
|
pub matrix_user_id: Option<String>,
|
||||||
pub fedi_name: String,
|
pub fedi_name: String,
|
||||||
pub admin: bool,
|
pub admin: bool,
|
||||||
pub banned: bool,
|
pub banned: bool,
|
||||||
|
|
|
@ -12,6 +12,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||||
.route("/login", web::get().to(index))
|
.route("/login", web::get().to(index))
|
||||||
.route("/create_post", web::get().to(index))
|
.route("/create_post", web::get().to(index))
|
||||||
.route("/create_community", 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/page/{page}", web::get().to(index))
|
||||||
.route("/communities", web::get().to(index))
|
.route("/communities", web::get().to(index))
|
||||||
.route("/post/{id}/comment/{id2}", web::get().to(index))
|
.route("/post/{id}/comment/{id2}", web::get().to(index))
|
||||||
|
|
|
@ -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! {
|
table! {
|
||||||
site (id) {
|
site (id) {
|
||||||
id -> Int4,
|
id -> Int4,
|
||||||
|
@ -272,6 +285,7 @@ table! {
|
||||||
lang -> Varchar,
|
lang -> Varchar,
|
||||||
show_avatars -> Bool,
|
show_avatars -> Bool,
|
||||||
send_notifications_to_email -> 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_like,
|
||||||
post_read,
|
post_read,
|
||||||
post_saved,
|
post_saved,
|
||||||
|
private_message,
|
||||||
site,
|
site,
|
||||||
user_,
|
user_,
|
||||||
user_ban,
|
user_ban,
|
||||||
|
|
|
@ -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)?;
|
let res = Oper::new(user_operation, password_change).perform(&conn)?;
|
||||||
Ok(serde_json::to_string(&res)?)
|
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)?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(`/`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,10 +12,15 @@ import {
|
||||||
GetUserMentionsResponse,
|
GetUserMentionsResponse,
|
||||||
UserMentionResponse,
|
UserMentionResponse,
|
||||||
CommentResponse,
|
CommentResponse,
|
||||||
|
PrivateMessage as PrivateMessageI,
|
||||||
|
GetPrivateMessagesForm,
|
||||||
|
PrivateMessagesResponse,
|
||||||
|
PrivateMessageResponse,
|
||||||
} from '../interfaces';
|
} from '../interfaces';
|
||||||
import { WebSocketService, UserService } from '../services';
|
import { WebSocketService, UserService } from '../services';
|
||||||
import { msgOp, fetchLimit } from '../utils';
|
import { msgOp, fetchLimit, isCommentType } from '../utils';
|
||||||
import { CommentNodes } from './comment-nodes';
|
import { CommentNodes } from './comment-nodes';
|
||||||
|
import { PrivateMessage } from './private-message';
|
||||||
import { SortSelect } from './sort-select';
|
import { SortSelect } from './sort-select';
|
||||||
import { i18n } from '../i18next';
|
import { i18n } from '../i18next';
|
||||||
import { T } from 'inferno-i18next';
|
import { T } from 'inferno-i18next';
|
||||||
|
@ -26,9 +31,10 @@ enum UnreadOrAll {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UnreadType {
|
enum UnreadType {
|
||||||
Both,
|
All,
|
||||||
Replies,
|
Replies,
|
||||||
Mentions,
|
Mentions,
|
||||||
|
Messages,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InboxState {
|
interface InboxState {
|
||||||
|
@ -36,6 +42,7 @@ interface InboxState {
|
||||||
unreadType: UnreadType;
|
unreadType: UnreadType;
|
||||||
replies: Array<Comment>;
|
replies: Array<Comment>;
|
||||||
mentions: Array<Comment>;
|
mentions: Array<Comment>;
|
||||||
|
messages: Array<PrivateMessageI>;
|
||||||
sort: SortType;
|
sort: SortType;
|
||||||
page: number;
|
page: number;
|
||||||
}
|
}
|
||||||
|
@ -44,9 +51,10 @@ export class Inbox extends Component<any, InboxState> {
|
||||||
private subscription: Subscription;
|
private subscription: Subscription;
|
||||||
private emptyState: InboxState = {
|
private emptyState: InboxState = {
|
||||||
unreadOrAll: UnreadOrAll.Unread,
|
unreadOrAll: UnreadOrAll.Unread,
|
||||||
unreadType: UnreadType.Both,
|
unreadType: UnreadType.All,
|
||||||
replies: [],
|
replies: [],
|
||||||
mentions: [],
|
mentions: [],
|
||||||
|
messages: [],
|
||||||
sort: SortType.New,
|
sort: SortType.New,
|
||||||
page: 1,
|
page: 1,
|
||||||
};
|
};
|
||||||
|
@ -103,7 +111,10 @@ export class Inbox extends Component<any, InboxState> {
|
||||||
</a>
|
</a>
|
||||||
</small>
|
</small>
|
||||||
</h5>
|
</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 && (
|
this.state.unreadOrAll == UnreadOrAll.Unread && (
|
||||||
<ul class="list-inline mb-1 text-muted small font-weight-bold">
|
<ul class="list-inline mb-1 text-muted small font-weight-bold">
|
||||||
<li className="list-inline-item">
|
<li className="list-inline-item">
|
||||||
|
@ -114,9 +125,10 @@ export class Inbox extends Component<any, InboxState> {
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{this.selects()}
|
{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.Replies && this.replies()}
|
||||||
{this.state.unreadType == UnreadType.Mentions && this.mentions()}
|
{this.state.unreadType == UnreadType.Mentions && this.mentions()}
|
||||||
|
{this.state.unreadType == UnreadType.Messages && this.messages()}
|
||||||
{this.paginator()}
|
{this.paginator()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -150,8 +162,8 @@ export class Inbox extends Component<any, InboxState> {
|
||||||
<option disabled>
|
<option disabled>
|
||||||
<T i18nKey="type">#</T>
|
<T i18nKey="type">#</T>
|
||||||
</option>
|
</option>
|
||||||
<option value={UnreadType.Both}>
|
<option value={UnreadType.All}>
|
||||||
<T i18nKey="both">#</T>
|
<T i18nKey="all">#</T>
|
||||||
</option>
|
</option>
|
||||||
<option value={UnreadType.Replies}>
|
<option value={UnreadType.Replies}>
|
||||||
<T i18nKey="replies">#</T>
|
<T i18nKey="replies">#</T>
|
||||||
|
@ -159,6 +171,9 @@ export class Inbox extends Component<any, InboxState> {
|
||||||
<option value={UnreadType.Mentions}>
|
<option value={UnreadType.Mentions}>
|
||||||
<T i18nKey="mentions">#</T>
|
<T i18nKey="mentions">#</T>
|
||||||
</option>
|
</option>
|
||||||
|
<option value={UnreadType.Messages}>
|
||||||
|
<T i18nKey="messages">#</T>
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<SortSelect
|
<SortSelect
|
||||||
sort={this.state.sort}
|
sort={this.state.sort}
|
||||||
|
@ -169,33 +184,29 @@ export class Inbox extends Component<any, InboxState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
both() {
|
all() {
|
||||||
let combined: Array<{
|
let combined: Array<Comment | PrivateMessageI> = [];
|
||||||
type_: string;
|
|
||||||
data: Comment;
|
|
||||||
}> = [];
|
|
||||||
let replies = this.state.replies.map(e => {
|
|
||||||
return { type_: 'replies', data: e };
|
|
||||||
});
|
|
||||||
let mentions = this.state.mentions.map(e => {
|
|
||||||
return { type_: 'mentions', data: e };
|
|
||||||
});
|
|
||||||
|
|
||||||
combined.push(...replies);
|
combined.push(...this.state.replies);
|
||||||
combined.push(...mentions);
|
combined.push(...this.state.mentions);
|
||||||
|
combined.push(...this.state.messages);
|
||||||
|
|
||||||
// Sort it
|
// Sort it
|
||||||
if (this.state.sort == SortType.New) {
|
combined.sort((a, b) => b.published.localeCompare(a.published));
|
||||||
combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
|
|
||||||
} else {
|
|
||||||
combined.sort((a, b) => b.data.score - a.data.score);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{combined.map(i => (
|
{combined.map(i =>
|
||||||
<CommentNodes nodes={[{ comment: i.data }]} noIndent markable />
|
isCommentType(i) ? (
|
||||||
))}
|
<CommentNodes
|
||||||
|
nodes={[{ comment: i }]}
|
||||||
|
noIndent
|
||||||
|
markable
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PrivateMessage privateMessage={i} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</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() {
|
paginator() {
|
||||||
return (
|
return (
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
@ -283,6 +304,13 @@ export class Inbox extends Component<any, InboxState> {
|
||||||
limit: fetchLimit,
|
limit: fetchLimit,
|
||||||
};
|
};
|
||||||
WebSocketService.Instance.getUserMentions(userMentionsForm);
|
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) {
|
handleSortChange(val: SortType) {
|
||||||
|
@ -314,9 +342,37 @@ export class Inbox extends Component<any, InboxState> {
|
||||||
this.sendUnreadCount();
|
this.sendUnreadCount();
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
this.setState(this.state);
|
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) {
|
} else if (op == UserOperation.MarkAllAsRead) {
|
||||||
this.state.replies = [];
|
this.state.replies = [];
|
||||||
this.state.mentions = [];
|
this.state.mentions = [];
|
||||||
|
this.state.messages = [];
|
||||||
|
this.sendUnreadCount();
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
this.setState(this.state);
|
this.setState(this.state);
|
||||||
} else if (op == UserOperation.EditComment) {
|
} else if (op == UserOperation.EditComment) {
|
||||||
|
@ -391,7 +447,10 @@ export class Inbox extends Component<any, InboxState> {
|
||||||
sendUnreadCount() {
|
sendUnreadCount() {
|
||||||
let count =
|
let count =
|
||||||
this.state.replies.filter(r => !r.read).length +
|
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({
|
UserService.Instance.sub.next({
|
||||||
user: UserService.Instance.user,
|
user: UserService.Instance.user,
|
||||||
unreadCount: count,
|
unreadCount: count,
|
||||||
|
|
|
@ -9,15 +9,19 @@ import {
|
||||||
GetRepliesResponse,
|
GetRepliesResponse,
|
||||||
GetUserMentionsForm,
|
GetUserMentionsForm,
|
||||||
GetUserMentionsResponse,
|
GetUserMentionsResponse,
|
||||||
|
GetPrivateMessagesForm,
|
||||||
|
PrivateMessagesResponse,
|
||||||
SortType,
|
SortType,
|
||||||
GetSiteResponse,
|
GetSiteResponse,
|
||||||
Comment,
|
Comment,
|
||||||
|
PrivateMessage,
|
||||||
} from '../interfaces';
|
} from '../interfaces';
|
||||||
import {
|
import {
|
||||||
msgOp,
|
msgOp,
|
||||||
pictshareAvatarThumbnail,
|
pictshareAvatarThumbnail,
|
||||||
showAvatars,
|
showAvatars,
|
||||||
fetchLimit,
|
fetchLimit,
|
||||||
|
isCommentType,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { version } from '../version';
|
import { version } from '../version';
|
||||||
import { i18n } from '../i18next';
|
import { i18n } from '../i18next';
|
||||||
|
@ -28,6 +32,7 @@ interface NavbarState {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
replies: Array<Comment>;
|
replies: Array<Comment>;
|
||||||
mentions: Array<Comment>;
|
mentions: Array<Comment>;
|
||||||
|
messages: Array<PrivateMessage>;
|
||||||
fetchCount: number;
|
fetchCount: number;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
|
@ -42,6 +47,7 @@ export class Navbar extends Component<any, NavbarState> {
|
||||||
fetchCount: 0,
|
fetchCount: 0,
|
||||||
replies: [],
|
replies: [],
|
||||||
mentions: [],
|
mentions: [],
|
||||||
|
messages: [],
|
||||||
expanded: false,
|
expanded: false,
|
||||||
siteName: undefined,
|
siteName: undefined,
|
||||||
};
|
};
|
||||||
|
@ -228,6 +234,20 @@ export class Navbar extends Component<any, NavbarState> {
|
||||||
this.state.mentions = unreadMentions;
|
this.state.mentions = unreadMentions;
|
||||||
this.setState(this.state);
|
this.setState(this.state);
|
||||||
this.sendUnreadCount();
|
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) {
|
} else if (op == UserOperation.GetSite) {
|
||||||
let res: GetSiteResponse = msg;
|
let res: GetSiteResponse = msg;
|
||||||
|
|
||||||
|
@ -259,9 +279,17 @@ export class Navbar extends Component<any, NavbarState> {
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: fetchLimit,
|
limit: fetchLimit,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let privateMessagesForm: GetPrivateMessagesForm = {
|
||||||
|
unread_only: true,
|
||||||
|
page: 1,
|
||||||
|
limit: fetchLimit,
|
||||||
|
};
|
||||||
|
|
||||||
if (this.currentLocation !== '/inbox') {
|
if (this.currentLocation !== '/inbox') {
|
||||||
WebSocketService.Instance.getReplies(repliesForm);
|
WebSocketService.Instance.getReplies(repliesForm);
|
||||||
WebSocketService.Instance.getUserMentions(userMentionsForm);
|
WebSocketService.Instance.getUserMentions(userMentionsForm);
|
||||||
|
WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
|
||||||
this.state.fetchCount++;
|
this.state.fetchCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -281,7 +309,8 @@ export class Navbar extends Component<any, NavbarState> {
|
||||||
get unreadCount() {
|
get unreadCount() {
|
||||||
return (
|
return (
|
||||||
this.state.replies.filter(r => !r.read).length +
|
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];
|
let recentReply = replies[0];
|
||||||
if (Notification.permission !== 'granted') Notification.requestPermission();
|
if (Notification.permission !== 'granted') Notification.requestPermission();
|
||||||
else {
|
else {
|
||||||
var notification = new Notification(
|
var notification = new Notification(
|
||||||
`${replies.length} ${i18n.t('unread_messages')}`,
|
`${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}`,
|
body: `${recentReply.creator_name}: ${recentReply.content}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
notification.onclick = () => {
|
notification.onclick = () => {
|
||||||
this.context.router.history.push(
|
this.context.router.history.push(
|
||||||
`/post/${recentReply.post_id}/comment/${recentReply.id}`
|
isCommentType(recentReply)
|
||||||
|
? `/post/${recentReply.post_id}/comment/${recentReply.id}`
|
||||||
|
: `/inbox`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
|
@ -405,13 +405,30 @@ export class User extends Component<any, UserState> {
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{this.isCurrentUser && (
|
{this.isCurrentUser ? (
|
||||||
<button
|
<button
|
||||||
class="btn btn-block btn-secondary mt-3"
|
class="btn btn-block btn-secondary mt-3"
|
||||||
onClick={linkEvent(this, this.handleLogoutClick)}
|
onClick={linkEvent(this, this.handleLogoutClick)}
|
||||||
>
|
>
|
||||||
<T i18nKey="logout">#</T>
|
<T i18nKey="logout">#</T>
|
||||||
</button>
|
</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>
|
||||||
|
@ -539,6 +556,26 @@ export class User extends Component<any, UserState> {
|
||||||
/>
|
/>
|
||||||
</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">
|
<div class="form-group row">
|
||||||
<label class="col-lg-5 col-form-label">
|
<label class="col-lg-5 col-form-label">
|
||||||
<T i18nKey="new_password">#</T>
|
<T i18nKey="new_password">#</T>
|
||||||
|
@ -875,6 +912,17 @@ export class User extends Component<any, UserState> {
|
||||||
i.setState(i.state);
|
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) {
|
handleUserSettingsNewPasswordChange(i: User, event: any) {
|
||||||
i.state.userSettingsForm.new_password = event.target.value;
|
i.state.userSettingsForm.new_password = event.target.value;
|
||||||
if (i.state.userSettingsForm.new_password == '') {
|
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.send_notifications_to_email = this.state.user.send_notifications_to_email;
|
||||||
this.state.userSettingsForm.show_avatars =
|
this.state.userSettingsForm.show_avatars =
|
||||||
UserService.Instance.user.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}`;
|
document.title = `/u/${this.state.user.name} - ${WebSocketService.Instance.site.name}`;
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { Footer } from './components/footer';
|
||||||
import { Login } from './components/login';
|
import { Login } from './components/login';
|
||||||
import { CreatePost } from './components/create-post';
|
import { CreatePost } from './components/create-post';
|
||||||
import { CreateCommunity } from './components/create-community';
|
import { CreateCommunity } from './components/create-community';
|
||||||
|
import { CreatePrivateMessage } from './components/create-private-message';
|
||||||
import { PasswordChange } from './components/password_change';
|
import { PasswordChange } from './components/password_change';
|
||||||
import { Post } from './components/post';
|
import { Post } from './components/post';
|
||||||
import { Community } from './components/community';
|
import { Community } from './components/community';
|
||||||
|
@ -46,6 +47,10 @@ class Index extends Component<any, any> {
|
||||||
<Route path={`/login`} component={Login} />
|
<Route path={`/login`} component={Login} />
|
||||||
<Route path={`/create_post`} component={CreatePost} />
|
<Route path={`/create_post`} component={CreatePost} />
|
||||||
<Route path={`/create_community`} component={CreateCommunity} />
|
<Route path={`/create_community`} component={CreateCommunity} />
|
||||||
|
<Route
|
||||||
|
path={`/create_private_message`}
|
||||||
|
component={CreatePrivateMessage}
|
||||||
|
/>
|
||||||
<Route path={`/communities/page/:page`} component={Communities} />
|
<Route path={`/communities/page/:page`} component={Communities} />
|
||||||
<Route path={`/communities`} component={Communities} />
|
<Route path={`/communities`} component={Communities} />
|
||||||
<Route path={`/post/:id/comment/:comment_id`} component={Post} />
|
<Route path={`/post/:id/comment/:comment_id`} component={Post} />
|
||||||
|
|
|
@ -38,6 +38,9 @@ export enum UserOperation {
|
||||||
DeleteAccount,
|
DeleteAccount,
|
||||||
PasswordReset,
|
PasswordReset,
|
||||||
PasswordChange,
|
PasswordChange,
|
||||||
|
CreatePrivateMessage,
|
||||||
|
EditPrivateMessage,
|
||||||
|
GetPrivateMessages,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CommentSortType {
|
export enum CommentSortType {
|
||||||
|
@ -89,6 +92,7 @@ export interface UserView {
|
||||||
name: string;
|
name: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
matrix_user_id?: string;
|
||||||
fedi_name: string;
|
fedi_name: string;
|
||||||
published: string;
|
published: string;
|
||||||
number_of_posts: number;
|
number_of_posts: number;
|
||||||
|
@ -218,6 +222,21 @@ export interface Site {
|
||||||
enable_nsfw: boolean;
|
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 {
|
export enum BanType {
|
||||||
Community,
|
Community,
|
||||||
Site,
|
Site,
|
||||||
|
@ -490,6 +509,7 @@ export interface UserSettingsForm {
|
||||||
lang: string;
|
lang: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
matrix_user_id?: string;
|
||||||
new_password?: string;
|
new_password?: string;
|
||||||
new_password_verify?: string;
|
new_password_verify?: string;
|
||||||
old_password?: string;
|
old_password?: string;
|
||||||
|
@ -729,3 +749,38 @@ export interface PasswordChangeForm {
|
||||||
password: string;
|
password: string;
|
||||||
password_verify: 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;
|
||||||
|
}
|
||||||
|
|
|
@ -32,10 +32,13 @@ import {
|
||||||
DeleteAccountForm,
|
DeleteAccountForm,
|
||||||
PasswordResetForm,
|
PasswordResetForm,
|
||||||
PasswordChangeForm,
|
PasswordChangeForm,
|
||||||
|
PrivateMessageForm,
|
||||||
|
EditPrivateMessageForm,
|
||||||
|
GetPrivateMessagesForm,
|
||||||
} from '../interfaces';
|
} from '../interfaces';
|
||||||
import { webSocket } from 'rxjs/webSocket';
|
import { webSocket } from 'rxjs/webSocket';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { retryWhen, delay, take } from 'rxjs/operators';
|
import { retryWhen, delay } from 'rxjs/operators';
|
||||||
import { UserService } from './';
|
import { UserService } from './';
|
||||||
import { i18n } from '../i18next';
|
import { i18n } from '../i18next';
|
||||||
|
|
||||||
|
@ -285,6 +288,27 @@ export class WebSocketService {
|
||||||
this.subject.next(this.wsSendWrapper(UserOperation.PasswordChange, form));
|
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) {
|
private wsSendWrapper(op: UserOperation, data: any) {
|
||||||
let send = { op: UserOperation[op], data: data };
|
let send = { op: UserOperation[op], data: data };
|
||||||
console.log(send);
|
console.log(send);
|
||||||
|
|
|
@ -23,6 +23,10 @@ export const en = {
|
||||||
list_of_communities: 'List of communities',
|
list_of_communities: 'List of communities',
|
||||||
number_of_communities: '{{count}} Communities',
|
number_of_communities: '{{count}} Communities',
|
||||||
community_reqs: 'lowercase, underscores, and no spaces.',
|
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',
|
edit: 'edit',
|
||||||
reply: 'reply',
|
reply: 'reply',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
|
@ -109,6 +113,7 @@ export const en = {
|
||||||
replies: 'Replies',
|
replies: 'Replies',
|
||||||
mentions: 'Mentions',
|
mentions: 'Mentions',
|
||||||
reply_sent: 'Reply sent',
|
reply_sent: 'Reply sent',
|
||||||
|
message_sent: 'Message sent',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
overview: 'Overview',
|
overview: 'Overview',
|
||||||
view: 'View',
|
view: 'View',
|
||||||
|
@ -119,6 +124,7 @@ export const en = {
|
||||||
notifications_error:
|
notifications_error:
|
||||||
'Desktop notifications not available in your browser. Try Firefox or Chrome.',
|
'Desktop notifications not available in your browser. Try Firefox or Chrome.',
|
||||||
unread_messages: 'Unread Messages',
|
unread_messages: 'Unread Messages',
|
||||||
|
messages: 'Messages',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
verify_password: 'Verify Password',
|
verify_password: 'Verify Password',
|
||||||
old_password: 'Old Password',
|
old_password: 'Old Password',
|
||||||
|
@ -128,6 +134,9 @@ export const en = {
|
||||||
new_password: 'New Password',
|
new_password: 'New Password',
|
||||||
no_email_setup: "This server hasn't correctly set up email.",
|
no_email_setup: "This server hasn't correctly set up email.",
|
||||||
email: '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',
|
send_notifications_to_email: 'Send notifications to Email',
|
||||||
optional: 'Optional',
|
optional: 'Optional',
|
||||||
expires: 'Expires',
|
expires: 'Expires',
|
||||||
|
@ -172,6 +181,7 @@ export const en = {
|
||||||
joined: 'Joined',
|
joined: 'Joined',
|
||||||
by: 'by',
|
by: 'by',
|
||||||
to: 'to',
|
to: 'to',
|
||||||
|
from: 'from',
|
||||||
transfer_community: 'transfer community',
|
transfer_community: 'transfer community',
|
||||||
transfer_site: 'transfer site',
|
transfer_site: 'transfer site',
|
||||||
are_you_sure: 'are you sure?',
|
are_you_sure: 'are you sure?',
|
||||||
|
@ -215,5 +225,8 @@ export const en = {
|
||||||
email_already_exists: 'Email already exists.',
|
email_already_exists: 'Email already exists.',
|
||||||
couldnt_update_user: "Couldn't update user.",
|
couldnt_update_user: "Couldn't update user.",
|
||||||
system_err_login: 'System error. Try logging out and back in.',
|
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.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,6 +11,7 @@ import 'moment/locale/it';
|
||||||
import {
|
import {
|
||||||
UserOperation,
|
UserOperation,
|
||||||
Comment,
|
Comment,
|
||||||
|
PrivateMessage,
|
||||||
User,
|
User,
|
||||||
SortType,
|
SortType,
|
||||||
ListingType,
|
ListingType,
|
||||||
|
@ -361,3 +362,7 @@ export function imageThumbnailer(url: string): string {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCommentType(item: Comment | PrivateMessage): item is Comment {
|
||||||
|
return (item as Comment).community_id !== undefined;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue