Adding username mentions / tagging from comments.

- Fixes #293
This commit is contained in:
Dessalines 2019-10-19 17:46:29 -07:00
parent 9be8675722
commit 2d5f13ae67
21 changed files with 1151 additions and 57 deletions

View File

@ -0,0 +1,2 @@
drop view user_mention_view;
drop table user_mention;

View File

@ -0,0 +1,35 @@
create table user_mention (
id serial primary key,
recipient_id int references user_ on update cascade on delete cascade not null,
comment_id int references comment on update cascade on delete cascade not null,
read boolean default false not null,
published timestamp not null default now(),
unique(recipient_id, comment_id)
);
create view user_mention_view as
select
c.id,
um.id as user_mention_id,
c.creator_id,
c.post_id,
c.parent_id,
c.content,
c.removed,
um.read,
c.published,
c.updated,
c.deleted,
c.community_id,
c.banned,
c.banned_from_community,
c.creator_name,
c.score,
c.upvotes,
c.downvotes,
c.user_id,
c.my_vote,
c.saved,
um.recipient_id
from user_mention um, comment_view c
where um.comment_id = c.id;

View File

@ -85,6 +85,35 @@ impl Perform<CommentResponse> for Oper<CreateComment> {
Err(_e) => return Err(APIError::err(&self.op, "couldnt_create_comment"))?,
};
// Scan the comment for user mentions, add those rows
let extracted_usernames = extract_usernames(&comment_form.content);
for username_mention in &extracted_usernames {
let mention_user = User_::read_from_name(&conn, username_mention.to_string());
if mention_user.is_ok() {
let mention_user_id = mention_user?.id;
// You can't mention yourself
// At some point, make it so you can't tag the parent creator either
// This can cause two notifications, one for reply and the other for mention
if mention_user_id != user_id {
let user_mention_form = UserMentionForm {
recipient_id: mention_user_id,
comment_id: inserted_comment.id,
read: None,
};
// Allow this to fail softly, since comment edits might re-update or replace it
// Let the uniqueness handle this fail
match UserMention::create(&conn, &user_mention_form) {
Ok(_mention) => (),
Err(_e) => eprintln!("{}", &_e),
}
}
}
}
// You like your own comment by default
let like_form = CommentLikeForm {
comment_id: inserted_comment.id,
@ -170,6 +199,35 @@ impl Perform<CommentResponse> for Oper<EditComment> {
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment"))?,
};
// Scan the comment for user mentions, add those rows
let extracted_usernames = extract_usernames(&comment_form.content);
for username_mention in &extracted_usernames {
let mention_user = User_::read_from_name(&conn, username_mention.to_string());
if mention_user.is_ok() {
let mention_user_id = mention_user?.id;
// You can't mention yourself
// At some point, make it so you can't tag the parent creator either
// This can cause two notifications, one for reply and the other for mention
if mention_user_id != user_id {
let user_mention_form = UserMentionForm {
recipient_id: mention_user_id,
comment_id: data.edit_id,
read: None,
};
// Allow this to fail softly, since comment edits might re-update or replace it
// Let the uniqueness handle this fail
match UserMention::create(&conn, &user_mention_form) {
Ok(_mention) => (),
Err(_e) => eprintln!("{}", &_e),
}
}
}
}
// Mod tables
if let Some(removed) = data.removed.to_owned() {
let form = ModRemoveCommentForm {

View File

@ -8,9 +8,11 @@ use crate::db::moderator_views::*;
use crate::db::post::*;
use crate::db::post_view::*;
use crate::db::user::*;
use crate::db::user_mention::*;
use crate::db::user_mention_view::*;
use crate::db::user_view::*;
use crate::db::*;
use crate::{has_slurs, naive_from_unix, naive_now, remove_slurs, Settings};
use crate::{extract_usernames, has_slurs, naive_from_unix, naive_now, remove_slurs, Settings};
use failure::Error;
use serde::{Deserialize, Serialize};
@ -43,6 +45,8 @@ pub enum UserOperation {
GetFollowedCommunities,
GetUserDetails,
GetReplies,
GetUserMentions,
EditUserMention,
GetModlog,
BanFromCommunity,
AddModToCommunity,

View File

@ -60,6 +60,12 @@ pub struct GetRepliesResponse {
replies: Vec<ReplyView>,
}
#[derive(Serialize, Deserialize)]
pub struct GetUserMentionsResponse {
op: String,
mentions: Vec<UserMentionView>,
}
#[derive(Serialize, Deserialize)]
pub struct MarkAllAsRead {
auth: String,
@ -103,6 +109,28 @@ pub struct GetReplies {
auth: String,
}
#[derive(Serialize, Deserialize)]
pub struct GetUserMentions {
sort: String,
page: Option<i64>,
limit: Option<i64>,
unread_only: bool,
auth: String,
}
#[derive(Serialize, Deserialize)]
pub struct EditUserMention {
user_mention_id: i32,
read: Option<bool>,
auth: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct UserMentionResponse {
op: String,
mention: UserMentionView,
}
#[derive(Serialize, Deserialize)]
pub struct DeleteAccount {
password: String,
@ -299,7 +327,6 @@ impl Perform<GetUserDetailsResponse> for Oper<GetUserDetails> {
None => false,
};
//TODO add save
let sort = SortType::from_str(&data.sort)?;
let user_details_id = match data.user_id {
@ -541,7 +568,6 @@ impl Perform<GetRepliesResponse> for Oper<GetReplies> {
data.limit,
)?;
// Return the jwt
Ok(GetRepliesResponse {
op: self.op.to_string(),
replies: replies,
@ -549,6 +575,71 @@ impl Perform<GetRepliesResponse> for Oper<GetReplies> {
}
}
impl Perform<GetUserMentionsResponse> for Oper<GetUserMentions> {
fn perform(&self) -> Result<GetUserMentionsResponse, Error> {
let data: &GetUserMentions = &self.data;
let conn = establish_connection();
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
};
let user_id = claims.id;
let sort = SortType::from_str(&data.sort)?;
let mentions = UserMentionView::get_mentions(
&conn,
user_id,
&sort,
data.unread_only,
data.page,
data.limit,
)?;
Ok(GetUserMentionsResponse {
op: self.op.to_string(),
mentions: mentions,
})
}
}
impl Perform<UserMentionResponse> for Oper<EditUserMention> {
fn perform(&self) -> Result<UserMentionResponse, Error> {
let data: &EditUserMention = &self.data;
let conn = establish_connection();
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
};
let user_id = claims.id;
let user_mention = UserMention::read(&conn, data.user_mention_id)?;
let user_mention_form = UserMentionForm {
recipient_id: user_id,
comment_id: user_mention.comment_id,
read: data.read.to_owned(),
};
let _updated_user_mention =
match UserMention::update(&conn, user_mention.id, &user_mention_form) {
Ok(comment) => comment,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment"))?,
};
let user_mention_view = UserMentionView::read(&conn, user_mention.id, user_id)?;
Ok(UserMentionResponse {
op: self.op.to_string(),
mention: user_mention_view,
})
}
}
impl Perform<GetRepliesResponse> for Oper<MarkAllAsRead> {
fn perform(&self) -> Result<GetRepliesResponse, Error> {
let data: &MarkAllAsRead = &self.data;
@ -581,11 +672,27 @@ impl Perform<GetRepliesResponse> for Oper<MarkAllAsRead> {
};
}
let replies = ReplyView::get_replies(&conn, user_id, &SortType::New, true, Some(1), Some(999))?;
// Mentions
let mentions =
UserMentionView::get_mentions(&conn, user_id, &SortType::New, true, Some(1), Some(999))?;
for mention in &mentions {
let mention_form = UserMentionForm {
recipient_id: mention.to_owned().recipient_id,
comment_id: mention.to_owned().id,
read: Some(true),
};
let _updated_mention =
match UserMention::update(&conn, mention.user_mention_id, &mention_form) {
Ok(mention) => mention,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment"))?,
};
}
Ok(GetRepliesResponse {
op: self.op.to_string(),
replies: replies,
replies: vec![],
})
}
}

View File

@ -69,7 +69,6 @@ impl CommentView {
let (limit, offset) = limit_and_offset(page, limit);
// TODO no limits here?
let mut query = comment_view.into_boxed();
// The view lets you pass a null user_id, if you're not logged in

View File

@ -14,6 +14,8 @@ pub mod moderator_views;
pub mod post;
pub mod post_view;
pub mod user;
pub mod user_mention;
pub mod user_mention_view;
pub mod user_view;
pub trait Crud<T> {

345
server/src/db/src/schema.rs Normal file
View File

@ -0,0 +1,345 @@
table! {
category (id) {
id -> Int4,
name -> Varchar,
}
}
table! {
comment (id) {
id -> Int4,
creator_id -> Int4,
post_id -> Int4,
parent_id -> Nullable<Int4>,
content -> Text,
removed -> Bool,
read -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
deleted -> Bool,
}
}
table! {
comment_like (id) {
id -> Int4,
user_id -> Int4,
comment_id -> Int4,
post_id -> Int4,
score -> Int2,
published -> Timestamp,
}
}
table! {
comment_saved (id) {
id -> Int4,
comment_id -> Int4,
user_id -> Int4,
published -> Timestamp,
}
}
table! {
community (id) {
id -> Int4,
name -> Varchar,
title -> Varchar,
description -> Nullable<Text>,
category_id -> Int4,
creator_id -> Int4,
removed -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
deleted -> Bool,
nsfw -> Bool,
}
}
table! {
community_follower (id) {
id -> Int4,
community_id -> Int4,
user_id -> Int4,
published -> Timestamp,
}
}
table! {
community_moderator (id) {
id -> Int4,
community_id -> Int4,
user_id -> Int4,
published -> Timestamp,
}
}
table! {
community_user_ban (id) {
id -> Int4,
community_id -> Int4,
user_id -> Int4,
published -> Timestamp,
}
}
table! {
mod_add (id) {
id -> Int4,
mod_user_id -> Int4,
other_user_id -> Int4,
removed -> Nullable<Bool>,
when_ -> Timestamp,
}
}
table! {
mod_add_community (id) {
id -> Int4,
mod_user_id -> Int4,
other_user_id -> Int4,
community_id -> Int4,
removed -> Nullable<Bool>,
when_ -> Timestamp,
}
}
table! {
mod_ban (id) {
id -> Int4,
mod_user_id -> Int4,
other_user_id -> Int4,
reason -> Nullable<Text>,
banned -> Nullable<Bool>,
expires -> Nullable<Timestamp>,
when_ -> Timestamp,
}
}
table! {
mod_ban_from_community (id) {
id -> Int4,
mod_user_id -> Int4,
other_user_id -> Int4,
community_id -> Int4,
reason -> Nullable<Text>,
banned -> Nullable<Bool>,
expires -> Nullable<Timestamp>,
when_ -> Timestamp,
}
}
table! {
mod_lock_post (id) {
id -> Int4,
mod_user_id -> Int4,
post_id -> Int4,
locked -> Nullable<Bool>,
when_ -> Timestamp,
}
}
table! {
mod_remove_comment (id) {
id -> Int4,
mod_user_id -> Int4,
comment_id -> Int4,
reason -> Nullable<Text>,
removed -> Nullable<Bool>,
when_ -> Timestamp,
}
}
table! {
mod_remove_community (id) {
id -> Int4,
mod_user_id -> Int4,
community_id -> Int4,
reason -> Nullable<Text>,
removed -> Nullable<Bool>,
expires -> Nullable<Timestamp>,
when_ -> Timestamp,
}
}
table! {
mod_remove_post (id) {
id -> Int4,
mod_user_id -> Int4,
post_id -> Int4,
reason -> Nullable<Text>,
removed -> Nullable<Bool>,
when_ -> Timestamp,
}
}
table! {
mod_sticky_post (id) {
id -> Int4,
mod_user_id -> Int4,
post_id -> Int4,
stickied -> Nullable<Bool>,
when_ -> Timestamp,
}
}
table! {
post (id) {
id -> Int4,
name -> Varchar,
url -> Nullable<Text>,
body -> Nullable<Text>,
creator_id -> Int4,
community_id -> Int4,
removed -> Bool,
locked -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
deleted -> Bool,
nsfw -> Bool,
stickied -> Bool,
}
}
table! {
post_like (id) {
id -> Int4,
post_id -> Int4,
user_id -> Int4,
score -> Int2,
published -> Timestamp,
}
}
table! {
post_read (id) {
id -> Int4,
post_id -> Int4,
user_id -> Int4,
published -> Timestamp,
}
}
table! {
post_saved (id) {
id -> Int4,
post_id -> Int4,
user_id -> Int4,
published -> Timestamp,
}
}
table! {
site (id) {
id -> Int4,
name -> Varchar,
description -> Nullable<Text>,
creator_id -> Int4,
published -> Timestamp,
updated -> Nullable<Timestamp>,
}
}
table! {
user_ (id) {
id -> Int4,
name -> Varchar,
fedi_name -> Varchar,
preferred_username -> Nullable<Varchar>,
password_encrypted -> Text,
email -> Nullable<Text>,
icon -> Nullable<Bytea>,
admin -> Bool,
banned -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
show_nsfw -> Bool,
theme -> Varchar,
}
}
table! {
user_ban (id) {
id -> Int4,
user_id -> Int4,
published -> Timestamp,
}
}
table! {
user_mention (id) {
id -> Int4,
recipient_id -> Int4,
comment_id -> Int4,
read -> Bool,
published -> Timestamp,
}
}
joinable!(comment -> post (post_id));
joinable!(comment -> user_ (creator_id));
joinable!(comment_like -> comment (comment_id));
joinable!(comment_like -> post (post_id));
joinable!(comment_like -> user_ (user_id));
joinable!(comment_saved -> comment (comment_id));
joinable!(comment_saved -> user_ (user_id));
joinable!(community -> category (category_id));
joinable!(community -> user_ (creator_id));
joinable!(community_follower -> community (community_id));
joinable!(community_follower -> user_ (user_id));
joinable!(community_moderator -> community (community_id));
joinable!(community_moderator -> user_ (user_id));
joinable!(community_user_ban -> community (community_id));
joinable!(community_user_ban -> user_ (user_id));
joinable!(mod_add_community -> community (community_id));
joinable!(mod_ban_from_community -> community (community_id));
joinable!(mod_lock_post -> post (post_id));
joinable!(mod_lock_post -> user_ (mod_user_id));
joinable!(mod_remove_comment -> comment (comment_id));
joinable!(mod_remove_comment -> user_ (mod_user_id));
joinable!(mod_remove_community -> community (community_id));
joinable!(mod_remove_community -> user_ (mod_user_id));
joinable!(mod_remove_post -> post (post_id));
joinable!(mod_remove_post -> user_ (mod_user_id));
joinable!(mod_sticky_post -> post (post_id));
joinable!(mod_sticky_post -> user_ (mod_user_id));
joinable!(post -> community (community_id));
joinable!(post -> user_ (creator_id));
joinable!(post_like -> post (post_id));
joinable!(post_like -> user_ (user_id));
joinable!(post_read -> post (post_id));
joinable!(post_read -> user_ (user_id));
joinable!(post_saved -> post (post_id));
joinable!(post_saved -> user_ (user_id));
joinable!(site -> user_ (creator_id));
joinable!(user_ban -> user_ (user_id));
joinable!(user_mention -> comment (comment_id));
joinable!(user_mention -> user_ (recipient_id));
allow_tables_to_appear_in_same_query!(
category,
comment,
comment_like,
comment_saved,
community,
community_follower,
community_moderator,
community_user_ban,
mod_add,
mod_add_community,
mod_ban,
mod_ban_from_community,
mod_lock_post,
mod_remove_comment,
mod_remove_community,
mod_remove_post,
mod_sticky_post,
post,
post_like,
post_read,
post_saved,
site,
user_,
user_ban,
user_mention,
);

View File

@ -0,0 +1,169 @@
use super::comment::Comment;
use super::*;
use crate::schema::user_mention;
#[derive(Queryable, Associations, Identifiable, PartialEq, Debug, Serialize, Deserialize)]
#[belongs_to(Comment)]
#[table_name = "user_mention"]
pub struct UserMention {
pub id: i32,
pub recipient_id: i32,
pub comment_id: i32,
pub read: bool,
pub published: chrono::NaiveDateTime,
}
#[derive(Insertable, AsChangeset, Clone)]
#[table_name = "user_mention"]
pub struct UserMentionForm {
pub recipient_id: i32,
pub comment_id: i32,
pub read: Option<bool>,
}
impl Crud<UserMentionForm> for UserMention {
fn read(conn: &PgConnection, user_mention_id: i32) -> Result<Self, Error> {
use crate::schema::user_mention::dsl::*;
user_mention.find(user_mention_id).first::<Self>(conn)
}
fn delete(conn: &PgConnection, user_mention_id: i32) -> Result<usize, Error> {
use crate::schema::user_mention::dsl::*;
diesel::delete(user_mention.find(user_mention_id)).execute(conn)
}
fn create(conn: &PgConnection, user_mention_form: &UserMentionForm) -> Result<Self, Error> {
use crate::schema::user_mention::dsl::*;
insert_into(user_mention)
.values(user_mention_form)
.get_result::<Self>(conn)
}
fn update(
conn: &PgConnection,
user_mention_id: i32,
user_mention_form: &UserMentionForm,
) -> Result<Self, Error> {
use crate::schema::user_mention::dsl::*;
diesel::update(user_mention.find(user_mention_id))
.set(user_mention_form)
.get_result::<Self>(conn)
}
}
#[cfg(test)]
mod tests {
use super::super::comment::*;
use super::super::community::*;
use super::super::post::*;
use super::super::user::*;
use super::*;
#[test]
fn test_crud() {
let conn = establish_connection();
let new_user = UserForm {
name: "terrylake".into(),
fedi_name: "rrf".into(),
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
admin: false,
banned: false,
updated: None,
show_nsfw: false,
theme: "darkly".into(),
};
let inserted_user = User_::create(&conn, &new_user).unwrap();
let recipient_form = UserForm {
name: "terrylakes recipient".into(),
fedi_name: "rrf".into(),
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
admin: false,
banned: false,
updated: None,
show_nsfw: false,
theme: "darkly".into(),
};
let inserted_recipient = User_::create(&conn, &recipient_form).unwrap();
let new_community = CommunityForm {
name: "test community lake".to_string(),
title: "nada".to_owned(),
description: None,
category_id: 1,
creator_id: inserted_user.id,
removed: None,
deleted: None,
updated: None,
nsfw: false,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();
let new_post = PostForm {
name: "A test post".into(),
creator_id: inserted_user.id,
url: None,
body: None,
community_id: inserted_community.id,
removed: None,
deleted: None,
locked: None,
stickied: None,
updated: None,
nsfw: false,
};
let inserted_post = Post::create(&conn, &new_post).unwrap();
let comment_form = CommentForm {
content: "A test comment".into(),
creator_id: inserted_user.id,
post_id: inserted_post.id,
removed: None,
deleted: None,
read: None,
parent_id: None,
updated: None,
};
let inserted_comment = Comment::create(&conn, &comment_form).unwrap();
let user_mention_form = UserMentionForm {
recipient_id: inserted_recipient.id,
comment_id: inserted_comment.id,
read: None,
};
let inserted_mention = UserMention::create(&conn, &user_mention_form).unwrap();
let expected_mention = UserMention {
id: inserted_mention.id,
recipient_id: inserted_mention.recipient_id,
comment_id: inserted_mention.comment_id,
read: false,
published: inserted_mention.published,
};
let read_mention = UserMention::read(&conn, inserted_mention.id).unwrap();
let updated_mention =
UserMention::update(&conn, inserted_mention.id, &user_mention_form).unwrap();
let num_deleted = UserMention::delete(&conn, inserted_mention.id).unwrap();
Comment::delete(&conn, inserted_comment.id).unwrap();
Post::delete(&conn, inserted_post.id).unwrap();
Community::delete(&conn, inserted_community.id).unwrap();
User_::delete(&conn, inserted_user.id).unwrap();
User_::delete(&conn, inserted_recipient.id).unwrap();
assert_eq!(expected_mention, read_mention);
assert_eq!(expected_mention, inserted_mention);
assert_eq!(expected_mention, updated_mention);
assert_eq!(1, num_deleted);
}
}

View File

@ -0,0 +1,117 @@
use super::*;
// The faked schema since diesel doesn't do views
table! {
user_mention_view (id) {
id -> Int4,
user_mention_id -> Int4,
creator_id -> Int4,
post_id -> Int4,
parent_id -> Nullable<Int4>,
content -> Text,
removed -> Bool,
read -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
deleted -> Bool,
community_id -> Int4,
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
saved -> Nullable<Bool>,
recipient_id -> Int4,
}
}
#[derive(
Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize, QueryableByName, Clone,
)]
#[table_name = "user_mention_view"]
pub struct UserMentionView {
pub id: i32,
pub user_mention_id: i32,
pub creator_id: i32,
pub post_id: i32,
pub parent_id: Option<i32>,
pub content: String,
pub removed: bool,
pub read: bool,
pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
pub community_id: i32,
pub banned: bool,
pub banned_from_community: bool,
pub creator_name: String,
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub saved: Option<bool>,
pub recipient_id: i32,
}
impl UserMentionView {
pub fn get_mentions(
conn: &PgConnection,
for_user_id: i32,
sort: &SortType,
unread_only: bool,
page: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<Self>, Error> {
use super::user_mention_view::user_mention_view::dsl::*;
let (limit, offset) = limit_and_offset(page, limit);
let mut query = user_mention_view.into_boxed();
query = query
.filter(user_id.eq(for_user_id))
.filter(recipient_id.eq(for_user_id));
if unread_only {
query = query.filter(read.eq(false));
}
query = match sort {
// SortType::Hot => query.order_by(hot_rank.desc()),
SortType::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(score.desc()),
SortType::TopYear => query
.filter(published.gt(now - 1.years()))
.order_by(score.desc()),
SortType::TopMonth => query
.filter(published.gt(now - 1.months()))
.order_by(score.desc()),
SortType::TopWeek => query
.filter(published.gt(now - 1.weeks()))
.order_by(score.desc()),
SortType::TopDay => query
.filter(published.gt(now - 1.days()))
.order_by(score.desc()),
_ => query.order_by(published.desc()),
};
query.limit(limit).offset(offset).load::<Self>(conn)
}
pub fn read(
conn: &PgConnection,
from_user_mention_id: i32,
from_recipient_id: i32,
) -> Result<Self, Error> {
use super::user_mention_view::user_mention_view::dsl::*;
user_mention_view
.filter(user_mention_id.eq(from_user_mention_id))
.filter(user_id.eq(from_recipient_id))
.first::<Self>(conn)
}
}

View File

@ -104,9 +104,23 @@ pub fn has_slurs(test: &str) -> bool {
SLUR_REGEX.is_match(test)
}
pub fn extract_usernames(test: &str) -> Vec<&str> {
let mut matches: Vec<&str> = USERNAME_MATCHES_REGEX
.find_iter(test)
.map(|mat| mat.as_str())
.collect();
// Unique
matches.sort_unstable();
matches.dedup();
// Remove /u/
matches.iter().map(|t| &t[3..]).collect()
}
#[cfg(test)]
mod tests {
use crate::{has_slurs, is_email_regex, remove_slurs, Settings};
use crate::{extract_usernames, has_slurs, is_email_regex, remove_slurs, Settings};
#[test]
fn test_api() {
assert_eq!(Settings::get().api_endpoint(), "rrr/api/v1");
@ -131,9 +145,17 @@ mod tests {
assert!(has_slurs(&test));
assert!(!has_slurs(slur_free));
}
#[test]
fn test_extract_usernames() {
let usernames = extract_usernames("this is a user mention for [/u/testme](/u/testme) and thats all. Oh [/u/another](/u/another) user. And the first again [/u/testme](/u/testme) okay");
let expected = vec!["another", "testme"];
assert_eq!(usernames, expected);
}
}
lazy_static! {
static ref EMAIL_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$").unwrap();
static ref SLUR_REGEX: Regex = Regex::new(r"(fag(g|got|tard)?|maricos?|cock\s?sucker(s|ing)?|nig(\b|g?(a|er)?s?)\b|dindu(s?)|mudslime?s?|kikes?|mongoloids?|towel\s*heads?|\bspi(c|k)s?\b|\bchinks?|niglets?|beaners?|\bnips?\b|\bcoons?\b|jungle\s*bunn(y|ies?)|jigg?aboo?s?|\bpakis?\b|rag\s*heads?|gooks?|cunts?|bitch(es|ing|y)?|puss(y|ies?)|twats?|feminazis?|whor(es?|ing)|\bslut(s|t?y)?|\btrann?(y|ies?)|ladyboy(s?)|\b(b|re|r)tard(ed)?s?)").unwrap();
static ref USERNAME_MATCHES_REGEX: Regex = Regex::new(r"/u/[a-zA-Z][0-9a-zA-Z_]*").unwrap();
}

View File

@ -266,6 +266,16 @@ table! {
}
}
table! {
user_mention (id) {
id -> Int4,
recipient_id -> Int4,
comment_id -> Int4,
read -> Bool,
published -> Timestamp,
}
}
joinable!(comment -> post (post_id));
joinable!(comment -> user_ (creator_id));
joinable!(comment_like -> comment (comment_id));
@ -303,6 +313,8 @@ joinable!(post_saved -> post (post_id));
joinable!(post_saved -> user_ (user_id));
joinable!(site -> user_ (creator_id));
joinable!(user_ban -> user_ (user_id));
joinable!(user_mention -> comment (comment_id));
joinable!(user_mention -> user_ (recipient_id));
allow_tables_to_appear_in_same_query!(
category,
@ -329,4 +341,5 @@ allow_tables_to_appear_in_same_query!(
site,
user_,
user_ban,
user_mention,
);

View File

@ -343,6 +343,16 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
let res = Oper::new(user_operation, get_replies).perform()?;
Ok(serde_json::to_string(&res)?)
}
UserOperation::GetUserMentions => {
let get_user_mentions: GetUserMentions = serde_json::from_str(data)?;
let res = Oper::new(user_operation, get_user_mentions).perform()?;
Ok(serde_json::to_string(&res)?)
}
UserOperation::EditUserMention => {
let edit_user_mention: EditUserMention = serde_json::from_str(data)?;
let res = Oper::new(user_operation, edit_user_mention).perform()?;
Ok(serde_json::to_string(&res)?)
}
UserOperation::MarkAllAsRead => {
let mark_all_as_read: MarkAllAsRead = serde_json::from_str(data)?;
let res = Oper::new(user_operation, mark_all_as_read).perform()?;

View File

@ -4,6 +4,7 @@ import {
CommentNode as CommentNodeI,
CommentLikeForm,
CommentForm as CommentFormI,
EditUserMentionForm,
SaveCommentForm,
BanFromCommunityForm,
BanUserForm,
@ -686,16 +687,25 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
}
handleMarkRead(i: CommentNode) {
let form: CommentFormI = {
content: i.props.node.comment.content,
edit_id: i.props.node.comment.id,
creator_id: i.props.node.comment.creator_id,
post_id: i.props.node.comment.post_id,
parent_id: i.props.node.comment.parent_id,
read: !i.props.node.comment.read,
auth: null,
};
WebSocketService.Instance.editComment(form);
// if it has a user_mention_id field, then its a mention
if (i.props.node.comment.user_mention_id) {
let form: EditUserMentionForm = {
user_mention_id: i.props.node.comment.user_mention_id,
read: !i.props.node.comment.read,
};
WebSocketService.Instance.editUserMention(form);
} else {
let form: CommentFormI = {
content: i.props.node.comment.content,
edit_id: i.props.node.comment.id,
creator_id: i.props.node.comment.creator_id,
post_id: i.props.node.comment.post_id,
parent_id: i.props.node.comment.parent_id,
read: !i.props.node.comment.read,
auth: null,
};
WebSocketService.Instance.editComment(form);
}
}
handleModBanFromCommunityShow(i: CommentNode) {

View File

@ -8,6 +8,9 @@ import {
SortType,
GetRepliesForm,
GetRepliesResponse,
GetUserMentionsForm,
GetUserMentionsResponse,
UserMentionResponse,
CommentResponse,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
@ -16,14 +19,22 @@ import { CommentNodes } from './comment-nodes';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
enum UnreadType {
enum UnreadOrAll {
Unread,
All,
}
enum UnreadType {
Both,
Replies,
Mentions,
}
interface InboxState {
unreadOrAll: UnreadOrAll;
unreadType: UnreadType;
replies: Array<Comment>;
mentions: Array<Comment>;
sort: SortType;
page: number;
}
@ -31,8 +42,10 @@ interface InboxState {
export class Inbox extends Component<any, InboxState> {
private subscription: Subscription;
private emptyState: InboxState = {
unreadType: UnreadType.Unread,
unreadOrAll: UnreadOrAll.Unread,
unreadType: UnreadType.Both,
replies: [],
mentions: [],
sort: SortType.New,
page: 1,
};
@ -83,8 +96,8 @@ export class Inbox extends Component<any, InboxState> {
</T>
</span>
</h5>
{this.state.replies.length > 0 &&
this.state.unreadType == UnreadType.Unread && (
{this.state.replies.length + this.state.mentions.length > 0 &&
this.state.unreadOrAll == UnreadOrAll.Unread && (
<ul class="list-inline mb-1 text-muted small font-weight-bold">
<li className="list-inline-item">
<span class="pointer" onClick={this.markAllAsRead}>
@ -94,7 +107,9 @@ export class Inbox extends Component<any, InboxState> {
</ul>
)}
{this.selects()}
{this.replies()}
{this.state.unreadType == UnreadType.Both && this.both()}
{this.state.unreadType == UnreadType.Replies && this.replies()}
{this.state.unreadType == UnreadType.Mentions && this.mentions()}
{this.paginator()}
</div>
</div>
@ -106,24 +121,42 @@ export class Inbox extends Component<any, InboxState> {
return (
<div className="mb-2">
<select
value={this.state.unreadType}
onChange={linkEvent(this, this.handleUnreadTypeChange)}
class="custom-select custom-select-sm w-auto"
value={this.state.unreadOrAll}
onChange={linkEvent(this, this.handleUnreadOrAllChange)}
class="custom-select custom-select-sm w-auto mr-2"
>
<option disabled>
<T i18nKey="type">#</T>
</option>
<option value={UnreadType.Unread}>
<option value={UnreadOrAll.Unread}>
<T i18nKey="unread">#</T>
</option>
<option value={UnreadType.All}>
<option value={UnreadOrAll.All}>
<T i18nKey="all">#</T>
</option>
</select>
<select
value={this.state.unreadType}
onChange={linkEvent(this, this.handleUnreadTypeChange)}
class="custom-select custom-select-sm w-auto mr-2"
>
<option disabled>
<T i18nKey="type">#</T>
</option>
<option value={UnreadType.Both}>
<T i18nKey="both">#</T>
</option>
<option value={UnreadType.Replies}>
<T i18nKey="replies">#</T>
</option>
<option value={UnreadType.Mentions}>
<T i18nKey="mentions">#</T>
</option>
</select>
<select
value={this.state.sort}
onChange={linkEvent(this, this.handleSortChange)}
class="custom-select custom-select-sm w-auto ml-2"
class="custom-select custom-select-sm w-auto"
>
<option disabled>
<T i18nKey="sort_type">#</T>
@ -151,6 +184,37 @@ export class Inbox extends Component<any, InboxState> {
);
}
both() {
let combined: Array<{
type_: string;
data: Comment;
}> = [];
let replies = this.state.replies.map(e => {
return { type_: 'replies', data: e };
});
let mentions = this.state.mentions.map(e => {
return { type_: 'mentions', data: e };
});
combined.push(...replies);
combined.push(...mentions);
// Sort it
if (this.state.sort == SortType.New) {
combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
} else {
combined.sort((a, b) => b.data.score - a.data.score);
}
return (
<div>
{combined.map(i => (
<CommentNodes nodes={[{ comment: i.data }]} noIndent markable />
))}
</div>
);
}
replies() {
return (
<div>
@ -161,6 +225,16 @@ export class Inbox extends Component<any, InboxState> {
);
}
mentions() {
return (
<div>
{this.state.mentions.map(mention => (
<CommentNodes nodes={[{ comment: mention }]} noIndent markable />
))}
</div>
);
}
paginator() {
return (
<div class="mt-2">
@ -194,6 +268,13 @@ export class Inbox extends Component<any, InboxState> {
i.refetch();
}
handleUnreadOrAllChange(i: Inbox, event: any) {
i.state.unreadOrAll = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.refetch();
}
handleUnreadTypeChange(i: Inbox, event: any) {
i.state.unreadType = Number(event.target.value);
i.state.page = 1;
@ -202,13 +283,21 @@ export class Inbox extends Component<any, InboxState> {
}
refetch() {
let form: GetRepliesForm = {
let repliesForm: GetRepliesForm = {
sort: SortType[this.state.sort],
unread_only: this.state.unreadType == UnreadType.Unread,
unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
page: this.state.page,
limit: 9999,
};
WebSocketService.Instance.getReplies(form);
WebSocketService.Instance.getReplies(repliesForm);
let userMentionsForm: GetUserMentionsForm = {
sort: SortType[this.state.sort],
unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
page: this.state.page,
limit: 9999,
};
WebSocketService.Instance.getUserMentions(userMentionsForm);
}
handleSortChange(i: Inbox, event: any) {
@ -228,13 +317,21 @@ export class Inbox extends Component<any, InboxState> {
if (msg.error) {
alert(i18n.t(msg.error));
return;
} else if (
op == UserOperation.GetReplies ||
op == UserOperation.MarkAllAsRead
) {
} else if (op == UserOperation.GetReplies) {
let res: GetRepliesResponse = msg;
this.state.replies = res.replies;
this.sendRepliesCount();
this.sendUnreadCount();
window.scrollTo(0, 0);
this.setState(this.state);
} else if (op == UserOperation.GetUserMentions) {
let res: GetUserMentionsResponse = msg;
this.state.mentions = res.mentions;
this.sendUnreadCount();
window.scrollTo(0, 0);
this.setState(this.state);
} else if (op == UserOperation.MarkAllAsRead) {
this.state.replies = [];
this.state.mentions = [];
window.scrollTo(0, 0);
this.setState(this.state);
} else if (op == UserOperation.EditComment) {
@ -250,7 +347,7 @@ export class Inbox extends Component<any, InboxState> {
found.score = res.comment.score;
// If youre in the unread view, just remove it from the list
if (this.state.unreadType == UnreadType.Unread && res.comment.read) {
if (this.state.unreadOrAll == UnreadOrAll.Unread && res.comment.read) {
this.state.replies = this.state.replies.filter(
r => r.id !== res.comment.id
);
@ -258,8 +355,30 @@ export class Inbox extends Component<any, InboxState> {
let found = this.state.replies.find(c => c.id == res.comment.id);
found.read = res.comment.read;
}
this.sendRepliesCount();
this.sendUnreadCount();
this.setState(this.state);
} else if (op == UserOperation.EditUserMention) {
let res: UserMentionResponse = msg;
let found = this.state.mentions.find(c => c.id == res.mention.id);
found.content = res.mention.content;
found.updated = res.mention.updated;
found.removed = res.mention.removed;
found.deleted = res.mention.deleted;
found.upvotes = res.mention.upvotes;
found.downvotes = res.mention.downvotes;
found.score = res.mention.score;
// If youre in the unread view, just remove it from the list
if (this.state.unreadOrAll == UnreadOrAll.Unread && res.mention.read) {
this.state.mentions = this.state.mentions.filter(
r => r.id !== res.mention.id
);
} else {
let found = this.state.mentions.find(c => c.id == res.mention.id);
found.read = res.mention.read;
}
this.sendUnreadCount();
this.setState(this.state);
} else if (op == UserOperation.CreateComment) {
// let res: CommentResponse = msg;
@ -284,10 +403,13 @@ export class Inbox extends Component<any, InboxState> {
}
}
sendRepliesCount() {
sendUnreadCount() {
let count =
this.state.replies.filter(r => !r.read).length +
this.state.mentions.filter(r => !r.read).length;
UserService.Instance.sub.next({
user: UserService.Instance.user,
unreadCount: this.state.replies.filter(r => !r.read).length,
unreadCount: count,
});
}
}

View File

@ -7,6 +7,8 @@ import {
UserOperation,
GetRepliesForm,
GetRepliesResponse,
GetUserMentionsForm,
GetUserMentionsResponse,
SortType,
GetSiteResponse,
Comment,
@ -21,6 +23,7 @@ interface NavbarState {
expanded: boolean;
expandUserDropdown: boolean;
replies: Array<Comment>;
mentions: Array<Comment>;
fetchCount: number;
unreadCount: number;
siteName: string;
@ -34,6 +37,7 @@ export class Navbar extends Component<any, NavbarState> {
unreadCount: 0,
fetchCount: 0,
replies: [],
mentions: [],
expanded: false,
expandUserDropdown: false,
siteName: undefined,
@ -44,7 +48,7 @@ export class Navbar extends Component<any, NavbarState> {
this.state = this.emptyState;
this.handleOverviewClick = this.handleOverviewClick.bind(this);
this.keepFetchingReplies();
this.keepFetchingUnreads();
// Subscribe to user changes
this.userSub = UserService.Instance.sub.subscribe(user => {
@ -233,7 +237,22 @@ export class Navbar extends Component<any, NavbarState> {
}
this.state.replies = unreadReplies;
this.sendRepliesCount(res);
this.setState(this.state);
this.sendUnreadCount();
} else if (op == UserOperation.GetUserMentions) {
let res: GetUserMentionsResponse = msg;
let unreadMentions = res.mentions.filter(r => !r.read);
if (
unreadMentions.length > 0 &&
this.state.fetchCount > 1 &&
JSON.stringify(this.state.mentions) !== JSON.stringify(unreadMentions)
) {
this.notify(unreadMentions);
}
this.state.mentions = unreadMentions;
this.setState(this.state);
this.sendUnreadCount();
} else if (op == UserOperation.GetSite) {
let res: GetSiteResponse = msg;
@ -245,12 +264,12 @@ export class Navbar extends Component<any, NavbarState> {
}
}
keepFetchingReplies() {
this.fetchReplies();
setInterval(() => this.fetchReplies(), 15000);
keepFetchingUnreads() {
this.fetchUnreads();
setInterval(() => this.fetchUnreads(), 15000);
}
fetchReplies() {
fetchUnreads() {
if (this.state.isLoggedIn) {
let repliesForm: GetRepliesForm = {
sort: SortType[SortType.New],
@ -258,8 +277,16 @@ export class Navbar extends Component<any, NavbarState> {
page: 1,
limit: 9999,
};
let userMentionsForm: GetUserMentionsForm = {
sort: SortType[SortType.New],
unread_only: true,
page: 1,
limit: 9999,
};
if (this.currentLocation !== '/inbox') {
WebSocketService.Instance.getReplies(repliesForm);
WebSocketService.Instance.getUserMentions(userMentionsForm);
this.state.fetchCount++;
}
}
@ -269,13 +296,20 @@ export class Navbar extends Component<any, NavbarState> {
return this.context.router.history.location.pathname;
}
sendRepliesCount(res: GetRepliesResponse) {
sendUnreadCount() {
UserService.Instance.sub.next({
user: UserService.Instance.user,
unreadCount: res.replies.filter(r => !r.read).length,
unreadCount: this.unreadCount,
});
}
get unreadCount() {
return (
this.state.replies.filter(r => !r.read).length +
this.state.mentions.filter(r => !r.read).length
);
}
requestNotificationPermission() {
if (UserService.Instance.user) {
document.addEventListener('DOMContentLoaded', function() {

View File

@ -396,11 +396,16 @@ export class Search extends Component<any, SearchState> {
let res = this.state.searchResponse;
return (
<div>
{res && res.op && res.posts.length == 0 && res.comments.length == 0 && (
<span>
<T i18nKey="no_results">#</T>
</span>
)}
{res &&
res.op &&
res.posts.length == 0 &&
res.comments.length == 0 &&
res.communities.length == 0 &&
res.users.length == 0 && (
<span>
<T i18nKey="no_results">#</T>
</span>
)}
</div>
);
}
@ -420,7 +425,6 @@ export class Search extends Component<any, SearchState> {
}
search() {
// TODO community
let form: SearchForm = {
q: this.state.q,
type_: SearchType[this.state.type_],

3
ui/src/i18next.ts vendored
View File

@ -11,7 +11,6 @@ import { zh } from './translations/zh';
import { nl } from './translations/nl';
// https://github.com/nimbusec-oss/inferno-i18next/blob/master/tests/T.test.js#L66
// TODO don't forget to add moment locales for new languages.
const resources = {
en,
eo,
@ -30,7 +29,7 @@ function format(value: any, format: any, lng: any) {
}
i18n.init({
debug: true,
debug: false,
// load: 'languageOnly',
// initImmediate: false,

30
ui/src/interfaces.ts vendored
View File

@ -20,6 +20,8 @@ export enum UserOperation {
GetFollowedCommunities,
GetUserDetails,
GetReplies,
GetUserMentions,
EditUserMention,
GetModlog,
BanFromCommunity,
AddModToCommunity,
@ -171,6 +173,8 @@ export interface Comment {
user_id?: number;
my_vote?: number;
saved?: boolean;
user_mention_id?: number; // For mention type
recipient_id?: number;
}
export interface Category {
@ -229,7 +233,7 @@ export interface UserDetailsResponse {
}
export interface GetRepliesForm {
sort: string; // TODO figure this one out
sort: string;
page?: number;
limit?: number;
unread_only: boolean;
@ -241,6 +245,30 @@ export interface GetRepliesResponse {
replies: Array<Comment>;
}
export interface GetUserMentionsForm {
sort: string;
page?: number;
limit?: number;
unread_only: boolean;
auth?: string;
}
export interface GetUserMentionsResponse {
op: string;
mentions: Array<Comment>;
}
export interface EditUserMentionForm {
user_mention_id: number;
read?: boolean;
auth?: string;
}
export interface UserMentionResponse {
op: string;
mention: Comment;
}
export interface BanFromCommunityForm {
community_id: number;
user_id: number;

View File

@ -25,6 +25,8 @@ import {
Site,
UserView,
GetRepliesForm,
GetUserMentionsForm,
EditUserMentionForm,
SearchForm,
UserSettingsForm,
DeleteAccountForm,
@ -222,6 +224,16 @@ export class WebSocketService {
this.subject.next(this.wsSendWrapper(UserOperation.GetReplies, form));
}
public getUserMentions(form: GetUserMentionsForm) {
this.setAuth(form);
this.subject.next(this.wsSendWrapper(UserOperation.GetUserMentions, form));
}
public editUserMention(form: EditUserMentionForm) {
this.setAuth(form);
this.subject.next(this.wsSendWrapper(UserOperation.EditUserMention, form));
}
public getModlog(form: GetModlogForm) {
this.subject.next(this.wsSendWrapper(UserOperation.GetModlog, form));
}

View File

@ -101,6 +101,8 @@ export const en = {
mark_all_as_read: 'mark all as read',
type: 'Type',
unread: 'Unread',
replies: 'Replies',
mentions: 'Mentions',
reply_sent: 'Reply sent',
search: 'Search',
overview: 'Overview',