mirror of https://github.com/LemmyNet/lemmy
parent
907064b94e
commit
18fed27fa3
@ -0,0 +1 @@
|
||||
drop view user_view;
|
@ -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;
|
||||
|
@ -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<Self, Error> {
|
||||
use actions::user_view::user_view::dsl::*;
|
||||
|
||||
user_view.find(from_user_id)
|
||||
.first::<Self>(conn)
|
||||
}
|
||||
}
|
||||
|
@ -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<CommentFormProps, CommentFormState> {
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<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)} placeholder="Comment here" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<button type="submit" class="btn btn-sm btn-secondary mr-2">{this.state.buttonTitle}</button>
|
||||
{this.props.node && <button type="button" class="btn btn-sm btn-secondary" onClick={linkEvent(this, this.handleReplyCancel)}>Cancel</button>}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleCommentSubmit(i: CommentForm, event: any) {
|
||||
if (i.props.edit) {
|
||||
WebSocketService.Instance.editComment(i.state.commentForm);
|
||||
} else {
|
||||
WebSocketService.Instance.createComment(i.state.commentForm);
|
||||
}
|
||||
|
||||
i.state.commentForm.content = undefined;
|
||||
i.setState(i.state);
|
||||
event.target.reset();
|
||||
if (i.props.node) {
|
||||
i.props.onReplyCancel();
|
||||
}
|
||||
}
|
||||
|
||||
handleCommentContentChange(i: CommentForm, event: any) {
|
||||
i.state.commentForm.content = event.target.value;
|
||||
i.setState(i.state);
|
||||
}
|
||||
|
||||
handleReplyCancel(i: CommentForm) {
|
||||
i.props.onReplyCancel();
|
||||
}
|
||||
}
|
@ -0,0 +1,148 @@
|
||||
import { Component, linkEvent } from 'inferno';
|
||||
import { Link } from 'inferno-router';
|
||||
import { CommentNode as CommentNodeI, CommentLikeForm, CommentForm as CommentFormI } from '../interfaces';
|
||||
import { WebSocketService, UserService } from '../services';
|
||||
import { mdToHtml } from '../utils';
|
||||
import { MomentTime } from './moment-time';
|
||||
import { CommentForm } from './comment-form';
|
||||
import { CommentNodes } from './comment-nodes';
|
||||
|
||||
interface CommentNodeState {
|
||||
showReply: boolean;
|
||||
showEdit: boolean;
|
||||
}
|
||||
|
||||
interface CommentNodeProps {
|
||||
node: CommentNodeI;
|
||||
noIndent?: boolean;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
|
||||
|
||||
private emptyState: CommentNodeState = {
|
||||
showReply: false,
|
||||
showEdit: false
|
||||
}
|
||||
|
||||
constructor(props: any, context: any) {
|
||||
super(props, context);
|
||||
|
||||
this.state = this.emptyState;
|
||||
this.handleReplyCancel = this.handleReplyCancel.bind(this);
|
||||
this.handleCommentLike = this.handleCommentLike.bind(this);
|
||||
this.handleCommentDisLike = this.handleCommentDisLike.bind(this);
|
||||
}
|
||||
|
||||
render() {
|
||||
let node = this.props.node;
|
||||
return (
|
||||
<div id={`comment-${node.comment.id}`} className={`comment ${node.comment.parent_id && !this.props.noIndent ? 'ml-4' : ''}`}>
|
||||
<div className={`float-left small text-center ${this.props.viewOnly && 'no-click'}`}>
|
||||
<div className={`pointer upvote ${node.comment.my_vote == 1 ? 'text-info' : 'text-muted'}`} onClick={linkEvent(node, this.handleCommentLike)}>▲</div>
|
||||
<div>{node.comment.score}</div>
|
||||
<div className={`pointer downvote ${node.comment.my_vote == -1 && 'text-danger'}`} onClick={linkEvent(node, this.handleCommentDisLike)}>▼</div>
|
||||
</div>
|
||||
<div className="details ml-4">
|
||||
<ul class="list-inline mb-0 text-muted small">
|
||||
<li className="list-inline-item">
|
||||
<Link to={`/user/${node.comment.creator_id}`}>{node.comment.creator_name}</Link>
|
||||
</li>
|
||||
<li className="list-inline-item">
|
||||
<span>(
|
||||
<span className="text-info">+{node.comment.upvotes}</span>
|
||||
<span> | </span>
|
||||
<span className="text-danger">-{node.comment.downvotes}</span>
|
||||
<span>) </span>
|
||||
</span>
|
||||
</li>
|
||||
<li className="list-inline-item">
|
||||
<span><MomentTime data={node.comment} /></span>
|
||||
</li>
|
||||
</ul>
|
||||
{this.state.showEdit && <CommentForm node={node} edit onReplyCancel={this.handleReplyCancel} />}
|
||||
{!this.state.showEdit &&
|
||||
<div>
|
||||
<div className="md-div" dangerouslySetInnerHTML={mdToHtml(node.comment.content)} />
|
||||
<ul class="list-inline mb-1 text-muted small font-weight-bold">
|
||||
{!this.props.viewOnly &&
|
||||
<span class="mr-2">
|
||||
<li className="list-inline-item">
|
||||
<span class="pointer" onClick={linkEvent(this, this.handleReplyClick)}>reply</span>
|
||||
</li>
|
||||
{this.myComment &&
|
||||
<li className="list-inline-item">
|
||||
<span class="pointer" onClick={linkEvent(this, this.handleEditClick)}>edit</span>
|
||||
</li>
|
||||
}
|
||||
{this.myComment &&
|
||||
<li className="list-inline-item">
|
||||
<span class="pointer" onClick={linkEvent(this, this.handleDeleteClick)}>delete</span>
|
||||
</li>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
<li className="list-inline-item">
|
||||
<Link className="text-muted" to={`/post/${node.comment.post_id}/comment/${node.comment.id}`} target="_blank">link</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{this.state.showReply && <CommentForm node={node} onReplyCancel={this.handleReplyCancel} />}
|
||||
{this.props.node.children && <CommentNodes nodes={this.props.node.children} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private get myComment(): boolean {
|
||||
return UserService.Instance.loggedIn && this.props.node.comment.creator_id == UserService.Instance.user.id;
|
||||
}
|
||||
|
||||
handleReplyClick(i: CommentNode) {
|
||||
i.state.showReply = true;
|
||||
i.setState(i.state);
|
||||
}
|
||||
|
||||
handleEditClick(i: CommentNode) {
|
||||
i.state.showEdit = true;
|
||||
i.setState(i.state);
|
||||
}
|
||||
|
||||
handleDeleteClick(i: CommentNode) {
|
||||
let deleteForm: CommentFormI = {
|
||||
content: "*deleted*",
|
||||
edit_id: i.props.node.comment.id,
|
||||
post_id: i.props.node.comment.post_id,
|
||||
parent_id: i.props.node.comment.parent_id,
|
||||
auth: null
|
||||
};
|
||||
WebSocketService.Instance.editComment(deleteForm);
|
||||
}
|
||||
|
||||
handleReplyCancel() {
|
||||
this.state.showReply = false;
|
||||
this.state.showEdit = false;
|
||||
this.setState(this.state);
|
||||
}
|
||||
|
||||
|
||||
handleCommentLike(i: CommentNodeI) {
|
||||
|
||||
let form: CommentLikeForm = {
|
||||
comment_id: i.comment.id,
|
||||
post_id: i.comment.post_id,
|
||||
score: (i.comment.my_vote == 1) ? 0 : 1
|
||||
};
|
||||
WebSocketService.Instance.likeComment(form);
|
||||
}
|
||||
|
||||
handleCommentDisLike(i: CommentNodeI) {
|
||||
let form: CommentLikeForm = {
|
||||
comment_id: i.comment.id,
|
||||
post_id: i.comment.post_id,
|
||||
score: (i.comment.my_vote == -1) ? 0 : -1
|
||||
};
|
||||
WebSocketService.Instance.likeComment(form);
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import { Component } from 'inferno';
|
||||
import { CommentNode as CommentNodeI } from '../interfaces';
|
||||
import { CommentNode } from './comment-node';
|
||||
|
||||
interface CommentNodesState {
|
||||
}
|
||||
|
||||
interface CommentNodesProps {
|
||||
nodes: Array<CommentNodeI>;
|
||||
noIndent?: boolean;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export class CommentNodes extends Component<CommentNodesProps, CommentNodesState> {
|
||||
|
||||
constructor(props: any, context: any) {
|
||||
super(props, context);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="comments">
|
||||
{this.props.nodes.map(node =>
|
||||
<CommentNode node={node} noIndent={this.props.noIndent} viewOnly={this.props.viewOnly}/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,264 @@
|
||||
import { Component, linkEvent } from 'inferno';
|
||||
import { Link } from 'inferno-router';
|
||||
import { Subscription } from "rxjs";
|
||||
import { retryWhen, delay, take } from 'rxjs/operators';
|
||||
import { UserOperation, Post, Comment, CommunityUser, GetUserDetailsForm, SortType, UserDetailsResponse, UserView } from '../interfaces';
|
||||
import { WebSocketService } from '../services';
|
||||
import { msgOp } from '../utils';
|
||||
import { PostListing } from './post-listing';
|
||||
import { CommentNodes } from './comment-nodes';
|
||||
import { MomentTime } from './moment-time';
|
||||
|
||||
enum View {
|
||||
Overview, Comments, Posts, Saved
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
user: UserView;
|
||||
follows: Array<CommunityUser>;
|
||||
moderates: Array<CommunityUser>;
|
||||
comments: Array<Comment>;
|
||||
posts: Array<Post>;
|
||||
saved?: Array<Post>;
|
||||
view: View;
|
||||
sort: SortType;
|
||||
}
|
||||
|
||||
export class User extends Component<any, UserState> {
|
||||
|
||||
private subscription: Subscription;
|
||||
private emptyState: UserState = {
|
||||
user: {
|
||||
id: null,
|
||||
name: null,
|
||||
fedi_name: null,
|
||||
published: null,
|
||||
number_of_posts: null,
|
||||
post_score: null,
|
||||
number_of_comments: null,
|
||||
comment_score: null,
|
||||
},
|
||||
follows: [],
|
||||
moderates: [],
|
||||
comments: [],
|
||||
posts: [],
|
||||
view: View.Overview,
|
||||
sort: SortType.New
|
||||
}
|
||||
|
||||
constructor(props: any, context: any) {
|
||||
super(props, context);
|
||||
|
||||
this.state = this.emptyState;
|
||||
|
||||
let userId = Number(this.props.match.params.id);
|
||||
|
||||
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')
|
||||
);
|
||||
|
||||
let form: GetUserDetailsForm = {
|
||||
user_id: userId,
|
||||
sort: SortType[this.state.sort],
|
||||
limit: 999
|
||||
};
|
||||
WebSocketService.Instance.getUserDetails(form);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-9">
|
||||
<h4>/u/{this.state.user.name}</h4>
|
||||
{this.selects()}
|
||||
{this.state.view == View.Overview &&
|
||||
this.overview()
|
||||
}
|
||||
{this.state.view == View.Comments &&
|
||||
this.comments()
|
||||
}
|
||||
{this.state.view == View.Posts &&
|
||||
this.posts()
|
||||
}
|
||||
</div>
|
||||
<div class="col-12 col-lg-3">
|
||||
{this.userInfo()}
|
||||
{this.moderates()}
|
||||
{this.follows()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
selects() {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<select value={this.state.view} onChange={linkEvent(this, this.handleViewChange)} class="custom-select 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">
|
||||
<option disabled>Sort Type</option>
|
||||
<option value={SortType.New}>New</option>
|
||||
<option value={SortType.TopDay}>Top Day</option>
|
||||
<option value={SortType.TopWeek}>Week</option>
|
||||
<option value={SortType.TopMonth}>Month</option>
|
||||
<option value={SortType.TopYear}>Year</option>
|
||||
<option value={SortType.TopAll}>All</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
overview() {
|
||||
let combined: Array<any> = [];
|
||||
combined.push(...this.state.comments);
|
||||
combined.push(...this.state.posts);
|
||||
|
||||
// Sort it
|
||||
if (this.state.sort == SortType.New) {
|
||||
combined.sort((a, b) => b.published.localeCompare(a.published));
|
||||
} else {
|
||||
combined.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{combined.map(i =>
|
||||
<div>
|
||||
{i.community_id
|
||||
? <PostListing post={i} showCommunity viewOnly />
|
||||
: <CommentNodes nodes={[{comment: i}]} noIndent viewOnly />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
comments() {
|
||||
return (
|
||||
<div>
|
||||
{this.state.comments.map(comment =>
|
||||
<CommentNodes nodes={[{comment: comment}]} noIndent viewOnly />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
posts() {
|
||||
return (
|
||||
<div>
|
||||
{this.state.posts.map(post =>
|
||||
<PostListing post={post} showCommunity viewOnly />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
userInfo() {
|
||||
let user = this.state.user;
|
||||
return (
|
||||
<div>
|
||||
<h4>{user.name}</h4>
|
||||
<div>Joined <MomentTime data={user} /></div>
|
||||
<table class="table table-bordered table-sm mt-2">
|
||||
<tr>
|
||||
<td>{user.post_score} points</td>
|
||||
<td>{user.number_of_posts} posts</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{user.comment_score} points</td>
|
||||
<td>{user.number_of_comments} comments</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
moderates() {
|
||||
return (
|
||||
<div>
|
||||
{this.state.moderates.length > 0 &&
|
||||
<div>
|
||||
<h4>Moderates</h4>
|
||||
<ul class="list-unstyled">
|
||||
{this.state.moderates.map(community =>
|
||||
<li><Link to={`/community/${community.community_id}`}>{community.community_name}</Link></li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
follows() {
|
||||
return (
|
||||
<div>
|
||||
{this.state.follows.length > 0 &&
|
||||
<div>
|
||||
<hr />
|
||||
<h4>Subscribed</h4>
|
||||
<ul class="list-unstyled">
|
||||
{this.state.follows.map(community =>
|
||||
<li><Link to={`/community/${community.community_id}`}>{community.community_name}</Link></li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
handleSortChange(i: User, event: any) {
|
||||
i.state.sort = Number(event.target.value);
|
||||
i.setState(i.state);
|
||||
|
||||
let form: GetUserDetailsForm = {
|
||||
user_id: i.state.user.id,
|
||||
sort: SortType[i.state.sort],
|
||||
limit: 999
|
||||
};
|
||||
WebSocketService.Instance.getUserDetails(form);
|
||||
}
|
||||
|
||||
handleViewChange(i: User, event: any) {
|
||||
i.state.view = Number(event.target.value);
|
||||
i.setState(i.state);
|
||||
}
|
||||
|
||||
parseMessage(msg: any) {
|
||||
console.log(msg);
|
||||
let op: UserOperation = msgOp(msg);
|
||||
if (msg.error) {
|
||||
alert(msg.error);
|
||||
return;
|
||||
} else if (op == UserOperation.GetUserDetails) {
|
||||
let res: UserDetailsResponse = msg;
|
||||
this.state.user = res.user;
|
||||
this.state.comments = res.comments;
|
||||
this.state.follows = res.follows;
|
||||
this.state.moderates = res.moderates;
|
||||
this.state.posts = res.posts;
|
||||
this.setState(this.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue