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

View file

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

View file

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

View file

@ -15,11 +15,13 @@ table! {
removed -> Bool,
published -> Timestamp,
updated -> Nullable<Timestamp>,
deleted -> Bool,
creator_name -> Varchar,
category_name -> Varchar,
number_of_subscribers -> BigInt,
number_of_posts -> BigInt,
number_of_comments -> BigInt,
hot_rank -> Int4,
user_id -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
}
@ -85,11 +87,13 @@ pub struct CommunityView {
pub removed: bool,
pub published: chrono::NaiveDateTime,
pub updated: Option<chrono::NaiveDateTime>,
pub deleted: bool,
pub creator_name: String,
pub category_name: String,
pub number_of_subscribers: i64,
pub number_of_posts: i64,
pub number_of_comments: i64,
pub hot_rank: i32,
pub user_id: Option<i32>,
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
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::TopAll => {
match from_user_id {
@ -139,6 +144,7 @@ impl CommunityView {
.limit(limit)
.offset(offset)
.filter(removed.eq(false))
.filter(deleted.eq(false))
.load::<Self>(conn)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ use bcrypt::{verify};
use std::str::FromStr;
use diesel::PgConnection;
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 actions::community::*;
@ -25,9 +26,14 @@ use actions::user_view::*;
use actions::moderator_views::*;
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)]
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)]
@ -48,12 +54,14 @@ pub struct WSMessage(pub String);
#[rtype(usize)]
pub struct Connect {
pub addr: Recipient<WSMessage>,
pub ip: String,
}
/// Session is disconnected
#[derive(Message)]
pub struct Disconnect {
pub id: usize,
pub ip: String,
}
/// Send message to specific room
@ -219,6 +227,7 @@ pub struct EditComment {
creator_id: i32,
post_id: i32,
removed: Option<bool>,
deleted: Option<bool>,
reason: Option<String>,
read: Option<bool>,
auth: String
@ -268,6 +277,7 @@ pub struct EditPost {
url: Option<String>,
body: Option<String>,
removed: Option<bool>,
deleted: Option<bool>,
locked: Option<bool>,
reason: Option<String>,
auth: String
@ -288,6 +298,7 @@ pub struct EditCommunity {
description: Option<String>,
category_id: i32,
removed: Option<bool>,
deleted: Option<bool>,
reason: Option<String>,
expires: Option<i64>,
auth: String
@ -320,6 +331,7 @@ pub struct GetUserDetails {
limit: Option<i64>,
community_id: Option<i32>,
saved_only: bool,
auth: Option<String>,
}
#[derive(Serialize, Deserialize)]
@ -478,10 +490,27 @@ pub struct SearchResponse {
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
/// session. implementation is super primitive
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
rng: ThreadRng,
}
@ -493,6 +522,7 @@ impl Default for ChatServer {
ChatServer {
sessions: HashMap::new(),
rate_limits: HashMap::new(),
rooms: rooms,
rng: rand::thread_rng(),
}
@ -505,8 +535,8 @@ impl ChatServer {
if let Some(sessions) = self.rooms.get(&room) {
for id in sessions {
if *id != skip_id {
if let Some(addr) = self.sessions.get(id) {
let _ = addr.do_send(WSMessage(message.to_owned()));
if let Some(info) = self.sessions.get(id) {
let _ = info.addr.do_send(WSMessage(message.to_owned()));
}
}
}
@ -531,8 +561,51 @@ impl ChatServer {
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`
impl Actor for ChatServer {
/// 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 {
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
// self.send_room_message(&"Main".to_owned(), "Someone joined", 0);
// register session with random id
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
// self.rooms.get_mut(&"Main".to_owned()).unwrap().insert(id);
@ -563,6 +652,7 @@ impl Handler<Connect> for ChatServer {
}
}
/// Handler for Disconnect message.
impl Handler<Disconnect> for ChatServer {
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)?;
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 {
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();
chat.check_rate_limit_register(addr)?;
// Make sure passwords match
if &self.password != &self.password_verify {
return Err(self.error("Passwords do not match."))?
@ -871,10 +967,12 @@ impl Perform for 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();
chat.check_rate_limit_register(addr)?;
let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims,
Err(_e) => {
@ -903,6 +1001,7 @@ impl Perform for CreateCommunity {
category_id: self.category_id,
creator_id: user_id,
removed: None,
deleted: None,
updated: None,
};
@ -1016,10 +1115,12 @@ impl Perform for 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();
chat.check_rate_limit_register(addr)?;
let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims,
Err(_e) => {
@ -1051,6 +1152,7 @@ impl Perform for CreatePost {
community_id: self.community_id,
creator_id: user_id,
removed: None,
deleted: None,
locked: None,
updated: None
};
@ -1227,6 +1329,8 @@ impl Perform for CreateComment {
let conn = establish_connection();
chat.check_rate_limit(addr)?;
let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims,
Err(_e) => {
@ -1255,6 +1359,7 @@ impl Perform for CreateComment {
post_id: self.post_id,
creator_id: user_id,
removed: None,
deleted: None,
read: None,
updated: None
};
@ -1371,6 +1476,7 @@ impl Perform for EditComment {
post_id: self.post_id,
creator_id: self.creator_id,
removed: self.removed.to_owned(),
deleted: self.deleted.to_owned(),
read: self.read.to_owned(),
updated: if self.read.is_some() { orig_comment.updated } else {Some(naive_now())}
};
@ -1483,6 +1589,8 @@ impl Perform for CreateCommentLike {
let conn = establish_connection();
chat.check_rate_limit(addr)?;
let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims,
Err(_e) => {
@ -1611,10 +1719,12 @@ impl Perform for 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();
chat.check_rate_limit(addr)?;
let claims = match Claims::decode(&self.auth) {
Ok(claims) => claims.claims,
Err(_e) => {
@ -1734,6 +1844,7 @@ impl Perform for EditPost {
creator_id: self.creator_id.to_owned(),
community_id: self.community_id,
removed: self.removed.to_owned(),
deleted: self.deleted.to_owned(),
locked: self.locked.to_owned(),
updated: Some(naive_now())
};
@ -1899,6 +2010,7 @@ impl Perform for EditCommunity {
category_id: self.category_id.to_owned(),
creator_id: user_id,
removed: self.removed.to_owned(),
deleted: self.deleted.to_owned(),
updated: Some(naive_now())
};
@ -2051,6 +2163,19 @@ impl Perform for GetUserDetails {
let conn = establish_connection();
let user_id: Option<i32> = match &self.auth {
Some(auth) => {
match Claims::decode(&auth) {
Ok(claims) => {
let user_id = claims.claims.id;
Some(user_id)
}
Err(_e) => None
}
}
None => None
};
//TODO add save
let sort = SortType::from_str(&self.sort)?;
@ -2081,7 +2206,7 @@ impl Perform for GetUserDetails {
self.community_id,
Some(user_details_id),
None,
None,
user_id,
self.saved_only,
false,
self.page,
@ -2103,7 +2228,7 @@ impl Perform for GetUserDetails {
None,
Some(user_details_id),
None,
None,
user_id,
self.saved_only,
self.page,
self.limit)?
@ -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('clean', _ => Sparky.src('dist/').clean('dist/'));
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'], _ => {
fuse.dev();
app.hmr().watch();

View file

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

View file

@ -56,7 +56,7 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
<form onSubmit={linkEvent(this, this.handleCommentSubmit)}>
<div class="form-group row">
<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 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 &&
<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">
{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>
</li>
<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>
</>
}
@ -252,11 +254,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
handleDeleteClick(i: CommentNode) {
let deleteForm: CommentFormI = {
content: '*deleted*',
content: i.props.node.comment.content,
edit_id: i.props.node.comment.id,
creator_id: i.props.node.comment.creator_id,
post_id: i.props.node.comment.post_id,
parent_id: i.props.node.comment.parent_id,
deleted: !i.props.node.comment.deleted,
auth: null
};
WebSocketService.Instance.editComment(deleteForm);

View file

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

View file

@ -88,7 +88,7 @@ export class CommunityForm extends Component<CommunityFormProps, CommunityFormSt
<div class="form-group row">
<label class="col-12 col-form-label">Sidebar</label>
<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 class="form-group row">
@ -120,10 +120,7 @@ export class CommunityForm extends Component<CommunityFormProps, CommunityFormSt
if (i.props.community) {
WebSocketService.Instance.editCommunity(i.state.communityForm);
} else {
setTimeout(function(){
WebSocketService.Instance.createCommunity(i.state.communityForm);
}, 10000);
}
i.setState(i.state);
}

View file

@ -1,11 +1,11 @@
import { Component } from 'inferno';
import { Component, linkEvent } from 'inferno';
import { Subscription } from "rxjs";
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 { PostListings } from './post-listings';
import { Sidebar } from './sidebar';
import { msgOp } from '../utils';
import { msgOp, routeSortTypeToEnum, fetchLimit } from '../utils';
interface State {
community: CommunityI;
@ -14,6 +14,9 @@ interface State {
moderators: Array<CommunityUser>;
admins: Array<UserView>;
loading: boolean;
posts: Array<Post>;
sort: SortType;
page: number;
}
export class Community extends Component<any, State> {
@ -38,7 +41,20 @@ export class Community extends Component<any, State> {
admins: [],
communityId: Number(this.props.match.params.id),
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) {
@ -66,6 +82,16 @@ export class Community extends Component<any, State> {
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() {
return (
<div class="container">
@ -78,7 +104,9 @@ export class Community extends Component<any, State> {
<small className="ml-2 text-muted font-italic">removed</small>
}
</h5>
{this.state.community && <PostListings communityId={this.state.community.id} />}
{this.selects()}
<PostListings posts={this.state.posts} />
{this.paginator()}
</div>
<div class="col-12 col-md-3">
<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) {
console.log(msg);
@ -105,9 +199,9 @@ export class Community extends Component<any, State> {
this.state.community = res.community;
this.state.moderators = res.moderators;
this.state.admins = res.admins;
this.state.loading = false;
document.title = `/f/${this.state.community.name} - Lemmy`;
document.title = `/c/${this.state.community.name} - Lemmy`;
this.setState(this.state);
this.fetchPosts();
} else if (op == UserOperation.EditCommunity) {
let res: CommunityResponse = msg;
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.number_of_subscribers = res.community.number_of_subscribers;
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() {
document.title = "Create Forum - Lemmy";
document.title = "Create Community - Lemmy";
}
render() {
@ -18,7 +18,7 @@ export class CreateCommunity extends Component<any, any> {
<div class="container">
<div class="row">
<div class="col-12 col-lg-6 mb-4">
<h5>Create Forum</h5>
<h5>Create Community</h5>
<CommunityForm onCreate={this.handleCommunityCreate}/>
</div>
</div>
@ -27,7 +27,7 @@ export class CreateCommunity extends Component<any, any> {
}
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="col-12 col-lg-6 mb-4">
<h5>Create a Post</h5>
<PostForm onCreate={this.handlePostCreate}/>
<PostForm onCreate={this.handlePostCreate} prevCommunityName={this.prevCommunityName} />
</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) {
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="row">
<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.replies()}
{this.paginator()}
@ -71,12 +80,12 @@ export class Inbox extends Component<any, InboxState> {
selects() {
return (
<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 value={UnreadType.Unread}>Unread</option>
<option value={UnreadType.All}>All</option>
</select>
<select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select w-auto ml-2">
<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 value={SortType.New}>New</option>
<option value={SortType.TopDay}>Top Day</option>
@ -147,13 +156,17 @@ export class Inbox extends Component<any, InboxState> {
i.refetch();
}
markAllAsRead() {
WebSocketService.Instance.markAllAsRead();
}
parseMessage(msg: any) {
console.log(msg);
let op: UserOperation = msgOp(msg);
if (msg.error) {
alert(msg.error);
return;
} else if (op == UserOperation.GetReplies) {
} else if (op == UserOperation.GetReplies || op == UserOperation.MarkAllAsRead) {
let res: GetRepliesResponse = msg;
this.state.replies = res.replies;
this.sendRepliesCount();
@ -165,6 +178,7 @@ export class Inbox extends Component<any, InboxState> {
found.content = res.comment.content;
found.updated = res.comment.updated;
found.removed = res.comment.removed;
found.deleted = res.comment.deleted;
found.upvotes = res.comment.upvotes;
found.downvotes = res.comment.downvotes;
found.score = res.comment.score;

View file

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

View file

@ -2,16 +2,11 @@ import { Component, linkEvent } from 'inferno';
import { Link } from 'inferno-router';
import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, CommunityUser, GetFollowedCommunitiesResponse, ListCommunitiesForm, ListCommunitiesResponse, Community, SortType, GetSiteResponse, 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 { PostListings } from './post-listings';
import { SiteForm } from './site-form';
import { msgOp, repoUrl, mdToHtml } from '../utils';
interface MainProps {
type: ListingType;
}
import { msgOp, repoUrl, mdToHtml, fetchLimit, routeSortTypeToEnum, routeListingTypeToEnum } from '../utils';
interface MainState {
subscribedCommunities: Array<CommunityUser>;
@ -19,9 +14,13 @@ interface MainState {
site: GetSiteResponse;
showEditSite: 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 emptyState: MainState = {
@ -43,7 +42,29 @@ export class Main extends Component<MainProps, MainState> {
banned: [],
},
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) {
@ -66,37 +87,51 @@ export class Main extends Component<MainProps, MainState> {
}
let listCommunitiesForm: ListCommunitiesForm = {
sort: SortType[SortType.New],
sort: SortType[SortType.Hot],
limit: 6
}
WebSocketService.Instance.listCommunities(listCommunitiesForm);
this.handleEditCancel = this.handleEditCancel.bind(this);
this.fetchPosts();
}
componentWillUnmount() {
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() {
return (
<div class="container">
<div class="row">
<div class="col-12 col-md-8">
<PostListings type={this.props.type} />
{this.posts()}
</div>
<div class="col-12 col-md-4">
{this.state.loading ?
<h5><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
{!this.state.loading &&
<div>
{this.trendingCommunities()}
{UserService.Instance.user && this.state.subscribedCommunities.length > 0 &&
<div>
<h5>Subscribed forums</h5>
<h5>Subscribed <Link class="text-white" to="/communities">communities</Link></h5>
<ul class="list-inline">
{this.state.subscribedCommunities.map(community =>
<li class="list-inline-item"><Link to={`/f/${community.community_name}`}>{community.community_name}</Link></li>
<li class="list-inline-item"><Link to={`/c/${community.community_name}`}>{community.community_name}</Link></li>
)}
</ul>
</div>
@ -113,10 +148,10 @@ export class Main extends Component<MainProps, MainState> {
trendingCommunities() {
return (
<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">
{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>
</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() {
return (
<div>
@ -175,7 +216,7 @@ export class Main extends Component<MainProps, MainState> {
landing() {
return (
<div>
<h5>Welcome to
<h5>Powered by
<svg class="icon mx-2"><use xlinkHref="#icon-mouse"></use></svg>
<a href={repoUrl}>Lemmy<sup>Beta</sup></a>
</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 {
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);
}
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) {
console.log(msg);
let op: UserOperation = msgOp(msg);
@ -211,12 +358,10 @@ export class Main extends Component<MainProps, MainState> {
} else if (op == UserOperation.GetFollowedCommunities) {
let res: GetFollowedCommunitiesResponse = msg;
this.state.subscribedCommunities = res.communities;
this.state.loading = false;
this.setState(this.state);
} else if (op == UserOperation.ListCommunities) {
let res: ListCommunitiesResponse = msg;
this.state.trendingCommunities = res.communities;
this.state.loading = false;
this.setState(this.state);
} else if (op == UserOperation.GetSite) {
let res: GetSiteResponse = msg;
@ -234,6 +379,19 @@ export class Main extends Component<MainProps, MainState> {
this.state.site.site = res.site;
this.state.showEditSite = false;
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.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).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><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><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).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><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><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' &&
@ -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> :
<div>
<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>
</h5>
<div class="table-responsive">

View file

@ -3,7 +3,7 @@ import { Link } from 'inferno-router';
import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators';
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 { version } from '../version';
@ -12,6 +12,7 @@ interface NavbarState {
expanded: boolean;
expandUserDropdown: boolean;
unreadCount: number;
siteName: string;
}
export class Navbar extends Component<any, NavbarState> {
@ -21,7 +22,8 @@ export class Navbar extends Component<any, NavbarState> {
isLoggedIn: (UserService.Instance.user !== undefined),
unreadCount: 0,
expanded: false,
expandUserDropdown: false
expandUserDropdown: false,
siteName: undefined
}
constructor(props: any, context: any) {
@ -45,6 +47,8 @@ export class Navbar extends Component<any, NavbarState> {
(err) => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
}
render() {
@ -59,30 +63,29 @@ export class Navbar extends Component<any, NavbarState> {
}
// TODO class active corresponding to current page
// TODO toggle css collapse
navbar() {
return (
<nav class="container navbar navbar-expand-md navbar-light navbar-bg p-0 px-3">
<a title={version} class="navbar-brand" href="#">
<svg class="icon mr-2"><use xlinkHref="#icon-mouse"></use></svg>
Lemmy
</a>
<Link title={version} class="navbar-brand" to="/">
<svg class="icon mr-2 mouse-icon"><use xlinkHref="#icon-mouse"></use></svg>
{this.state.siteName}
</Link>
<button class="navbar-toggler" type="button" onClick={linkEvent(this, this.expandNavbar)}>
<span class="navbar-toggler-icon"></span>
</button>
<div className={`${!this.state.expanded && 'collapse'} navbar-collapse`}>
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<Link class="nav-link" to="/communities">Forums</Link>
<Link class="nav-link" to="/communities">Communities</Link>
</li>
<li class="nav-item">
<Link class="nav-link" to="/search">Search</Link>
</li>
<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 class="nav-item">
<Link class="nav-link" to="/create_community">Create Forum</Link>
<Link class="nav-link" to="/create_community">Create Community</Link>
</li>
</ul>
<ul class="navbar-nav ml-auto mr-2">
@ -145,6 +148,14 @@ export class Navbar extends Component<any, NavbarState> {
} else if (op == UserOperation.GetReplies) {
let res: GetRepliesResponse = msg;
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) {
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 { PostForm as PostFormI, Post, PostResponse, UserOperation, Community, ListCommunitiesResponse, ListCommunitiesForm, SortType } from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { msgOp } from '../utils';
import { msgOp, getPageTitle } from '../utils';
import * as autosize from 'autosize';
interface PostFormProps {
post?: Post; // If a post is given, that means this is an edit
prevCommunityName?: string;
onCancel?(): any;
onCreate?(id: number): any;
onEdit?(post: Post): any;
@ -17,6 +18,7 @@ interface PostFormState {
postForm: PostFormI;
communities: Array<Community>;
loading: boolean;
suggestedTitle: string;
}
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,
},
communities: [],
loading: false
loading: false,
suggestedTitle: undefined,
}
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>
<div class="col-sm-10">
<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 class="form-group row">
@ -93,13 +99,13 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
<div class="form-group row">
<label class="col-sm-2 col-form-label">Body</label>
<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>
{/* Cant change a community from an edit */}
{!this.props.post &&
<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">
<select class="form-control" value={this.state.postForm.community_id} onInput={linkEvent(this, this.handlePostCommunityChange)}>
{this.state.communities.map(community =>
@ -134,8 +140,18 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
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) {
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);
}
@ -170,6 +186,9 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
this.state.communities = res.communities;
if (this.props.post) {
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 {
this.state.postForm.community_id = res.communities[0].id;
}

View file

@ -85,6 +85,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{post.removed &&
<small className="ml-2 text-muted font-italic">removed</small>
}
{post.deleted &&
<small className="ml-2 text-muted font-italic">deleted</small>
}
{post.locked &&
<small className="ml-2 text-muted font-italic">locked</small>
}
@ -118,7 +121,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{this.props.showCommunity &&
<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>
}
</li>
@ -140,7 +143,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{UserService.Instance.user && this.props.editable &&
<ul class="list-inline mb-1 text-muted small font-weight-bold">
<li className="list-inline-item mr-2">
<span class="pointer" onClick={linkEvent(this, this.handleSavePostClick)}>{this.props.post.saved ? 'unsave' : 'save'}</span>
<span class="pointer" onClick={linkEvent(this, this.handleSavePostClick)}>{post.saved ? 'unsave' : 'save'}</span>
</li>
{this.myPost &&
<>
@ -148,7 +151,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<span class="pointer" onClick={linkEvent(this, this.handleEditClick)}>edit</span>
</li>
<li className="list-inline-item mr-2">
<span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>delete</span>
<span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>
{!post.deleted ? 'delete' : 'restore'}
</span>
</li>
</>
}
@ -237,12 +242,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
handleDeleteClick(i: PostListing) {
let deleteForm: PostFormI = {
body: '',
body: i.props.post.body,
community_id: i.props.post.community_id,
name: "deleted",
url: '',
name: i.props.post.name,
url: i.props.post.url,
edit_id: i.props.post.id,
creator_id: i.props.post.creator_id,
deleted: !i.props.post.deleted,
auth: null
};
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 { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, Post, GetPostsForm, SortType, ListingType, GetPostsResponse, CreatePostLikeResponse, CommunityUser} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { Post } from '../interfaces';
import { PostListing } from './post-listing';
import { msgOp, fetchLimit } from '../utils';
interface PostListingsProps {
type?: ListingType;
communityId?: number;
}
interface PostListingsState {
moderators: Array<CommunityUser>;
posts: Array<Post>;
sortType: SortType;
type_: ListingType;
page: number;
loading: boolean;
showCommunity?: boolean;
}
export class PostListings extends Component<PostListingsProps, PostListingsState> {
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
}
export class PostListings extends Component<PostListingsProps, any> {
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
(msg) => this.parseMessage(msg),
(err) => console.error(err),
() => console.log('complete')
);
this.refetch();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
render() {
return (
<div>
{this.state.loading ?
<h5><svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg></h5> :
<div>
{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()}
{this.props.posts.length > 0 ? this.props.posts.map(post =>
<PostListing post={post} showCommunity={this.props.showCommunity} />) :
<div>No posts. {this.props.showCommunity !== undefined && <span>Subscribe to some <Link to="/communities">communities</Link>.</span>}
</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.updated = res.comment.updated;
found.removed = res.comment.removed;
found.deleted = res.comment.deleted;
found.upvotes = res.comment.upvotes;
found.downvotes = res.comment.downvotes;
found.score = res.comment.score;

View file

@ -97,13 +97,13 @@ export class Search extends Component<any, SearchState> {
selects() {
return (
<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 value={SearchType.Both}>Both</option>
<option value={SearchType.Comments}>Comments</option>
<option value={SearchType.Posts}>Posts</option>
</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 value={SortType.New}>New</option>
<option value={SortType.TopDay}>Top Day</option>

View file

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

View file

@ -56,8 +56,11 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
{community.removed &&
<small className="ml-2 text-muted font-italic">removed</small>
}
{community.deleted &&
<small className="ml-2 text-muted font-italic">deleted</small>
}
</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">
{this.canMod &&
<>
@ -66,7 +69,9 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
</li>
{this.amCreator &&
<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>
}
</>
@ -142,9 +147,18 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
this.setState(this.state);
}
// TODO no deleting communities yet
// handleDeleteClick(i: Sidebar, event) {
// }
handleDeleteClick(i: Sidebar) {
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) {
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);
}
handleDeleteClick() {
}
handleModRemoveShow(i: Sidebar) {
i.state.showRemoveDialog = true;
i.setState(i.state);

View file

@ -49,7 +49,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
<div class="form-group row">
<label class="col-12 col-form-label">Sidebar</label>
<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 class="form-group row">

View file

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

View file

@ -4,7 +4,7 @@ import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, Post, Comment, CommunityUser, GetUserDetailsForm, SortType, UserDetailsResponse, UserView, CommentResponse } from '../interfaces';
import { WebSocketService } from '../services';
import { msgOp, fetchLimit } from '../utils';
import { msgOp, fetchLimit, routeSortTypeToEnum, capitalizeFirstLetter } from '../utils';
import { PostListing } from './post-listing';
import { CommentNodes } from './comment-nodes';
import { MomentTime } from './moment-time';
@ -25,6 +25,7 @@ interface UserState {
view: View;
sort: SortType;
page: number;
loading: boolean;
}
export class User extends Component<any, UserState> {
@ -47,9 +48,10 @@ export class User extends Component<any, UserState> {
moderates: [],
comments: [],
posts: [],
view: View.Overview,
sort: SortType.New,
page: 1,
loading: true,
view: this.getViewFromProps(this.props),
sort: this.getSortTypeFromProps(this.props),
page: this.getPageFromProps(this.props),
}
constructor(props: any, context: any) {
@ -71,13 +73,42 @@ export class User extends Component<any, UserState> {
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() {
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() {
return (
<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="col-12 col-md-9">
<h5>/u/{this.state.user.name}</h5>
@ -102,6 +133,7 @@ export class User extends Component<any, UserState> {
{this.follows()}
</div>
</div>
}
</div>
)
}
@ -109,14 +141,14 @@ export class User extends Component<any, UserState> {
selects() {
return (
<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 value={View.Overview}>Overview</option>
<option value={View.Comments}>Comments</option>
<option value={View.Posts}>Posts</option>
<option value={View.Saved}>Saved</option>
</select>
<select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select w-auto ml-2">
<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 value={SortType.New}>New</option>
<option value={SortType.TopDay}>Top Day</option>
@ -209,7 +241,7 @@ export class User extends Component<any, UserState> {
<h5>Moderates</h5>
<ul class="list-unstyled">
{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>
</div>
@ -227,7 +259,7 @@ export class User extends Component<any, UserState> {
<h5>Subscribed</h5>
<ul class="list-unstyled">
{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>
</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) {
i.state.page++;
i.setState(i.state);
i.updateUrl();
i.refetch();
}
prevPage(i: User) {
i.state.page--;
i.setState(i.state);
i.updateUrl();
i.refetch();
}
@ -275,6 +315,7 @@ export class User extends Component<any, UserState> {
i.state.sort = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.updateUrl();
i.refetch();
}
@ -282,6 +323,7 @@ export class User extends Component<any, UserState> {
i.state.view = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.updateUrl();
i.refetch();
}
@ -298,6 +340,7 @@ export class User extends Component<any, UserState> {
this.state.follows = res.follows;
this.state.moderates = res.moderates;
this.state.posts = res.posts;
this.state.loading = false;
document.title = `/u/${this.state.user.name} - Lemmy`;
this.setState(this.state);
} else if (op == UserOperation.EditComment) {
@ -307,6 +350,7 @@ export class User extends Component<any, UserState> {
found.content = res.comment.content;
found.updated = res.comment.updated;
found.removed = res.comment.removed;
found.deleted = res.comment.deleted;
found.upvotes = res.comment.upvotes;
found.downvotes = res.comment.downvotes;
found.score = res.comment.score;

View file

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

View file

@ -5,7 +5,7 @@
<meta charset="utf-8">
<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="apple-touch-icon" href="/static/assets/apple-touch-icon.png" />
<title>Lemmy</title>
<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>

View file

@ -1,9 +1,8 @@
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 { Footer } from './components/footer';
import { Home } from './components/home';
import { Login } from './components/login';
import { CreatePost } from './components/create-post';
import { CreateCommunity } from './components/create-community';
@ -37,18 +36,21 @@ class Index extends Component<any, any> {
return (
<HashRouter>
<Navbar />
<div class="mt-3 p-0">
<div class="mt-1 p-0">
<Switch>
<Route exact path="/all" component={Home} />
<Route exact path="/" component={Home} />
<Route path={`/home/type/:type/sort/:sort/page/:page`} component={Main} />
<Route exact path={`/`} component={Main} />
<Route path={`/login`} component={Login} />
<Route path={`/create_post`} component={CreatePost} />
<Route path={`/create_community`} component={CreateCommunity} />
<Route path={`/communities/page/:page`} component={Communities} />
<Route path={`/communities`} component={Communities} />
<Route path={`/post/:id/comment/:comment_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={`/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={`/u/:username`} component={User} />
<Route path={`/inbox`} component={Inbox} />

View file

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

View file

@ -147,6 +147,7 @@ export class WebSocketService {
}
public getUserDetails(form: GetUserDetailsForm) {
this.setAuth(form, false);
this.subject.next(this.wsSendWrapper(UserOperation.GetUserDetails, form));
}
@ -177,6 +178,12 @@ export class WebSocketService {
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) {
let send = { op: UserOperation[op], data: data };
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_container from 'markdown-it-container';
export let repoUrl = 'https://github.com/dessalines/lemmy';
@ -12,6 +13,23 @@ var md = new markdown_it({
html: true,
linkify: 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 {
@ -67,3 +85,35 @@ export function isImage(url: string) {
}
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"
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"
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-0.0.7.tgz#75070485a3d8ad11e7deb8287f4430be15bf4d39"
integrity sha512-WyL6pa76ollQFQNEaLVa41ZUUvDvPY+qAUmlsphnrpL6I9p1m868b26FyeoOmo7X3/Ta/S9WKXcEYXUSHnxoVQ==
@ -1674,6 +1681,11 @@ map-visit@^1.0.0:
dependencies:
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:
version "8.4.2"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.2.tgz#386f98998dc15a37722aa7722084f4020bdd9b54"