From 49bf16e7d451388d894f93a994f3bf18571f9594 Mon Sep 17 00:00:00 2001 From: Dessalines Date: Sun, 7 Apr 2019 22:19:02 -0700 Subject: Adding user details / overview page. - Fixes #19 --- README.md | 11 +- .../2019-02-27-170003_create_community/up.sql | 4 +- .../2019-04-08-015947_create_user_view/down.sql | 1 + .../2019-04-08-015947_create_user_view/up.sql | 11 + server/src/actions/comment_view.rs | 50 +++- server/src/actions/mod.rs | 1 + server/src/actions/post_view.rs | 57 +++-- server/src/actions/user_view.rs | 40 +++ server/src/lib.rs | 8 +- server/src/websocket_server/server.rs | 100 +++++++- ui/package.json | 3 + ui/src/components/comment-form.tsx | 93 +++++++ ui/src/components/comment-node.tsx | 148 +++++++++++ ui/src/components/comment-nodes.tsx | 30 +++ ui/src/components/communities.tsx | 12 +- ui/src/components/community-form.tsx | 22 +- ui/src/components/community.tsx | 12 +- ui/src/components/create-community.tsx | 4 +- ui/src/components/create-post.tsx | 4 +- ui/src/components/home.tsx | 1 - ui/src/components/login.tsx | 20 +- ui/src/components/main.tsx | 10 +- ui/src/components/moment-time.tsx | 4 +- ui/src/components/navbar.tsx | 7 +- ui/src/components/post-form.tsx | 23 +- ui/src/components/post-listing.tsx | 21 +- ui/src/components/post-listings.tsx | 36 ++- ui/src/components/post.tsx | 279 ++------------------- ui/src/components/sidebar.tsx | 10 +- ui/src/components/user.tsx | 264 +++++++++++++++++++ ui/src/index.tsx | 6 +- ui/src/interfaces.ts | 41 ++- ui/src/main.css | 9 + ui/src/services/WebSocketService.ts | 9 +- ui/src/utils.ts | 1 - ui/yarn.lock | 36 +++ 36 files changed, 969 insertions(+), 419 deletions(-) create mode 100644 server/migrations/2019-04-08-015947_create_user_view/down.sql create mode 100644 server/migrations/2019-04-08-015947_create_user_view/up.sql create mode 100644 server/src/actions/user_view.rs create mode 100644 ui/src/components/comment-form.tsx create mode 100644 ui/src/components/comment-node.tsx create mode 100644 ui/src/components/comment-nodes.tsx create mode 100644 ui/src/components/user.tsx diff --git a/README.md b/README.md index 96d81ead..1c518a4e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

Lemmy

+

Lemmy

