Merge branch 'replies' into dev

- Adding reply notifications. Fixes #13.
- Adding Saving posts and comments, and read. Fixes #47.
- Adding proper removed support for comments, communities, posts. Fixes
- Removing reliance on google fonts. Fixes #78.
- Mod related bugs. Fixes #68.
This commit is contained in:
Dessalines 2019-04-20 11:19:58 -07:00
commit bc20155de0
45 changed files with 1479 additions and 417 deletions

View file

@ -38,7 +38,7 @@ create table community (
description text,
category_id int references category on update cascade on delete cascade not null,
creator_id int references user_ on update cascade on delete cascade not null,
removed boolean default false,
removed boolean default false not null,
published timestamp not null default now(),
updated timestamp
);

View file

@ -1,2 +1,4 @@
drop table post_read;
drop table post_saved;
drop table post_like;
drop table post;

View file

@ -5,8 +5,8 @@ create table post (
body text,
creator_id int references user_ on update cascade on delete cascade not null,
community_id int references community on update cascade on delete cascade not null,
removed boolean default false,
locked boolean default false,
removed boolean default false not null,
locked boolean default false not null,
published timestamp not null default now(),
updated timestamp
);
@ -20,3 +20,18 @@ create table post_like (
unique(post_id, user_id)
);
create table post_saved (
id serial primary key,
post_id int references post on update cascade on delete cascade not null,
user_id int references user_ on update cascade on delete cascade not null,
published timestamp not null default now(),
unique(post_id, user_id)
);
create table post_read (
id serial primary key,
post_id int references post on update cascade on delete cascade not null,
user_id int references user_ on update cascade on delete cascade not null,
published timestamp not null default now(),
unique(post_id, user_id)
);

View file

@ -1,2 +1,3 @@
drop table comment_saved;
drop table comment_like;
drop table comment;

View file

@ -4,7 +4,8 @@ create table comment (
post_id int references post on update cascade on delete cascade not null,
parent_id int references comment on update cascade on delete cascade,
content text not null,
removed boolean default false,
removed boolean default false not null,
read boolean default false not null,
published timestamp not null default now(),
updated timestamp
);
@ -18,3 +19,11 @@ create table comment_like (
published timestamp not null default now(),
unique(comment_id, user_id)
);
create table comment_saved (
id serial primary key,
comment_id int references comment on update cascade on delete cascade not null,
user_id int references user_ on update cascade on delete cascade not null,
published timestamp not null default now(),
unique(comment_id, user_id)
);

View file

@ -31,7 +31,8 @@ ap.*,
u.id as user_id,
coalesce(pl.score, 0) as my_vote,
(select cf.id::bool from community_follower cf where u.id = cf.user_id and cf.community_id = ap.community_id) as subscribed,
u.admin or (select cm.id::bool from community_moderator cm where u.id = cm.user_id and cm.community_id = ap.community_id) as am_mod
(select pr.id::bool from post_read pr where u.id = pr.user_id and pr.post_id = ap.id) as read,
(select ps.id::bool from post_saved ps where u.id = ps.user_id and ps.post_id = ap.id) as saved
from user_ u
cross join all_post ap
left join post_like pl on u.id = pl.user_id and ap.id = pl.post_id
@ -43,6 +44,7 @@ ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as am_mod
null as read,
null as saved
from all_post ap
;

View file

@ -13,19 +13,16 @@ with all_community as
select
ac.*,
u.id as user_id,
cf.id::boolean as subscribed,
u.admin or (select cm.id::bool from community_moderator cm where u.id = cm.user_id and cm.community_id = ac.id) as am_mod
(select cf.id::boolean from community_follower cf where u.id = cf.user_id and ac.id = cf.community_id) as subscribed
from user_ u
cross join all_community ac
left join community_follower cf on u.id = cf.user_id and ac.id = cf.community_id
union all
select
ac.*,
null as user_id,
null as subscribed,
null as am_mod
null as subscribed
from all_community ac
;

View file

@ -1 +1,2 @@
drop view reply_view;
drop view comment_view;

View file

@ -4,7 +4,8 @@ with all_comment as
select
c.*,
(select community_id from post p where p.id = c.post_id),
(select cb.id::bool from community_user_ban cb where c.creator_id = cb.user_id) as banned,
(select u.banned from user_ u where c.creator_id = u.id) as banned,
(select cb.id::bool from community_user_ban cb, post p where c.creator_id = cb.user_id and p.id = c.post_id and p.community_id = cb.community_id) as banned_from_community,
(select name from user_ where c.creator_id = user_.id) as creator_name,
coalesce(sum(cl.score), 0) as score,
count (case when cl.score = 1 then 1 else null end) as upvotes,
@ -18,7 +19,7 @@ select
ac.*,
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
u.admin or (select cm.id::bool from community_moderator cm, post p where u.id = cm.user_id and ac.post_id = p.id and p.community_id = cm.community_id) as am_mod
(select cs.id::bool from comment_saved cs where u.id = cs.user_id and cs.comment_id = ac.id) as saved
from user_ u
cross join all_comment ac
left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
@ -29,6 +30,31 @@ select
ac.*,
null as user_id,
null as my_vote,
null as am_mod
null as saved
from all_comment ac
;
create view reply_view as
with closereply as (
select
c2.id,
c2.creator_id as sender_id,
c.creator_id as recipient_id
from comment c
inner join comment c2 on c.id = c2.parent_id
where c2.creator_id != c.creator_id
-- Do union where post is null
union
select
c.id,
c.creator_id as sender_id,
p.creator_id as recipient_id
from comment c, post p
where c.post_id = p.id and c.parent_id is null and c.creator_id != p.creator_id
)
select cv.*,
closereply.recipient_id
from comment_view cv, closereply
where closereply.id = cv.id
;

View file

@ -43,8 +43,7 @@ create view mod_ban_view as
select mb.*,
(select name from user_ u where mb.mod_user_id = u.id) as mod_user_name,
(select name from user_ u where mb.other_user_id = u.id) as other_user_name
from mod_ban_from_community mb;
from mod_ban mb;
create view mod_add_community_view as
select ma.*,
@ -53,7 +52,6 @@ select ma.*,
(select name from community c where ma.community_id = c.id) as community_name
from mod_add_community ma;
create view mod_add_view as
select ma.*,
(select name from user_ u where ma.mod_user_id = u.id) as mod_user_name,

View file

@ -1,9 +1,9 @@
extern crate diesel;
use schema::{comment, comment_like};
use schema::{comment, comment_like, comment_saved};
use diesel::*;
use diesel::result::Error;
use serde::{Deserialize, Serialize};
use {Crud, Likeable};
use {Crud, Likeable, Saveable};
use actions::post::Post;
// WITH RECURSIVE MyTree AS (
@ -22,7 +22,8 @@ pub struct Comment {
pub post_id: i32,
pub parent_id: Option<i32>,
pub content: String,
pub removed: Option<bool>,
pub removed: bool,
pub read: bool,
pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>
}
@ -35,30 +36,10 @@ pub struct CommentForm {
pub parent_id: Option<i32>,
pub content: String,
pub removed: Option<bool>,
pub read: Option<bool>,
pub updated: Option<chrono::NaiveDateTime>
}
#[derive(Identifiable, Queryable, Associations, PartialEq, Debug, Clone)]
#[belongs_to(Comment)]
#[table_name = "comment_like"]
pub struct CommentLike {
pub id: i32,
pub user_id: i32,
pub comment_id: i32,
pub post_id: i32,
pub score: i16,
pub published: chrono::NaiveDateTime,
}
#[derive(Insertable, AsChangeset, Clone)]
#[table_name="comment_like"]
pub struct CommentLikeForm {
pub user_id: i32,
pub comment_id: i32,
pub post_id: i32,
pub score: i16
}
impl Crud<CommentForm> for Comment {
fn read(conn: &PgConnection, comment_id: i32) -> Result<Self, Error> {
use schema::comment::dsl::*;
@ -87,6 +68,27 @@ impl Crud<CommentForm> for Comment {
}
}
#[derive(Identifiable, Queryable, Associations, PartialEq, Debug, Clone)]
#[belongs_to(Comment)]
#[table_name = "comment_like"]
pub struct CommentLike {
pub id: i32,
pub user_id: i32,
pub comment_id: i32,
pub post_id: i32,
pub score: i16,
pub published: chrono::NaiveDateTime,
}
#[derive(Insertable, AsChangeset, Clone)]
#[table_name="comment_like"]
pub struct CommentLikeForm {
pub user_id: i32,
pub comment_id: i32,
pub post_id: i32,
pub score: i16
}
impl Likeable <CommentLikeForm> for CommentLike {
fn read(conn: &PgConnection, comment_id_from: i32) -> Result<Vec<Self>, Error> {
use schema::comment_like::dsl::*;
@ -119,6 +121,39 @@ impl CommentLike {
}
}
#[derive(Identifiable, Queryable, Associations, PartialEq, Debug)]
#[belongs_to(Comment)]
#[table_name = "comment_saved"]
pub struct CommentSaved {
pub id: i32,
pub comment_id: i32,
pub user_id: i32,
pub published: chrono::NaiveDateTime,
}
#[derive(Insertable, AsChangeset, Clone)]
#[table_name="comment_saved"]
pub struct CommentSavedForm {
pub comment_id: i32,
pub user_id: i32,
}
impl Saveable <CommentSavedForm> for CommentSaved {
fn save(conn: &PgConnection, comment_saved_form: &CommentSavedForm) -> Result<Self, Error> {
use schema::comment_saved::dsl::*;
insert_into(comment_saved)
.values(comment_saved_form)
.get_result::<Self>(conn)
}
fn unsave(conn: &PgConnection, comment_saved_form: &CommentSavedForm) -> Result<usize, Error> {
use schema::comment_saved::dsl::*;
diesel::delete(comment_saved
.filter(comment_id.eq(comment_saved_form.comment_id))
.filter(user_id.eq(comment_saved_form.user_id)))
.execute(conn)
}
}
#[cfg(test)]
mod tests {
use establish_connection;
@ -174,6 +209,7 @@ mod tests {
creator_id: inserted_user.id,
post_id: inserted_post.id,
removed: None,
read: None,
parent_id: None,
updated: None
};
@ -185,7 +221,8 @@ mod tests {
content: "A test comment".into(),
creator_id: inserted_user.id,
post_id: inserted_post.id,
removed: Some(false),
removed: false,
read: false,
parent_id: None,
published: inserted_comment.published,
updated: None
@ -197,11 +234,13 @@ mod tests {
post_id: inserted_post.id,
parent_id: Some(inserted_comment.id),
removed: None,
read: None,
updated: None
};
let inserted_child_comment = Comment::create(&conn, &child_comment_form).unwrap();
// Comment Like
let comment_like_form = CommentLikeForm {
comment_id: inserted_comment.id,
post_id: inserted_post.id,
@ -220,9 +259,25 @@ mod tests {
score: 1
};
// Comment Saved
let comment_saved_form = CommentSavedForm {
comment_id: inserted_comment.id,
user_id: inserted_user.id,
};
let inserted_comment_saved = CommentSaved::save(&conn, &comment_saved_form).unwrap();
let expected_comment_saved = CommentSaved {
id: inserted_comment_saved.id,
comment_id: inserted_comment.id,
user_id: inserted_user.id,
published: inserted_comment_saved.published,
};
let read_comment = Comment::read(&conn, inserted_comment.id).unwrap();
let updated_comment = Comment::update(&conn, inserted_comment.id, &comment_form).unwrap();
let like_removed = CommentLike::remove(&conn, &comment_like_form).unwrap();
let saved_removed = CommentSaved::unsave(&conn, &comment_saved_form).unwrap();
let num_deleted = Comment::delete(&conn, inserted_comment.id).unwrap();
Comment::delete(&conn, inserted_child_comment.id).unwrap();
Post::delete(&conn, inserted_post.id).unwrap();
@ -233,8 +288,10 @@ mod tests {
assert_eq!(expected_comment, inserted_comment);
assert_eq!(expected_comment, updated_comment);
assert_eq!(expected_comment_like, inserted_comment_like);
assert_eq!(expected_comment_saved, inserted_comment_saved);
assert_eq!(expected_comment.id, inserted_child_comment.parent_id.unwrap());
assert_eq!(1, like_removed);
assert_eq!(1, saved_removed);
assert_eq!(1, num_deleted);
}

View file

@ -13,18 +13,20 @@ table! {
post_id -> Int4,
parent_id -> Nullable<Int4>,
content -> Text,
removed -> Nullable<Bool>,
removed -> Bool,
read -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
community_id -> Int4,
banned -> Nullable<Bool>,
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
am_mod -> Nullable<Bool>,
saved -> Nullable<Bool>,
}
}
@ -36,18 +38,20 @@ pub struct CommentView {
pub post_id: i32,
pub parent_id: Option<i32>,
pub content: String,
pub removed: Option<bool>,
pub removed: bool,
pub read: bool,
pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>,
pub community_id: i32,
pub banned: Option<bool>,
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 am_mod: Option<bool>,
pub saved: Option<bool>,
}
impl CommentView {
@ -57,6 +61,7 @@ impl CommentView {
for_post_id: Option<i32>,
for_creator_id: Option<i32>,
my_user_id: Option<i32>,
saved_only: bool,
page: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<Self>, Error> {
@ -81,6 +86,10 @@ impl CommentView {
if let Some(for_post_id) = for_post_id {
query = query.filter(post_id.eq(for_post_id));
};
if saved_only {
query = query.filter(saved.eq(true));
}
query = match sort {
// SortType::Hot => query.order_by(hot_rank.desc()),
@ -127,6 +136,107 @@ impl CommentView {
}
// The faked schema since diesel doesn't do views
table! {
reply_view (id) {
id -> Int4,
creator_id -> Int4,
post_id -> Int4,
parent_id -> Nullable<Int4>,
content -> Text,
removed -> Bool,
read -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
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="reply_view"]
pub struct ReplyView {
pub 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 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 ReplyView {
pub fn get_replies(conn: &PgConnection,
for_user_id: i32,
sort: &SortType,
unread_only: bool,
page: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<Self>, Error> {
use actions::comment_view::reply_view::dsl::*;
let (limit, offset) = limit_and_offset(page, limit);
let mut query = reply_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)
}
}
#[cfg(test)]
mod tests {
use establish_connection;
@ -205,8 +315,10 @@ mod tests {
post_id: inserted_post.id,
community_id: inserted_community.id,
parent_id: None,
removed: Some(false),
banned: None,
removed: false,
read: false,
banned: false,
banned_from_community: false,
published: inserted_comment.published,
updated: None,
creator_name: inserted_user.name.to_owned(),
@ -215,7 +327,7 @@ mod tests {
upvotes: 1,
user_id: None,
my_vote: None,
am_mod: None,
saved: None,
};
let expected_comment_view_with_user = CommentView {
@ -225,8 +337,10 @@ mod tests {
post_id: inserted_post.id,
community_id: inserted_community.id,
parent_id: None,
removed: Some(false),
banned: None,
removed: false,
read: false,
banned: false,
banned_from_community: false,
published: inserted_comment.published,
updated: None,
creator_name: inserted_user.name.to_owned(),
@ -235,11 +349,11 @@ mod tests {
upvotes: 1,
user_id: Some(inserted_user.id),
my_vote: Some(1),
am_mod: None,
saved: None,
};
let read_comment_views_no_user = CommentView::list(&conn, &SortType::New, Some(inserted_post.id), None, None, None, None).unwrap();
let read_comment_views_with_user = CommentView::list(&conn, &SortType::New, Some(inserted_post.id), None, Some(inserted_user.id), None, None).unwrap();
let read_comment_views_no_user = CommentView::list(&conn, &SortType::New, Some(inserted_post.id), None, None, false, None, None).unwrap();
let read_comment_views_with_user = CommentView::list(&conn, &SortType::New, Some(inserted_post.id), None, Some(inserted_user.id), false, None, None).unwrap();
let like_removed = CommentLike::remove(&conn, &comment_like_form).unwrap();
let num_deleted = Comment::delete(&conn, inserted_comment.id).unwrap();
Post::delete(&conn, inserted_post.id).unwrap();

View file

@ -14,7 +14,7 @@ pub struct Community {
pub description: Option<String>,
pub category_id: i32,
pub creator_id: i32,
pub removed: Option<bool>,
pub removed: bool,
pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>
}
@ -249,7 +249,7 @@ mod tests {
title: "nada".to_owned(),
description: None,
category_id: 1,
removed: Some(false),
removed: false,
published: inserted_community.published,
updated: None
};

View file

@ -12,7 +12,7 @@ table! {
description -> Nullable<Text>,
category_id -> Int4,
creator_id -> Int4,
removed -> Nullable<Bool>,
removed -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
creator_name -> Varchar,
@ -22,7 +22,6 @@ table! {
number_of_comments -> BigInt,
user_id -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
am_mod -> Nullable<Bool>,
}
}
@ -83,7 +82,7 @@ pub struct CommunityView {
pub description: Option<String>,
pub category_id: i32,
pub creator_id: i32,
pub removed: Option<bool>,
pub removed: bool,
pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>,
pub creator_name: String,
@ -93,7 +92,6 @@ pub struct CommunityView {
pub number_of_comments: i64,
pub user_id: Option<i32>,
pub subscribed: Option<bool>,
pub am_mod: Option<bool>,
}
impl CommunityView {

View file

@ -1,9 +1,9 @@
extern crate diesel;
use schema::{post, post_like};
use schema::{post, post_like, post_saved, post_read};
use diesel::*;
use diesel::result::Error;
use serde::{Deserialize, Serialize};
use {Crud, Likeable};
use {Crud, Likeable, Saveable, Readable};
#[derive(Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize)]
#[table_name="post"]
@ -14,8 +14,8 @@ pub struct Post {
pub body: Option<String>,
pub creator_id: i32,
pub community_id: i32,
pub removed: Option<bool>,
pub locked: Option<bool>,
pub removed: bool,
pub locked: bool,
pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>
}
@ -33,25 +33,6 @@ pub struct PostForm {
pub updated: Option<chrono::NaiveDateTime>
}
#[derive(Identifiable, Queryable, Associations, PartialEq, Debug)]
#[belongs_to(Post)]
#[table_name = "post_like"]
pub struct PostLike {
pub id: i32,
pub post_id: i32,
pub user_id: i32,
pub score: i16,
pub published: chrono::NaiveDateTime,
}
#[derive(Insertable, AsChangeset, Clone)]
#[table_name="post_like"]
pub struct PostLikeForm {
pub post_id: i32,
pub user_id: i32,
pub score: i16
}
impl Crud<PostForm> for Post {
fn read(conn: &PgConnection, post_id: i32) -> Result<Self, Error> {
use schema::post::dsl::*;
@ -80,6 +61,25 @@ impl Crud<PostForm> for Post {
}
}
#[derive(Identifiable, Queryable, Associations, PartialEq, Debug)]
#[belongs_to(Post)]
#[table_name = "post_like"]
pub struct PostLike {
pub id: i32,
pub post_id: i32,
pub user_id: i32,
pub score: i16,
pub published: chrono::NaiveDateTime,
}
#[derive(Insertable, AsChangeset, Clone)]
#[table_name="post_like"]
pub struct PostLikeForm {
pub post_id: i32,
pub user_id: i32,
pub score: i16
}
impl Likeable <PostLikeForm> for PostLike {
fn read(conn: &PgConnection, post_id_from: i32) -> Result<Vec<Self>, Error> {
use schema::post_like::dsl::*;
@ -102,6 +102,72 @@ impl Likeable <PostLikeForm> for PostLike {
}
}
#[derive(Identifiable, Queryable, Associations, PartialEq, Debug)]
#[belongs_to(Post)]
#[table_name = "post_saved"]
pub struct PostSaved {
pub id: i32,
pub post_id: i32,
pub user_id: i32,
pub published: chrono::NaiveDateTime,
}
#[derive(Insertable, AsChangeset, Clone)]
#[table_name="post_saved"]
pub struct PostSavedForm {
pub post_id: i32,
pub user_id: i32,
}
impl Saveable <PostSavedForm> for PostSaved {
fn save(conn: &PgConnection, post_saved_form: &PostSavedForm) -> Result<Self, Error> {
use schema::post_saved::dsl::*;
insert_into(post_saved)
.values(post_saved_form)
.get_result::<Self>(conn)
}
fn unsave(conn: &PgConnection, post_saved_form: &PostSavedForm) -> Result<usize, Error> {
use schema::post_saved::dsl::*;
diesel::delete(post_saved
.filter(post_id.eq(post_saved_form.post_id))
.filter(user_id.eq(post_saved_form.user_id)))
.execute(conn)
}
}
#[derive(Identifiable, Queryable, Associations, PartialEq, Debug)]
#[belongs_to(Post)]
#[table_name = "post_read"]
pub struct PostRead {
pub id: i32,
pub post_id: i32,
pub user_id: i32,
pub published: chrono::NaiveDateTime,
}
#[derive(Insertable, AsChangeset, Clone)]
#[table_name="post_read"]
pub struct PostReadForm {
pub post_id: i32,
pub user_id: i32,
}
impl Readable <PostReadForm> for PostRead {
fn mark_as_read(conn: &PgConnection, post_read_form: &PostReadForm) -> Result<Self, Error> {
use schema::post_read::dsl::*;
insert_into(post_read)
.values(post_read_form)
.get_result::<Self>(conn)
}
fn mark_as_unread(conn: &PgConnection, post_read_form: &PostReadForm) -> Result<usize, Error> {
use schema::post_read::dsl::*;
diesel::delete(post_read
.filter(post_id.eq(post_read_form.post_id))
.filter(user_id.eq(post_read_form.user_id)))
.execute(conn)
}
}
#[cfg(test)]
mod tests {
use establish_connection;
@ -159,11 +225,12 @@ mod tests {
creator_id: inserted_user.id,
community_id: inserted_community.id,
published: inserted_post.published,
removed: Some(false),
locked: Some(false),
removed: false,
locked: false,
updated: None
};
// Post Like
let post_like_form = PostLikeForm {
post_id: inserted_post.id,
user_id: inserted_user.id,
@ -179,10 +246,42 @@ mod tests {
published: inserted_post_like.published,
score: 1
};
// Post Save
let post_saved_form = PostSavedForm {
post_id: inserted_post.id,
user_id: inserted_user.id,
};
let inserted_post_saved = PostSaved::save(&conn, &post_saved_form).unwrap();
let expected_post_saved = PostSaved {
id: inserted_post_saved.id,
post_id: inserted_post.id,
user_id: inserted_user.id,
published: inserted_post_saved.published,
};
// Post Read
let post_read_form = PostReadForm {
post_id: inserted_post.id,
user_id: inserted_user.id,
};
let inserted_post_read = PostRead::mark_as_read(&conn, &post_read_form).unwrap();
let expected_post_read = PostRead {
id: inserted_post_read.id,
post_id: inserted_post.id,
user_id: inserted_user.id,
published: inserted_post_read.published,
};
let read_post = Post::read(&conn, inserted_post.id).unwrap();
let updated_post = Post::update(&conn, inserted_post.id, &new_post).unwrap();
let like_removed = PostLike::remove(&conn, &post_like_form).unwrap();
let saved_removed = PostSaved::unsave(&conn, &post_saved_form).unwrap();
let read_removed = PostRead::mark_as_unread(&conn, &post_read_form).unwrap();
let num_deleted = Post::delete(&conn, inserted_post.id).unwrap();
Community::delete(&conn, inserted_community.id).unwrap();
User_::delete(&conn, inserted_user.id).unwrap();
@ -191,7 +290,11 @@ mod tests {
assert_eq!(expected_post, inserted_post);
assert_eq!(expected_post, updated_post);
assert_eq!(expected_post_like, inserted_post_like);
assert_eq!(expected_post_saved, inserted_post_saved);
assert_eq!(expected_post_read, inserted_post_read);
assert_eq!(1, like_removed);
assert_eq!(1, saved_removed);
assert_eq!(1, read_removed);
assert_eq!(1, num_deleted);
}

View file

@ -19,8 +19,8 @@ table! {
body -> Nullable<Text>,
creator_id -> Int4,
community_id -> Int4,
removed -> Nullable<Bool>,
locked -> Nullable<Bool>,
removed -> Bool,
locked -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
creator_name -> Varchar,
@ -33,7 +33,8 @@ table! {
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
am_mod -> Nullable<Bool>,
read -> Nullable<Bool>,
saved -> Nullable<Bool>,
}
}
@ -47,8 +48,8 @@ pub struct PostView {
pub body: Option<String>,
pub creator_id: i32,
pub community_id: i32,
pub removed: Option<bool>,
pub locked: Option<bool>,
pub removed: bool,
pub locked: bool,
pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>,
pub creator_name: String,
@ -61,7 +62,8 @@ pub struct PostView {
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub subscribed: Option<bool>,
pub am_mod: Option<bool>,
pub read: Option<bool>,
pub saved: Option<bool>,
}
impl PostView {
@ -71,6 +73,8 @@ impl PostView {
for_community_id: Option<i32>,
for_creator_id: Option<i32>,
my_user_id: Option<i32>,
saved_only: bool,
unread_only: bool,
page: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<Self>, Error> {
@ -88,6 +92,15 @@ impl PostView {
query = query.filter(creator_id.eq(for_creator_id));
};
// TODO these are wrong, bc they'll only show saved for your logged in user, not theirs
if saved_only {
query = query.filter(saved.eq(true));
};
if unread_only {
query = query.filter(read.eq(false));
};
match type_ {
PostListingType::Subscribed => {
query = query.filter(subscribed.eq(true));
@ -239,8 +252,8 @@ mod tests {
creator_id: inserted_user.id,
creator_name: user_name.to_owned(),
community_id: inserted_community.id,
removed: Some(false),
locked: Some(false),
removed: false,
locked: false,
community_name: community_name.to_owned(),
number_of_comments: 0,
score: 1,
@ -250,7 +263,8 @@ mod tests {
published: inserted_post.published,
updated: None,
subscribed: None,
am_mod: None,
read: None,
saved: None,
};
let expected_post_listing_with_user = PostView {
@ -260,8 +274,8 @@ mod tests {
name: post_name.to_owned(),
url: None,
body: None,
removed: Some(false),
locked: Some(false),
removed: false,
locked: false,
creator_id: inserted_user.id,
creator_name: user_name.to_owned(),
community_id: inserted_community.id,
@ -274,12 +288,13 @@ mod tests {
published: inserted_post.published,
updated: None,
subscribed: None,
am_mod: None,
read: None,
saved: None,
};
let read_post_listings_with_user = PostView::list(&conn, PostListingType::Community, &SortType::New, Some(inserted_community.id), None, Some(inserted_user.id), None, None).unwrap();
let read_post_listings_no_user = PostView::list(&conn, PostListingType::Community, &SortType::New, Some(inserted_community.id), None, None, None, None).unwrap();
let read_post_listings_with_user = PostView::list(&conn, PostListingType::Community, &SortType::New, Some(inserted_community.id), None, Some(inserted_user.id), false, false, None, None).unwrap();
let read_post_listings_no_user = PostView::list(&conn, PostListingType::Community, &SortType::New, Some(inserted_community.id), None, None, false, false, None, None).unwrap();
let read_post_listing_no_user = PostView::read(&conn, inserted_post.id, None).unwrap();
let read_post_listing_with_user = PostView::read(&conn, inserted_post.id, Some(inserted_user.id)).unwrap();

View file

@ -55,6 +55,16 @@ pub trait Bannable<T> {
fn unban(conn: &PgConnection, form: &T) -> Result<usize, Error> where Self: Sized;
}
pub trait Saveable<T> {
fn save(conn: &PgConnection, form: &T) -> Result<Self, Error> where Self: Sized;
fn unsave(conn: &PgConnection, form: &T) -> Result<usize, Error> where Self: Sized;
}
pub trait Readable<T> {
fn mark_as_read(conn: &PgConnection, form: &T) -> Result<Self, Error> where Self: Sized;
fn mark_as_unread(conn: &PgConnection, form: &T) -> Result<usize, Error> where Self: Sized;
}
pub fn establish_connection() -> PgConnection {
let db_url = Settings::get().db_url;
PgConnection::establish(&db_url)

View file

@ -12,7 +12,8 @@ table! {
post_id -> Int4,
parent_id -> Nullable<Int4>,
content -> Text,
removed -> Nullable<Bool>,
removed -> Bool,
read -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
}
@ -29,6 +30,15 @@ table! {
}
}
table! {
comment_saved (id) {
id -> Int4,
comment_id -> Int4,
user_id -> Int4,
published -> Timestamp,
}
}
table! {
community (id) {
id -> Int4,
@ -37,7 +47,7 @@ table! {
description -> Nullable<Text>,
category_id -> Int4,
creator_id -> Int4,
removed -> Nullable<Bool>,
removed -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
}
@ -168,8 +178,8 @@ table! {
body -> Nullable<Text>,
creator_id -> Int4,
community_id -> Int4,
removed -> Nullable<Bool>,
locked -> Nullable<Bool>,
removed -> Bool,
locked -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
}
@ -185,6 +195,24 @@ table! {
}
}
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,
@ -225,6 +253,8 @@ 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));
@ -247,6 +277,10 @@ 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));
@ -254,6 +288,7 @@ allow_tables_to_appear_in_same_query!(
category,
comment,
comment_like,
comment_saved,
community,
community_follower,
community_moderator,
@ -268,6 +303,8 @@ allow_tables_to_appear_in_same_query!(
mod_remove_post,
post,
post_like,
post_read,
post_saved,
site,
user_,
user_ban,

View file

@ -11,7 +11,7 @@ use bcrypt::{verify};
use std::str::FromStr;
use diesel::PgConnection;
use {Crud, Joinable, Likeable, Followable, Bannable, establish_connection, naive_now, naive_from_unix, SortType, has_slurs, remove_slurs};
use {Crud, Joinable, Likeable, Followable, Bannable, Saveable, establish_connection, naive_now, naive_from_unix, SortType, has_slurs, remove_slurs};
use actions::community::*;
use actions::user::*;
use actions::post::*;
@ -26,7 +26,7 @@ use actions::moderator::*;
#[derive(EnumString,ToString,Debug)]
pub enum UserOperation {
Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser
Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, SaveComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, SavePost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetReplies, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser
}
#[derive(Serialize, Deserialize)]
@ -164,7 +164,8 @@ pub struct GetPostResponse {
post: PostView,
comments: Vec<CommentView>,
community: CommunityView,
moderators: Vec<CommunityModeratorView>
moderators: Vec<CommunityModeratorView>,
admins: Vec<UserView>,
}
#[derive(Serialize, Deserialize)]
@ -214,6 +215,14 @@ pub struct EditComment {
post_id: i32,
removed: Option<bool>,
reason: Option<String>,
read: Option<bool>,
auth: String
}
#[derive(Serialize, Deserialize)]
pub struct SaveComment {
comment_id: i32,
save: bool,
auth: String
}
@ -254,8 +263,15 @@ pub struct EditPost {
url: Option<String>,
body: Option<String>,
removed: Option<bool>,
reason: Option<String>,
locked: Option<bool>,
reason: Option<String>,
auth: String
}
#[derive(Serialize, Deserialize)]
pub struct SavePost {
post_id: i32,
save: bool,
auth: String
}
@ -297,7 +313,7 @@ pub struct GetUserDetails {
page: Option<i64>,
limit: Option<i64>,
community_id: Option<i32>,
auth: Option<String>
saved_only: bool,
}
#[derive(Serialize, Deserialize)]
@ -308,8 +324,6 @@ pub struct GetUserDetailsResponse {
moderates: Vec<CommunityModeratorView>,
comments: Vec<CommentView>,
posts: Vec<PostView>,
saved_posts: Vec<PostView>,
saved_comments: Vec<CommentView>,
}
#[derive(Serialize, Deserialize)]
@ -426,6 +440,21 @@ pub struct BanUserResponse {
banned: bool,
}
#[derive(Serialize, Deserialize)]
pub struct GetReplies {
sort: String,
page: Option<i64>,
limit: Option<i64>,
unread_only: bool,
auth: String
}
#[derive(Serialize, Deserialize)]
pub struct GetRepliesResponse {
op: String,
replies: Vec<ReplyView>,
}
/// `ChatServer` manages chat rooms and responsible for coordinating chat
/// session. implementation is super primitive
pub struct ChatServer {
@ -468,6 +497,8 @@ impl ChatServer {
Some(community_id),
None,
None,
false,
false,
None,
Some(9999))
.unwrap();
@ -491,7 +522,6 @@ impl Handler<Connect> for ChatServer {
type Result = usize;
fn handle(&mut self, msg: Connect, _: &mut Context<Self>) -> Self::Result {
println!("Someone joined");
// notify all users in same room
// self.send_room_message(&"Main".to_owned(), "Someone joined", 0);
@ -513,7 +543,6 @@ impl Handler<Disconnect> for ChatServer {
type Result = ();
fn handle(&mut self, msg: Disconnect, _: &mut Context<Self>) {
println!("Someone disconnected");
// let mut rooms: Vec<i32> = Vec::new();
@ -586,6 +615,10 @@ impl Handler<StandardMessage> for ChatServer {
let edit_comment: EditComment = serde_json::from_str(data).unwrap();
edit_comment.perform(self, msg.id)
},
UserOperation::SaveComment => {
let save_post: SaveComment = serde_json::from_str(data).unwrap();
save_post.perform(self, msg.id)
},
UserOperation::CreateCommentLike => {
let create_comment_like: CreateCommentLike = serde_json::from_str(data).unwrap();
create_comment_like.perform(self, msg.id)
@ -602,6 +635,10 @@ impl Handler<StandardMessage> for ChatServer {
let edit_post: EditPost = serde_json::from_str(data).unwrap();
edit_post.perform(self, msg.id)
},
UserOperation::SavePost => {
let save_post: SavePost = serde_json::from_str(data).unwrap();
save_post.perform(self, msg.id)
},
UserOperation::EditCommunity => {
let edit_community: EditCommunity = serde_json::from_str(data).unwrap();
edit_community.perform(self, msg.id)
@ -650,6 +687,10 @@ impl Handler<StandardMessage> for ChatServer {
let ban_user: BanUser = serde_json::from_str(data).unwrap();
ban_user.perform(self, msg.id)
},
UserOperation::GetReplies => {
let get_replies: GetReplies = serde_json::from_str(data).unwrap();
get_replies.perform(self, msg.id)
},
};
MessageResult(res)
@ -745,11 +786,11 @@ impl Perform for Register {
}
};
// If its an admin, add them as a mod to main
// If its an admin, add them as a mod and follower to main
if self.admin {
let community_moderator_form = CommunityModeratorForm {
community_id: 1,
user_id: inserted_user.id
user_id: inserted_user.id,
};
let _inserted_community_moderator = match CommunityModerator::join(&conn, &community_moderator_form) {
@ -758,6 +799,18 @@ impl Perform for Register {
return self.error("Community moderator already exists.");
}
};
let community_follower_form = CommunityFollowerForm {
community_id: 1,
user_id: inserted_user.id,
};
let _inserted_community_follower = match CommunityFollower::follow(&conn, &community_follower_form) {
Ok(user) => user,
Err(_e) => {
return self.error("Community follower already exists.");
}
};
}
@ -797,8 +850,12 @@ impl Perform for CreateCommunity {
let user_id = claims.id;
// When you create a community, make sure the user becomes a moderator and a follower
// Check for a site ban
if UserView::read(&conn, user_id).unwrap().banned {
return self.error("You have been banned from the site");
}
// When you create a community, make sure the user becomes a moderator and a follower
let community_form = CommunityForm {
name: self.name.to_owned(),
title: self.title.to_owned(),
@ -934,11 +991,16 @@ impl Perform for CreatePost {
let user_id = claims.id;
// Check for a ban
// Check for a community ban
if CommunityUserBanView::get(&conn, user_id, self.community_id).is_ok() {
return self.error("You have been banned from this community");
}
// Check for a site ban
if UserView::read(&conn, user_id).unwrap().banned {
return self.error("You have been banned from the site");
}
let post_form = PostForm {
name: self.name.to_owned(),
url: self.url.to_owned(),
@ -1031,12 +1093,14 @@ impl Perform for GetPost {
chat.rooms.get_mut(&self.id).unwrap().insert(addr);
let comments = CommentView::list(&conn, &SortType::New, Some(self.id), None, user_id, None, Some(9999)).unwrap();
let comments = CommentView::list(&conn, &SortType::New, Some(self.id), None, user_id, false, None, Some(9999)).unwrap();
let community = CommunityView::read(&conn, post_view.community_id, user_id).unwrap();
let moderators = CommunityModeratorView::for_community(&conn, post_view.community_id).unwrap();
let admins = UserView::admins(&conn).unwrap();
// Return the jwt
serde_json::to_string(
&GetPostResponse {
@ -1044,7 +1108,8 @@ impl Perform for GetPost {
post: post_view,
comments: comments,
community: community,
moderators: moderators
moderators: moderators,
admins: admins,
}
)
.unwrap()
@ -1117,11 +1182,16 @@ impl Perform for CreateComment {
let user_id = claims.id;
// Check for a ban
// Check for a community ban
let post = Post::read(&conn, self.post_id).unwrap();
if CommunityUserBanView::get(&conn, user_id, post.community_id).is_ok() {
return self.error("You have been banned from this community");
}
// Check for a site ban
if UserView::read(&conn, user_id).unwrap().banned {
return self.error("You have been banned from the site");
}
let content_slurs_removed = remove_slurs(&self.content.to_owned());
@ -1131,6 +1201,7 @@ impl Perform for CreateComment {
post_id: self.post_id,
creator_id: user_id,
removed: None,
read: None,
updated: None
};
@ -1202,24 +1273,38 @@ impl Perform for EditComment {
let user_id = claims.id;
// Verify its the creator or a mod
// Verify its the creator or a mod, or an admin
let orig_comment = CommentView::read(&conn, self.edit_id, None).unwrap();
let mut editors: Vec<i32> = CommunityModeratorView::for_community(&conn, orig_comment.community_id)
let mut editors: Vec<i32> = vec![self.creator_id];
editors.append(
&mut CommunityModeratorView::for_community(&conn, orig_comment.community_id)
.unwrap()
.into_iter()
.map(|m| m.user_id)
.collect();
editors.push(self.creator_id);
.collect()
);
editors.append(
&mut UserView::admins(&conn)
.unwrap()
.into_iter()
.map(|a| a.id)
.collect()
);
if !editors.contains(&user_id) {
return self.error("Not allowed to edit comment.");
}
// Check for a ban
// Check for a community ban
if CommunityUserBanView::get(&conn, user_id, orig_comment.community_id).is_ok() {
return self.error("You have been banned from this community");
}
// Check for a site ban
if UserView::read(&conn, user_id).unwrap().banned {
return self.error("You have been banned from the site");
}
let content_slurs_removed = remove_slurs(&self.content.to_owned());
let comment_form = CommentForm {
@ -1228,6 +1313,7 @@ impl Perform for EditComment {
post_id: self.post_id,
creator_id: self.creator_id,
removed: self.removed.to_owned(),
read: self.read.to_owned(),
updated: Some(naive_now())
};
@ -1278,6 +1364,60 @@ impl Perform for EditComment {
}
}
impl Perform for SaveComment {
fn op_type(&self) -> UserOperation {
UserOperation::SaveComment
}
fn perform(&self, _chat: &mut ChatServer, _addr: usize) -> String {
let conn = establish_connection();
let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims,
Err(_e) => {
return self.error("Not logged in.");
}
};
let user_id = claims.id;
let comment_saved_form = CommentSavedForm {
comment_id: self.comment_id,
user_id: user_id,
};
if self.save {
match CommentSaved::save(&conn, &comment_saved_form) {
Ok(comment) => comment,
Err(_e) => {
return self.error("Couldnt do comment save");
}
};
} else {
match CommentSaved::unsave(&conn, &comment_saved_form) {
Ok(comment) => comment,
Err(_e) => {
return self.error("Couldnt do comment save");
}
};
}
let comment_view = CommentView::read(&conn, self.comment_id, Some(user_id)).unwrap();
let comment_out = serde_json::to_string(
&CommentResponse {
op: self.op_type().to_string(),
comment: comment_view
}
)
.unwrap();
comment_out
}
}
impl Perform for CreateCommentLike {
fn op_type(&self) -> UserOperation {
UserOperation::CreateCommentLike
@ -1296,12 +1436,17 @@ impl Perform for CreateCommentLike {
let user_id = claims.id;
// Check for a ban
// Check for a community ban
let post = Post::read(&conn, self.post_id).unwrap();
if CommunityUserBanView::get(&conn, user_id, post.community_id).is_ok() {
return self.error("You have been banned from this community");
}
// Check for a site ban
if UserView::read(&conn, user_id).unwrap().banned {
return self.error("You have been banned from the site");
}
let like_form = CommentLikeForm {
comment_id: self.comment_id,
post_id: self.post_id,
@ -1377,7 +1522,7 @@ impl Perform for GetPosts {
let type_ = PostListingType::from_str(&self.type_).expect("listing type");
let sort = SortType::from_str(&self.sort).expect("listing sort");
let posts = match PostView::list(&conn, type_, &sort, self.community_id, None, user_id, self.page, self.limit) {
let posts = match PostView::list(&conn, type_, &sort, self.community_id, None, user_id, false, false, self.page, self.limit) {
Ok(posts) => posts,
Err(_e) => {
return self.error("Couldn't get posts");
@ -1414,12 +1559,17 @@ impl Perform for CreatePostLike {
let user_id = claims.id;
// Check for a ban
// Check for a community ban
let post = Post::read(&conn, self.post_id).unwrap();
if CommunityUserBanView::get(&conn, user_id, post.community_id).is_ok() {
return self.error("You have been banned from this community");
}
// Check for a site ban
if UserView::read(&conn, user_id).unwrap().banned {
return self.error("You have been banned from the site");
}
let like_form = PostLikeForm {
post_id: self.post_id,
user_id: user_id,
@ -1483,22 +1633,36 @@ impl Perform for EditPost {
let user_id = claims.id;
// Verify its the creator or a mod
let mut editors: Vec<i32> = CommunityModeratorView::for_community(&conn, self.community_id)
// Verify its the creator or a mod or admin
let mut editors: Vec<i32> = vec![self.creator_id];
editors.append(
&mut CommunityModeratorView::for_community(&conn, self.community_id)
.unwrap()
.into_iter()
.map(|m| m.user_id)
.collect();
editors.push(self.creator_id);
.collect()
);
editors.append(
&mut UserView::admins(&conn)
.unwrap()
.into_iter()
.map(|a| a.id)
.collect()
);
if !editors.contains(&user_id) {
return self.error("Not allowed to edit comment.");
return self.error("Not allowed to edit post.");
}
// Check for a ban
// Check for a community ban
if CommunityUserBanView::get(&conn, user_id, self.community_id).is_ok() {
return self.error("You have been banned from this community");
}
// Check for a site ban
if UserView::read(&conn, user_id).unwrap().banned {
return self.error("You have been banned from the site");
}
let post_form = PostForm {
name: self.name.to_owned(),
url: self.url.to_owned(),
@ -1564,6 +1728,59 @@ impl Perform for EditPost {
}
}
impl Perform for SavePost {
fn op_type(&self) -> UserOperation {
UserOperation::SavePost
}
fn perform(&self, _chat: &mut ChatServer, _addr: usize) -> String {
let conn = establish_connection();
let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims,
Err(_e) => {
return self.error("Not logged in.");
}
};
let user_id = claims.id;
let post_saved_form = PostSavedForm {
post_id: self.post_id,
user_id: user_id,
};
if self.save {
match PostSaved::save(&conn, &post_saved_form) {
Ok(post) => post,
Err(_e) => {
return self.error("Couldnt do post save");
}
};
} else {
match PostSaved::unsave(&conn, &post_saved_form) {
Ok(post) => post,
Err(_e) => {
return self.error("Couldnt do post save");
}
};
}
let post_view = PostView::read(&conn, self.post_id, Some(user_id)).unwrap();
let post_out = serde_json::to_string(
&PostResponse {
op: self.op_type().to_string(),
post: post_view
}
)
.unwrap();
post_out
}
}
impl Perform for EditCommunity {
fn op_type(&self) -> UserOperation {
UserOperation::EditCommunity
@ -1586,6 +1803,11 @@ impl Perform for EditCommunity {
let user_id = claims.id;
// Check for a site ban
if UserView::read(&conn, user_id).unwrap().banned {
return self.error("You have been banned from the site");
}
// Verify its a mod
let moderator_view = CommunityModeratorView::for_community(&conn, self.edit_id).unwrap();
let mod_ids: Vec<i32> = moderator_view.into_iter().map(|m| m.user_id).collect();
@ -1750,26 +1972,21 @@ impl Perform for GetUserDetails {
let conn = establish_connection();
let user_id: Option<i32> = match &self.auth {
Some(auth) => {
match Claims::decode(&auth) {
Ok(claims) => {
let user_id = claims.claims.id;
Some(user_id)
}
Err(_e) => None
}
}
None => None
};
//TODO add save
let sort = SortType::from_str(&self.sort).expect("listing sort");
let user_view = UserView::read(&conn, self.user_id).unwrap();
let posts = PostView::list(&conn, PostListingType::All, &sort, self.community_id, Some(self.user_id), user_id, self.page, self.limit).unwrap();
let comments = CommentView::list(&conn, &sort, None, Some(self.user_id), user_id, self.page, self.limit).unwrap();
let posts = if self.saved_only {
PostView::list(&conn, PostListingType::All, &sort, self.community_id, None, Some(self.user_id), self.saved_only, false, self.page, self.limit).unwrap()
} else {
PostView::list(&conn, PostListingType::All, &sort, self.community_id, Some(self.user_id), None, self.saved_only, false, self.page, self.limit).unwrap()
};
let comments = if self.saved_only {
CommentView::list(&conn, &sort, None, None, Some(self.user_id), self.saved_only, self.page, self.limit).unwrap()
} else {
CommentView::list(&conn, &sort, None, Some(self.user_id), None, self.saved_only, self.page, self.limit).unwrap()
};
let follows = CommunityFollowerView::for_user(&conn, self.user_id).unwrap();
let moderates = CommunityModeratorView::for_user(&conn, self.user_id).unwrap();
@ -1782,8 +1999,6 @@ impl Perform for GetUserDetails {
moderates: moderates,
comments: comments,
posts: posts,
saved_posts: Vec::new(),
saved_comments: Vec::new(),
}
)
.unwrap()
@ -1834,6 +2049,39 @@ impl Perform for GetModlog {
}
}
impl Perform for GetReplies {
fn op_type(&self) -> UserOperation {
UserOperation::GetReplies
}
fn perform(&self, _chat: &mut ChatServer, _addr: usize) -> String {
let conn = establish_connection();
let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims,
Err(_e) => {
return self.error("Not logged in.");
}
};
let user_id = claims.id;
let sort = SortType::from_str(&self.sort).expect("listing sort");
let replies = ReplyView::get_replies(&conn, user_id, &sort, self.unread_only, self.page, self.limit).unwrap();
// Return the jwt
serde_json::to_string(
&GetRepliesResponse {
op: self.op_type().to_string(),
replies: replies,
}
)
.unwrap()
}
}
impl Perform for BanFromCommunity {
fn op_type(&self) -> UserOperation {
UserOperation::BanFromCommunity

View file

@ -1,12 +1,14 @@
import { Component, linkEvent } from 'inferno';
import { Link } from 'inferno-router';
import { CommentNode as CommentNodeI, CommentLikeForm, CommentForm as CommentFormI, BanFromCommunityForm, CommunityUser, AddModToCommunityForm } from '../interfaces';
import { CommentNode as CommentNodeI, CommentLikeForm, CommentForm as CommentFormI, SaveCommentForm, BanFromCommunityForm, BanUserForm, CommunityUser, UserView, AddModToCommunityForm, AddAdminForm } from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { mdToHtml, getUnixTime } from '../utils';
import { mdToHtml, getUnixTime, canMod, isMod } from '../utils';
import { MomentTime } from './moment-time';
import { CommentForm } from './comment-form';
import { CommentNodes } from './comment-nodes';
enum BanType {Community, Site};
interface CommentNodeState {
showReply: boolean;
showEdit: boolean;
@ -15,6 +17,7 @@ interface CommentNodeState {
showBanDialog: boolean;
banReason: string;
banExpires: string;
banType: BanType;
}
interface CommentNodeProps {
@ -22,7 +25,9 @@ interface CommentNodeProps {
noIndent?: boolean;
viewOnly?: boolean;
locked?: boolean;
markable?: boolean;
moderators: Array<CommunityUser>;
admins: Array<UserView>;
}
export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
@ -35,6 +40,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
showBanDialog: false,
banReason: null,
banExpires: null,
banType: BanType.Community
}
constructor(props: any, context: any) {
@ -60,6 +66,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
<li className="list-inline-item">
<Link className="text-info" to={`/user/${node.comment.creator_id}`}>{node.comment.creator_name}</Link>
</li>
{this.isMod &&
<li className="list-inline-item badge badge-secondary">mod</li>
}
{this.isAdmin &&
<li className="list-inline-item badge badge-secondary">admin</li>
}
<li className="list-inline-item">
<span>(
<span className="text-info">+{node.comment.upvotes}</span>
@ -77,51 +89,79 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
<div>
<div className="md-div" dangerouslySetInnerHTML={mdToHtml(node.comment.removed ? '*removed*' : node.comment.content)} />
<ul class="list-inline mb-1 text-muted small font-weight-bold">
{!this.props.viewOnly &&
<span class="mr-2">
{UserService.Instance.user && !this.props.viewOnly &&
<>
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleReplyClick)}>reply</span>
</li>
<li className="list-inline-item mr-2">
<span class="pointer" onClick={linkEvent(this, this.handleSaveCommentClick)}>{node.comment.saved ? 'unsave' : 'save'}</span>
</li>
{this.myComment &&
<>
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleEditClick)}>edit</span>
</li>
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>delete</span>
</li>
</>
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleEditClick)}>edit</span>
</li>
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>delete</span>
</li>
</>
}
{this.canMod &&
<>
{/* Admins and mods can remove comments */}
{this.canMod &&
<li className="list-inline-item">
{!this.props.node.comment.removed ?
<span class="pointer" onClick={linkEvent(this, this.handleModRemoveShow)}>remove</span> :
<span class="pointer" onClick={linkEvent(this, this.handleModRemoveSubmit)}>restore</span>
}
</li>
{!this.isMod &&
<>
}
{/* Mods can ban from community, and appoint as mods to community */}
{this.canMod &&
<>
{!this.isMod &&
<li className="list-inline-item">
{!this.props.node.comment.banned ?
<span class="pointer" onClick={linkEvent(this, this.handleModBanShow)}>ban</span> :
<span class="pointer" onClick={linkEvent(this, this.handleModBanSubmit)}>unban</span>
{!this.props.node.comment.banned_from_community ?
<span class="pointer" onClick={linkEvent(this, this.handleModBanFromCommunityShow)}>ban</span> :
<span class="pointer" onClick={linkEvent(this, this.handleModBanFromCommunitySubmit)}>unban</span>
}
</li>
</>
}
{!this.props.node.comment.banned &&
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleAddModToCommunity)}>{`${this.isMod ? 'remove' : 'appoint'} as mod`}</span>
</li>
}
</>
}
{!this.props.node.comment.banned_from_community &&
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleAddModToCommunity)}>{`${this.isMod ? 'remove' : 'appoint'} as mod`}</span>
</li>
}
</>
}
</span>
{/* Admins can ban from all, and appoint other admins */}
{this.canAdmin &&
<>
{!this.isAdmin &&
<li className="list-inline-item">
{!this.props.node.comment.banned ?
<span class="pointer" onClick={linkEvent(this, this.handleModBanShow)}>ban from site</span> :
<span class="pointer" onClick={linkEvent(this, this.handleModBanSubmit)}>unban from site</span>
}
</li>
}
{!this.props.node.comment.banned &&
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleAddAdmin)}>{`${this.isAdmin ? 'remove' : 'appoint'} as admin`}</span>
</li>
}
</>
}
</>
}
<li className="list-inline-item">
<Link className="text-muted" to={`/post/${node.comment.post_id}/comment/${node.comment.id}`} target="_blank">link</Link>
</li>
{this.props.markable &&
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleMarkRead)}>{`mark as ${node.comment.read ? 'unread' : 'read'}`}</span>
</li>
}
</ul>
</div>
}
@ -133,22 +173,35 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
</form>
}
{this.state.showBanDialog &&
<form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
<div class="form-group row">
<label class="col-form-label">Reason</label>
<input type="text" class="form-control mr-2" placeholder="Optional" value={this.state.banReason} onInput={linkEvent(this, this.handleModBanReasonChange)} />
</div>
<div class="form-group row">
<label class="col-form-label">Expires</label>
<input type="date" class="form-control mr-2" placeholder="Expires" value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} />
</div>
<div class="form-group row">
<button type="submit" class="btn btn-secondary">Ban {this.props.node.comment.creator_name}</button>
</div>
</form>
<form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
<div class="form-group row">
<label class="col-form-label">Reason</label>
<input type="text" class="form-control mr-2" placeholder="Optional" value={this.state.banReason} onInput={linkEvent(this, this.handleModBanReasonChange)} />
</div>
<div class="form-group row">
<label class="col-form-label">Expires</label>
<input type="date" class="form-control mr-2" placeholder="Expires" value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} />
</div>
<div class="form-group row">
<button type="submit" class="btn btn-secondary">Ban {this.props.node.comment.creator_name}</button>
</div>
</form>
}
{this.state.showReply &&
<CommentForm
node={node}
onReplyCancel={this.handleReplyCancel}
disabled={this.props.locked}
/>
}
{this.props.node.children &&
<CommentNodes
nodes={this.props.node.children}
locked={this.props.locked}
moderators={this.props.moderators}
admins={this.props.admins}
/>
}
{this.state.showReply && <CommentForm node={node} onReplyCancel={this.handleReplyCancel} disabled={this.props.locked} />}
{this.props.node.children && <CommentNodes nodes={this.props.node.children} locked={this.props.locked} moderators={this.props.moderators}/>}
</div>
)
}
@ -158,27 +211,22 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
}
get canMod(): boolean {
let adminsThenMods = this.props.admins.map(a => a.id)
.concat(this.props.moderators.map(m => m.user_id));
// You can do moderator actions only on the mods added after you.
if (UserService.Instance.user) {
let modIds = this.props.moderators.map(m => m.user_id);
let yourIndex = modIds.findIndex(id => id == UserService.Instance.user.id);
if (yourIndex == -1) {
return false;
} else {
console.log(modIds);
modIds = modIds.slice(0, yourIndex+1); // +1 cause you cant mod yourself
console.log(modIds);
return !modIds.includes(this.props.node.comment.creator_id);
}
} else {
return false;
}
return canMod(UserService.Instance.user, adminsThenMods, this.props.node.comment.creator_id);
}
get isMod(): boolean {
return this.props.moderators.map(m => m.user_id).includes(this.props.node.comment.creator_id);
return this.props.moderators && isMod(this.props.moderators.map(m => m.user_id), this.props.node.comment.creator_id);
}
get isAdmin(): boolean {
return this.props.admins && isMod(this.props.admins.map(a => a.id), this.props.node.comment.creator_id);
}
get canAdmin(): boolean {
return this.props.admins && canMod(UserService.Instance.user, this.props.admins.map(a => a.id), this.props.node.comment.creator_id);
}
handleReplyClick(i: CommentNode) {
@ -193,7 +241,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
handleDeleteClick(i: CommentNode) {
let deleteForm: CommentFormI = {
content: "*deleted*",
content: '*deleted*',
edit_id: i.props.node.comment.id,
creator_id: i.props.node.comment.creator_id,
post_id: i.props.node.comment.post_id,
@ -203,6 +251,16 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
WebSocketService.Instance.editComment(deleteForm);
}
handleSaveCommentClick(i: CommentNode) {
let saved = (i.props.node.comment.saved == undefined) ? true : !i.props.node.comment.saved;
let form: SaveCommentForm = {
comment_id: i.props.node.comment.id,
save: saved
};
WebSocketService.Instance.saveComment(form);
}
handleReplyCancel() {
this.state.showReply = false;
this.state.showEdit = false;
@ -257,8 +315,29 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
i.setState(i.state);
}
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);
}
handleModBanFromCommunityShow(i: CommentNode) {
i.state.showBanDialog = true;
i.state.banType = BanType.Community;
i.setState(i.state);
}
handleModBanShow(i: CommentNode) {
i.state.showBanDialog = true;
i.state.banType = BanType.Site;
i.setState(i.state);
}
@ -272,16 +351,42 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
i.setState(i.state);
}
handleModBanFromCommunitySubmit(i: CommentNode) {
i.state.banType = BanType.Community;
i.setState(i.state);
i.handleModBanBothSubmit(i);
}
handleModBanSubmit(i: CommentNode) {
i.state.banType = BanType.Site;
i.setState(i.state);
i.handleModBanBothSubmit(i);
}
handleModBanBothSubmit(i: CommentNode) {
event.preventDefault();
let form: BanFromCommunityForm = {
user_id: i.props.node.comment.creator_id,
community_id: i.props.node.comment.community_id,
ban: !i.props.node.comment.banned,
reason: i.state.banReason,
expires: getUnixTime(i.state.banExpires),
};
WebSocketService.Instance.banFromCommunity(form);
console.log(BanType[i.state.banType]);
console.log(i.props.node.comment.banned);
if (i.state.banType == BanType.Community) {
let form: BanFromCommunityForm = {
user_id: i.props.node.comment.creator_id,
community_id: i.props.node.comment.community_id,
ban: !i.props.node.comment.banned_from_community,
reason: i.state.banReason,
expires: getUnixTime(i.state.banExpires),
};
WebSocketService.Instance.banFromCommunity(form);
} else {
let form: BanUserForm = {
user_id: i.props.node.comment.creator_id,
ban: !i.props.node.comment.banned,
reason: i.state.banReason,
expires: getUnixTime(i.state.banExpires),
};
WebSocketService.Instance.banUser(form);
}
i.state.showBanDialog = false;
i.setState(i.state);
@ -296,4 +401,13 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
WebSocketService.Instance.addModToCommunity(form);
i.setState(i.state);
}
handleAddAdmin(i: CommentNode) {
let form: AddAdminForm = {
user_id: i.props.node.comment.creator_id,
added: !i.isAdmin,
};
WebSocketService.Instance.addAdmin(form);
i.setState(i.state);
}
}

View file

@ -1,5 +1,5 @@
import { Component } from 'inferno';
import { CommentNode as CommentNodeI, CommunityUser } from '../interfaces';
import { CommentNode as CommentNodeI, CommunityUser, UserView } from '../interfaces';
import { CommentNode } from './comment-node';
interface CommentNodesState {
@ -8,9 +8,11 @@ interface CommentNodesState {
interface CommentNodesProps {
nodes: Array<CommentNodeI>;
moderators?: Array<CommunityUser>;
admins?: Array<UserView>;
noIndent?: boolean;
viewOnly?: boolean;
locked?: boolean;
markable?: boolean;
}
export class CommentNodes extends Component<CommentNodesProps, CommentNodesState> {
@ -27,7 +29,10 @@ export class CommentNodes extends Component<CommentNodesProps, CommentNodesState
noIndent={this.props.noIndent}
viewOnly={this.props.viewOnly}
locked={this.props.locked}
moderators={this.props.moderators}/>
moderators={this.props.moderators}
admins={this.props.admins}
markable={this.props.markable}
/>
)}
</div>
)

View file

@ -53,9 +53,9 @@ export class Communities extends Component<any, CommunitiesState> {
return (
<div class="container">
{this.state.loading ?
<h4 class=""><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h4> :
<h5 class=""><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
<div>
<h4>Communities</h4>
<h5>Communities</h5>
<div class="table-responsive">
<table id="community_table" class="table table-sm table-hover">
<thead class="pointer">

View file

@ -60,14 +60,14 @@ export class Community extends Component<any, State> {
return (
<div class="container">
{this.state.loading ?
<h4><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h4> :
<h5><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
<div class="row">
<div class="col-12 col-md-9">
<h4>{this.state.community.title}
<h5>{this.state.community.title}
{this.state.community.removed &&
<small className="ml-2 text-muted font-italic">removed</small>
}
</h4>
</h5>
<PostListings communityId={this.state.communityId} />
</div>
<div class="col-12 col-md-3">

View file

@ -13,7 +13,7 @@ export class CreateCommunity extends Component<any, any> {
<div class="container">
<div class="row">
<div class="col-12 col-lg-6 mb-4">
<h4>Create Forum</h4>
<h5>Create Forum</h5>
<CommunityForm onCreate={this.handleCommunityCreate}/>
</div>
</div>

View file

@ -13,7 +13,7 @@ export class CreatePost extends Component<any, any> {
<div class="container">
<div class="row">
<div class="col-12 col-lg-6 mb-4">
<h4>Create a Post</h4>
<h5>Create a Post</h5>
<PostForm onCreate={this.handlePostCreate}/>
</div>
</div>

177
ui/src/components/inbox.tsx Normal file
View file

@ -0,0 +1,177 @@
import { Component, linkEvent } from 'inferno';
import { Link } from 'inferno-router';
import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, Comment, SortType, GetRepliesForm, GetRepliesResponse, CommentResponse } from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { msgOp } from '../utils';
import { CommentNodes } from './comment-nodes';
enum UnreadType {
Unread, All
}
interface InboxState {
unreadType: UnreadType;
replies: Array<Comment>;
sort: SortType;
page: number;
}
export class Inbox extends Component<any, InboxState> {
private subscription: Subscription;
private emptyState: InboxState = {
unreadType: UnreadType.Unread,
replies: [],
sort: SortType.New,
page: 1,
}
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
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')
);
this.refetch();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
render() {
let user = UserService.Instance.user;
return (
<div class="container">
<div class="row">
<div class="col-12">
<h5>Inbox for <Link to={`/user/${user.id}`}>{user.username}</Link></h5>
{this.selects()}
{this.replies()}
{this.paginator()}
</div>
</div>
</div>
)
}
selects() {
return (
<div className="mb-2">
<select value={this.state.unreadType} onChange={linkEvent(this, this.handleUnreadTypeChange)} class="custom-select w-auto">
<option disabled>Type</option>
<option value={UnreadType.Unread}>Unread</option>
<option value={UnreadType.All}>All</option>
</select>
<select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select w-auto ml-2">
<option disabled>Sort Type</option>
<option value={SortType.New}>New</option>
<option value={SortType.TopDay}>Top Day</option>
<option value={SortType.TopWeek}>Week</option>
<option value={SortType.TopMonth}>Month</option>
<option value={SortType.TopYear}>Year</option>
<option value={SortType.TopAll}>All</option>
</select>
</div>
)
}
replies() {
return (
<div>
{this.state.replies.map(reply =>
<CommentNodes nodes={[{comment: reply}]} noIndent viewOnly markable />
)}
</div>
);
}
paginator() {
return (
<div class="mt-2">
{this.state.page > 1 &&
<button class="btn btn-sm btn-secondary mr-1" onClick={linkEvent(this, this.prevPage)}>Prev</button>
}
<button class="btn btn-sm btn-secondary" onClick={linkEvent(this, this.nextPage)}>Next</button>
</div>
);
}
nextPage(i: Inbox) {
i.state.page++;
i.setState(i.state);
i.refetch();
}
prevPage(i: Inbox) {
i.state.page--;
i.setState(i.state);
i.refetch();
}
handleUnreadTypeChange(i: Inbox, event: any) {
i.state.unreadType = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.refetch();
}
refetch() {
let form: GetRepliesForm = {
sort: SortType[this.state.sort],
unread_only: (this.state.unreadType == UnreadType.Unread),
page: this.state.page,
limit: 9999,
};
WebSocketService.Instance.getReplies(form);
}
handleSortChange(i: Inbox, event: any) {
i.state.sort = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.refetch();
}
parseMessage(msg: any) {
console.log(msg);
let op: UserOperation = msgOp(msg);
if (msg.error) {
alert(msg.error);
return;
} else if (op == UserOperation.GetReplies) {
let res: GetRepliesResponse = msg;
this.state.replies = res.replies;
this.sendRepliesCount();
this.setState(this.state);
} else if (op == UserOperation.EditComment) {
let res: CommentResponse = msg;
// If youre in the unread view, just remove it from the list
if (this.state.unreadType == UnreadType.Unread && res.comment.read) {
this.state.replies = this.state.replies.filter(r => r.id !== res.comment.id);
} else {
let found = this.state.replies.find(c => c.id == res.comment.id);
found.read = res.comment.read;
}
this.sendRepliesCount();
this.setState(this.state);
}
}
sendRepliesCount() {
UserService.Instance.sub.next({user: UserService.Instance.user, unreadCount: this.state.replies.filter(r => !r.read).length});
}
}

View file

@ -67,7 +67,7 @@ export class Login extends Component<any, State> {
return (
<div>
<form onSubmit={linkEvent(this, this.handleLoginSubmit)}>
<h4>Login</h4>
<h5>Login</h5>
<div class="form-group row">
<label class="col-sm-2 col-form-label">Email or Username</label>
<div class="col-sm-10">
@ -94,7 +94,7 @@ export class Login extends Component<any, State> {
registerForm() {
return (
<form onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
<h4>Sign Up</h4>
<h5>Sign Up</h5>
<div class="form-group row">
<label class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-10">

View file

@ -2,7 +2,7 @@ import { Component } from 'inferno';
import { Link } from 'inferno-router';
import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, CommunityUser, GetFollowedCommunitiesResponse, ListCommunitiesForm, ListCommunitiesResponse, Community, SortType, GetSiteResponse } from '../interfaces';
import { UserOperation, CommunityUser, GetFollowedCommunitiesResponse, ListCommunitiesForm, ListCommunitiesResponse, Community, SortType, GetSiteResponse, GetRepliesResponse, GetRepliesForm } from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { PostListings } from './post-listings';
import { msgOp, repoUrl, mdToHtml } from '../utils';
@ -55,6 +55,15 @@ export class Main extends Component<any, State> {
if (UserService.Instance.user) {
WebSocketService.Instance.getFollowedCommunities();
// Get replies for the count
let repliesForm: GetRepliesForm = {
sort: SortType[SortType.New],
unread_only: true,
page: 1,
limit: 9999,
};
WebSocketService.Instance.getReplies(repliesForm);
}
let listCommunitiesForm: ListCommunitiesForm = {
@ -78,12 +87,12 @@ export class Main extends Component<any, State> {
</div>
<div class="col-12 col-md-4">
{this.state.loading ?
<h4><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h4> :
<h5><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
<div>
{this.trendingCommunities()}
{UserService.Instance.user && this.state.subscribedCommunities.length > 0 &&
<div>
<h4>Subscribed forums</h4>
<h5>Subscribed forums</h5>
<ul class="list-inline">
{this.state.subscribedCommunities.map(community =>
<li class="list-inline-item"><Link to={`/community/${community.community_id}`}>{community.community_name}</Link></li>
@ -103,7 +112,7 @@ export class Main extends Component<any, State> {
trendingCommunities() {
return (
<div>
<h4>Trending <Link class="text-white" to="/communities">forums</Link></h4>
<h5>Trending <Link class="text-white" to="/communities">forums</Link></h5>
<ul class="list-inline">
{this.state.trendingCommunities.map(community =>
<li class="list-inline-item"><Link to={`/community/${community.id}`}>{community.name}</Link></li>
@ -116,7 +125,7 @@ export class Main extends Component<any, State> {
landing() {
return (
<div>
<h4>{`${this.state.site.site.name}`}</h4>
<h5>{`${this.state.site.site.name}`}</h5>
<ul class="my-1 list-inline">
<li className="list-inline-item badge badge-light">{this.state.site.site.number_of_users} Users</li>
<li className="list-inline-item badge badge-light">{this.state.site.site.number_of_posts} Posts</li>
@ -136,10 +145,10 @@ export class Main extends Component<any, State> {
<hr />
</div>
}
<h4>Welcome to
<h5>Welcome to
<svg class="icon mx-2"><use xlinkHref="#icon-mouse"></use></svg>
<a href={repoUrl}>Lemmy<sup>Beta</sup></a>
</h4>
</h5>
<p>Lemmy is a <a href="https://en.wikipedia.org/wiki/Link_aggregation">link aggregator</a> / reddit alternative, intended to work in the <a href="https://en.wikipedia.org/wiki/Fediverse">fediverse</a>.</p>
<p>Its self-hostable, has live-updating comment threads, and is tiny (<code>~80kB</code>). Federation into the ActivityPub network is on the roadmap.</p>
<p>This is a <b>very early beta version</b>, and a lot of features are currently broken or missing.</p>
@ -176,7 +185,14 @@ export class Main extends Component<any, State> {
this.state.site.site = res.site;
this.state.site.banned = res.banned;
this.setState(this.state);
} else if (op == UserOperation.GetReplies) {
let res: GetRepliesResponse = msg;
this.sendRepliesCount(res);
}
}
sendRepliesCount(res: GetRepliesResponse) {
UserService.Instance.sub.next({user: UserService.Instance.user, unreadCount: res.replies.filter(r => !r.read).length});
}
}

View file

@ -9,7 +9,7 @@ import { MomentTime } from './moment-time';
import * as moment from 'moment';
interface ModlogState {
combined: Array<{type_: string, data: ModRemovePost | ModLockPost | ModRemoveCommunity}>,
combined: Array<{type_: string, data: ModRemovePost | ModLockPost | ModRemoveCommunity | ModAdd | ModBan}>,
communityId?: number,
communityName?: string,
page: number;
@ -51,6 +51,8 @@ export class Modlog extends Component<any, ModlogState> {
let removed_communities = addTypeInfo(res.removed_communities, "removed_communities");
let banned_from_community = addTypeInfo(res.banned_from_community, "banned_from_community");
let added_to_community = addTypeInfo(res.added_to_community, "added_to_community");
let added = addTypeInfo(res.added, "added");
let banned = addTypeInfo(res.banned, "banned");
this.state.combined = [];
this.state.combined.push(...removed_posts);
@ -59,9 +61,11 @@ export class Modlog extends Component<any, ModlogState> {
this.state.combined.push(...removed_communities);
this.state.combined.push(...banned_from_community);
this.state.combined.push(...added_to_community);
this.state.combined.push(...added);
this.state.combined.push(...banned);
if (this.state.communityId && this.state.combined.length > 0) {
this.state.communityName = this.state.combined[0].data.community_name;
this.state.communityName = (this.state.combined[0].data as ModRemovePost).community_name;
}
// Sort them by time
@ -95,13 +99,14 @@ export class Modlog extends Component<any, ModlogState> {
<>
{(i.data as ModRemoveComment).removed? 'Removed' : 'Restored'}
<span> Comment <Link to={`/post/${(i.data as ModRemoveComment).post_id}/comment/${(i.data as ModRemoveComment).comment_id}`}>{(i.data as ModRemoveComment).comment_content}</Link></span>
<span> by <Link to={`/user/${(i.data as ModRemoveComment).comment_user_id}`}>{(i.data as ModRemoveComment).comment_user_name}</Link></span>
<div>{(i.data as ModRemoveComment).reason && ` reason: ${(i.data as ModRemoveComment).reason}`}</div>
</>
}
{i.type_ == 'removed_communities' &&
<>
{(i.data as ModRemoveCommunity).removed ? 'Removed' : 'Restored'}
<span> Community <Link to={`/community/${i.data.community_id}`}>{i.data.community_name}</Link></span>
<span> Community <Link to={`/community/${(i.data as ModRemoveCommunity).community_id}`}>{(i.data as ModRemoveCommunity).community_name}</Link></span>
<div>{(i.data as ModRemoveCommunity).reason && ` reason: ${(i.data as ModRemoveCommunity).reason}`}</div>
<div>{(i.data as ModRemoveCommunity).expires && ` expires: ${moment.utc((i.data as ModRemoveCommunity).expires).fromNow()}`}</div>
</>
@ -110,6 +115,8 @@ export class Modlog extends Component<any, ModlogState> {
<>
<span>{(i.data as ModBanFromCommunity).banned ? 'Banned ' : 'Unbanned '} </span>
<span><Link to={`/user/${(i.data as ModBanFromCommunity).other_user_id}`}>{(i.data as ModBanFromCommunity).other_user_name}</Link></span>
<span> from the community </span>
<span><Link to={`/community/${(i.data as ModBanFromCommunity).community_id}`}>{(i.data as ModBanFromCommunity).community_name}</Link></span>
<div>{(i.data as ModBanFromCommunity).reason && ` reason: ${(i.data as ModBanFromCommunity).reason}`}</div>
<div>{(i.data as ModBanFromCommunity).expires && ` expires: ${moment.utc((i.data as ModBanFromCommunity).expires).fromNow()}`}</div>
</>
@ -119,12 +126,27 @@ export class Modlog extends Component<any, ModlogState> {
<span>{(i.data as ModAddCommunity).removed ? 'Removed ' : 'Appointed '} </span>
<span><Link to={`/user/${(i.data as ModAddCommunity).other_user_id}`}>{(i.data as ModAddCommunity).other_user_name}</Link></span>
<span> as a mod to the community </span>
<span><Link to={`/community/${i.data.community_id}`}>{i.data.community_name}</Link></span>
<span><Link to={`/community/${(i.data as ModAddCommunity).community_id}`}>{(i.data as ModAddCommunity).community_name}</Link></span>
</>
}
{i.type_ == 'banned' &&
<>
<span>{(i.data as ModBan).banned ? 'Banned ' : 'Unbanned '} </span>
<span><Link to={`/user/${(i.data as ModBan).other_user_id}`}>{(i.data as ModBan).other_user_name}</Link></span>
<div>{(i.data as ModBan).reason && ` reason: ${(i.data as ModBan).reason}`}</div>
<div>{(i.data as ModBan).expires && ` expires: ${moment.utc((i.data as ModBan).expires).fromNow()}`}</div>
</>
}
{i.type_ == 'added' &&
<>
<span>{(i.data as ModAdd).removed ? 'Removed ' : 'Appointed '} </span>
<span><Link to={`/user/${(i.data as ModAdd).other_user_id}`}>{(i.data as ModAdd).other_user_name}</Link></span>
<span> as an admin </span>
</>
}
</td>
</tr>
)
)
}
</tbody>
@ -136,12 +158,12 @@ export class Modlog extends Component<any, ModlogState> {
return (
<div class="container">
{this.state.loading ?
<h4 class=""><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h4> :
<h5 class=""><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
<div>
<h4>
<h5>
{this.state.communityName && <Link className="text-white" to={`/community/${this.state.communityId}`}>/f/{this.state.communityName} </Link>}
<span>Modlog</span>
</h4>
</h5>
<div class="table-responsive">
<table id="modlog_table" class="table table-sm table-hover">
<thead class="pointer">
@ -183,7 +205,7 @@ export class Modlog extends Component<any, ModlogState> {
i.setState(i.state);
i.refetch();
}
refetch(){
let modlogForm: GetModlogForm = {
community_id: this.state.communityId,

View file

@ -7,12 +7,14 @@ interface NavbarState {
isLoggedIn: boolean;
expanded: boolean;
expandUserDropdown: boolean;
unreadCount: number;
}
export class Navbar extends Component<any, NavbarState> {
emptyState: NavbarState = {
isLoggedIn: UserService.Instance.user !== undefined,
isLoggedIn: (UserService.Instance.user !== undefined),
unreadCount: 0,
expanded: false,
expandUserDropdown: false
}
@ -24,8 +26,9 @@ export class Navbar extends Component<any, NavbarState> {
// Subscribe to user changes
UserService.Instance.sub.subscribe(user => {
let loggedIn: boolean = user !== undefined;
this.setState({isLoggedIn: loggedIn});
this.state.isLoggedIn = user.user !== undefined;
this.state.unreadCount = user.unreadCount;
this.setState(this.state);
});
}
@ -64,16 +67,26 @@ export class Navbar extends Component<any, NavbarState> {
</ul>
<ul class="navbar-nav ml-auto mr-2">
{this.state.isLoggedIn ?
<li className={`nav-item dropdown ${this.state.expandUserDropdown && 'show'}`}>
<a class="pointer nav-link dropdown-toggle" onClick={linkEvent(this, this.expandUserDropdown)} role="button">
{UserService.Instance.user.username}
</a>
<div className={`dropdown-menu dropdown-menu-right ${this.state.expandUserDropdown && 'show'}`}>
<a role="button" class="dropdown-item pointer" onClick={linkEvent(this, this.handleOverviewClick)}>Overview</a>
<a role="button" class="dropdown-item pointer" onClick={ linkEvent(this, this.handleLogoutClick) }>Logout</a>
</div>
</li> :
<Link class="nav-link" to="/login">Login / Sign up</Link>
<>
{
<li className="nav-item">
<Link class="nav-link" to="/inbox">🖂
{this.state.unreadCount> 0 && <span class="badge badge-light">{this.state.unreadCount}</span>}
</Link>
</li>
}
<li className={`nav-item dropdown ${this.state.expandUserDropdown && 'show'}`}>
<a class="pointer nav-link dropdown-toggle" onClick={linkEvent(this, this.expandUserDropdown)} role="button">
{UserService.Instance.user.username}
</a>
<div className={`dropdown-menu dropdown-menu-right ${this.state.expandUserDropdown && 'show'}`}>
<a role="button" class="dropdown-item pointer" onClick={linkEvent(this, this.handleOverviewClick)}>Overview</a>
<a role="button" class="dropdown-item pointer" onClick={ linkEvent(this, this.handleLogoutClick) }>Logout</a>
</div>
</li>
</>
:
<Link class="nav-link" to="/login">Login / Sign up</Link>
}
</ul>
</div>
@ -89,6 +102,7 @@ export class Navbar extends Component<any, NavbarState> {
handleLogoutClick(i: Navbar) {
i.state.expandUserDropdown = false;
UserService.Instance.logout();
i.context.router.history.push('/');
}
handleOverviewClick(i: Navbar) {

View file

@ -1,10 +1,10 @@
import { Component, linkEvent } from 'inferno';
import { Link } from 'inferno-router';
import { WebSocketService, UserService } from '../services';
import { Post, CreatePostLikeForm, PostForm as PostFormI } from '../interfaces';
import { Post, CreatePostLikeForm, PostForm as PostFormI, SavePostForm, CommunityUser, UserView } from '../interfaces';
import { MomentTime } from './moment-time';
import { PostForm } from './post-form';
import { mdToHtml } from '../utils';
import { mdToHtml, canMod, isMod } from '../utils';
interface PostListingState {
showEdit: boolean;
@ -19,6 +19,8 @@ interface PostListingProps {
showCommunity?: boolean;
showBody?: boolean;
viewOnly?: boolean;
moderators?: Array<CommunityUser>;
admins?: Array<UserView>;
}
export class PostListing extends Component<PostListingProps, PostListingState> {
@ -60,17 +62,17 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<div>{post.score}</div>
<div className={`pointer downvote ${post.my_vote == -1 && 'text-danger'}`} onClick={linkEvent(this, this.handlePostDisLike)}></div>
</div>
<div className="ml-4">
<div className="pt-1 ml-4">
{post.url
? <div className="mb-0">
<h4 className="d-inline"><a className="text-white" href={post.url} title={post.url}>{post.name}</a>
<h5 className="d-inline"><a className="text-white" href={post.url} title={post.url}>{post.name}</a>
{post.removed &&
<small className="ml-2 text-muted font-italic">removed</small>
}
{post.locked &&
<small className="ml-2 text-muted font-italic">locked</small>
}
</h4>
</h5>
<small><a className="ml-2 text-muted font-italic" href={post.url} title={post.url}>{(new URL(post.url)).hostname}</a></small>
{ !this.state.iframeExpanded
? <span class="badge badge-light pointer ml-2 text-muted small" title="Expand here" onClick={linkEvent(this, this.handleIframeExpandClick)}>+</span>
@ -83,14 +85,14 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</span>
}
</div>
: <h4 className="mb-0"><Link className="text-white" to={`/post/${post.id}`}>{post.name}</Link>
: <h5 className="mb-0"><Link className="text-white" to={`/post/${post.id}`}>{post.name}</Link>
{post.removed &&
<small className="ml-2 text-muted font-italic">removed</small>
}
{post.locked &&
<small className="ml-2 text-muted font-italic">locked</small>
}
</h4>
</h5>
}
</div>
<div className="details ml-4 mb-1">
@ -98,6 +100,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<li className="list-inline-item">
<span>by </span>
<Link className="text-info" to={`/user/${post.creator_id}`}>{post.creator_name}</Link>
{this.isMod &&
<span className="mx-1 badge badge-secondary">mod</span>
}
{this.isAdmin &&
<span className="mx-1 badge badge-secondary">admin</span>
}
{this.props.showCommunity &&
<span>
<span> to </span>
@ -120,19 +128,22 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<Link className="text-muted" to={`/post/${post.id}`}>{post.number_of_comments} Comments</Link>
</li>
</ul>
{this.props.editable &&
{UserService.Instance.user && this.props.editable &&
<ul class="list-inline mb-1 text-muted small font-weight-bold">
<li className="list-inline-item mr-2">
<span class="pointer" onClick={linkEvent(this, this.handleSavePostClick)}>{this.props.post.saved ? 'unsave' : 'save'}</span>
</li>
{this.myPost &&
<span>
<>
<li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleEditClick)}>edit</span>
</li>
<li className="list-inline-item mr-2">
<span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>delete</span>
</li>
</span>
</>
}
{this.props.post.am_mod &&
{this.canMod &&
<span>
<li className="list-inline-item">
{!this.props.post.removed ?
@ -163,6 +174,29 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
return UserService.Instance.user && this.props.post.creator_id == UserService.Instance.user.id;
}
get canMod(): boolean {
if (this.props.editable) {
let adminsThenMods = this.props.admins.map(a => a.id)
.concat(this.props.moderators.map(m => m.user_id));
return canMod(UserService.Instance.user, adminsThenMods, this.props.post.creator_id);
} else return false;
}
get isMod(): boolean {
return this.props.moderators && isMod(this.props.moderators.map(m => m.user_id), this.props.post.creator_id);
}
get isAdmin(): boolean {
return this.props.admins && isMod(this.props.admins.map(a => a.id), this.props.post.creator_id);
}
get canAdmin(): boolean {
return this.props.admins && canMod(UserService.Instance.user, this.props.admins.map(a => a.id), this.props.post.creator_id);
}
handlePostLike(i: PostListing) {
let form: CreatePostLikeForm = {
@ -209,6 +243,16 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
WebSocketService.Instance.editPost(deleteForm);
}
handleSavePostClick(i: PostListing) {
let saved = (i.props.post.saved == undefined) ? true : !i.props.post.saved;
let form: SavePostForm = {
post_id: i.props.post.id,
save: saved
};
WebSocketService.Instance.savePost(form);
}
handleModRemoveShow(i: PostListing) {
i.state.showRemoveDialog = true;
i.setState(i.state);

View file

@ -61,7 +61,7 @@ export class PostListings extends Component<PostListingsProps, PostListingsState
return (
<div>
{this.state.loading ?
<h4><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h4> :
<h5><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
<div>
{this.selects()}
{this.state.posts.length > 0

View file

@ -1,7 +1,7 @@
import { Component, linkEvent } from 'inferno';
import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, Community, Post as PostI, GetPostResponse, PostResponse, Comment, CommentResponse, CommentSortType, CreatePostLikeResponse, CommunityUser, CommunityResponse, CommentNode as CommentNodeI, BanFromCommunityResponse, AddModToCommunityResponse } from '../interfaces';
import { UserOperation, Community, Post as PostI, GetPostResponse, PostResponse, Comment, CommentResponse, CommentSortType, CreatePostLikeResponse, CommunityUser, CommunityResponse, CommentNode as CommentNodeI, BanFromCommunityResponse, BanUserResponse, AddModToCommunityResponse, AddAdminResponse, UserView } from '../interfaces';
import { WebSocketService } from '../services';
import { msgOp, hotRank } from '../utils';
import { PostListing } from './post-listing';
@ -10,13 +10,13 @@ import { CommentForm } from './comment-form';
import { CommentNodes } from './comment-nodes';
import * as autosize from 'autosize';
interface PostState {
post: PostI;
comments: Array<Comment>;
commentSort: CommentSortType;
community: Community;
moderators: Array<CommunityUser>;
admins: Array<UserView>;
scrolled?: boolean;
scrolled_comment_id?: number;
loading: boolean;
@ -31,6 +31,7 @@ export class Post extends Component<any, PostState> {
commentSort: CommentSortType.Hot,
community: null,
moderators: [],
admins: [],
scrolled: false,
loading: true
}
@ -77,10 +78,17 @@ export class Post extends Component<any, PostState> {
return (
<div class="container">
{this.state.loading ?
<h4><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h4> :
<h5><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
<div class="row">
<div class="col-12 col-md-8 col-lg-7 mb-3">
<PostListing post={this.state.post} showBody showCommunity editable />
<PostListing
post={this.state.post}
showBody
showCommunity
editable
moderators={this.state.moderators}
admins={this.state.admins}
/>
<div className="mb-2" />
<CommentForm postId={this.state.post.id} disabled={this.state.post.locked} />
{this.sortRadios()}
@ -123,9 +131,15 @@ export class Post extends Component<any, PostState> {
newComments() {
return (
<div class="sticky-top">
<h4>New Comments</h4>
<h5>New Comments</h5>
{this.state.comments.map(comment =>
<CommentNodes nodes={[{comment: comment}]} noIndent locked={this.state.post.locked} moderators={this.state.moderators} />
<CommentNodes
nodes={[{comment: comment}]}
noIndent
locked={this.state.post.locked}
moderators={this.state.moderators}
admins={this.state.admins}
/>
)}
</div>
)
@ -187,8 +201,13 @@ export class Post extends Component<any, PostState> {
commentsTree() {
let nodes = this.buildCommentsTree();
return (
<div className="">
<CommentNodes nodes={nodes} locked={this.state.post.locked} moderators={this.state.moderators} />
<div>
<CommentNodes
nodes={nodes}
locked={this.state.post.locked}
moderators={this.state.moderators}
admins={this.state.admins}
/>
</div>
);
}
@ -202,9 +221,11 @@ export class Post extends Component<any, PostState> {
} else if (op == UserOperation.GetPost) {
let res: GetPostResponse = msg;
this.state.post = res.post;
this.state.post = res.post;
this.state.comments = res.comments;
this.state.community = res.community;
this.state.moderators = res.moderators;
this.state.admins = res.admins;
this.state.loading = false;
this.setState(this.state);
} else if (op == UserOperation.CreateComment) {
@ -222,8 +243,12 @@ export class Post extends Component<any, PostState> {
found.score = res.comment.score;
this.setState(this.state);
}
else if (op == UserOperation.CreateCommentLike) {
} else if (op == UserOperation.SaveComment) {
let res: CommentResponse = msg;
let found = this.state.comments.find(c => c.id == res.comment.id);
found.saved = res.comment.saved;
this.setState(this.state);
} else if (op == UserOperation.CreateCommentLike) {
let res: CommentResponse = msg;
let found: Comment = this.state.comments.find(c => c.id === res.comment.id);
found.score = res.comment.score;
@ -243,6 +268,10 @@ export class Post extends Component<any, PostState> {
let res: PostResponse = msg;
this.state.post = res.post;
this.setState(this.state);
} else if (op == UserOperation.SavePost) {
let res: PostResponse = msg;
this.state.post = res.post;
this.setState(this.state);
} else if (op == UserOperation.EditCommunity) {
let res: CommunityResponse = msg;
this.state.community = res.community;
@ -257,12 +286,21 @@ export class Post extends Component<any, PostState> {
} else if (op == UserOperation.BanFromCommunity) {
let res: BanFromCommunityResponse = msg;
this.state.comments.filter(c => c.creator_id == res.user.id)
.forEach(c => c.banned = res.banned);
.forEach(c => c.banned_from_community = res.banned);
this.setState(this.state);
} else if (op == UserOperation.AddModToCommunity) {
let res: AddModToCommunityResponse = msg;
this.state.moderators = res.moderators;
this.setState(this.state);
} else if (op == UserOperation.BanUser) {
let res: BanUserResponse = msg;
this.state.comments.filter(c => c.creator_id == res.user.id)
.forEach(c => c.banned = res.banned);
this.setState(this.state);
} else if (op == UserOperation.AddAdmin) {
let res: AddAdminResponse = msg;
this.state.admins = res.admins;
this.setState(this.state);
}
}

View file

@ -61,7 +61,7 @@ export class Setup extends Component<any, State> {
registerUser() {
return (
<form onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
<h4>Set up Site Administrator</h4>
<h5>Set up Site Administrator</h5>
<div class="form-group row">
<label class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-10">

View file

@ -48,11 +48,11 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
let community = this.props.community;
return (
<div>
<h4 className="mb-0">{community.title}
<h5 className="mb-0">{community.title}
{community.removed &&
<small className="ml-2 text-muted font-italic">removed</small>
}
</h4>
</h5>
<Link className="text-muted" to={`/community/${community.id}`}>/f/{community.name}</Link>
{community.am_mod &&
<ul class="list-inline mb-1 text-muted small font-weight-bold">

View file

@ -33,7 +33,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
render() {
return (
<form onSubmit={linkEvent(this, this.handleCreateSiteSubmit)}>
<h4>{`${this.props.site ? 'Edit' : 'Name'} your Site`}</h4>
<h5>{`${this.props.site ? 'Edit' : 'Name'} your Site`}</h5>
<div class="form-group row">
<label class="col-12 col-form-label">Name</label>
<div class="col-12">

View file

@ -77,7 +77,7 @@ export class User extends Component<any, UserState> {
<div class="container">
<div class="row">
<div class="col-12 col-md-9">
<h4>/u/{this.state.user.name}</h4>
<h5>/u/{this.state.user.name}</h5>
{this.selects()}
{this.state.view == View.Overview &&
this.overview()
@ -88,6 +88,9 @@ export class User extends Component<any, UserState> {
{this.state.view == View.Posts &&
this.posts()
}
{this.state.view == View.Saved &&
this.overview()
}
{this.paginator()}
</div>
<div class="col-12 col-md-3">
@ -108,7 +111,7 @@ export class User extends Component<any, UserState> {
<option value={View.Overview}>Overview</option>
<option value={View.Comments}>Comments</option>
<option value={View.Posts}>Posts</option>
{/* <option value={View.Saved}>Saved</option> */}
<option value={View.Saved}>Saved</option>
</select>
<select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select w-auto ml-2">
<option disabled>Sort Type</option>
@ -178,7 +181,7 @@ export class User extends Component<any, UserState> {
let user = this.state.user;
return (
<div>
<h4>{user.name}</h4>
<h5>{user.name}</h5>
<div>Joined <MomentTime data={user} /></div>
<table class="table table-bordered table-sm mt-2">
<tr>
@ -200,7 +203,7 @@ export class User extends Component<any, UserState> {
<div>
{this.state.moderates.length > 0 &&
<div>
<h4>Moderates</h4>
<h5>Moderates</h5>
<ul class="list-unstyled">
{this.state.moderates.map(community =>
<li><Link to={`/community/${community.community_id}`}>{community.community_name}</Link></li>
@ -218,7 +221,7 @@ export class User extends Component<any, UserState> {
{this.state.follows.length > 0 &&
<div>
<hr />
<h4>Subscribed</h4>
<h5>Subscribed</h5>
<ul class="list-unstyled">
{this.state.follows.map(community =>
<li><Link to={`/community/${community.community_id}`}>{community.community_name}</Link></li>
@ -257,6 +260,7 @@ export class User extends Component<any, UserState> {
let form: GetUserDetailsForm = {
user_id: this.state.user_id,
sort: SortType[this.state.sort],
saved_only: this.state.view == View.Saved,
page: this.state.page,
limit: fetchLimit,
};

View file

@ -82,3 +82,10 @@ blockquote {
margin: 0.5em 5px;
padding: 0.1em 5px;
}
.badge-notify{
/* background:red; */
position:relative;
top: -20px;
left: -35px;
}

View file

@ -7,9 +7,7 @@
<link rel="shortcut icon" type="image/svg+xml" href="/static/assets/favicon.svg" />
<title>Lemmy</title>
<link rel="stylesheet" href="https://bootswatch.com/4/darkly/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/balloon-css/0.5.0/balloon.min.css">
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,700,800" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/sortable/0.8.0/js/sortable.min.js"></script>
</head>

View file

@ -13,9 +13,11 @@ import { Communities } from './components/communities';
import { User } from './components/user';
import { Modlog } from './components/modlog';
import { Setup } from './components/setup';
import { Inbox } from './components/inbox';
import { Symbols } from './components/symbols';
import './main.css';
import './css/bootstrap.min.css';
import './css/main.css';
import { WebSocketService, UserService } from './services';
@ -45,6 +47,7 @@ class Index extends Component<any, any> {
<Route path={`/community/:id`} component={Community} />
<Route path={`/user/:id/:heading`} component={User} />
<Route path={`/user/:id`} component={User} />
<Route path={`/inbox`} component={Inbox} />
<Route path={`/modlog/community/:community_id`} component={Modlog} />
<Route path={`/modlog`} component={Modlog} />
<Route path={`/setup`} component={Setup} />

View file

@ -1,5 +1,5 @@
export enum UserOperation {
Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser
Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, SaveComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, SavePost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetReplies, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser
}
export enum CommentSortType {
@ -41,65 +41,69 @@ export interface CommunityUser {
}
export interface Community {
user_id?: number;
subscribed?: boolean;
am_mod?: boolean;
removed?: boolean;
id: number;
name: string;
title: string;
description?: string;
creator_id: number;
creator_name: string;
category_id: number;
creator_id: number;
removed: boolean;
published: string;
updated?: string;
creator_name: string;
category_name: string;
number_of_subscribers: number;
number_of_posts: number;
number_of_comments: number;
published: string;
updated?: string;
user_id?: number;
subscribed?: boolean;
}
export interface Post {
user_id?: number;
my_vote?: number;
am_mod?: boolean;
removed?: boolean;
locked?: boolean;
id: number;
name: string;
url?: string;
body?: string;
creator_id: number;
creator_name: string;
community_id: number;
removed: boolean;
locked: boolean;
published: string;
updated?: string;
creator_name: string;
community_name: string;
number_of_comments: number;
score: number;
upvotes: number;
downvotes: number;
hot_rank: number;
published: string;
updated?: string;
user_id?: number;
my_vote?: number;
subscribed?: boolean;
read?: boolean;
saved?: boolean;
}
export interface Comment {
id: number;
content: string;
creator_id: number;
creator_name: string;
post_id: number,
community_id: number,
parent_id?: number;
content: string;
removed: boolean;
read: boolean;
published: string;
updated?: string;
community_id: number,
banned: boolean;
banned_from_community: boolean;
creator_name: string;
score: number;
upvotes: number;
downvotes: number;
user_id?: number;
my_vote?: number;
am_mod?: boolean;
removed?: boolean;
banned?: boolean;
saved?: boolean;
}
export interface Category {
@ -137,7 +141,7 @@ export interface GetUserDetailsForm {
page?: number;
limit?: number;
community_id?: number;
auth?: string;
saved_only: boolean;
}
export interface UserDetailsResponse {
@ -147,7 +151,19 @@ export interface UserDetailsResponse {
moderates: Array<CommunityUser>;
comments: Array<Comment>;
posts: Array<Post>;
saved?: Array<Post>;
}
export interface GetRepliesForm {
sort: string; // TODO figure this one out
page?: number;
limit?: number;
unread_only: boolean;
auth?: string;
}
export interface GetRepliesResponse {
op: string;
replies: Array<Comment>;
}
export interface BanFromCommunityForm {
@ -324,7 +340,7 @@ export interface CommunityForm {
description?: string,
category_id: number,
edit_id?: number;
removed?: boolean;
removed: boolean;
reason?: string;
expires?: number;
auth?: string;
@ -368,8 +384,8 @@ export interface PostForm {
edit_id?: number;
creator_id: number;
removed?: boolean;
reason?: string;
locked?: boolean;
reason?: string;
auth: string;
}
@ -379,6 +395,13 @@ export interface GetPostResponse {
comments: Array<Comment>;
community: Community;
moderators: Array<CommunityUser>;
admins: Array<UserView>;
}
export interface SavePostForm {
post_id: number;
save: boolean;
auth?: string;
}
export interface PostResponse {
@ -394,9 +417,16 @@ export interface CommentForm {
creator_id: number;
removed?: boolean;
reason?: string;
read?: boolean;
auth: string;
}
export interface SaveCommentForm {
comment_id: number;
save: boolean;
auth?: string;
}
export interface CommentResponse {
op: string;
comment: Comment;

View file

@ -1,87 +0,0 @@
body {
font-family: 'Open Sans', sans-serif;
}
.pointer {
cursor: pointer;
}
.no-click {
pointer-events:none;
opacity: 0.65;
}
.upvote:hover {
color: var(--info);
}
.downvote:hover {
color: var(--danger);
}
.form-control, .form-control:focus {
background-color: var(--secondary);
color: #fff;
}
.form-control:disabled {
background-color: var(--secondary);
opacity: .5;
}
.custom-select {
color: #fff;
background-color: var(--secondary);
}
.mark {
background-color: #322a00;
}
.md-div p {
margin-bottom: 0px;
}
.md-div img {
max-width: 100%;
height: auto;
}
.listing {
min-height: 61px;
}
.icon {
display: inline-flex;
width: 1em;
height: 1em;
stroke-width: 0;
stroke: currentColor;
fill: currentColor;
vertical-align: middle;
align-self: center;
}
.spin {
animation: spins 2s linear infinite;
}
@keyframes spins {
0% { transform: rotate(0deg); }
100% { transform: rotate(359deg); }
}
.dropdown-menu {
z-index: 2000;
}
.navbar-bg {
background-color: #222;
}
blockquote {
border-left: 3px solid #ccc;
margin: 0.5em 5px;
padding: 0.1em 5px;
}

View file

@ -4,9 +4,10 @@ import * as jwt_decode from 'jwt-decode';
import { Subject } from 'rxjs';
export class UserService {
private static _instance: UserService;
public user: User;
public sub: Subject<User> = new Subject<User>();
public sub: Subject<{user: User, unreadCount: number}> = new Subject<{user: User, unreadCount: number}>();
private constructor() {
let jwt = Cookies.get("jwt");
@ -28,7 +29,7 @@ export class UserService {
this.user = undefined;
Cookies.remove("jwt");
console.log("Logged out.");
this.sub.next(undefined);
this.sub.next({user: undefined, unreadCount: 0});
}
public get auth(): string {
@ -37,7 +38,7 @@ export class UserService {
private setUser(jwt: string) {
this.user = jwt_decode(jwt);
this.sub.next(this.user);
this.sub.next({user: this.user, unreadCount: 0});
console.log(this.user);
}

View file

@ -1,5 +1,5 @@
import { wsUri } from '../env';
import { LoginForm, RegisterForm, UserOperation, CommunityForm, PostForm, CommentForm, CommentLikeForm, GetPostsForm, CreatePostLikeForm, FollowCommunityForm, GetUserDetailsForm, ListCommunitiesForm, GetModlogForm, BanFromCommunityForm, AddModToCommunityForm, SiteForm, Site, UserView } from '../interfaces';
import { LoginForm, RegisterForm, UserOperation, CommunityForm, PostForm, SavePostForm, CommentForm, SaveCommentForm, CommentLikeForm, GetPostsForm, CreatePostLikeForm, FollowCommunityForm, GetUserDetailsForm, ListCommunitiesForm, GetModlogForm, BanFromCommunityForm, AddModToCommunityForm, AddAdminForm, BanUserForm, SiteForm, Site, UserView, GetRepliesForm } from '../interfaces';
import { webSocket } from 'rxjs/webSocket';
import { Subject } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
@ -96,6 +96,11 @@ export class WebSocketService {
this.subject.next(this.wsSendWrapper(UserOperation.CreateCommentLike, form));
}
public saveComment(form: SaveCommentForm) {
this.setAuth(form);
this.subject.next(this.wsSendWrapper(UserOperation.SaveComment, form));
}
public getPosts(form: GetPostsForm) {
this.setAuth(form, false);
this.subject.next(this.wsSendWrapper(UserOperation.GetPosts, form));
@ -111,6 +116,11 @@ export class WebSocketService {
this.subject.next(this.wsSendWrapper(UserOperation.EditPost, postForm));
}
public savePost(form: SavePostForm) {
this.setAuth(form);
this.subject.next(this.wsSendWrapper(UserOperation.SavePost, form));
}
public banFromCommunity(form: BanFromCommunityForm) {
this.setAuth(form);
this.subject.next(this.wsSendWrapper(UserOperation.BanFromCommunity, form));
@ -121,11 +131,25 @@ export class WebSocketService {
this.subject.next(this.wsSendWrapper(UserOperation.AddModToCommunity, form));
}
public banUser(form: BanUserForm) {
this.setAuth(form);
this.subject.next(this.wsSendWrapper(UserOperation.BanUser, form));
}
public addAdmin(form: AddAdminForm) {
this.setAuth(form);
this.subject.next(this.wsSendWrapper(UserOperation.AddAdmin, form));
}
public getUserDetails(form: GetUserDetailsForm) {
this.setAuth(form, false);
this.subject.next(this.wsSendWrapper(UserOperation.GetUserDetails, form));
}
public getReplies(form: GetRepliesForm) {
this.setAuth(form);
this.subject.next(this.wsSendWrapper(UserOperation.GetReplies, form));
}
public getModlog(form: GetModlogForm) {
this.subject.next(this.wsSendWrapper(UserOperation.GetModlog, form));
}

View file

@ -1,4 +1,4 @@
import { UserOperation, Comment } from './interfaces';
import { UserOperation, Comment, User } from './interfaces';
import * as markdown_it from 'markdown-it';
export let repoUrl = 'https://github.com/dessalines/lemmy';
@ -40,4 +40,23 @@ export function addTypeInfo<T>(arr: Array<T>, name: string): Array<{type_: strin
return arr.map(e => {return {type_: name, data: e}});
}
export function canMod(user: User, modIds: Array<number>, creator_id: number): boolean {
// You can do moderator actions only on the mods added after you.
if (user) {
let yourIndex = modIds.findIndex(id => id == user.id);
if (yourIndex == -1) {
return false;
} else {
modIds = modIds.slice(0, yourIndex+1); // +1 cause you cant mod yourself
return !modIds.includes(creator_id);
}
} else {
return false;
}
}
export function isMod(modIds: Array<number>, creator_id: number): boolean {
return modIds.includes(creator_id);
}
export let fetchLimit: number = 20;