Merge branch 'master' into master

This commit is contained in:
Felix Pojtinger 2019-05-04 14:23:37 +02:00 committed by GitHub
commit 45dd106c13
48 changed files with 1259 additions and 349 deletions

View File

@ -51,6 +51,7 @@ Each lemmy server can set its own moderation policy; appointing site-wide admins
- Lead singer from [motorhead](https://invidio.us/watch?v=pWB5JZRGl0U). - Lead singer from [motorhead](https://invidio.us/watch?v=pWB5JZRGl0U).
- The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>). - The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>).
- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).
- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/). - The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).
Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Inferno](https://www.infernojs.org), [Typescript](https://www.typescriptlang.org/) and [Diesel](http://diesel.rs/). Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Inferno](https://www.infernojs.org), [Typescript](https://www.typescriptlang.org/) and [Diesel](http://diesel.rs/).

View File

@ -0,0 +1,137 @@
drop view reply_view;
drop view comment_view;
drop view community_view;
drop view post_view;
alter table community drop column deleted;
alter table post drop column deleted;
alter table comment drop column deleted;
create view community_view as
with all_community as
(
select *,
(select name from user_ u where c.creator_id = u.id) as creator_name,
(select name from category ct where c.category_id = ct.id) as category_name,
(select count(*) from community_follower cf where cf.community_id = c.id) as number_of_subscribers,
(select count(*) from post p where p.community_id = c.id) as number_of_posts,
(select count(*) from comment co, post p where c.id = p.community_id and p.id = co.post_id) as number_of_comments
from community c
)
select
ac.*,
u.id as user_id,
(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
union all
select
ac.*,
null as user_id,
null as subscribed
from all_community ac
;
create or replace view post_view as
with all_post as
(
select
p.*,
(select name from user_ where p.creator_id = user_.id) as creator_name,
(select name from community where p.community_id = community.id) as community_name,
(select removed from community c where p.community_id = c.id) as community_removed,
(select count(*) from comment where comment.post_id = p.id) as number_of_comments,
coalesce(sum(pl.score), 0) as score,
count (case when pl.score = 1 then 1 else null end) as upvotes,
count (case when pl.score = -1 then 1 else null end) as downvotes,
hot_rank(coalesce(sum(pl.score) , 0), p.published) as hot_rank
from post p
left join post_like pl on p.id = pl.post_id
group by p.id
)
select
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,
(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
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;
create view comment_view as
with all_comment as
(
select
c.*,
(select community_id from post p where p.id = c.post_id),
(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,
count (case when cl.score = -1 then 1 else null end) as downvotes
from comment c
left join comment_like cl on c.id = cl.comment_id
group by c.id
)
select
ac.*,
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
(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
union all
select
ac.*,
null as user_id,
null as my_vote,
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

@ -0,0 +1,141 @@
alter table community add column deleted boolean default false not null;
alter table post add column deleted boolean default false not null;
alter table comment add column deleted boolean default false not null;
-- The views
drop view community_view;
create view community_view as
with all_community as
(
select *,
(select name from user_ u where c.creator_id = u.id) as creator_name,
(select name from category ct where c.category_id = ct.id) as category_name,
(select count(*) from community_follower cf where cf.community_id = c.id) as number_of_subscribers,
(select count(*) from post p where p.community_id = c.id) as number_of_posts,
(select count(*) from comment co, post p where c.id = p.community_id and p.id = co.post_id) as number_of_comments
from community c
)
select
ac.*,
u.id as user_id,
(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
union all
select
ac.*,
null as user_id,
null as subscribed
from all_community ac
;
drop view post_view;
create view post_view as
with all_post as
(
select
p.*,
(select name from user_ where p.creator_id = user_.id) as creator_name,
(select name from community where p.community_id = community.id) as community_name,
(select removed from community c where p.community_id = c.id) as community_removed,
(select deleted from community c where p.community_id = c.id) as community_deleted,
(select count(*) from comment where comment.post_id = p.id) as number_of_comments,
coalesce(sum(pl.score), 0) as score,
count (case when pl.score = 1 then 1 else null end) as upvotes,
count (case when pl.score = -1 then 1 else null end) as downvotes,
hot_rank(coalesce(sum(pl.score) , 0), p.published) as hot_rank
from post p
left join post_like pl on p.id = pl.post_id
group by p.id
)
select
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,
(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
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;
drop view reply_view;
drop view comment_view;
create view comment_view as
with all_comment as
(
select
c.*,
(select community_id from post p where p.id = c.post_id),
(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,
count (case when cl.score = -1 then 1 else null end) as downvotes
from comment c
left join comment_like cl on c.id = cl.comment_id
group by c.id
)
select
ac.*,
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
(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
union all
select
ac.*,
null as user_id,
null as my_vote,
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

@ -0,0 +1,28 @@
drop view community_view;
create view community_view as
with all_community as
(
select *,
(select name from user_ u where c.creator_id = u.id) as creator_name,
(select name from category ct where c.category_id = ct.id) as category_name,
(select count(*) from community_follower cf where cf.community_id = c.id) as number_of_subscribers,
(select count(*) from post p where p.community_id = c.id) as number_of_posts,
(select count(*) from comment co, post p where c.id = p.community_id and p.id = co.post_id) as number_of_comments
from community c
)
select
ac.*,
u.id as user_id,
(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
union all
select
ac.*,
null as user_id,
null as subscribed
from all_community ac
;

View File

@ -0,0 +1,29 @@
drop view community_view;
create view community_view as
with all_community as
(
select *,
(select name from user_ u where c.creator_id = u.id) as creator_name,
(select name from category ct where c.category_id = ct.id) as category_name,
(select count(*) from community_follower cf where cf.community_id = c.id) as number_of_subscribers,
(select count(*) from post p where p.community_id = c.id) as number_of_posts,
(select count(*) from comment co, post p where c.id = p.community_id and p.id = co.post_id) as number_of_comments,
hot_rank((select count(*) from community_follower cf where cf.community_id = c.id), c.published) as hot_rank
from community c
)
select
ac.*,
u.id as user_id,
(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
union all
select
ac.*,
null as user_id,
null as subscribed
from all_community ac
;

View File

@ -25,7 +25,8 @@ pub struct Comment {
pub removed: bool, pub removed: bool,
pub read: bool, pub read: bool,
pub published: chrono::NaiveDateTime, pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime> pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
} }
#[derive(Insertable, AsChangeset, Clone)] #[derive(Insertable, AsChangeset, Clone)]
@ -37,7 +38,8 @@ pub struct CommentForm {
pub content: String, pub content: String,
pub removed: Option<bool>, pub removed: Option<bool>,
pub read: Option<bool>, pub read: Option<bool>,
pub updated: Option<chrono::NaiveDateTime> pub updated: Option<chrono::NaiveDateTime>,
pub deleted: Option<bool>,
} }
impl Crud<CommentForm> for Comment { impl Crud<CommentForm> for Comment {
@ -186,6 +188,7 @@ mod tests {
category_id: 1, category_id: 1,
creator_id: inserted_user.id, creator_id: inserted_user.id,
removed: None, removed: None,
deleted: None,
updated: None updated: None
}; };
@ -198,6 +201,7 @@ mod tests {
body: None, body: None,
community_id: inserted_community.id, community_id: inserted_community.id,
removed: None, removed: None,
deleted: None,
locked: None, locked: None,
updated: None updated: None
}; };
@ -209,6 +213,7 @@ mod tests {
creator_id: inserted_user.id, creator_id: inserted_user.id,
post_id: inserted_post.id, post_id: inserted_post.id,
removed: None, removed: None,
deleted: None,
read: None, read: None,
parent_id: None, parent_id: None,
updated: None updated: None
@ -222,6 +227,7 @@ mod tests {
creator_id: inserted_user.id, creator_id: inserted_user.id,
post_id: inserted_post.id, post_id: inserted_post.id,
removed: false, removed: false,
deleted: false,
read: false, read: false,
parent_id: None, parent_id: None,
published: inserted_comment.published, published: inserted_comment.published,
@ -234,6 +240,7 @@ mod tests {
post_id: inserted_post.id, post_id: inserted_post.id,
parent_id: Some(inserted_comment.id), parent_id: Some(inserted_comment.id),
removed: None, removed: None,
deleted: None,
read: None, read: None,
updated: None updated: None
}; };

View File

@ -17,6 +17,7 @@ table! {
read -> Bool, read -> Bool,
published -> Timestamp, published -> Timestamp,
updated -> Nullable<Timestamp>, updated -> Nullable<Timestamp>,
deleted -> Bool,
community_id -> Int4, community_id -> Int4,
banned -> Bool, banned -> Bool,
banned_from_community -> Bool, banned_from_community -> Bool,
@ -42,6 +43,7 @@ pub struct CommentView {
pub read: bool, pub read: bool,
pub published: chrono::NaiveDateTime, pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>, pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
pub community_id: i32, pub community_id: i32,
pub banned: bool, pub banned: bool,
pub banned_from_community: bool, pub banned_from_community: bool,
@ -115,6 +117,7 @@ impl CommentView {
_ => query.order_by(published.desc()) _ => query.order_by(published.desc())
}; };
// Note: deleted and removed comments are done on the front side
query query
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
@ -153,6 +156,7 @@ table! {
read -> Bool, read -> Bool,
published -> Timestamp, published -> Timestamp,
updated -> Nullable<Timestamp>, updated -> Nullable<Timestamp>,
deleted -> Bool,
community_id -> Int4, community_id -> Int4,
banned -> Bool, banned -> Bool,
banned_from_community -> Bool, banned_from_community -> Bool,
@ -179,6 +183,7 @@ pub struct ReplyView {
pub read: bool, pub read: bool,
pub published: chrono::NaiveDateTime, pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>, pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
pub community_id: i32, pub community_id: i32,
pub banned: bool, pub banned: bool,
pub banned_from_community: bool, pub banned_from_community: bool,
@ -275,6 +280,7 @@ mod tests {
category_id: 1, category_id: 1,
creator_id: inserted_user.id, creator_id: inserted_user.id,
removed: None, removed: None,
deleted: None,
updated: None updated: None
}; };
@ -287,6 +293,7 @@ mod tests {
body: None, body: None,
community_id: inserted_community.id, community_id: inserted_community.id,
removed: None, removed: None,
deleted: None,
locked: None, locked: None,
updated: None updated: None
}; };
@ -299,6 +306,7 @@ mod tests {
post_id: inserted_post.id, post_id: inserted_post.id,
parent_id: None, parent_id: None,
removed: None, removed: None,
deleted: None,
read: None, read: None,
updated: None updated: None
}; };
@ -322,6 +330,7 @@ mod tests {
community_id: inserted_community.id, community_id: inserted_community.id,
parent_id: None, parent_id: None,
removed: false, removed: false,
deleted: false,
read: false, read: false,
banned: false, banned: false,
banned_from_community: false, banned_from_community: false,
@ -344,6 +353,7 @@ mod tests {
community_id: inserted_community.id, community_id: inserted_community.id,
parent_id: None, parent_id: None,
removed: false, removed: false,
deleted: false,
read: false, read: false,
banned: false, banned: false,
banned_from_community: false, banned_from_community: false,

View File

@ -16,7 +16,8 @@ pub struct Community {
pub creator_id: i32, pub creator_id: i32,
pub removed: bool, pub removed: bool,
pub published: chrono::NaiveDateTime, pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime> pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
} }
#[derive(Insertable, AsChangeset, Clone, Serialize, Deserialize)] #[derive(Insertable, AsChangeset, Clone, Serialize, Deserialize)]
@ -28,7 +29,8 @@ pub struct CommunityForm {
pub category_id: i32, pub category_id: i32,
pub creator_id: i32, pub creator_id: i32,
pub removed: Option<bool>, pub removed: Option<bool>,
pub updated: Option<chrono::NaiveDateTime> pub updated: Option<chrono::NaiveDateTime>,
pub deleted: Option<bool>,
} }
impl Crud<CommunityForm> for Community { impl Crud<CommunityForm> for Community {
@ -245,6 +247,7 @@ mod tests {
description: None, description: None,
category_id: 1, category_id: 1,
removed: None, removed: None,
deleted: None,
updated: None, updated: None,
}; };
@ -258,6 +261,7 @@ mod tests {
description: None, description: None,
category_id: 1, category_id: 1,
removed: false, removed: false,
deleted: false,
published: inserted_community.published, published: inserted_community.published,
updated: None updated: None
}; };

View File

@ -15,11 +15,13 @@ table! {
removed -> Bool, removed -> Bool,
published -> Timestamp, published -> Timestamp,
updated -> Nullable<Timestamp>, updated -> Nullable<Timestamp>,
deleted -> Bool,
creator_name -> Varchar, creator_name -> Varchar,
category_name -> Varchar, category_name -> Varchar,
number_of_subscribers -> BigInt, number_of_subscribers -> BigInt,
number_of_posts -> BigInt, number_of_posts -> BigInt,
number_of_comments -> BigInt, number_of_comments -> BigInt,
hot_rank -> Int4,
user_id -> Nullable<Int4>, user_id -> Nullable<Int4>,
subscribed -> Nullable<Bool>, subscribed -> Nullable<Bool>,
} }
@ -85,11 +87,13 @@ pub struct CommunityView {
pub removed: bool, pub removed: bool,
pub published: chrono::NaiveDateTime, pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>, pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
pub creator_name: String, pub creator_name: String,
pub category_name: String, pub category_name: String,
pub number_of_subscribers: i64, pub number_of_subscribers: i64,
pub number_of_posts: i64, pub number_of_posts: i64,
pub number_of_comments: i64, pub number_of_comments: i64,
pub hot_rank: i32,
pub user_id: Option<i32>, pub user_id: Option<i32>,
pub subscribed: Option<bool>, pub subscribed: Option<bool>,
} }
@ -125,6 +129,7 @@ impl CommunityView {
// The view lets you pass a null user_id, if you're not logged in // The view lets you pass a null user_id, if you're not logged in
match sort { match sort {
SortType::Hot => query = query.order_by(hot_rank.desc()).filter(user_id.is_null()),
SortType::New => query = query.order_by(published.desc()).filter(user_id.is_null()), SortType::New => query = query.order_by(published.desc()).filter(user_id.is_null()),
SortType::TopAll => { SortType::TopAll => {
match from_user_id { match from_user_id {
@ -139,6 +144,7 @@ impl CommunityView {
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
.filter(removed.eq(false)) .filter(removed.eq(false))
.filter(deleted.eq(false))
.load::<Self>(conn) .load::<Self>(conn)
} }
} }

View File

@ -442,6 +442,7 @@ mod tests {
category_id: 1, category_id: 1,
creator_id: inserted_user.id, creator_id: inserted_user.id,
removed: None, removed: None,
deleted: None,
updated: None updated: None
}; };
@ -454,6 +455,7 @@ mod tests {
creator_id: inserted_user.id, creator_id: inserted_user.id,
community_id: inserted_community.id, community_id: inserted_community.id,
removed: None, removed: None,
deleted: None,
locked: None, locked: None,
updated: None updated: None
}; };
@ -465,6 +467,7 @@ mod tests {
creator_id: inserted_user.id, creator_id: inserted_user.id,
post_id: inserted_post.id, post_id: inserted_post.id,
removed: None, removed: None,
deleted: None,
read: None, read: None,
parent_id: None, parent_id: None,
updated: None updated: None

View File

@ -17,7 +17,8 @@ pub struct Post {
pub removed: bool, pub removed: bool,
pub locked: bool, pub locked: bool,
pub published: chrono::NaiveDateTime, pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime> pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
} }
#[derive(Insertable, AsChangeset, Clone)] #[derive(Insertable, AsChangeset, Clone)]
@ -30,7 +31,8 @@ pub struct PostForm {
pub community_id: i32, pub community_id: i32,
pub removed: Option<bool>, pub removed: Option<bool>,
pub locked: Option<bool>, pub locked: Option<bool>,
pub updated: Option<chrono::NaiveDateTime> pub updated: Option<chrono::NaiveDateTime>,
pub deleted: Option<bool>,
} }
impl Crud<PostForm> for Post { impl Crud<PostForm> for Post {
@ -199,6 +201,7 @@ mod tests {
category_id: 1, category_id: 1,
creator_id: inserted_user.id, creator_id: inserted_user.id,
removed: None, removed: None,
deleted: None,
updated: None updated: None
}; };
@ -211,6 +214,7 @@ mod tests {
creator_id: inserted_user.id, creator_id: inserted_user.id,
community_id: inserted_community.id, community_id: inserted_community.id,
removed: None, removed: None,
deleted: None,
locked: None, locked: None,
updated: None updated: None
}; };
@ -227,6 +231,7 @@ mod tests {
published: inserted_post.published, published: inserted_post.published,
removed: false, removed: false,
locked: false, locked: false,
deleted: false,
updated: None updated: None
}; };

View File

@ -23,9 +23,11 @@ table! {
locked -> Bool, locked -> Bool,
published -> Timestamp, published -> Timestamp,
updated -> Nullable<Timestamp>, updated -> Nullable<Timestamp>,
deleted -> Bool,
creator_name -> Varchar, creator_name -> Varchar,
community_name -> Varchar, community_name -> Varchar,
community_removed -> Bool, community_removed -> Bool,
community_deleted -> Bool,
number_of_comments -> BigInt, number_of_comments -> BigInt,
score -> BigInt, score -> BigInt,
upvotes -> BigInt, upvotes -> BigInt,
@ -53,9 +55,11 @@ pub struct PostView {
pub locked: bool, pub locked: bool,
pub published: chrono::NaiveDateTime, pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>, pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
pub creator_name: String, pub creator_name: String,
pub community_name: String, pub community_name: String,
pub community_removed: bool, pub community_removed: bool,
pub community_deleted: bool,
pub number_of_comments: i64, pub number_of_comments: i64,
pub score: i64, pub score: i64,
pub upvotes: i64, pub upvotes: i64,
@ -144,7 +148,9 @@ impl PostView {
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
.filter(removed.eq(false)) .filter(removed.eq(false))
.filter(community_removed.eq(false)); .filter(deleted.eq(false))
.filter(community_removed.eq(false))
.filter(community_deleted.eq(false));
query.load::<Self>(conn) query.load::<Self>(conn)
} }
@ -206,6 +212,7 @@ mod tests {
creator_id: inserted_user.id, creator_id: inserted_user.id,
category_id: 1, category_id: 1,
removed: None, removed: None,
deleted: None,
updated: None updated: None
}; };
@ -218,6 +225,7 @@ mod tests {
creator_id: inserted_user.id, creator_id: inserted_user.id,
community_id: inserted_community.id, community_id: inserted_community.id,
removed: None, removed: None,
deleted: None,
locked: None, locked: None,
updated: None updated: None
}; };
@ -258,9 +266,11 @@ mod tests {
creator_name: user_name.to_owned(), creator_name: user_name.to_owned(),
community_id: inserted_community.id, community_id: inserted_community.id,
removed: false, removed: false,
deleted: false,
locked: false, locked: false,
community_name: community_name.to_owned(), community_name: community_name.to_owned(),
community_removed: false, community_removed: false,
community_deleted: false,
number_of_comments: 0, number_of_comments: 0,
score: 1, score: 1,
upvotes: 1, upvotes: 1,
@ -281,12 +291,14 @@ mod tests {
url: None, url: None,
body: None, body: None,
removed: false, removed: false,
deleted: false,
locked: false, locked: false,
creator_id: inserted_user.id, creator_id: inserted_user.id,
creator_name: user_name.to_owned(), creator_name: user_name.to_owned(),
community_id: inserted_community.id, community_id: inserted_community.id,
community_name: community_name.to_owned(), community_name: community_name.to_owned(),
community_removed: false, community_removed: false,
community_deleted: false,
number_of_comments: 0, number_of_comments: 0,
score: 1, score: 1,
upvotes: 1, upvotes: 1,

View File

@ -29,7 +29,8 @@ fn chat_route(req: &HttpRequest<WsChatSessionState>) -> Result<HttpResponse, Err
req, req,
WSSession { WSSession {
id: 0, id: 0,
hb: Instant::now() hb: Instant::now(),
ip: req.connection_info().remote().unwrap_or("127.0.0.1:12345").split(":").next().unwrap_or("127.0.0.1").to_string()
}, },
) )
} }
@ -37,6 +38,7 @@ fn chat_route(req: &HttpRequest<WsChatSessionState>) -> Result<HttpResponse, Err
struct WSSession { struct WSSession {
/// unique session id /// unique session id
id: usize, id: usize,
ip: String,
/// Client must send ping at least once per 10 seconds (CLIENT_TIMEOUT), /// Client must send ping at least once per 10 seconds (CLIENT_TIMEOUT),
/// otherwise we drop connection. /// otherwise we drop connection.
hb: Instant hb: Instant
@ -61,6 +63,7 @@ impl Actor for WSSession {
.addr .addr
.send(Connect { .send(Connect {
addr: addr.recipient(), addr: addr.recipient(),
ip: self.ip.to_owned(),
}) })
.into_actor(self) .into_actor(self)
.then(|res, act, ctx| { .then(|res, act, ctx| {
@ -76,7 +79,10 @@ impl Actor for WSSession {
fn stopping(&mut self, ctx: &mut Self::Context) -> Running { fn stopping(&mut self, ctx: &mut Self::Context) -> Running {
// notify chat server // notify chat server
ctx.state().addr.do_send(Disconnect { id: self.id }); ctx.state().addr.do_send(Disconnect {
id: self.id,
ip: self.ip.to_owned(),
});
Running::Stop Running::Stop
} }
} }
@ -111,7 +117,7 @@ impl StreamHandler<ws::Message, ws::ProtocolError> for WSSession {
.addr .addr
.send(StandardMessage { .send(StandardMessage {
id: self.id, id: self.id,
msg: m msg: m,
}) })
.into_actor(self) .into_actor(self)
.then(|res, _, ctx| { .then(|res, _, ctx| {
@ -215,7 +221,7 @@ impl WSSession {
// notify chat server // notify chat server
ctx.state() ctx.state()
.addr .addr
.do_send(Disconnect { id: act.id }); .do_send(Disconnect { id: act.id, ip: act.ip.to_owned() });
// stop actor // stop actor
ctx.stop(); ctx.stop();

View File

@ -16,6 +16,7 @@ table! {
read -> Bool, read -> Bool,
published -> Timestamp, published -> Timestamp,
updated -> Nullable<Timestamp>, updated -> Nullable<Timestamp>,
deleted -> Bool,
} }
} }
@ -50,6 +51,7 @@ table! {
removed -> Bool, removed -> Bool,
published -> Timestamp, published -> Timestamp,
updated -> Nullable<Timestamp>, updated -> Nullable<Timestamp>,
deleted -> Bool,
} }
} }
@ -182,6 +184,7 @@ table! {
locked -> Bool, locked -> Bool,
published -> Timestamp, published -> Timestamp,
updated -> Nullable<Timestamp>, updated -> Nullable<Timestamp>,
deleted -> Bool,
} }
} }

View File

@ -11,6 +11,7 @@ use bcrypt::{verify};
use std::str::FromStr; use std::str::FromStr;
use diesel::PgConnection; use diesel::PgConnection;
use failure::Error; use failure::Error;
use std::time::{SystemTime};
use {Crud, Joinable, Likeable, Followable, Bannable, Saveable, establish_connection, naive_now, naive_from_unix, SortType, SearchType, has_slurs, remove_slurs}; use {Crud, Joinable, Likeable, Followable, Bannable, Saveable, establish_connection, naive_now, naive_from_unix, SortType, SearchType, has_slurs, remove_slurs};
use actions::community::*; use actions::community::*;
@ -25,9 +26,14 @@ use actions::user_view::*;
use actions::moderator_views::*; use actions::moderator_views::*;
use actions::moderator::*; use actions::moderator::*;
const RATE_LIMIT_MESSAGES: i32 = 30;
const RATE_LIMIT_PER_SECOND: i32 = 60;
const RATE_LIMIT_REGISTER_MESSAGES: i32 = 1;
const RATE_LIMIT_REGISTER_PER_SECOND: i32 = 60;
#[derive(EnumString,ToString,Debug)] #[derive(EnumString,ToString,Debug)]
pub enum UserOperation { pub enum UserOperation {
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, Search 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, Search, MarkAllAsRead
} }
#[derive(Fail, Debug)] #[derive(Fail, Debug)]
@ -48,12 +54,14 @@ pub struct WSMessage(pub String);
#[rtype(usize)] #[rtype(usize)]
pub struct Connect { pub struct Connect {
pub addr: Recipient<WSMessage>, pub addr: Recipient<WSMessage>,
pub ip: String,
} }
/// Session is disconnected /// Session is disconnected
#[derive(Message)] #[derive(Message)]
pub struct Disconnect { pub struct Disconnect {
pub id: usize, pub id: usize,
pub ip: String,
} }
/// Send message to specific room /// Send message to specific room
@ -219,6 +227,7 @@ pub struct EditComment {
creator_id: i32, creator_id: i32,
post_id: i32, post_id: i32,
removed: Option<bool>, removed: Option<bool>,
deleted: Option<bool>,
reason: Option<String>, reason: Option<String>,
read: Option<bool>, read: Option<bool>,
auth: String auth: String
@ -268,6 +277,7 @@ pub struct EditPost {
url: Option<String>, url: Option<String>,
body: Option<String>, body: Option<String>,
removed: Option<bool>, removed: Option<bool>,
deleted: Option<bool>,
locked: Option<bool>, locked: Option<bool>,
reason: Option<String>, reason: Option<String>,
auth: String auth: String
@ -288,6 +298,7 @@ pub struct EditCommunity {
description: Option<String>, description: Option<String>,
category_id: i32, category_id: i32,
removed: Option<bool>, removed: Option<bool>,
deleted: Option<bool>,
reason: Option<String>, reason: Option<String>,
expires: Option<i64>, expires: Option<i64>,
auth: String auth: String
@ -320,6 +331,7 @@ pub struct GetUserDetails {
limit: Option<i64>, limit: Option<i64>,
community_id: Option<i32>, community_id: Option<i32>,
saved_only: bool, saved_only: bool,
auth: Option<String>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -478,10 +490,27 @@ pub struct SearchResponse {
posts: Vec<PostView>, posts: Vec<PostView>,
} }
#[derive(Serialize, Deserialize)]
pub struct MarkAllAsRead {
auth: String
}
#[derive(Debug)]
pub struct RateLimitBucket {
last_checked: SystemTime,
allowance: f64
}
pub struct SessionInfo {
pub addr: Recipient<WSMessage>,
pub ip: String,
}
/// `ChatServer` manages chat rooms and responsible for coordinating chat /// `ChatServer` manages chat rooms and responsible for coordinating chat
/// session. implementation is super primitive /// session. implementation is super primitive
pub struct ChatServer { pub struct ChatServer {
sessions: HashMap<usize, Recipient<WSMessage>>, // A map from generated random ID to session addr sessions: HashMap<usize, SessionInfo>, // A map from generated random ID to session addr
rate_limits: HashMap<String, RateLimitBucket>,
rooms: HashMap<i32, HashSet<usize>>, // A map from room / post name to set of connectionIDs rooms: HashMap<i32, HashSet<usize>>, // A map from room / post name to set of connectionIDs
rng: ThreadRng, rng: ThreadRng,
} }
@ -493,6 +522,7 @@ impl Default for ChatServer {
ChatServer { ChatServer {
sessions: HashMap::new(), sessions: HashMap::new(),
rate_limits: HashMap::new(),
rooms: rooms, rooms: rooms,
rng: rand::thread_rng(), rng: rand::thread_rng(),
} }
@ -505,8 +535,8 @@ impl ChatServer {
if let Some(sessions) = self.rooms.get(&room) { if let Some(sessions) = self.rooms.get(&room) {
for id in sessions { for id in sessions {
if *id != skip_id { if *id != skip_id {
if let Some(addr) = self.sessions.get(id) { if let Some(info) = self.sessions.get(id) {
let _ = addr.do_send(WSMessage(message.to_owned())); let _ = info.addr.do_send(WSMessage(message.to_owned()));
} }
} }
} }
@ -531,8 +561,51 @@ impl ChatServer {
Ok(()) Ok(())
} }
fn check_rate_limit_register(&mut self, addr: usize) -> Result<(), Error> {
self.check_rate_limit_full(addr, RATE_LIMIT_REGISTER_MESSAGES, RATE_LIMIT_REGISTER_PER_SECOND)
}
fn check_rate_limit(&mut self, addr: usize) -> Result<(), Error> {
self.check_rate_limit_full(addr, RATE_LIMIT_MESSAGES, RATE_LIMIT_PER_SECOND)
}
fn check_rate_limit_full(&mut self, addr: usize, rate: i32, per: i32) -> Result<(), Error> {
if let Some(info) = self.sessions.get(&addr) {
if let Some(rate_limit) = self.rate_limits.get_mut(&info.ip) {
// The initial value
if rate_limit.allowance == -2f64 {
rate_limit.allowance = rate as f64;
};
let current = SystemTime::now();
let time_passed = current.duration_since(rate_limit.last_checked)?.as_secs() as f64;
rate_limit.last_checked = current;
rate_limit.allowance += time_passed * (rate as f64 / per as f64);
if rate_limit.allowance > rate as f64 {
rate_limit.allowance = rate as f64;
}
if rate_limit.allowance < 1.0 {
println!("Rate limited IP: {}, time_passed: {}, allowance: {}", &info.ip, time_passed, rate_limit.allowance);
Err(ErrorMessage {
op: "Rate Limit".to_string(),
message: format!("Too many requests. {} per {} seconds", rate, per),
})?
} else {
rate_limit.allowance -= 1.0;
Ok(())
}
} else {
Ok(())
}
} else {
Ok(())
}
}
} }
/// Make actor from `ChatServer` /// Make actor from `ChatServer`
impl Actor for ChatServer { impl Actor for ChatServer {
/// We are going to use simple Context, we just need ability to communicate /// We are going to use simple Context, we just need ability to communicate
@ -546,14 +619,30 @@ impl Actor for ChatServer {
impl Handler<Connect> for ChatServer { impl Handler<Connect> for ChatServer {
type Result = usize; type Result = usize;
fn handle(&mut self, msg: Connect, _: &mut Context<Self>) -> Self::Result { fn handle(&mut self, msg: Connect, _ctx: &mut Context<Self>) -> Self::Result {
// notify all users in same room // notify all users in same room
// self.send_room_message(&"Main".to_owned(), "Someone joined", 0); // self.send_room_message(&"Main".to_owned(), "Someone joined", 0);
// register session with random id // register session with random id
let id = self.rng.gen::<usize>(); let id = self.rng.gen::<usize>();
self.sessions.insert(id, msg.addr); println!("{} joined", &msg.ip);
self.sessions.insert(id, SessionInfo {
addr: msg.addr,
ip: msg.ip.to_owned(),
});
if self.rate_limits.get(&msg.ip).is_none() {
self.rate_limits.insert(msg.ip, RateLimitBucket {
last_checked: SystemTime::now(),
allowance: -2f64,
});
}
// for (k,v) in &self.rate_limits {
// println!("{}: {:?}", k,v);
// }
// auto join session to Main room // auto join session to Main room
// self.rooms.get_mut(&"Main".to_owned()).unwrap().insert(id); // self.rooms.get_mut(&"Main".to_owned()).unwrap().insert(id);
@ -563,6 +652,7 @@ impl Handler<Connect> for ChatServer {
} }
} }
/// Handler for Disconnect message. /// Handler for Disconnect message.
impl Handler<Disconnect> for ChatServer { impl Handler<Disconnect> for ChatServer {
type Result = (); type Result = ();
@ -728,6 +818,10 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
let search: Search = serde_json::from_str(data)?; let search: Search = serde_json::from_str(data)?;
search.perform(chat, msg.id) search.perform(chat, msg.id)
}, },
UserOperation::MarkAllAsRead => {
let mark_all_as_read: MarkAllAsRead = serde_json::from_str(data)?;
mark_all_as_read.perform(chat, msg.id)
},
} }
} }
@ -781,10 +875,12 @@ impl Perform for Register {
fn op_type(&self) -> UserOperation { fn op_type(&self) -> UserOperation {
UserOperation::Register UserOperation::Register
} }
fn perform(&self, _chat: &mut ChatServer, _addr: usize) -> Result<String, Error> { fn perform(&self, chat: &mut ChatServer, addr: usize) -> Result<String, Error> {
let conn = establish_connection(); let conn = establish_connection();
chat.check_rate_limit_register(addr)?;
// Make sure passwords match // Make sure passwords match
if &self.password != &self.password_verify { if &self.password != &self.password_verify {
return Err(self.error("Passwords do not match."))? return Err(self.error("Passwords do not match."))?
@ -871,10 +967,12 @@ impl Perform for CreateCommunity {
UserOperation::CreateCommunity UserOperation::CreateCommunity
} }
fn perform(&self, _chat: &mut ChatServer, _addr: usize) -> Result<String, Error> { fn perform(&self, chat: &mut ChatServer, addr: usize) -> Result<String, Error> {
let conn = establish_connection(); let conn = establish_connection();
chat.check_rate_limit_register(addr)?;
let claims = match Claims::decode(&self.auth) { let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims, Ok(claims) => claims.claims,
Err(_e) => { Err(_e) => {
@ -903,6 +1001,7 @@ impl Perform for CreateCommunity {
category_id: self.category_id, category_id: self.category_id,
creator_id: user_id, creator_id: user_id,
removed: None, removed: None,
deleted: None,
updated: None, updated: None,
}; };
@ -1016,10 +1115,12 @@ impl Perform for CreatePost {
UserOperation::CreatePost UserOperation::CreatePost
} }
fn perform(&self, _chat: &mut ChatServer, _addr: usize) -> Result<String, Error> { fn perform(&self, chat: &mut ChatServer, addr: usize) -> Result<String, Error> {
let conn = establish_connection(); let conn = establish_connection();
chat.check_rate_limit_register(addr)?;
let claims = match Claims::decode(&self.auth) { let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims, Ok(claims) => claims.claims,
Err(_e) => { Err(_e) => {
@ -1051,6 +1152,7 @@ impl Perform for CreatePost {
community_id: self.community_id, community_id: self.community_id,
creator_id: user_id, creator_id: user_id,
removed: None, removed: None,
deleted: None,
locked: None, locked: None,
updated: None updated: None
}; };
@ -1227,6 +1329,8 @@ impl Perform for CreateComment {
let conn = establish_connection(); let conn = establish_connection();
chat.check_rate_limit(addr)?;
let claims = match Claims::decode(&self.auth) { let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims, Ok(claims) => claims.claims,
Err(_e) => { Err(_e) => {
@ -1255,6 +1359,7 @@ impl Perform for CreateComment {
post_id: self.post_id, post_id: self.post_id,
creator_id: user_id, creator_id: user_id,
removed: None, removed: None,
deleted: None,
read: None, read: None,
updated: None updated: None
}; };
@ -1371,9 +1476,10 @@ impl Perform for EditComment {
post_id: self.post_id, post_id: self.post_id,
creator_id: self.creator_id, creator_id: self.creator_id,
removed: self.removed.to_owned(), removed: self.removed.to_owned(),
deleted: self.deleted.to_owned(),
read: self.read.to_owned(), read: self.read.to_owned(),
updated: if self.read.is_some() { orig_comment.updated } else {Some(naive_now())} updated: if self.read.is_some() { orig_comment.updated } else {Some(naive_now())}
}; };
let _updated_comment = match Comment::update(&conn, self.edit_id, &comment_form) { let _updated_comment = match Comment::update(&conn, self.edit_id, &comment_form) {
Ok(comment) => comment, Ok(comment) => comment,
@ -1483,6 +1589,8 @@ impl Perform for CreateCommentLike {
let conn = establish_connection(); let conn = establish_connection();
chat.check_rate_limit(addr)?;
let claims = match Claims::decode(&self.auth) { let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims, Ok(claims) => claims.claims,
Err(_e) => { Err(_e) => {
@ -1611,10 +1719,12 @@ impl Perform for CreatePostLike {
UserOperation::CreatePostLike UserOperation::CreatePostLike
} }
fn perform(&self, _chat: &mut ChatServer, _addr: usize) -> Result<String, Error> { fn perform(&self, chat: &mut ChatServer, addr: usize) -> Result<String, Error> {
let conn = establish_connection(); let conn = establish_connection();
chat.check_rate_limit(addr)?;
let claims = match Claims::decode(&self.auth) { let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims, Ok(claims) => claims.claims,
Err(_e) => { Err(_e) => {
@ -1734,6 +1844,7 @@ impl Perform for EditPost {
creator_id: self.creator_id.to_owned(), creator_id: self.creator_id.to_owned(),
community_id: self.community_id, community_id: self.community_id,
removed: self.removed.to_owned(), removed: self.removed.to_owned(),
deleted: self.deleted.to_owned(),
locked: self.locked.to_owned(), locked: self.locked.to_owned(),
updated: Some(naive_now()) updated: Some(naive_now())
}; };
@ -1899,6 +2010,7 @@ impl Perform for EditCommunity {
category_id: self.category_id.to_owned(), category_id: self.category_id.to_owned(),
creator_id: user_id, creator_id: user_id,
removed: self.removed.to_owned(), removed: self.removed.to_owned(),
deleted: self.deleted.to_owned(),
updated: Some(naive_now()) updated: Some(naive_now())
}; };
@ -2051,6 +2163,19 @@ impl Perform for GetUserDetails {
let conn = establish_connection(); 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 //TODO add save
let sort = SortType::from_str(&self.sort)?; let sort = SortType::from_str(&self.sort)?;
@ -2081,7 +2206,7 @@ impl Perform for GetUserDetails {
self.community_id, self.community_id,
Some(user_details_id), Some(user_details_id),
None, None,
None, user_id,
self.saved_only, self.saved_only,
false, false,
self.page, self.page,
@ -2103,7 +2228,7 @@ impl Perform for GetUserDetails {
None, None,
Some(user_details_id), Some(user_details_id),
None, None,
None, user_id,
self.saved_only, self.saved_only,
self.page, self.page,
self.limit)? self.limit)?
@ -2663,7 +2788,7 @@ impl Perform for Search {
}, },
SearchType::Comments => { SearchType::Comments => {
comments = CommentView::list(&conn, comments = CommentView::list(&conn,
&sort, &sort,
None, None,
None, None,
Some(self.q.to_owned()), Some(self.q.to_owned()),
@ -2685,7 +2810,7 @@ impl Perform for Search {
self.page, self.page,
self.limit)?; self.limit)?;
comments = CommentView::list(&conn, comments = CommentView::list(&conn,
&sort, &sort,
None, None,
None, None,
Some(self.q.to_owned()), Some(self.q.to_owned()),
@ -2709,3 +2834,57 @@ impl Perform for Search {
) )
} }
} }
impl Perform for MarkAllAsRead {
fn op_type(&self) -> UserOperation {
UserOperation::MarkAllAsRead
}
fn perform(&self, _chat: &mut ChatServer, _addr: usize) -> Result<String, Error> {
let conn = establish_connection();
let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims,
Err(_e) => {
return Err(self.error("Not logged in."))?
}
};
let user_id = claims.id;
let replies = ReplyView::get_replies(&conn, user_id, &SortType::New, true, Some(1), Some(999))?;
for reply in &replies {
let comment_form = CommentForm {
content: reply.to_owned().content,
parent_id: reply.to_owned().parent_id,
post_id: reply.to_owned().post_id,
creator_id: reply.to_owned().creator_id,
removed: None,
deleted: None,
read: Some(true),
updated: reply.to_owned().updated
};
let _updated_comment = match Comment::update(&conn, reply.id, &comment_form) {
Ok(comment) => comment,
Err(_e) => {
return Err(self.error("Couldn't update Comment"))?
}
};
}
let replies = ReplyView::get_replies(&conn, user_id, &SortType::New, true, Some(1), Some(999))?;
Ok(
serde_json::to_string(
&GetRepliesResponse {
op: self.op_type().to_string(),
replies: replies,
}
)?
)
}
}

View File

@ -45,7 +45,7 @@ Sparky.task('config', _ => {
// Sparky.task('version', _ => setVersion()); // Sparky.task('version', _ => setVersion());
Sparky.task('clean', _ => Sparky.src('dist/').clean('dist/')); Sparky.task('clean', _ => Sparky.src('dist/').clean('dist/'));
Sparky.task('env', _ => (isProduction = true)); Sparky.task('env', _ => (isProduction = true));
Sparky.task('copy-assets', () => Sparky.src('assets/*.svg').dest('dist/')); Sparky.task('copy-assets', () => Sparky.src('assets/*.*').dest('dist/'));
Sparky.task('dev', ['clean', 'config', 'copy-assets'], _ => { Sparky.task('dev', ['clean', 'config', 'copy-assets'], _ => {
fuse.dev(); fuse.dev();
app.hmr().watch(); app.hmr().watch();

View File

@ -19,6 +19,7 @@
"@types/js-cookie": "^2.2.1", "@types/js-cookie": "^2.2.1",
"@types/jwt-decode": "^2.2.1", "@types/jwt-decode": "^2.2.1",
"@types/markdown-it": "^0.0.7", "@types/markdown-it": "^0.0.7",
"@types/markdown-it-container": "^2.0.2",
"autosize": "^4.0.2", "autosize": "^4.0.2",
"classcat": "^1.1.3", "classcat": "^1.1.3",
"dotenv": "^6.1.0", "dotenv": "^6.1.0",
@ -27,6 +28,7 @@
"js-cookie": "^2.2.0", "js-cookie": "^2.2.0",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",
"markdown-it": "^8.4.2", "markdown-it": "^8.4.2",
"markdown-it-container": "^2.0.0",
"moment": "^2.24.0", "moment": "^2.24.0",
"rxjs": "^6.4.0" "rxjs": "^6.4.0"
}, },

View File

@ -56,7 +56,7 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
<form onSubmit={linkEvent(this, this.handleCommentSubmit)}> <form onSubmit={linkEvent(this, this.handleCommentSubmit)}>
<div class="form-group row"> <div class="form-group row">
<div class="col-sm-12"> <div class="col-sm-12">
<textarea class="form-control" value={this.state.commentForm.content} onInput={linkEvent(this, this.handleCommentContentChange)} required disabled={this.props.disabled} rows={2} /> <textarea class="form-control" value={this.state.commentForm.content} onInput={linkEvent(this, this.handleCommentContentChange)} required disabled={this.props.disabled} rows={2} maxLength={10000} />
</div> </div>
</div> </div>
<div class="row"> <div class="row">

View File

@ -92,7 +92,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
{this.state.showEdit && <CommentForm node={node} edit onReplyCancel={this.handleReplyCancel} disabled={this.props.locked} />} {this.state.showEdit && <CommentForm node={node} edit onReplyCancel={this.handleReplyCancel} disabled={this.props.locked} />}
{!this.state.showEdit && {!this.state.showEdit &&
<div> <div>
<div className="md-div" dangerouslySetInnerHTML={mdToHtml(node.comment.removed ? '*removed*' : node.comment.content)} /> <div className="md-div" dangerouslySetInnerHTML={mdToHtml(node.comment.removed ? '*removed*' : node.comment.deleted ? '*deleted*' : node.comment.content)} />
<ul class="list-inline mb-1 text-muted small font-weight-bold"> <ul class="list-inline mb-1 text-muted small font-weight-bold">
{UserService.Instance.user && !this.props.viewOnly && {UserService.Instance.user && !this.props.viewOnly &&
<> <>
@ -108,7 +108,9 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
<span class="pointer" onClick={linkEvent(this, this.handleEditClick)}>edit</span> <span class="pointer" onClick={linkEvent(this, this.handleEditClick)}>edit</span>
</li> </li>
<li className="list-inline-item"> <li className="list-inline-item">
<span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>delete</span> <span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>
{!this.props.node.comment.deleted ? 'delete' : 'restore'}
</span>
</li> </li>
</> </>
} }
@ -252,11 +254,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
handleDeleteClick(i: CommentNode) { handleDeleteClick(i: CommentNode) {
let deleteForm: CommentFormI = { let deleteForm: CommentFormI = {
content: '*deleted*', content: i.props.node.comment.content,
edit_id: i.props.node.comment.id, edit_id: i.props.node.comment.id,
creator_id: i.props.node.comment.creator_id, creator_id: i.props.node.comment.creator_id,
post_id: i.props.node.comment.post_id, post_id: i.props.node.comment.post_id,
parent_id: i.props.node.comment.parent_id, parent_id: i.props.node.comment.parent_id,
deleted: !i.props.node.comment.deleted,
auth: null auth: null
}; };
WebSocketService.Instance.editComment(deleteForm); WebSocketService.Instance.editComment(deleteForm);

View File

@ -10,6 +10,7 @@ declare const Sortable: any;
interface CommunitiesState { interface CommunitiesState {
communities: Array<Community>; communities: Array<Community>;
page: number;
loading: boolean; loading: boolean;
} }
@ -17,7 +18,8 @@ export class Communities extends Component<any, CommunitiesState> {
private subscription: Subscription; private subscription: Subscription;
private emptyState: CommunitiesState = { private emptyState: CommunitiesState = {
communities: [], communities: [],
loading: true loading: true,
page: this.getPageFromProps(this.props),
} }
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -31,13 +33,12 @@ export class Communities extends Component<any, CommunitiesState> {
() => console.log('complete') () => console.log('complete')
); );
let listCommunitiesForm: ListCommunitiesForm = { this.refetch();
sort: SortType[SortType.TopAll],
limit: 100,
}
WebSocketService.Instance.listCommunities(listCommunitiesForm); }
getPageFromProps(props: any): number {
return (props.match.params.page) ? Number(props.match.params.page) : 1;
} }
componentWillUnmount() { componentWillUnmount() {
@ -45,40 +46,49 @@ export class Communities extends Component<any, CommunitiesState> {
} }
componentDidMount() { componentDidMount() {
document.title = "Forums - Lemmy"; document.title = "Communities - Lemmy";
let table = document.querySelector('#community_table'); let table = document.querySelector('#community_table');
Sortable.initTable(table); Sortable.initTable(table);
} }
// Necessary for back button for some reason
componentWillReceiveProps(nextProps: any) {
if (nextProps.history.action == 'POP') {
this.state = this.emptyState;
this.state.page = this.getPageFromProps(nextProps);
this.refetch();
}
}
render() { render() {
return ( return (
<div class="container"> <div class="container">
{this.state.loading ? {this.state.loading ?
<h5 class=""><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> : <h5 class=""><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
<div> <div>
<h5>Forums</h5> <h5>List of communities</h5>
<div class="table-responsive"> <div class="table-responsive">
<table id="community_table" class="table table-sm table-hover"> <table id="community_table" class="table table-sm table-hover">
<thead class="pointer"> <thead class="pointer">
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Title</th> <th class="d-none d-lg-table-cell">Title</th>
<th>Category</th> <th>Category</th>
<th class="text-right d-none d-md-table-cell">Subscribers</th> <th class="text-right">Subscribers</th>
<th class="text-right d-none d-md-table-cell">Posts</th> <th class="text-right d-none d-lg-table-cell">Posts</th>
<th class="text-right d-none d-md-table-cell">Comments</th> <th class="text-right d-none d-lg-table-cell">Comments</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{this.state.communities.map(community => {this.state.communities.map(community =>
<tr> <tr>
<td><Link to={`/f/${community.name}`}>{community.name}</Link></td> <td><Link to={`/c/${community.name}`}>{community.name}</Link></td>
<td>{community.title}</td> <td class="d-none d-lg-table-cell">{community.title}</td>
<td>{community.category_name}</td> <td>{community.category_name}</td>
<td class="text-right d-none d-md-table-cell">{community.number_of_subscribers}</td> <td class="text-right">{community.number_of_subscribers}</td>
<td class="text-right d-none d-md-table-cell">{community.number_of_posts}</td> <td class="text-right d-none d-lg-table-cell">{community.number_of_posts}</td>
<td class="text-right d-none d-md-table-cell">{community.number_of_comments}</td> <td class="text-right d-none d-lg-table-cell">{community.number_of_comments}</td>
<td class="text-right"> <td class="text-right">
{community.subscribed ? {community.subscribed ?
<span class="pointer btn-link" onClick={linkEvent(community.id, this.handleUnsubscribe)}>Unsubscribe</span> : <span class="pointer btn-link" onClick={linkEvent(community.id, this.handleUnsubscribe)}>Unsubscribe</span> :
@ -90,12 +100,42 @@ export class Communities extends Component<any, CommunitiesState> {
</tbody> </tbody>
</table> </table>
</div> </div>
{this.paginator()}
</div> </div>
} }
</div> </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>
);
}
updateUrl() {
this.props.history.push(`/communities/page/${this.state.page}`);
}
nextPage(i: Communities) {
i.state.page++;
i.setState(i.state);
i.updateUrl();
i.refetch();
}
prevPage(i: Communities) {
i.state.page--;
i.setState(i.state);
i.updateUrl();
i.refetch();
}
handleUnsubscribe(communityId: number) { handleUnsubscribe(communityId: number) {
let form: FollowCommunityForm = { let form: FollowCommunityForm = {
community_id: communityId, community_id: communityId,
@ -112,6 +152,17 @@ export class Communities extends Component<any, CommunitiesState> {
WebSocketService.Instance.followCommunity(form); WebSocketService.Instance.followCommunity(form);
} }
refetch() {
let listCommunitiesForm: ListCommunitiesForm = {
sort: SortType[SortType.TopAll],
limit: 100,
page: this.state.page,
}
WebSocketService.Instance.listCommunities(listCommunitiesForm);
}
parseMessage(msg: any) { parseMessage(msg: any) {
console.log(msg); console.log(msg);
let op: UserOperation = msgOp(msg); let op: UserOperation = msgOp(msg);

View File

@ -88,7 +88,7 @@ export class CommunityForm extends Component<CommunityFormProps, CommunityFormSt
<div class="form-group row"> <div class="form-group row">
<label class="col-12 col-form-label">Sidebar</label> <label class="col-12 col-form-label">Sidebar</label>
<div class="col-12"> <div class="col-12">
<textarea value={this.state.communityForm.description} onInput={linkEvent(this, this.handleCommunityDescriptionChange)} class="form-control" rows={3} /> <textarea value={this.state.communityForm.description} onInput={linkEvent(this, this.handleCommunityDescriptionChange)} class="form-control" rows={3} maxLength={10000} />
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
@ -120,10 +120,7 @@ export class CommunityForm extends Component<CommunityFormProps, CommunityFormSt
if (i.props.community) { if (i.props.community) {
WebSocketService.Instance.editCommunity(i.state.communityForm); WebSocketService.Instance.editCommunity(i.state.communityForm);
} else { } else {
WebSocketService.Instance.createCommunity(i.state.communityForm);
setTimeout(function(){
WebSocketService.Instance.createCommunity(i.state.communityForm);
}, 10000);
} }
i.setState(i.state); i.setState(i.state);
} }

View File

@ -1,11 +1,11 @@
import { Component } from 'inferno'; import { Component, linkEvent } from 'inferno';
import { Subscription } from "rxjs"; import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators'; import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, Community as CommunityI, GetCommunityResponse, CommunityResponse, CommunityUser, UserView } from '../interfaces'; import { UserOperation, Community as CommunityI, GetCommunityResponse, CommunityResponse, CommunityUser, UserView, SortType, Post, GetPostsForm, ListingType, GetPostsResponse, CreatePostLikeResponse } from '../interfaces';
import { WebSocketService } from '../services'; import { WebSocketService } from '../services';
import { PostListings } from './post-listings'; import { PostListings } from './post-listings';
import { Sidebar } from './sidebar'; import { Sidebar } from './sidebar';
import { msgOp } from '../utils'; import { msgOp, routeSortTypeToEnum, fetchLimit } from '../utils';
interface State { interface State {
community: CommunityI; community: CommunityI;
@ -14,6 +14,9 @@ interface State {
moderators: Array<CommunityUser>; moderators: Array<CommunityUser>;
admins: Array<UserView>; admins: Array<UserView>;
loading: boolean; loading: boolean;
posts: Array<Post>;
sort: SortType;
page: number;
} }
export class Community extends Component<any, State> { export class Community extends Component<any, State> {
@ -38,7 +41,20 @@ export class Community extends Component<any, State> {
admins: [], admins: [],
communityId: Number(this.props.match.params.id), communityId: Number(this.props.match.params.id),
communityName: this.props.match.params.name, communityName: this.props.match.params.name,
loading: true loading: true,
posts: [],
sort: this.getSortTypeFromProps(this.props),
page: this.getPageFromProps(this.props),
}
getSortTypeFromProps(props: any): SortType {
return (props.match.params.sort) ?
routeSortTypeToEnum(props.match.params.sort) :
SortType.Hot;
}
getPageFromProps(props: any): number {
return (props.match.params.page) ? Number(props.match.params.page) : 1;
} }
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -66,6 +82,16 @@ export class Community extends Component<any, State> {
this.subscription.unsubscribe(); this.subscription.unsubscribe();
} }
// Necessary for back button for some reason
componentWillReceiveProps(nextProps: any) {
if (nextProps.history.action == 'POP') {
this.state = this.emptyState;
this.state.sort = this.getSortTypeFromProps(nextProps);
this.state.page = this.getPageFromProps(nextProps);
this.fetchPosts();
}
}
render() { render() {
return ( return (
<div class="container"> <div class="container">
@ -78,7 +104,9 @@ export class Community extends Component<any, State> {
<small className="ml-2 text-muted font-italic">removed</small> <small className="ml-2 text-muted font-italic">removed</small>
} }
</h5> </h5>
{this.state.community && <PostListings communityId={this.state.community.id} />} {this.selects()}
<PostListings posts={this.state.posts} />
{this.paginator()}
</div> </div>
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<Sidebar <Sidebar
@ -93,6 +121,72 @@ export class Community extends Component<any, State> {
) )
} }
selects() {
return (
<div className="mb-2">
<select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select custom-select-sm w-auto">
<option disabled>Sort Type</option>
<option value={SortType.Hot}>Hot</option>
<option value={SortType.New}>New</option>
<option disabled></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>
)
}
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: Community) {
i.state.page++;
i.setState(i.state);
i.updateUrl();
i.fetchPosts();
}
prevPage(i: Community) {
i.state.page--;
i.setState(i.state);
i.updateUrl();
i.fetchPosts();
}
handleSortChange(i: Community, event: any) {
i.state.sort = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.updateUrl();
i.fetchPosts();
}
updateUrl() {
let sortStr = SortType[this.state.sort].toLowerCase();
this.props.history.push(`/c/${this.state.community.name}/sort/${sortStr}/page/${this.state.page}`);
}
fetchPosts() {
let getPostsForm: GetPostsForm = {
page: this.state.page,
limit: fetchLimit,
sort: SortType[this.state.sort],
type_: ListingType[ListingType.Community],
community_id: this.state.community.id,
}
WebSocketService.Instance.getPosts(getPostsForm);
}
parseMessage(msg: any) { parseMessage(msg: any) {
console.log(msg); console.log(msg);
@ -105,9 +199,9 @@ export class Community extends Component<any, State> {
this.state.community = res.community; this.state.community = res.community;
this.state.moderators = res.moderators; this.state.moderators = res.moderators;
this.state.admins = res.admins; this.state.admins = res.admins;
this.state.loading = false; document.title = `/c/${this.state.community.name} - Lemmy`;
document.title = `/f/${this.state.community.name} - Lemmy`;
this.setState(this.state); this.setState(this.state);
this.fetchPosts();
} else if (op == UserOperation.EditCommunity) { } else if (op == UserOperation.EditCommunity) {
let res: CommunityResponse = msg; let res: CommunityResponse = msg;
this.state.community = res.community; this.state.community = res.community;
@ -117,6 +211,19 @@ export class Community extends Component<any, State> {
this.state.community.subscribed = res.community.subscribed; this.state.community.subscribed = res.community.subscribed;
this.state.community.number_of_subscribers = res.community.number_of_subscribers; this.state.community.number_of_subscribers = res.community.number_of_subscribers;
this.setState(this.state); this.setState(this.state);
} else if (op == UserOperation.GetPosts) {
let res: GetPostsResponse = msg;
this.state.posts = res.posts;
this.state.loading = false;
this.setState(this.state);
} else if (op == UserOperation.CreatePostLike) {
let res: CreatePostLikeResponse = msg;
let found = this.state.posts.find(c => c.id == res.post.id);
found.my_vote = res.post.my_vote;
found.score = res.post.score;
found.upvotes = res.post.upvotes;
found.downvotes = res.post.downvotes;
this.setState(this.state);
} }
} }
} }

View File

@ -10,7 +10,7 @@ export class CreateCommunity extends Component<any, any> {
} }
componentDidMount() { componentDidMount() {
document.title = "Create Forum - Lemmy"; document.title = "Create Community - Lemmy";
} }
render() { render() {
@ -18,7 +18,7 @@ export class CreateCommunity extends Component<any, any> {
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-lg-6 mb-4"> <div class="col-12 col-lg-6 mb-4">
<h5>Create Forum</h5> <h5>Create Community</h5>
<CommunityForm onCreate={this.handleCommunityCreate}/> <CommunityForm onCreate={this.handleCommunityCreate}/>
</div> </div>
</div> </div>
@ -27,7 +27,7 @@ export class CreateCommunity extends Component<any, any> {
} }
handleCommunityCreate(community: Community) { handleCommunityCreate(community: Community) {
this.props.history.push(`/f/${community.name}`); this.props.history.push(`/c/${community.name}`);
} }
} }

View File

@ -18,13 +18,23 @@ export class CreatePost extends Component<any, any> {
<div class="row"> <div class="row">
<div class="col-12 col-lg-6 mb-4"> <div class="col-12 col-lg-6 mb-4">
<h5>Create a Post</h5> <h5>Create a Post</h5>
<PostForm onCreate={this.handlePostCreate}/> <PostForm onCreate={this.handlePostCreate} prevCommunityName={this.prevCommunityName} />
</div> </div>
</div> </div>
</div> </div>
) )
} }
get prevCommunityName(): string {
if (this.props.location.state) {
let lastLocation = this.props.location.state.prevPath;
if (lastLocation.includes("/c/")) {
return lastLocation.split("/c/")[1];
}
}
return undefined;
}
handlePostCreate(id: number) { handlePostCreate(id: number) {
this.props.history.push(`/post/${id}`); this.props.history.push(`/post/${id}`);
} }

View File

@ -1,24 +0,0 @@
import { Component } from 'inferno';
import { Main } from './main';
import { ListingType } from '../interfaces';
export class Home extends Component<any, any> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
return (
<Main type={this.listType()}/>
)
}
componentDidMount() {
document.title = "Lemmy";
}
listType(): ListingType {
return (this.props.match.path == '/all') ? ListingType.All : ListingType.Subscribed;
}
}

View File

@ -58,7 +58,16 @@ export class Inbox extends Component<any, InboxState> {
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<h5>Inbox for <Link to={`/u/${user.username}`}>{user.username}</Link></h5> <h5 class="mb-0">
<span>Inbox for <Link to={`/u/${user.username}`}>{user.username}</Link></span>
</h5>
{this.state.replies.length > 0 && this.state.unreadType == UnreadType.Unread &&
<ul class="list-inline mb-1 text-muted small font-weight-bold">
<li className="list-inline-item">
<span class="pointer" onClick={this.markAllAsRead}>mark all as read</span>
</li>
</ul>
}
{this.selects()} {this.selects()}
{this.replies()} {this.replies()}
{this.paginator()} {this.paginator()}
@ -71,12 +80,12 @@ export class Inbox extends Component<any, InboxState> {
selects() { selects() {
return ( return (
<div className="mb-2"> <div className="mb-2">
<select value={this.state.unreadType} onChange={linkEvent(this, this.handleUnreadTypeChange)} class="custom-select w-auto"> <select value={this.state.unreadType} onChange={linkEvent(this, this.handleUnreadTypeChange)} class="custom-select custom-select-sm w-auto">
<option disabled>Type</option> <option disabled>Type</option>
<option value={UnreadType.Unread}>Unread</option> <option value={UnreadType.Unread}>Unread</option>
<option value={UnreadType.All}>All</option> <option value={UnreadType.All}>All</option>
</select> </select>
<select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select w-auto ml-2"> <select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select custom-select-sm w-auto ml-2">
<option disabled>Sort Type</option> <option disabled>Sort Type</option>
<option value={SortType.New}>New</option> <option value={SortType.New}>New</option>
<option value={SortType.TopDay}>Top Day</option> <option value={SortType.TopDay}>Top Day</option>
@ -147,13 +156,17 @@ export class Inbox extends Component<any, InboxState> {
i.refetch(); i.refetch();
} }
markAllAsRead() {
WebSocketService.Instance.markAllAsRead();
}
parseMessage(msg: any) { parseMessage(msg: any) {
console.log(msg); console.log(msg);
let op: UserOperation = msgOp(msg); let op: UserOperation = msgOp(msg);
if (msg.error) { if (msg.error) {
alert(msg.error); alert(msg.error);
return; return;
} else if (op == UserOperation.GetReplies) { } else if (op == UserOperation.GetReplies || op == UserOperation.MarkAllAsRead) {
let res: GetRepliesResponse = msg; let res: GetRepliesResponse = msg;
this.state.replies = res.replies; this.state.replies = res.replies;
this.sendRepliesCount(); this.sendRepliesCount();
@ -165,6 +178,7 @@ export class Inbox extends Component<any, InboxState> {
found.content = res.comment.content; found.content = res.comment.content;
found.updated = res.comment.updated; found.updated = res.comment.updated;
found.removed = res.comment.removed; found.removed = res.comment.removed;
found.deleted = res.comment.deleted;
found.upvotes = res.comment.upvotes; found.upvotes = res.comment.upvotes;
found.downvotes = res.comment.downvotes; found.downvotes = res.comment.downvotes;
found.score = res.comment.score; found.score = res.comment.score;

View File

@ -95,7 +95,7 @@ export class Login extends Component<any, State> {
</div> </div>
</div> </div>
</form> </form>
Forgot your password or deleted your account? Reset your password. TODO {/* Forgot your password or deleted your account? Reset your password. TODO */}
</div> </div>
); );
} }
@ -161,7 +161,6 @@ export class Login extends Component<any, State> {
event.preventDefault(); event.preventDefault();
i.state.registerLoading = true; i.state.registerLoading = true;
i.setState(i.state); i.setState(i.state);
event.preventDefault();
let endTimer = new Date().getTime(); let endTimer = new Date().getTime();
let elapsed = endTimer - i.state.registerForm.spam_timeri; let elapsed = endTimer - i.state.registerForm.spam_timeri;
@ -209,14 +208,14 @@ export class Login extends Component<any, State> {
return; return;
} else { } else {
if (op == UserOperation.Login) { if (op == UserOperation.Login) {
this.state.loginLoading = false; this.state = this.emptyState;
this.state.registerLoading = false; this.setState(this.state);
let res: LoginResponse = msg; let res: LoginResponse = msg;
UserService.Instance.login(res); UserService.Instance.login(res);
this.props.history.push('/'); this.props.history.push('/');
} else if (op == UserOperation.Register) { } else if (op == UserOperation.Register) {
this.state.loginLoading = false; this.state = this.emptyState;
this.state.registerLoading = false; this.setState(this.state);
let res: LoginResponse = msg; let res: LoginResponse = msg;
UserService.Instance.login(res); UserService.Instance.login(res);
this.props.history.push('/communities'); this.props.history.push('/communities');

View File

@ -2,16 +2,11 @@ import { Component, linkEvent } from 'inferno';
import { Link } from 'inferno-router'; import { Link } from 'inferno-router';
import { Subscription } from "rxjs"; import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators'; import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, CommunityUser, GetFollowedCommunitiesResponse, ListCommunitiesForm, ListCommunitiesResponse, Community, SortType, GetSiteResponse, ListingType, SiteResponse } from '../interfaces'; import { UserOperation, CommunityUser, GetFollowedCommunitiesResponse, ListCommunitiesForm, ListCommunitiesResponse, Community, SortType, GetSiteResponse, ListingType, SiteResponse, GetPostsResponse, CreatePostLikeResponse, Post, GetPostsForm } from '../interfaces';
import { WebSocketService, UserService } from '../services'; import { WebSocketService, UserService } from '../services';
import { PostListings } from './post-listings'; import { PostListings } from './post-listings';
import { SiteForm } from './site-form'; import { SiteForm } from './site-form';
import { msgOp, repoUrl, mdToHtml } from '../utils'; import { msgOp, repoUrl, mdToHtml, fetchLimit, routeSortTypeToEnum, routeListingTypeToEnum } from '../utils';
interface MainProps {
type: ListingType;
}
interface MainState { interface MainState {
subscribedCommunities: Array<CommunityUser>; subscribedCommunities: Array<CommunityUser>;
@ -19,9 +14,13 @@ interface MainState {
site: GetSiteResponse; site: GetSiteResponse;
showEditSite: boolean; showEditSite: boolean;
loading: boolean; loading: boolean;
posts: Array<Post>;
type_: ListingType;
sort: SortType;
page: number;
} }
export class Main extends Component<MainProps, MainState> { export class Main extends Component<any, MainState> {
private subscription: Subscription; private subscription: Subscription;
private emptyState: MainState = { private emptyState: MainState = {
@ -43,7 +42,29 @@ export class Main extends Component<MainProps, MainState> {
banned: [], banned: [],
}, },
showEditSite: false, showEditSite: false,
loading: true loading: true,
posts: [],
type_: this.getListingTypeFromProps(this.props),
sort: this.getSortTypeFromProps(this.props),
page: this.getPageFromProps(this.props),
}
getListingTypeFromProps(props: any): ListingType {
return (props.match.params.type) ?
routeListingTypeToEnum(props.match.params.type) :
UserService.Instance.user ?
ListingType.Subscribed :
ListingType.All;
}
getSortTypeFromProps(props: any): SortType {
return (props.match.params.sort) ?
routeSortTypeToEnum(props.match.params.sort) :
SortType.Hot;
}
getPageFromProps(props: any): number {
return (props.match.params.page) ? Number(props.match.params.page) : 1;
} }
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -66,43 +87,57 @@ export class Main extends Component<MainProps, MainState> {
} }
let listCommunitiesForm: ListCommunitiesForm = { let listCommunitiesForm: ListCommunitiesForm = {
sort: SortType[SortType.New], sort: SortType[SortType.Hot],
limit: 6 limit: 6
} }
WebSocketService.Instance.listCommunities(listCommunitiesForm); WebSocketService.Instance.listCommunities(listCommunitiesForm);
this.handleEditCancel = this.handleEditCancel.bind(this); this.fetchPosts();
} }
componentWillUnmount() { componentWillUnmount() {
this.subscription.unsubscribe(); this.subscription.unsubscribe();
} }
componentDidMount() {
document.title = "Lemmy";
}
// Necessary for back button for some reason
componentWillReceiveProps(nextProps: any) {
if (nextProps.history.action == 'POP') {
this.state = this.emptyState;
this.state.type_ = this.getListingTypeFromProps(nextProps);
this.state.sort = this.getSortTypeFromProps(nextProps);
this.state.page = this.getPageFromProps(nextProps);
this.fetchPosts();
}
}
render() { render() {
return ( return (
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-md-8"> <div class="col-12 col-md-8">
<PostListings type={this.props.type} /> {this.posts()}
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
{this.state.loading ? {!this.state.loading &&
<h5><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> : <div>
<div> {this.trendingCommunities()}
{this.trendingCommunities()} {UserService.Instance.user && this.state.subscribedCommunities.length > 0 &&
{UserService.Instance.user && this.state.subscribedCommunities.length > 0 && <div>
<div> <h5>Subscribed <Link class="text-white" to="/communities">communities</Link></h5>
<h5>Subscribed forums</h5> <ul class="list-inline">
<ul class="list-inline"> {this.state.subscribedCommunities.map(community =>
{this.state.subscribedCommunities.map(community => <li class="list-inline-item"><Link to={`/c/${community.community_name}`}>{community.community_name}</Link></li>
<li class="list-inline-item"><Link to={`/f/${community.community_name}`}>{community.community_name}</Link></li> )}
)} </ul>
</ul> </div>
</div> }
} {this.sidebar()}
{this.sidebar()} </div>
</div>
} }
</div> </div>
</div> </div>
@ -113,10 +148,10 @@ export class Main extends Component<MainProps, MainState> {
trendingCommunities() { trendingCommunities() {
return ( return (
<div> <div>
<h5>Trending <Link class="text-white" to="/communities">forums</Link></h5> <h5>Trending <Link class="text-white" to="/communities">communities</Link></h5>
<ul class="list-inline"> <ul class="list-inline">
{this.state.trendingCommunities.map(community => {this.state.trendingCommunities.map(community =>
<li class="list-inline-item"><Link to={`/f/${community.name}`}>{community.name}</Link></li> <li class="list-inline-item"><Link to={`/c/${community.name}`}>{community.name}</Link></li>
)} )}
</ul> </ul>
</div> </div>
@ -138,6 +173,12 @@ export class Main extends Component<MainProps, MainState> {
) )
} }
updateUrl() {
let typeStr = ListingType[this.state.type_].toLowerCase();
let sortStr = SortType[this.state.sort].toLowerCase();
this.props.history.push(`/home/type/${typeStr}/sort/${sortStr}/page/${this.state.page}`);
}
siteInfo() { siteInfo() {
return ( return (
<div> <div>
@ -175,7 +216,7 @@ export class Main extends Component<MainProps, MainState> {
landing() { landing() {
return ( return (
<div> <div>
<h5>Welcome to <h5>Powered by
<svg class="icon mx-2"><use xlinkHref="#icon-mouse"></use></svg> <svg class="icon mx-2"><use xlinkHref="#icon-mouse"></use></svg>
<a href={repoUrl}>Lemmy<sup>Beta</sup></a> <a href={repoUrl}>Lemmy<sup>Beta</sup></a>
</h5> </h5>
@ -188,6 +229,72 @@ export class Main extends Component<MainProps, MainState> {
) )
} }
posts() {
return (
<div>
{this.state.loading ?
<h5><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
<div>
{this.selects()}
<PostListings posts={this.state.posts} showCommunity />
{this.paginator()}
</div>
}
</div>
)
}
selects() {
return (
<div className="mb-2">
<div class="btn-group btn-group-toggle">
<label className={`btn btn-sm btn-secondary
${this.state.type_ == ListingType.Subscribed && 'active'}
${UserService.Instance.user == undefined ? 'disabled' : 'pointer'}
`}>
<input type="radio"
value={ListingType.Subscribed}
checked={this.state.type_ == ListingType.Subscribed}
onChange={linkEvent(this, this.handleTypeChange)}
disabled={UserService.Instance.user == undefined}
/>
Subscribed
</label>
<label className={`pointer btn btn-sm btn-secondary ${this.state.type_ == ListingType.All && 'active'}`}>
<input type="radio"
value={ListingType.All}
checked={this.state.type_ == ListingType.All}
onChange={linkEvent(this, this.handleTypeChange)}
/>
All
</label>
</div>
<select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="ml-2 custom-select custom-select-sm w-auto">
<option disabled>Sort Type</option>
<option value={SortType.Hot}>Hot</option>
<option value={SortType.New}>New</option>
<option disabled></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>
)
}
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>
);
}
get canAdmin(): boolean { get canAdmin(): boolean {
return UserService.Instance.user && this.state.site.admins.map(a => a.id).includes(UserService.Instance.user.id); return UserService.Instance.user && this.state.site.admins.map(a => a.id).includes(UserService.Instance.user.id);
} }
@ -202,6 +309,46 @@ export class Main extends Component<MainProps, MainState> {
this.setState(this.state); this.setState(this.state);
} }
nextPage(i: Main) {
i.state.page++;
i.setState(i.state);
i.updateUrl();
i.fetchPosts();
}
prevPage(i: Main) {
i.state.page--;
i.setState(i.state);
i.updateUrl();
i.fetchPosts();
}
handleSortChange(i: Main, event: any) {
i.state.sort = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.updateUrl();
i.fetchPosts();
}
handleTypeChange(i: Main, event: any) {
i.state.type_ = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.updateUrl();
i.fetchPosts();
}
fetchPosts() {
let getPostsForm: GetPostsForm = {
page: this.state.page,
limit: fetchLimit,
sort: SortType[this.state.sort],
type_: ListingType[this.state.type_]
}
WebSocketService.Instance.getPosts(getPostsForm);
}
parseMessage(msg: any) { parseMessage(msg: any) {
console.log(msg); console.log(msg);
let op: UserOperation = msgOp(msg); let op: UserOperation = msgOp(msg);
@ -211,12 +358,10 @@ export class Main extends Component<MainProps, MainState> {
} else if (op == UserOperation.GetFollowedCommunities) { } else if (op == UserOperation.GetFollowedCommunities) {
let res: GetFollowedCommunitiesResponse = msg; let res: GetFollowedCommunitiesResponse = msg;
this.state.subscribedCommunities = res.communities; this.state.subscribedCommunities = res.communities;
this.state.loading = false;
this.setState(this.state); this.setState(this.state);
} else if (op == UserOperation.ListCommunities) { } else if (op == UserOperation.ListCommunities) {
let res: ListCommunitiesResponse = msg; let res: ListCommunitiesResponse = msg;
this.state.trendingCommunities = res.communities; this.state.trendingCommunities = res.communities;
this.state.loading = false;
this.setState(this.state); this.setState(this.state);
} else if (op == UserOperation.GetSite) { } else if (op == UserOperation.GetSite) {
let res: GetSiteResponse = msg; let res: GetSiteResponse = msg;
@ -234,6 +379,19 @@ export class Main extends Component<MainProps, MainState> {
this.state.site.site = res.site; this.state.site.site = res.site;
this.state.showEditSite = false; this.state.showEditSite = false;
this.setState(this.state); this.setState(this.state);
} else if (op == UserOperation.GetPosts) {
let res: GetPostsResponse = msg;
this.state.posts = res.posts;
this.state.loading = false;
this.setState(this.state);
} else if (op == UserOperation.CreatePostLike) {
let res: CreatePostLikeResponse = msg;
let found = this.state.posts.find(c => c.id == res.post.id);
found.my_vote = res.post.my_vote;
found.score = res.post.score;
found.upvotes = res.post.upvotes;
found.downvotes = res.post.downvotes;
this.setState(this.state);
} }
} }
} }

View File

@ -110,7 +110,7 @@ export class Modlog extends Component<any, ModlogState> {
{i.type_ == 'removed_communities' && {i.type_ == 'removed_communities' &&
<> <>
{(i.data as ModRemoveCommunity).removed ? 'Removed' : 'Restored'} {(i.data as ModRemoveCommunity).removed ? 'Removed' : 'Restored'}
<span> Community <Link to={`/f/${(i.data as ModRemoveCommunity).community_name}`}>{(i.data as ModRemoveCommunity).community_name}</Link></span> <span> Community <Link to={`/c/${(i.data as ModRemoveCommunity).community_name}`}>{(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).reason && ` reason: ${(i.data as ModRemoveCommunity).reason}`}</div>
<div>{(i.data as ModRemoveCommunity).expires && ` expires: ${moment.utc((i.data as ModRemoveCommunity).expires).fromNow()}`}</div> <div>{(i.data as ModRemoveCommunity).expires && ` expires: ${moment.utc((i.data as ModRemoveCommunity).expires).fromNow()}`}</div>
</> </>
@ -120,7 +120,7 @@ export class Modlog extends Component<any, ModlogState> {
<span>{(i.data as ModBanFromCommunity).banned ? 'Banned ' : 'Unbanned '} </span> <span>{(i.data as ModBanFromCommunity).banned ? 'Banned ' : 'Unbanned '} </span>
<span><Link to={`/u/${(i.data as ModBanFromCommunity).other_user_name}`}>{(i.data as ModBanFromCommunity).other_user_name}</Link></span> <span><Link to={`/u/${(i.data as ModBanFromCommunity).other_user_name}`}>{(i.data as ModBanFromCommunity).other_user_name}</Link></span>
<span> from the community </span> <span> from the community </span>
<span><Link to={`/f/${(i.data as ModBanFromCommunity).community_name}`}>{(i.data as ModBanFromCommunity).community_name}</Link></span> <span><Link to={`/c/${(i.data as ModBanFromCommunity).community_name}`}>{(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).reason && ` reason: ${(i.data as ModBanFromCommunity).reason}`}</div>
<div>{(i.data as ModBanFromCommunity).expires && ` expires: ${moment.utc((i.data as ModBanFromCommunity).expires).fromNow()}`}</div> <div>{(i.data as ModBanFromCommunity).expires && ` expires: ${moment.utc((i.data as ModBanFromCommunity).expires).fromNow()}`}</div>
</> </>
@ -130,7 +130,7 @@ export class Modlog extends Component<any, ModlogState> {
<span>{(i.data as ModAddCommunity).removed ? 'Removed ' : 'Appointed '} </span> <span>{(i.data as ModAddCommunity).removed ? 'Removed ' : 'Appointed '} </span>
<span><Link to={`/u/${(i.data as ModAddCommunity).other_user_name}`}>{(i.data as ModAddCommunity).other_user_name}</Link></span> <span><Link to={`/u/${(i.data as ModAddCommunity).other_user_name}`}>{(i.data as ModAddCommunity).other_user_name}</Link></span>
<span> as a mod to the community </span> <span> as a mod to the community </span>
<span><Link to={`/f/${(i.data as ModAddCommunity).community_name}`}>{(i.data as ModAddCommunity).community_name}</Link></span> <span><Link to={`/c/${(i.data as ModAddCommunity).community_name}`}>{(i.data as ModAddCommunity).community_name}</Link></span>
</> </>
} }
{i.type_ == 'banned' && {i.type_ == 'banned' &&
@ -165,7 +165,7 @@ export class Modlog extends Component<any, ModlogState> {
<h5 class=""><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> : <h5 class=""><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
<div> <div>
<h5> <h5>
{this.state.communityName && <Link className="text-white" to={`/f/${this.state.communityName}`}>/f/{this.state.communityName} </Link>} {this.state.communityName && <Link className="text-white" to={`/c/${this.state.communityName}`}>/c/{this.state.communityName} </Link>}
<span>Modlog</span> <span>Modlog</span>
</h5> </h5>
<div class="table-responsive"> <div class="table-responsive">

View File

@ -3,7 +3,7 @@ import { Link } from 'inferno-router';
import { Subscription } from "rxjs"; import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators'; import { retryWhen, delay, take } from 'rxjs/operators';
import { WebSocketService, UserService } from '../services'; import { WebSocketService, UserService } from '../services';
import { UserOperation, GetRepliesForm, GetRepliesResponse, SortType } from '../interfaces'; import { UserOperation, GetRepliesForm, GetRepliesResponse, SortType, GetSiteResponse } from '../interfaces';
import { msgOp } from '../utils'; import { msgOp } from '../utils';
import { version } from '../version'; import { version } from '../version';
@ -12,6 +12,7 @@ interface NavbarState {
expanded: boolean; expanded: boolean;
expandUserDropdown: boolean; expandUserDropdown: boolean;
unreadCount: number; unreadCount: number;
siteName: string;
} }
export class Navbar extends Component<any, NavbarState> { export class Navbar extends Component<any, NavbarState> {
@ -21,7 +22,8 @@ export class Navbar extends Component<any, NavbarState> {
isLoggedIn: (UserService.Instance.user !== undefined), isLoggedIn: (UserService.Instance.user !== undefined),
unreadCount: 0, unreadCount: 0,
expanded: false, expanded: false,
expandUserDropdown: false expandUserDropdown: false,
siteName: undefined
} }
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -45,6 +47,8 @@ export class Navbar extends Component<any, NavbarState> {
(err) => console.error(err), (err) => console.error(err),
() => console.log('complete') () => console.log('complete')
); );
WebSocketService.Instance.getSite();
} }
render() { render() {
@ -59,30 +63,29 @@ export class Navbar extends Component<any, NavbarState> {
} }
// TODO class active corresponding to current page // TODO class active corresponding to current page
// TODO toggle css collapse
navbar() { navbar() {
return ( return (
<nav class="container navbar navbar-expand-md navbar-light navbar-bg p-0 px-3"> <nav class="container navbar navbar-expand-md navbar-light navbar-bg p-0 px-3">
<a title={version} class="navbar-brand" href="#"> <Link title={version} class="navbar-brand" to="/">
<svg class="icon mr-2"><use xlinkHref="#icon-mouse"></use></svg> <svg class="icon mr-2 mouse-icon"><use xlinkHref="#icon-mouse"></use></svg>
Lemmy {this.state.siteName}
</a> </Link>
<button class="navbar-toggler" type="button" onClick={linkEvent(this, this.expandNavbar)}> <button class="navbar-toggler" type="button" onClick={linkEvent(this, this.expandNavbar)}>
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div className={`${!this.state.expanded && 'collapse'} navbar-collapse`}> <div className={`${!this.state.expanded && 'collapse'} navbar-collapse`}>
<ul class="navbar-nav mr-auto"> <ul class="navbar-nav mr-auto">
<li class="nav-item"> <li class="nav-item">
<Link class="nav-link" to="/communities">Forums</Link> <Link class="nav-link" to="/communities">Communities</Link>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<Link class="nav-link" to="/search">Search</Link> <Link class="nav-link" to="/search">Search</Link>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<Link class="nav-link" to="/create_post">Create Post</Link> <Link class="nav-link" to={{pathname: '/create_post', state: { prevPath: this.currentLocation }}}>Create Post</Link>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<Link class="nav-link" to="/create_community">Create Forum</Link> <Link class="nav-link" to="/create_community">Create Community</Link>
</li> </li>
</ul> </ul>
<ul class="navbar-nav ml-auto mr-2"> <ul class="navbar-nav ml-auto mr-2">
@ -145,6 +148,14 @@ export class Navbar extends Component<any, NavbarState> {
} else if (op == UserOperation.GetReplies) { } else if (op == UserOperation.GetReplies) {
let res: GetRepliesResponse = msg; let res: GetRepliesResponse = msg;
this.sendRepliesCount(res); this.sendRepliesCount(res);
} else if (op == UserOperation.GetSite) {
let res: GetSiteResponse = msg;
if (res.site) {
this.state.siteName = res.site.name;
WebSocketService.Instance.site = res.site;
this.setState(this.state);
}
} }
} }
@ -165,6 +176,10 @@ export class Navbar extends Component<any, NavbarState> {
} }
} }
get currentLocation() {
return this.context.router.history.location.pathname;
}
sendRepliesCount(res: GetRepliesResponse) { sendRepliesCount(res: GetRepliesResponse) {
UserService.Instance.sub.next({user: UserService.Instance.user, unreadCount: res.replies.filter(r => !r.read).length}); UserService.Instance.sub.next({user: UserService.Instance.user, unreadCount: res.replies.filter(r => !r.read).length});
} }

View File

@ -3,11 +3,12 @@ import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators'; import { retryWhen, delay, take } from 'rxjs/operators';
import { PostForm as PostFormI, Post, PostResponse, UserOperation, Community, ListCommunitiesResponse, ListCommunitiesForm, SortType } from '../interfaces'; import { PostForm as PostFormI, Post, PostResponse, UserOperation, Community, ListCommunitiesResponse, ListCommunitiesForm, SortType } from '../interfaces';
import { WebSocketService, UserService } from '../services'; import { WebSocketService, UserService } from '../services';
import { msgOp } from '../utils'; import { msgOp, getPageTitle } from '../utils';
import * as autosize from 'autosize'; import * as autosize from 'autosize';
interface PostFormProps { interface PostFormProps {
post?: Post; // If a post is given, that means this is an edit post?: Post; // If a post is given, that means this is an edit
prevCommunityName?: string;
onCancel?(): any; onCancel?(): any;
onCreate?(id: number): any; onCreate?(id: number): any;
onEdit?(post: Post): any; onEdit?(post: Post): any;
@ -17,6 +18,7 @@ interface PostFormState {
postForm: PostFormI; postForm: PostFormI;
communities: Array<Community>; communities: Array<Community>;
loading: boolean; loading: boolean;
suggestedTitle: string;
} }
export class PostForm extends Component<PostFormProps, PostFormState> { export class PostForm extends Component<PostFormProps, PostFormState> {
@ -30,7 +32,8 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
creator_id: (UserService.Instance.user) ? UserService.Instance.user.id : null, creator_id: (UserService.Instance.user) ? UserService.Instance.user.id : null,
}, },
communities: [], communities: [],
loading: false loading: false,
suggestedTitle: undefined,
} }
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -82,6 +85,9 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
<label class="col-sm-2 col-form-label">URL</label> <label class="col-sm-2 col-form-label">URL</label>
<div class="col-sm-10"> <div class="col-sm-10">
<input type="url" class="form-control" value={this.state.postForm.url} onInput={linkEvent(this, this.handlePostUrlChange)} /> <input type="url" class="form-control" value={this.state.postForm.url} onInput={linkEvent(this, this.handlePostUrlChange)} />
{this.state.suggestedTitle &&
<span class="text-muted small font-weight-bold pointer" onClick={linkEvent(this, this.copySuggestedTitle)}>copy suggested title: {this.state.suggestedTitle}</span>
}
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
@ -93,13 +99,13 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-2 col-form-label">Body</label> <label class="col-sm-2 col-form-label">Body</label>
<div class="col-sm-10"> <div class="col-sm-10">
<textarea value={this.state.postForm.body} onInput={linkEvent(this, this.handlePostBodyChange)} class="form-control" rows={4} /> <textarea value={this.state.postForm.body} onInput={linkEvent(this, this.handlePostBodyChange)} class="form-control" rows={4} maxLength={10000} />
</div> </div>
</div> </div>
{/* Cant change a community from an edit */} {/* Cant change a community from an edit */}
{!this.props.post && {!this.props.post &&
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-2 col-form-label">Forum</label> <label class="col-sm-2 col-form-label">Community</label>
<div class="col-sm-10"> <div class="col-sm-10">
<select class="form-control" value={this.state.postForm.community_id} onInput={linkEvent(this, this.handlePostCommunityChange)}> <select class="form-control" value={this.state.postForm.community_id} onInput={linkEvent(this, this.handlePostCommunityChange)}>
{this.state.communities.map(community => {this.state.communities.map(community =>
@ -134,8 +140,18 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
i.setState(i.state); i.setState(i.state);
} }
copySuggestedTitle(i: PostForm) {
i.state.postForm.name = i.state.suggestedTitle;
i.state.suggestedTitle = undefined;
i.setState(i.state);
}
handlePostUrlChange(i: PostForm, event: any) { handlePostUrlChange(i: PostForm, event: any) {
i.state.postForm.url = event.target.value; i.state.postForm.url = event.target.value;
getPageTitle(i.state.postForm.url).then(d => {
i.state.suggestedTitle = d;
i.setState(i.state);
});
i.setState(i.state); i.setState(i.state);
} }
@ -170,6 +186,9 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
this.state.communities = res.communities; this.state.communities = res.communities;
if (this.props.post) { if (this.props.post) {
this.state.postForm.community_id = this.props.post.community_id; this.state.postForm.community_id = this.props.post.community_id;
} else if (this.props.prevCommunityName) {
let foundCommunityId = res.communities.find(r => r.name == this.props.prevCommunityName).id;
this.state.postForm.community_id = foundCommunityId;
} else { } else {
this.state.postForm.community_id = res.communities[0].id; this.state.postForm.community_id = res.communities[0].id;
} }

View File

@ -85,6 +85,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{post.removed && {post.removed &&
<small className="ml-2 text-muted font-italic">removed</small> <small className="ml-2 text-muted font-italic">removed</small>
} }
{post.deleted &&
<small className="ml-2 text-muted font-italic">deleted</small>
}
{post.locked && {post.locked &&
<small className="ml-2 text-muted font-italic">locked</small> <small className="ml-2 text-muted font-italic">locked</small>
} }
@ -118,7 +121,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{this.props.showCommunity && {this.props.showCommunity &&
<span> <span>
<span> to </span> <span> to </span>
<Link to={`/f/${post.community_name}`}>{post.community_name}</Link> <Link to={`/c/${post.community_name}`}>{post.community_name}</Link>
</span> </span>
} }
</li> </li>
@ -140,7 +143,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{UserService.Instance.user && this.props.editable && {UserService.Instance.user && this.props.editable &&
<ul class="list-inline mb-1 text-muted small font-weight-bold"> <ul class="list-inline mb-1 text-muted small font-weight-bold">
<li className="list-inline-item mr-2"> <li className="list-inline-item mr-2">
<span class="pointer" onClick={linkEvent(this, this.handleSavePostClick)}>{this.props.post.saved ? 'unsave' : 'save'}</span> <span class="pointer" onClick={linkEvent(this, this.handleSavePostClick)}>{post.saved ? 'unsave' : 'save'}</span>
</li> </li>
{this.myPost && {this.myPost &&
<> <>
@ -148,7 +151,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<span class="pointer" onClick={linkEvent(this, this.handleEditClick)}>edit</span> <span class="pointer" onClick={linkEvent(this, this.handleEditClick)}>edit</span>
</li> </li>
<li className="list-inline-item mr-2"> <li className="list-inline-item mr-2">
<span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>delete</span> <span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>
{!post.deleted ? 'delete' : 'restore'}
</span>
</li> </li>
</> </>
} }
@ -237,12 +242,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
handleDeleteClick(i: PostListing) { handleDeleteClick(i: PostListing) {
let deleteForm: PostFormI = { let deleteForm: PostFormI = {
body: '', body: i.props.post.body,
community_id: i.props.post.community_id, community_id: i.props.post.community_id,
name: "deleted", name: i.props.post.name,
url: '', url: i.props.post.url,
edit_id: i.props.post.id, edit_id: i.props.post.id,
creator_id: i.props.post.creator_id, creator_id: i.props.post.creator_id,
deleted: !i.props.post.deleted,
auth: null auth: null
}; };
WebSocketService.Instance.editPost(deleteForm); WebSocketService.Instance.editPost(deleteForm);

View File

@ -1,183 +1,28 @@
import { Component, linkEvent } from 'inferno'; import { Component } from 'inferno';
import { Link } from 'inferno-router'; import { Link } from 'inferno-router';
import { Subscription } from "rxjs"; import { Post } from '../interfaces';
import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, Post, GetPostsForm, SortType, ListingType, GetPostsResponse, CreatePostLikeResponse, CommunityUser} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { PostListing } from './post-listing'; import { PostListing } from './post-listing';
import { msgOp, fetchLimit } from '../utils';
interface PostListingsProps { interface PostListingsProps {
type?: ListingType;
communityId?: number;
}
interface PostListingsState {
moderators: Array<CommunityUser>;
posts: Array<Post>; posts: Array<Post>;
sortType: SortType; showCommunity?: boolean;
type_: ListingType;
page: number;
loading: boolean;
} }
export class PostListings extends Component<PostListingsProps, PostListingsState> { export class PostListings extends Component<PostListingsProps, any> {
private subscription: Subscription;
private emptyState: PostListingsState = {
moderators: [],
posts: [],
sortType: SortType.Hot,
type_: (this.props.type !== undefined && UserService.Instance.user) ? this.props.type :
this.props.communityId
? ListingType.Community
: UserService.Instance.user
? ListingType.Subscribed
: ListingType.All,
page: 1,
loading: true
}
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); 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() { render() {
return ( return (
<div> <div>
{this.state.loading ? {this.props.posts.length > 0 ? this.props.posts.map(post =>
<h5><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> : <PostListing post={post} showCommunity={this.props.showCommunity} />) :
<div> <div>No posts. {this.props.showCommunity !== undefined && <span>Subscribe to some <Link to="/communities">communities</Link>.</span>}
{this.selects()}
{this.state.posts.length > 0
? this.state.posts.map(post =>
<PostListing post={post} showCommunity={!this.props.communityId}/>)
: <div>No Listings. {!this.props.communityId && <span>Subscribe to some <Link to="/communities">forums</Link>.</span>}</div>
}
{this.paginator()}
</div> </div>
} }
</div> </div>
) )
} }
selects() {
return (
<div className="mb-2">
<select value={this.state.sortType} onChange={linkEvent(this, this.handleSortChange)} class="custom-select w-auto">
<option disabled>Sort Type</option>
<option value={SortType.Hot}>Hot</option>
<option value={SortType.New}>New</option>
<option disabled></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>
{!this.props.communityId &&
UserService.Instance.user &&
<select value={this.state.type_} onChange={linkEvent(this, this.handleTypeChange)} class="ml-2 custom-select w-auto">
<option disabled>Type</option>
<option value={ListingType.All}>All</option>
<option value={ListingType.Subscribed}>Subscribed</option>
</select>
}
</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: PostListings) {
i.state.page++;
i.setState(i.state);
i.refetch();
}
prevPage(i: PostListings) {
i.state.page--;
i.setState(i.state);
i.refetch();
}
handleSortChange(i: PostListings, event: any) {
i.state.sortType = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.refetch();
}
refetch() {
let getPostsForm: GetPostsForm = {
community_id: this.props.communityId,
page: this.state.page,
limit: fetchLimit,
sort: SortType[this.state.sortType],
type_: ListingType[this.state.type_]
}
WebSocketService.Instance.getPosts(getPostsForm);
}
handleTypeChange(i: PostListings, event: any) {
i.state.type_ = Number(event.target.value);
i.state.page = 1;
if (i.state.type_ == ListingType.All) {
i.context.router.history.push('/all');
} else {
i.context.router.history.push('/');
}
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.GetPosts) {
let res: GetPostsResponse = msg;
this.state.posts = res.posts;
this.state.loading = false;
this.setState(this.state);
} else if (op == UserOperation.CreatePostLike) {
let res: CreatePostLikeResponse = msg;
let found = this.state.posts.find(c => c.id == res.post.id);
found.my_vote = res.post.my_vote;
found.score = res.post.score;
found.upvotes = res.post.upvotes;
found.downvotes = res.post.downvotes;
this.setState(this.state);
}
}
} }

View File

@ -244,6 +244,7 @@ export class Post extends Component<any, PostState> {
found.content = res.comment.content; found.content = res.comment.content;
found.updated = res.comment.updated; found.updated = res.comment.updated;
found.removed = res.comment.removed; found.removed = res.comment.removed;
found.deleted = res.comment.deleted;
found.upvotes = res.comment.upvotes; found.upvotes = res.comment.upvotes;
found.downvotes = res.comment.downvotes; found.downvotes = res.comment.downvotes;
found.score = res.comment.score; found.score = res.comment.score;

View File

@ -97,13 +97,13 @@ export class Search extends Component<any, SearchState> {
selects() { selects() {
return ( return (
<div className="mb-2"> <div className="mb-2">
<select value={this.state.type_} onChange={linkEvent(this, this.handleTypeChange)} class="custom-select w-auto"> <select value={this.state.type_} onChange={linkEvent(this, this.handleTypeChange)} class="custom-select custom-select-sm w-auto">
<option disabled>Type</option> <option disabled>Type</option>
<option value={SearchType.Both}>Both</option> <option value={SearchType.Both}>Both</option>
<option value={SearchType.Comments}>Comments</option> <option value={SearchType.Comments}>Comments</option>
<option value={SearchType.Posts}>Posts</option> <option value={SearchType.Posts}>Posts</option>
</select> </select>
<select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select w-auto ml-2"> <select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select custom-select-sm w-auto ml-2">
<option disabled>Sort Type</option> <option disabled>Sort Type</option>
<option value={SortType.New}>New</option> <option value={SortType.New}>New</option>
<option value={SortType.TopDay}>Top Day</option> <option value={SortType.TopDay}>Top Day</option>

View File

@ -20,6 +20,7 @@ export class Setup extends Component<any, State> {
username: undefined, username: undefined,
password: undefined, password: undefined,
password_verify: undefined, password_verify: undefined,
spam_timeri: 3000,
admin: true, admin: true,
}, },
doneRegisteringUser: false, doneRegisteringUser: false,

View File

@ -56,8 +56,11 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
{community.removed && {community.removed &&
<small className="ml-2 text-muted font-italic">removed</small> <small className="ml-2 text-muted font-italic">removed</small>
} }
{community.deleted &&
<small className="ml-2 text-muted font-italic">deleted</small>
}
</h5> </h5>
<Link className="text-muted" to={`/f/${community.name}`}>/f/{community.name}</Link> <Link className="text-muted" to={`/c/${community.name}`}>/c/{community.name}</Link>
<ul class="list-inline mb-1 text-muted small font-weight-bold"> <ul class="list-inline mb-1 text-muted small font-weight-bold">
{this.canMod && {this.canMod &&
<> <>
@ -66,7 +69,9 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
</li> </li>
{this.amCreator && {this.amCreator &&
<li className="list-inline-item"> <li className="list-inline-item">
{/* <span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>delete</span> */} <span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>
{!community.deleted ? 'delete' : 'restore'}
</span>
</li> </li>
} }
</> </>
@ -142,9 +147,18 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
this.setState(this.state); this.setState(this.state);
} }
// TODO no deleting communities yet handleDeleteClick(i: Sidebar) {
// handleDeleteClick(i: Sidebar, event) { event.preventDefault();
// } let deleteForm: CommunityFormI = {
name: i.props.community.name,
title: i.props.community.title,
category_id: i.props.community.category_id,
edit_id: i.props.community.id,
deleted: !i.props.community.deleted,
auth: null,
};
WebSocketService.Instance.editCommunity(deleteForm);
}
handleUnsubscribe(communityId: number) { handleUnsubscribe(communityId: number) {
let form: FollowCommunityForm = { let form: FollowCommunityForm = {
@ -174,9 +188,6 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
return UserService.Instance.user && this.props.admins.map(a => a.id).includes(UserService.Instance.user.id); return UserService.Instance.user && this.props.admins.map(a => a.id).includes(UserService.Instance.user.id);
} }
handleDeleteClick() {
}
handleModRemoveShow(i: Sidebar) { handleModRemoveShow(i: Sidebar) {
i.state.showRemoveDialog = true; i.state.showRemoveDialog = true;
i.setState(i.state); i.setState(i.state);

View File

@ -49,7 +49,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
<div class="form-group row"> <div class="form-group row">
<label class="col-12 col-form-label">Sidebar</label> <label class="col-12 col-form-label">Sidebar</label>
<div class="col-12"> <div class="col-12">
<textarea value={this.state.siteForm.description} onInput={linkEvent(this, this.handleSiteDescriptionChange)} class="form-control" rows={3} /> <textarea value={this.state.siteForm.description} onInput={linkEvent(this, this.handleSiteDescriptionChange)} class="form-control" rows={3} maxLength={10000} />
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">

View File

@ -3,7 +3,6 @@ import { Component } from 'inferno';
let general = let general =
[ [
"Nathan J. Goode", "Nathan J. Goode",
"Eduardo Cavazos"
]; ];
// let highlighted = []; // let highlighted = [];
// let silver = []; // let silver = [];

View File

@ -4,7 +4,7 @@ import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators'; import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, Post, Comment, CommunityUser, GetUserDetailsForm, SortType, UserDetailsResponse, UserView, CommentResponse } from '../interfaces'; import { UserOperation, Post, Comment, CommunityUser, GetUserDetailsForm, SortType, UserDetailsResponse, UserView, CommentResponse } from '../interfaces';
import { WebSocketService } from '../services'; import { WebSocketService } from '../services';
import { msgOp, fetchLimit } from '../utils'; import { msgOp, fetchLimit, routeSortTypeToEnum, capitalizeFirstLetter } from '../utils';
import { PostListing } from './post-listing'; import { PostListing } from './post-listing';
import { CommentNodes } from './comment-nodes'; import { CommentNodes } from './comment-nodes';
import { MomentTime } from './moment-time'; import { MomentTime } from './moment-time';
@ -25,6 +25,7 @@ interface UserState {
view: View; view: View;
sort: SortType; sort: SortType;
page: number; page: number;
loading: boolean;
} }
export class User extends Component<any, UserState> { export class User extends Component<any, UserState> {
@ -47,9 +48,10 @@ export class User extends Component<any, UserState> {
moderates: [], moderates: [],
comments: [], comments: [],
posts: [], posts: [],
view: View.Overview, loading: true,
sort: SortType.New, view: this.getViewFromProps(this.props),
page: 1, sort: this.getSortTypeFromProps(this.props),
page: this.getPageFromProps(this.props),
} }
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -71,13 +73,42 @@ export class User extends Component<any, UserState> {
this.refetch(); this.refetch();
} }
getViewFromProps(props: any): View {
return (props.match.params.view) ?
View[capitalizeFirstLetter(props.match.params.view)] :
View.Overview;
}
getSortTypeFromProps(props: any): SortType {
return (props.match.params.sort) ?
routeSortTypeToEnum(props.match.params.sort) :
SortType.New;
}
getPageFromProps(props: any): number {
return (props.match.params.page) ? Number(props.match.params.page) : 1;
}
componentWillUnmount() { componentWillUnmount() {
this.subscription.unsubscribe(); this.subscription.unsubscribe();
} }
// Necessary for back button for some reason
componentWillReceiveProps(nextProps: any) {
if (nextProps.history.action == 'POP') {
this.state = this.emptyState;
this.state.view = this.getViewFromProps(nextProps);
this.state.sort = this.getSortTypeFromProps(nextProps);
this.state.page = this.getPageFromProps(nextProps);
this.refetch();
}
}
render() { render() {
return ( return (
<div class="container"> <div class="container">
{this.state.loading ?
<h5><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
<div class="row"> <div class="row">
<div class="col-12 col-md-9"> <div class="col-12 col-md-9">
<h5>/u/{this.state.user.name}</h5> <h5>/u/{this.state.user.name}</h5>
@ -102,6 +133,7 @@ export class User extends Component<any, UserState> {
{this.follows()} {this.follows()}
</div> </div>
</div> </div>
}
</div> </div>
) )
} }
@ -109,14 +141,14 @@ export class User extends Component<any, UserState> {
selects() { selects() {
return ( return (
<div className="mb-2"> <div className="mb-2">
<select value={this.state.view} onChange={linkEvent(this, this.handleViewChange)} class="custom-select w-auto"> <select value={this.state.view} onChange={linkEvent(this, this.handleViewChange)} class="custom-select custom-select-sm w-auto">
<option disabled>View</option> <option disabled>View</option>
<option value={View.Overview}>Overview</option> <option value={View.Overview}>Overview</option>
<option value={View.Comments}>Comments</option> <option value={View.Comments}>Comments</option>
<option value={View.Posts}>Posts</option> <option value={View.Posts}>Posts</option>
<option value={View.Saved}>Saved</option> <option value={View.Saved}>Saved</option>
</select> </select>
<select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select w-auto ml-2"> <select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select custom-select-sm w-auto ml-2">
<option disabled>Sort Type</option> <option disabled>Sort Type</option>
<option value={SortType.New}>New</option> <option value={SortType.New}>New</option>
<option value={SortType.TopDay}>Top Day</option> <option value={SortType.TopDay}>Top Day</option>
@ -209,7 +241,7 @@ export class User extends Component<any, UserState> {
<h5>Moderates</h5> <h5>Moderates</h5>
<ul class="list-unstyled"> <ul class="list-unstyled">
{this.state.moderates.map(community => {this.state.moderates.map(community =>
<li><Link to={`/f/${community.community_name}`}>{community.community_name}</Link></li> <li><Link to={`/c/${community.community_name}`}>{community.community_name}</Link></li>
)} )}
</ul> </ul>
</div> </div>
@ -227,7 +259,7 @@ export class User extends Component<any, UserState> {
<h5>Subscribed</h5> <h5>Subscribed</h5>
<ul class="list-unstyled"> <ul class="list-unstyled">
{this.state.follows.map(community => {this.state.follows.map(community =>
<li><Link to={`/f/${community.community_name}`}>{community.community_name}</Link></li> <li><Link to={`/c/${community.community_name}`}>{community.community_name}</Link></li>
)} )}
</ul> </ul>
</div> </div>
@ -247,15 +279,23 @@ export class User extends Component<any, UserState> {
); );
} }
updateUrl() {
let viewStr = View[this.state.view].toLowerCase();
let sortStr = SortType[this.state.sort].toLowerCase();
this.props.history.push(`/u/${this.state.user.name}/view/${viewStr}/sort/${sortStr}/page/${this.state.page}`);
}
nextPage(i: User) { nextPage(i: User) {
i.state.page++; i.state.page++;
i.setState(i.state); i.setState(i.state);
i.updateUrl();
i.refetch(); i.refetch();
} }
prevPage(i: User) { prevPage(i: User) {
i.state.page--; i.state.page--;
i.setState(i.state); i.setState(i.state);
i.updateUrl();
i.refetch(); i.refetch();
} }
@ -275,6 +315,7 @@ export class User extends Component<any, UserState> {
i.state.sort = Number(event.target.value); i.state.sort = Number(event.target.value);
i.state.page = 1; i.state.page = 1;
i.setState(i.state); i.setState(i.state);
i.updateUrl();
i.refetch(); i.refetch();
} }
@ -282,6 +323,7 @@ export class User extends Component<any, UserState> {
i.state.view = Number(event.target.value); i.state.view = Number(event.target.value);
i.state.page = 1; i.state.page = 1;
i.setState(i.state); i.setState(i.state);
i.updateUrl();
i.refetch(); i.refetch();
} }
@ -298,6 +340,7 @@ export class User extends Component<any, UserState> {
this.state.follows = res.follows; this.state.follows = res.follows;
this.state.moderates = res.moderates; this.state.moderates = res.moderates;
this.state.posts = res.posts; this.state.posts = res.posts;
this.state.loading = false;
document.title = `/u/${this.state.user.name} - Lemmy`; document.title = `/u/${this.state.user.name} - Lemmy`;
this.setState(this.state); this.setState(this.state);
} else if (op == UserOperation.EditComment) { } else if (op == UserOperation.EditComment) {
@ -307,6 +350,7 @@ export class User extends Component<any, UserState> {
found.content = res.comment.content; found.content = res.comment.content;
found.updated = res.comment.updated; found.updated = res.comment.updated;
found.removed = res.comment.removed; found.removed = res.comment.removed;
found.deleted = res.comment.deleted;
found.upvotes = res.comment.upvotes; found.upvotes = res.comment.upvotes;
found.downvotes = res.comment.downvotes; found.downvotes = res.comment.downvotes;
found.score = res.comment.score; found.score = res.comment.score;

View File

@ -87,6 +87,10 @@ blockquote {
margin-top: 6px; margin-top: 6px;
} }
.mouse-icon {
margin-top: -4px;
}
.new-comments { .new-comments {
max-height: 100vh; max-height: 100vh;
overflow: hidden; overflow: hidden;

View File

@ -5,7 +5,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="shortcut icon" type="image/svg+xml" href="/static/assets/favicon.svg" /> <link rel="shortcut icon" type="image/svg+xml" href="/static/assets/favicon.svg" />
<link rel="apple-touch-icon" href="/static/assets/apple-touch-icon.png" />
<title>Lemmy</title> <title>Lemmy</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/balloon-css/0.5.0/balloon.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/balloon-css/0.5.0/balloon.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/sortable/0.8.0/js/sortable.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/sortable/0.8.0/js/sortable.min.js"></script>

View File

@ -1,9 +1,8 @@
import { render, Component } from 'inferno'; import { render, Component } from 'inferno';
import { HashRouter, Route, Switch } from 'inferno-router'; import { HashRouter, BrowserRouter, Route, Switch } from 'inferno-router';
import { Main } from './components/main';
import { Navbar } from './components/navbar'; import { Navbar } from './components/navbar';
import { Footer } from './components/footer'; import { Footer } from './components/footer';
import { Home } from './components/home';
import { Login } from './components/login'; import { Login } from './components/login';
import { CreatePost } from './components/create-post'; import { CreatePost } from './components/create-post';
import { CreateCommunity } from './components/create-community'; import { CreateCommunity } from './components/create-community';
@ -37,18 +36,21 @@ class Index extends Component<any, any> {
return ( return (
<HashRouter> <HashRouter>
<Navbar /> <Navbar />
<div class="mt-3 p-0"> <div class="mt-1 p-0">
<Switch> <Switch>
<Route exact path="/all" component={Home} /> <Route path={`/home/type/:type/sort/:sort/page/:page`} component={Main} />
<Route exact path="/" component={Home} /> <Route exact path={`/`} component={Main} />
<Route path={`/login`} component={Login} /> <Route path={`/login`} component={Login} />
<Route path={`/create_post`} component={CreatePost} /> <Route path={`/create_post`} component={CreatePost} />
<Route path={`/create_community`} component={CreateCommunity} /> <Route path={`/create_community`} component={CreateCommunity} />
<Route path={`/communities/page/:page`} component={Communities} />
<Route path={`/communities`} component={Communities} /> <Route path={`/communities`} component={Communities} />
<Route path={`/post/:id/comment/:comment_id`} component={Post} /> <Route path={`/post/:id/comment/:comment_id`} component={Post} />
<Route path={`/post/:id`} component={Post} /> <Route path={`/post/:id`} component={Post} />
<Route path={`/c/:name/sort/:sort/page/:page`} component={Community} />
<Route path={`/community/:id`} component={Community} /> <Route path={`/community/:id`} component={Community} />
<Route path={`/f/:name`} component={Community} /> <Route path={`/c/:name`} component={Community} />
<Route path={`/u/:username/view/:view/sort/:sort/page/:page`} component={User} />
<Route path={`/user/:id`} component={User} /> <Route path={`/user/:id`} component={User} />
<Route path={`/u/:username`} component={User} /> <Route path={`/u/:username`} component={User} />
<Route path={`/inbox`} component={Inbox} /> <Route path={`/inbox`} component={Inbox} />

View File

@ -1,5 +1,5 @@
export enum UserOperation { export enum UserOperation {
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, Search 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, Search, MarkAllAsRead
} }
export enum CommentSortType { export enum CommentSortType {
@ -52,6 +52,7 @@ export interface Community {
category_id: number; category_id: number;
creator_id: number; creator_id: number;
removed: boolean; removed: boolean;
deleted: boolean;
published: string; published: string;
updated?: string; updated?: string;
creator_name: string; creator_name: string;
@ -71,6 +72,7 @@ export interface Post {
creator_id: number; creator_id: number;
community_id: number; community_id: number;
removed: boolean; removed: boolean;
deleted: boolean;
locked: boolean; locked: boolean;
published: string; published: string;
updated?: string; updated?: string;
@ -96,6 +98,7 @@ export interface Comment {
parent_id?: number; parent_id?: number;
content: string; content: string;
removed: boolean; removed: boolean;
deleted: boolean;
read: boolean; read: boolean;
published: string; published: string;
updated?: string; updated?: string;
@ -348,6 +351,7 @@ export interface CommunityForm {
category_id: number, category_id: number,
edit_id?: number; edit_id?: number;
removed?: boolean; removed?: boolean;
deleted?: boolean;
reason?: string; reason?: string;
expires?: number; expires?: number;
auth?: string; auth?: string;
@ -392,6 +396,7 @@ export interface PostForm {
edit_id?: number; edit_id?: number;
creator_id: number; creator_id: number;
removed?: boolean; removed?: boolean;
deleted?: boolean;
locked?: boolean; locked?: boolean;
reason?: string; reason?: string;
auth: string; auth: string;
@ -424,6 +429,7 @@ export interface CommentForm {
edit_id?: number; edit_id?: number;
creator_id: number; creator_id: number;
removed?: boolean; removed?: boolean;
deleted?: boolean;
reason?: string; reason?: string;
read?: boolean; read?: boolean;
auth: string; auth: string;

View File

@ -147,6 +147,7 @@ export class WebSocketService {
} }
public getUserDetails(form: GetUserDetailsForm) { public getUserDetails(form: GetUserDetailsForm) {
this.setAuth(form, false);
this.subject.next(this.wsSendWrapper(UserOperation.GetUserDetails, form)); this.subject.next(this.wsSendWrapper(UserOperation.GetUserDetails, form));
} }
@ -177,6 +178,12 @@ export class WebSocketService {
this.subject.next(this.wsSendWrapper(UserOperation.Search, form)); this.subject.next(this.wsSendWrapper(UserOperation.Search, form));
} }
public markAllAsRead() {
let form = {};
this.setAuth(form);
this.subject.next(this.wsSendWrapper(UserOperation.MarkAllAsRead, form));
}
private wsSendWrapper(op: UserOperation, data: any) { private wsSendWrapper(op: UserOperation, data: any) {
let send = { op: UserOperation[op], data: data }; let send = { op: UserOperation[op], data: data };
console.log(send); console.log(send);

View File

@ -1,5 +1,6 @@
import { UserOperation, Comment, User } from './interfaces'; import { UserOperation, Comment, User, SortType, ListingType } from './interfaces';
import * as markdown_it from 'markdown-it'; import * as markdown_it from 'markdown-it';
import * as markdown_it_container from 'markdown-it-container';
export let repoUrl = 'https://github.com/dessalines/lemmy'; export let repoUrl = 'https://github.com/dessalines/lemmy';
@ -12,6 +13,23 @@ var md = new markdown_it({
html: true, html: true,
linkify: true, linkify: true,
typographer: true typographer: true
}).use(markdown_it_container, 'spoiler', {
validate: function(params: any) {
return params.trim().match(/^spoiler\s+(.*)$/);
},
render: function (tokens: any, idx: any) {
var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
if (tokens[idx].nesting === 1) {
// opening tag
return '<details><summary>' + md.utils.escapeHtml(m[1]) + '</summary>\n';
} else {
// closing tag
return '</details>\n';
}
}
}); });
export function hotRank(comment: Comment): number { export function hotRank(comment: Comment): number {
@ -67,3 +85,35 @@ export function isImage(url: string) {
} }
export let fetchLimit: number = 20; export let fetchLimit: number = 20;
export function capitalizeFirstLetter(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function routeSortTypeToEnum(sort: string): SortType {
if (sort == 'new') {
return SortType.New;
} else if (sort == 'hot') {
return SortType.Hot;
} else if (sort == 'topday') {
return SortType.TopDay;
} else if (sort == 'topweek') {
return SortType.TopWeek;
} else if (sort == 'topmonth') {
return SortType.TopMonth;
} else if (sort == 'topall') {
return SortType.TopAll;
}
}
export function routeListingTypeToEnum(type: string): ListingType {
return ListingType[capitalizeFirstLetter(type)];
}
export async function getPageTitle(url: string) {
let res = await fetch(`https://textance.herokuapp.com/title/${url}`);
let data = await res.text();
return data;
}

View File

@ -1 +1 @@
export let version: string = "v0.0.3-37-g5c8e23b"; export let version: string = "v0.0.3-91-gb304b48";

View File

@ -38,7 +38,14 @@
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-2.1.0.tgz#ea3dd64c4805597311790b61e872cbd1ed2cd806" resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-2.1.0.tgz#ea3dd64c4805597311790b61e872cbd1ed2cd806"
integrity sha512-Q7DYAOi9O/+cLLhdaSvKdaumWyHbm7HAk/bFwwyTuU0arR5yyCeW5GOoqt4tJTpDRxhpx9Q8kQL6vMpuw9hDSw== integrity sha512-Q7DYAOi9O/+cLLhdaSvKdaumWyHbm7HAk/bFwwyTuU0arR5yyCeW5GOoqt4tJTpDRxhpx9Q8kQL6vMpuw9hDSw==
"@types/markdown-it@^0.0.7": "@types/markdown-it-container@^2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@types/markdown-it-container/-/markdown-it-container-2.0.2.tgz#0e624653415a1c2f088a5ae51f7bfff480c03f49"
integrity sha512-T770GL+zJz8Ssh1NpLiOruYhrU96yb8ovPSegLrWY5XIkJc6PVVC7kH/oQaVD0rkePpWMFJK018OgS/pwviOMw==
dependencies:
"@types/markdown-it" "*"
"@types/markdown-it@*", "@types/markdown-it@^0.0.7":
version "0.0.7" version "0.0.7"
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-0.0.7.tgz#75070485a3d8ad11e7deb8287f4430be15bf4d39" resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-0.0.7.tgz#75070485a3d8ad11e7deb8287f4430be15bf4d39"
integrity sha512-WyL6pa76ollQFQNEaLVa41ZUUvDvPY+qAUmlsphnrpL6I9p1m868b26FyeoOmo7X3/Ta/S9WKXcEYXUSHnxoVQ== integrity sha512-WyL6pa76ollQFQNEaLVa41ZUUvDvPY+qAUmlsphnrpL6I9p1m868b26FyeoOmo7X3/Ta/S9WKXcEYXUSHnxoVQ==
@ -1674,6 +1681,11 @@ map-visit@^1.0.0:
dependencies: dependencies:
object-visit "^1.0.0" object-visit "^1.0.0"
markdown-it-container@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/markdown-it-container/-/markdown-it-container-2.0.0.tgz#0019b43fd02eefece2f1960a2895fba81a404695"
integrity sha1-ABm0P9Au7+zi8ZYKKJX7qBpARpU=
markdown-it@^8.4.2: markdown-it@^8.4.2:
version "8.4.2" version "8.4.2"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.2.tgz#386f98998dc15a37722aa7722084f4020bdd9b54" resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.2.tgz#386f98998dc15a37722aa7722084f4020bdd9b54"