[![Build Status](https://travis-ci.org/dessalines/lemmy.svg?branch=master)](https://travis-ci.org/dessalines/lemmy) [![star this repo](http://githubbadges.com/star.svg?user=dessalines&repo=lemmy&style=flat)](https://github.com/dessalines/lemmy) @@ -19,6 +19,15 @@ Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Infern ## Features - TBD +- +the name + +Lead singer from motorhead. +The old school video game. +The furry rodents. + +Goals r/ censorship + ## Install ### Docker ``` diff --git a/server/migrations/2019-02-27-170003_create_community/up.sql b/server/migrations/2019-02-27-170003_create_community/up.sql index f78486d5..46b4df52 100644 --- a/server/migrations/2019-02-27-170003_create_community/up.sql +++ b/server/migrations/2019-02-27-170003_create_community/up.sql @@ -31,8 +31,6 @@ insert into category (name) values ('Meta'), ('Other'); - - create table community ( id serial primary key, name varchar(20) not null unique, @@ -58,4 +56,4 @@ create table community_follower ( published timestamp not null default now() ); -insert into community (name, title, category_id, creator_id) values ('main', 'The default Community', 1, 1); +insert into community (name, title, category_id, creator_id) values ('main', 'The Default Community', 1, 1); diff --git a/server/migrations/2019-04-08-015947_create_user_view/down.sql b/server/migrations/2019-04-08-015947_create_user_view/down.sql new file mode 100644 index 00000000..c94d94c4 --- /dev/null +++ b/server/migrations/2019-04-08-015947_create_user_view/down.sql @@ -0,0 +1 @@ +drop view user_view; diff --git a/server/migrations/2019-04-08-015947_create_user_view/up.sql b/server/migrations/2019-04-08-015947_create_user_view/up.sql new file mode 100644 index 00000000..69d052de --- /dev/null +++ b/server/migrations/2019-04-08-015947_create_user_view/up.sql @@ -0,0 +1,11 @@ +create view user_view as +select id, +name, +fedi_name, +published, +(select count(*) from post p where p.creator_id = u.id) as number_of_posts, +(select coalesce(sum(score), 0) from post p, post_like pl where u.id = p.creator_id and p.id = pl.post_id) as post_score, +(select count(*) from comment c where c.creator_id = u.id) as number_of_comments, +(select coalesce(sum(score), 0) from comment c, comment_like cl where u.id = c.creator_id and c.id = cl.comment_id) as comment_score +from user_ u; + diff --git a/server/src/actions/comment_view.rs b/server/src/actions/comment_view.rs index dcfcc250..417a6772 100644 --- a/server/src/actions/comment_view.rs +++ b/server/src/actions/comment_view.rs @@ -1,7 +1,9 @@ extern crate diesel; use diesel::*; use diesel::result::Error; +use diesel::dsl::*; use serde::{Deserialize, Serialize}; +use { SortType }; // The faked schema since diesel doesn't do views table! { @@ -42,33 +44,61 @@ pub struct CommentView { impl CommentView { - pub fn list(conn: &PgConnection, from_post_id: i32, from_user_id: Option) -> Result, Error> { + pub fn list(conn: &PgConnection, + sort: &SortType, + for_post_id: Option, + for_creator_id: Option, + my_user_id: Option, + limit: i64) -> Result, Error> { use actions::comment_view::comment_view::dsl::*; - use diesel::prelude::*; - let mut query = comment_view.into_boxed(); + let mut query = comment_view.limit(limit).into_boxed(); // The view lets you pass a null user_id, if you're not logged in - if let Some(from_user_id) = from_user_id { - query = query.filter(user_id.eq(from_user_id)); + if let Some(my_user_id) = my_user_id { + query = query.filter(user_id.eq(my_user_id)); } else { query = query.filter(user_id.is_null()); } - query = query.filter(post_id.eq(from_post_id)).order_by(published.desc()); + if let Some(for_creator_id) = for_creator_id { + query = query.filter(creator_id.eq(for_creator_id)); + }; + + if let Some(for_post_id) = for_post_id { + query = query.filter(post_id.eq(for_post_id)); + }; + + query = match sort { + // SortType::Hot => query.order_by(hot_rank.desc()), + SortType::New => query.order_by(published.desc()), + SortType::TopAll => query.order_by(score.desc()), + SortType::TopYear => query + .filter(published.gt(now - 1.years())) + .order_by(score.desc()), + SortType::TopMonth => query + .filter(published.gt(now - 1.months())) + .order_by(score.desc()), + SortType::TopWeek => query + .filter(published.gt(now - 1.weeks())) + .order_by(score.desc()), + SortType::TopDay => query + .filter(published.gt(now - 1.days())) + .order_by(score.desc()), + _ => query.order_by(published.desc()) + }; query.load::(conn) } - pub fn read(conn: &PgConnection, from_comment_id: i32, from_user_id: Option) -> Result { + pub fn read(conn: &PgConnection, from_comment_id: i32, my_user_id: Option) -> Result { use actions::comment_view::comment_view::dsl::*; - use diesel::prelude::*; let mut query = comment_view.into_boxed(); // The view lets you pass a null user_id, if you're not logged in - if let Some(from_user_id) = from_user_id { - query = query.filter(user_id.eq(from_user_id)); + if let Some(my_user_id) = my_user_id { + query = query.filter(user_id.eq(my_user_id)); } else { query = query.filter(user_id.is_null()); } diff --git a/server/src/actions/mod.rs b/server/src/actions/mod.rs index c17fd81a..819d5cda 100644 --- a/server/src/actions/mod.rs +++ b/server/src/actions/mod.rs @@ -6,3 +6,4 @@ pub mod post_view; pub mod comment_view; pub mod category; pub mod community_view; +pub mod user_view; diff --git a/server/src/actions/post_view.rs b/server/src/actions/post_view.rs index 1db15ea6..6afba18d 100644 --- a/server/src/actions/post_view.rs +++ b/server/src/actions/post_view.rs @@ -1,18 +1,15 @@ extern crate diesel; use diesel::*; use diesel::result::Error; +use diesel::dsl::*; use serde::{Deserialize, Serialize}; +use { SortType }; #[derive(EnumString,ToString,Debug, Serialize, Deserialize)] -pub enum ListingType { +pub enum PostListingType { All, Subscribed, Community } -#[derive(EnumString,ToString,Debug, Serialize, Deserialize)] -pub enum ListingSortType { - Hot, New, TopDay, TopWeek, TopMonth, TopYear, TopAll -} - // The faked schema since diesel doesn't do views table! { post_view (id) { @@ -62,45 +59,53 @@ pub struct PostView { } impl PostView { - pub fn list(conn: &PgConnection, type_: ListingType, sort: ListingSortType, from_community_id: Option, from_user_id: Option, limit: i64) -> Result, Error> { + pub fn list(conn: &PgConnection, + type_: PostListingType, + sort: &SortType, + for_community_id: Option, + for_creator_id: Option, + my_user_id: Option, + limit: i64) -> Result, Error> { use actions::post_view::post_view::dsl::*; - use diesel::dsl::*; - use diesel::prelude::*; let mut query = post_view.limit(limit).into_boxed(); - if let Some(from_community_id) = from_community_id { - query = query.filter(community_id.eq(from_community_id)); + if let Some(for_community_id) = for_community_id { + query = query.filter(community_id.eq(for_community_id)); + }; + + if let Some(for_creator_id) = for_creator_id { + query = query.filter(creator_id.eq(for_creator_id)); }; match type_ { - ListingType::Subscribed => { + PostListingType::Subscribed => { query = query.filter(subscribed.eq(true)); }, _ => {} }; // The view lets you pass a null user_id, if you're not logged in - if let Some(from_user_id) = from_user_id { - query = query.filter(user_id.eq(from_user_id)); + if let Some(my_user_id) = my_user_id { + query = query.filter(user_id.eq(my_user_id)); } else { query = query.filter(user_id.is_null()); } query = match sort { - ListingSortType::Hot => query.order_by(hot_rank.desc()), - ListingSortType::New => query.order_by(published.desc()), - ListingSortType::TopAll => query.order_by(score.desc()), - ListingSortType::TopYear => query + SortType::Hot => query.order_by(hot_rank.desc()), + SortType::New => query.order_by(published.desc()), + SortType::TopAll => query.order_by(score.desc()), + SortType::TopYear => query .filter(published.gt(now - 1.years())) .order_by(score.desc()), - ListingSortType::TopMonth => query + SortType::TopMonth => query .filter(published.gt(now - 1.months())) .order_by(score.desc()), - ListingSortType::TopWeek => query + SortType::TopWeek => query .filter(published.gt(now - 1.weeks())) .order_by(score.desc()), - ListingSortType::TopDay => query + SortType::TopDay => query .filter(published.gt(now - 1.days())) .order_by(score.desc()) }; @@ -109,7 +114,7 @@ impl PostView { } - pub fn read(conn: &PgConnection, from_post_id: i32, from_user_id: Option) -> Result { + pub fn read(conn: &PgConnection, from_post_id: i32, my_user_id: Option) -> Result { use actions::post_view::post_view::dsl::*; use diesel::prelude::*; @@ -118,8 +123,8 @@ impl PostView { query = query.filter(id.eq(from_post_id)); - if let Some(from_user_id) = from_user_id { - query = query.filter(user_id.eq(from_user_id)); + if let Some(my_user_id) = my_user_id { + query = query.filter(user_id.eq(my_user_id)); } else { query = query.filter(user_id.is_null()); }; @@ -244,8 +249,8 @@ mod tests { }; - let read_post_listings_with_user = PostView::list(&conn, ListingType::Community, ListingSortType::New, Some(inserted_community.id), Some(inserted_user.id), 10).unwrap(); - let read_post_listings_no_user = PostView::list(&conn, ListingType::Community, ListingSortType::New, Some(inserted_community.id), None, 10).unwrap(); + let read_post_listings_with_user = PostView::list(&conn, PostListingType::Community, SortType::New, Some(inserted_community.id), Some(inserted_user.id), 10).unwrap(); + let read_post_listings_no_user = PostView::list(&conn, PostListingType::Community, SortType::New, Some(inserted_community.id), None, 10).unwrap(); let read_post_listing_no_user = PostView::read(&conn, inserted_post.id, None).unwrap(); let read_post_listing_with_user = PostView::read(&conn, inserted_post.id, Some(inserted_user.id)).unwrap(); diff --git a/server/src/actions/user_view.rs b/server/src/actions/user_view.rs new file mode 100644 index 00000000..5873a5c8 --- /dev/null +++ b/server/src/actions/user_view.rs @@ -0,0 +1,40 @@ +extern crate diesel; +use diesel::*; +use diesel::result::Error; +use serde::{Deserialize, Serialize}; + +table! { + user_view (id) { + id -> Int4, + name -> Varchar, + fedi_name -> Varchar, + published -> Timestamp, + number_of_posts -> BigInt, + post_score -> BigInt, + number_of_comments -> BigInt, + comment_score -> BigInt, + } +} + +#[derive(Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize,QueryableByName,Clone)] +#[table_name="user_view"] +pub struct UserView { + pub id: i32, + pub name: String, + pub fedi_name: String, + pub published: chrono::NaiveDateTime, + pub number_of_posts: i64, + pub post_score: i64, + pub number_of_comments: i64, + pub comment_score: i64, +} + +impl UserView { + pub fn read(conn: &PgConnection, from_user_id: i32) -> Result { + use actions::user_view::user_view::dsl::*; + + user_view.find(from_user_id) + .first::(conn) + } +} + diff --git a/server/src/lib.rs b/server/src/lib.rs index 0d81d507..9cdbd33e 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -24,6 +24,8 @@ use diesel::result::Error; use dotenv::dotenv; use std::env; use regex::Regex; +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, NaiveDateTime, Utc}; pub trait Crud { fn create(conn: &PgConnection, form: &T) -> Result where Self: Sized; @@ -73,7 +75,11 @@ impl Settings { } } -use chrono::{DateTime, NaiveDateTime, Utc}; +#[derive(EnumString,ToString,Debug, Serialize, Deserialize)] +pub enum SortType { + Hot, New, TopDay, TopWeek, TopMonth, TopYear, TopAll +} + pub fn to_datetime_utc(ndt: NaiveDateTime) -> DateTime { DateTime::::from_utc(ndt, Utc) } diff --git a/server/src/websocket_server/server.rs b/server/src/websocket_server/server.rs index 6aae4f2f..e116fadc 100644 --- a/server/src/websocket_server/server.rs +++ b/server/src/websocket_server/server.rs @@ -10,7 +10,7 @@ use serde_json::{Value}; use bcrypt::{verify}; use std::str::FromStr; -use {Crud, Joinable, Likeable, Followable, establish_connection, naive_now}; +use {Crud, Joinable, Likeable, Followable, establish_connection, naive_now, SortType}; use actions::community::*; use actions::user::*; use actions::post::*; @@ -19,10 +19,11 @@ use actions::post_view::*; use actions::comment_view::*; use actions::category::*; use actions::community_view::*; +use actions::user_view::*; #[derive(EnumString,ToString,Debug)] pub enum UserOperation { - Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, EditCommunity, FollowCommunity, GetFollowedCommunities + Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails } #[derive(Serialize, Deserialize)] @@ -272,6 +273,26 @@ pub struct GetFollowedCommunitiesResponse { communities: Vec } +#[derive(Serialize, Deserialize)] +pub struct GetUserDetails { + user_id: i32, + sort: String, + limit: i64, + community_id: Option, + auth: Option +} + +#[derive(Serialize, Deserialize)] +pub struct GetUserDetailsResponse { + op: String, + user: UserView, + follows: Vec, + moderates: Vec, + comments: Vec, + posts: Vec, + saved_posts: Vec, + saved_comments: Vec, +} /// `ChatServer` manages chat rooms and responsible for coordinating chat /// session. implementation is super primitive @@ -466,13 +487,17 @@ impl Handler for ChatServer { let followed_communities: GetFollowedCommunities = serde_json::from_str(&data.to_string()).unwrap(); followed_communities.perform(self, msg.id) }, - _ => { - let e = ErrorMessage { - op: "Unknown".to_string(), - error: "Unknown User Operation".to_string() - }; - serde_json::to_string(&e).unwrap() - } + UserOperation::GetUserDetails => { + let get_user_details: GetUserDetails = serde_json::from_str(&data.to_string()).unwrap(); + get_user_details.perform(self, msg.id) + }, + // _ => { + // let e = ErrorMessage { + // op: "Unknown".to_string(), + // error: "Unknown User Operation".to_string() + // }; + // serde_json::to_string(&e).unwrap() + // } }; MessageResult(res) @@ -808,7 +833,7 @@ impl Perform for GetPost { chat.rooms.get_mut(&self.id).unwrap().insert(addr); - let comments = CommentView::list(&conn, self.id, user_id).unwrap(); + let comments = CommentView::list(&conn, &SortType::New, Some(self.id), None, user_id, 999).unwrap(); let community = CommunityView::read(&conn, post_view.community_id, user_id).unwrap(); @@ -1110,10 +1135,10 @@ impl Perform for GetPosts { None => None }; - let type_ = ListingType::from_str(&self.type_).expect("listing type"); - let sort = ListingSortType::from_str(&self.sort).expect("listing sort"); + let type_ = PostListingType::from_str(&self.type_).expect("listing type"); + let sort = SortType::from_str(&self.sort).expect("listing sort"); - let posts = match PostView::list(&conn, type_, sort, self.community_id, user_id, self.limit) { + let posts = match PostView::list(&conn, type_, &sort, self.community_id, None, user_id, self.limit) { Ok(posts) => posts, Err(_e) => { eprintln!("{}", _e); @@ -1412,6 +1437,55 @@ impl Perform for GetFollowedCommunities { } } +impl Perform for GetUserDetails { + fn op_type(&self) -> UserOperation { + UserOperation::GetUserDetails + } + + fn perform(&self, _chat: &mut ChatServer, _addr: usize) -> String { + + let conn = establish_connection(); + + let user_id: Option = match &self.auth { + Some(auth) => { + match Claims::decode(&auth) { + Ok(claims) => { + let user_id = claims.claims.id; + Some(user_id) + } + Err(_e) => None + } + } + None => None + }; + + + //TODO add save + let sort = SortType::from_str(&self.sort).expect("listing sort"); + + let user_view = UserView::read(&conn, self.user_id).unwrap(); + let posts = PostView::list(&conn, PostListingType::All, &sort, self.community_id, Some(self.user_id), user_id, self.limit).unwrap(); + let comments = CommentView::list(&conn, &sort, None, Some(self.user_id), user_id, self.limit).unwrap(); + let follows = CommunityFollowerView::for_user(&conn, self.user_id).unwrap(); + let moderates = CommunityModeratorView::for_user(&conn, self.user_id).unwrap(); + + // Return the jwt + serde_json::to_string( + &GetUserDetailsResponse { + op: self.op_type().to_string(), + user: user_view, + follows: follows, + moderates: moderates, + comments: comments, + posts: posts, + saved_posts: Vec::new(), + saved_comments: Vec::new(), + } + ) + .unwrap() + } +} + // impl Handler for ChatServer { // type Result = MessageResult; diff --git a/ui/package.json b/ui/package.json index 1b82db12..b5bb14ef 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,7 +15,10 @@ }, "engineStrict": true, "dependencies": { + "@types/autosize": "^3.0.6", "@types/js-cookie": "^2.2.1", + "@types/jwt-decode": "^2.2.1", + "@types/markdown-it": "^0.0.7", "autosize": "^4.0.2", "classcat": "^1.1.3", "dotenv": "^6.1.0", diff --git a/ui/src/components/comment-form.tsx b/ui/src/components/comment-form.tsx new file mode 100644 index 00000000..a87dd356 --- /dev/null +++ b/ui/src/components/comment-form.tsx @@ -0,0 +1,93 @@ +import { Component, linkEvent } from 'inferno'; +import { CommentNode as CommentNodeI, CommentForm as CommentFormI } from '../interfaces'; +import { WebSocketService } from '../services'; +import * as autosize from 'autosize'; + +interface CommentFormProps { + postId?: number; + node?: CommentNodeI; + onReplyCancel?(): any; + edit?: boolean; +} + +interface CommentFormState { + commentForm: CommentFormI; + buttonTitle: string; +} + +export class CommentForm extends Component { + + private emptyState: CommentFormState = { + commentForm: { + auth: null, + content: null, + post_id: this.props.node ? this.props.node.comment.post_id : this.props.postId + }, + buttonTitle: !this.props.node ? "Post" : this.props.edit ? "Edit" : "Reply" + } + + constructor(props: any, context: any) { + super(props, context); + + this.state = this.emptyState; + + if (this.props.node) { + if (this.props.edit) { + this.state.commentForm.edit_id = this.props.node.comment.id; + this.state.commentForm.parent_id = this.props.node.comment.parent_id; + this.state.commentForm.content = this.props.node.comment.content; + } else { + // A reply gets a new parent id + this.state.commentForm.parent_id = this.props.node.comment.id; + } + } + } + + componentDidMount() { + autosize(document.querySelectorAll('textarea')); + } + + render() { + return ( +
+
+
+
+