You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

708 lines
22 KiB

import React, { Component } from 'react';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
Community,
Post as PostI,
GetPostResponse,
PostResponse,
Comment,
CommentResponse,
CommentSortType,
CommentViewType,
CommunityUser,
CommunityResponse,
CommentNode as CommentNodeI,
BanFromCommunityResponse,
BanUserResponse,
AddModToCommunityResponse,
AddAdminResponse,
AddSitemodResponse,
SearchType,
SortType,
SearchForm,
SearchResponse,
GetSiteResponse,
WebSocketJsonResponse,
MarkCommentReadForm,
FollowCommunityForm,
GetCommentResponse,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import {
wsJsonToRes,
toast,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeRes,
commentsToFlatNodes,
setupTippy,
commentFetchLimit,
debounce,
isCommentChanged,
isPostChanged,
api,
isMod,
} from '../utils';
import { PostListing } from './post-listing';
import { Sidebar } from './sidebar';
import { MemoizedCommentForm } from './comment-form';
import { CommentNodes } from './comment-nodes';
import autosize from 'autosize';
import { i18n } from '../i18next';
import { SpinnerSection } from './Spinner';
import { Icon } from './icon';
import { Box, Label, Select } from 'theme-ui';
import { Flex } from './elements/Block';
import Header, { Separator } from './Header';
import Button, { ResponsiveButton } from './elements/Button';
import Tooltip from './Tooltip';
import StyledLink from '../StyledLink';
import { siteSubject } from '../services/SiteService';
import { AxiosError } from 'axios';
import update from 'immutability-helper';
import { List, Map } from 'immutable';
interface PostState {
post: PostI;
comments: List<Comment>;
commentLoadTo: number;
commentSort: CommentSortType;
commentViewType: CommentViewType;
community: Community;
moderators: Array<CommunityUser>;
online: number;
scrolled?: boolean;
scrolled_comment_id?: number;
loading: boolean;
crossPosts: Array<PostI>;
siteRes: GetSiteResponse;
}
export class Post extends Component<any, PostState> {
private subscription: Subscription;
private siteSub: Subscription;
private debouncedScroll;
private emptyState: PostState = {
// if the user routed to this page, they already have most of the post data, so show it immediately
post: this.props?.location?.state?.post || null,
comments: List(),
commentLoadTo: commentFetchLimit,
commentSort: CommentSortType.Active,
commentViewType: CommentViewType.Tree,
community: null,
moderators: [],
online: null,
scrolled: false,
loading: true,
crossPosts: [],
siteRes: {
admins: [],
sitemods: [],
site: {
id: undefined,
name: undefined,
creator_id: undefined,
published: undefined,
creator_name: undefined,
number_of_users: undefined,
number_of_posts: undefined,
number_of_comments: undefined,
number_of_communities: undefined,
enable_create_communities: false,
enable_downvotes: true,
open_registration: undefined,
enable_nsfw: true,
autosubscribe_comms: [],
},
online: null,
},
};
state = this.emptyState;
componentWillUnmount(): void {
this.subscription.unsubscribe();
this.siteSub.unsubscribe();
window.removeEventListener('scroll', this.debouncedScroll);
}
componentDidMount(): void {
window.scrollTo(0, 0);
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.siteSub = siteSubject.subscribe(res => {
this.setState({ siteRes: res });
});
this.fetch();
this.debouncedScroll = debounce(this.updateScroll(this), 500);
autosize(document.querySelectorAll('textarea'));
window.addEventListener('scroll', this.debouncedScroll, false);
}
async fetch(): Promise<void> {
const commentId: number | null = this.props.match.params.comment_id;
if (commentId) {
const params = new URLSearchParams({
comment_id: commentId,
} as any);
if (UserService.Instance.user) {
params.append('auth', UserService.Instance.auth);
}
//TODO: maybe live updates when viewing a single comment chain?
WebSocketService.Instance.leaveAll();
const res = await api.get(`comment?${params.toString()}`);
const data = (res.data as unknown) as GetCommentResponse;
this.setState({
post: data.post,
comments: List(data.comments),
community: data.community,
moderators: data.moderators,
online: 1,
scrolled_comment_id: commentId,
loading: false,
});
} else {
const params = new URLSearchParams({
id: this.props.match.params.id,
});
const auth = UserService.Instance.auth;
if (auth) {
params.append('auth', auth);
}
api
.get<GetPostResponse>(`/post?${params.toString()}`)
.then(res => {
const data = res.data;
this.setState(
prev => ({
post: data.post,
comments: List(data.comments),
community: data.community,
moderators: data.moderators,
siteRes: {
...prev.siteRes,
admins: data.admins,
sitemods: data.sitemods,
},
online: data.online + 1, // have to add 1 to count yourself
loading: false,
}),
() => {
//join websocket room for this post & leave rooms everywhere else.
WebSocketService.Instance.postJoin(this.state.post.id);
document.title = `${this.state.post.name} - ${this.state.siteRes.site.name}`;
// Get cross-posts
if (this.state.post.url) {
let form: SearchForm = {
q: this.state.post.url,
type_: SearchType[SearchType.Url],
sort: SortType[SortType.TopAll],
page: 1,
limit: 6,
};
WebSocketService.Instance.search(form);
}
setupTippy();
}
);
})
.catch((err: Error | AxiosError) => {
const res = (err as AxiosError).response;
if (res) {
const data = res.data as { error: string };
toast(i18n.t(data.error), 'danger');
} else {
console.log(err);
}
this.setState({
loading: false,
});
});
}
}
componentDidUpdate(
_lastProps: any,
lastState: PostState,
_snapshot: any
): void {
if (
this.state.scrolled_comment_id &&
!this.state.scrolled &&
lastState.comments.size == 0
) {
var elmnt = document.getElementById(
`comment-${this.state.scrolled_comment_id}`
);
if (elmnt) {
elmnt.scrollIntoView({ behavior: 'smooth', block: 'center' });
elmnt.classList.add('mark');
this.state.scrolled = true;
this.markScrolledAsRead(this.state.scrolled_comment_id);
}
}
// Necessary if you are on a post and you click another post (same route)
if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
// Couldnt get a refresh working. This does for now.
location.reload();
// let currentId = this.props.match.params.id;
// WebSocketService.Instance.getPost(currentId);
// this.context.router.history.push('/sponsors');
// this.context.refresh();
// this.context.router.history.push(_lastProps.location.pathname);
}
}
markScrolledAsRead(commentId: number): void {
let found = this.state.comments.find(c => c.id == commentId);
let parent = this.state.comments.find(c => found.parent_id == c.id);
let parent_user_id = parent
? parent.creator_id
: this.state.post.creator_id;
if (
UserService.Instance.user &&
UserService.Instance.user.id == parent_user_id
) {
let form: MarkCommentReadForm = {
edit_id: found.id,
read: true,
};
WebSocketService.Instance.markCommentAsRead(form);
}
}
handleSubscribe = (communityId: number): void => {
let form: FollowCommunityForm = {
community_id: communityId,
follow: true,
};
WebSocketService.Instance.followCommunity(form);
};
handleUnsubscribe = (communityId: number): void => {
let form: FollowCommunityForm = {
community_id: communityId,
follow: false,
};
WebSocketService.Instance.followCommunity(form);
};
isLocked(): boolean {
// Returns true if the current user is not allowed
// to comment on this post.
return (
this.state.post.locked &&
(!UserService.Instance.user ||
!isMod(
[
...this.state.moderators.map(m => m.user_id),
...this.state.siteRes.sitemods.map(m => m.id),
...this.state.siteRes.admins.map(m => m.id),
],
UserService.Instance.user.id
))
);
}
render(): JSX.Element {
return (
<div className="container">
{this.state.loading && this.state.post === null ? (
<SpinnerSection />
) : (
<>
<Header
title={this.state?.community?.title}
subtitle={
<StyledLink to={`/c/${this.state?.post?.community_name}`}>
{`/c/${this.state?.post?.community_name}`}
</StyledLink>
}
details={{
[i18n.t('number_viewing_label')]: this.state.online,
[i18n.t('members')]: this.state?.community
?.number_of_subscribers,
}}
>
<Flex>
{this.state?.community?.subscribed ? (
<Button
variant="outline"
onClick={() =>
this.handleUnsubscribe(this.state?.community?.id)
}
>
{i18n.t('joined')}
</Button>
) : (
<Button
variant="outline"
onClick={() =>
this.handleSubscribe(this.state?.community?.id)
}
>
{i18n.t('subscribe')}
</Button>
)}
<Separator />
<Tooltip label={i18n.t('create_post')}>
<ResponsiveButton
as={StyledLink}
to={`/create_post?community=${this.state?.community?.name}`}
variant="primary"
mobileText={<Icon name="plus" />}
>
{i18n.t('create_post')}
</ResponsiveButton>
</Tooltip>
</Flex>
</Header>
<div className="row">
<div className="col-12 col-md-8 mb-3 main-content">
{this.state.post !== null && (
<PostListing
post={this.state.post}
showBody
showCommunity
moderators={this.state.moderators}
admins={this.state.siteRes.admins}
sitemods={this.state.siteRes.sitemods}
enableDownvotes
enableNsfw
/>
)}
<div className="mb-2" />
{!this.state.loading ? (
<>
<MemoizedCommentForm
postId={this.state.post.id}
disabled={this.isLocked()}
/>
{this.state.comments.size > 0 && this.sortRadios()}
{this.state.commentViewType == CommentViewType.Tree &&
this.commentsTree()}
{this.state.commentViewType == CommentViewType.Chat &&
this.commentsFlat()}
</>
) : (
<SpinnerSection />
)}
</div>
<aside className="flex-1 post-sidebar-container sidebar">
{/* {this.state.comments.length > 0 && this.newComments()} */}
<Box mx={2}>{!this.state.loading && this.sidebar()}</Box>
</aside>
</div>
</>
)}
</div>
);
}
sortRadios() {
return (
<>
<Box my={3} css={{ maxWidth: '125px' }}>
<Label mb={1} htmlFor="comment-sort-type">
Sort By
</Label>
<Select
value={this.state.commentSort}
onChange={this.handleCommentSortChange}
id="comment-sort-type"
>
<option value={CommentSortType.Active}>Active</option>
<option value={CommentSortType.Hot}>{i18n.t('hot')}</option>
<option value={CommentSortType.Top}>{i18n.t('top')}</option>
<option value={CommentSortType.New}>{i18n.t('new')}</option>
<option value={CommentSortType.Old}>{i18n.t('old')}</option>
</Select>
</Box>
</>
);
}
commentsFlat() {
return (
<div className="d-none d-md-block new-comments mb-3 card border-secondary sidebar-content">
<div className="card-body small">
<h6>{i18n.t('recent_comments')}</h6>
<CommentNodes
nodes={commentsToFlatNodes(this.state.comments)}
noIndent
locked={this.state.post.locked}
moderators={this.state.moderators}
admins={this.state.siteRes.admins}
sitemods={this.state.siteRes.sitemods}
postCreatorId={this.state.post.creator_id}
showContext
enableDownvotes
sort={this.state.commentSort}
/>
</div>
</div>
);
}
sidebar(): JSX.Element {
return (
<div className="mb-3">
<Sidebar
community={this.state.community}
moderators={this.state.moderators}
admins={this.state.siteRes.admins}
sitemods={this.state.siteRes.sitemods}
online={this.state.online}
enableNsfw={this.state.siteRes.site.enable_nsfw}
/>
</div>
);
}
handleCommentSortChange = (event: any) => {
this.setState({
commentSort: Number(event.target.value),
commentViewType: CommentViewType.Tree,
});
};
handleCommentViewTypeChange(i: Post, event: any) {
i.state.commentViewType = Number(event.target.value);
i.state.commentSort = CommentSortType.New;
i.setState(i.state);
}
buildCommentsTree(): List<CommentNodeI> {
let map = Map<number, CommentNodeI>();
for (let comment of this.state.comments) {
let node: CommentNodeI = {
comment: comment,
children: List(),
};
map = map.set(comment.id, { ...node });
}
let tree: List<CommentNodeI> = List();
for (let comment of this.state.comments) {
let child = map.get(comment.id);
if (comment.parent_id) {
let parent_ = map.get(comment.parent_id);
if (parent_ && parent !== undefined) {
parent_.children = parent_.children.push(child);
} else {
// for viewing a direct comment link
// the depth change is to give it a distinct color from its immediate children
child.comment.depth = -1;
tree = tree.push(child);
}
} else {
tree = tree.push(child);
}
this.setDepth(child);
}
return tree;
}
setDepth(node: CommentNodeI, i = 0): void {
for (let child of node.children) {
child.comment.depth = i;
this.setDepth(child, i + 1);
}
}
commentsTree(): JSX.Element {
let nodes = this.buildCommentsTree();
const commentId: number | null = this.props.match.params.comment_id;
const comment_parent_id: number | null = this.state.comments.find(
c => c.id == commentId
)?.parent_id;
return (
<div>
{commentId && (
<div className="alert alert-light" role="alert">
<svg className="icon icon-inline mr-2">
<use xlinkHref="#icon-alert-triangle" />
</svg>
You are viewing a single comment thread.&nbsp;&nbsp;
<a href={`/post/${this.state.post.id}`}>
Click here to view the post.
</a>
&nbsp;&nbsp;
{comment_parent_id && (
<a
href={`/post/${this.state.post.id}/comment/${comment_parent_id}`}
>
Click here to view the context.
</a>
)}
</div>
)}
<CommentNodes
nodes={nodes}
locked={this.state.post.locked}
moderators={this.state.moderators}
admins={this.state.siteRes.admins}
sitemods={this.state.siteRes.sitemods}
postCreatorId={this.state.post.creator_id}
sort={this.state.commentSort}
maxView={this.state.commentLoadTo}
enableDownvotes
/>
</div>
);
}
updateScroll(i: Post): (any) => void {
return function eventFunc(evt) {
//distance to page bottom
let toPageBottom = Math.max(
document.body.offsetHeight - (window.pageYOffset + window.innerHeight)
);
if (toPageBottom < 400) {
i.state.commentLoadTo += commentFetchLimit;
i.setState(i.state);
}
};
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.setState({ loading: false });
return;
} else if (msg.reconnect) {
this.fetch();
} else if (res.op == UserOperation.CreateComment) {
let data = res.data as CommentResponse;
if (
// make sure we filter out comments from other posts
// user might have more than one tab open
this.state.post.id == data.comment.post_id &&
// Necessary since it might be a user reply
data.recipient_ids.length == 0
) {
// this.state.comments.unshift(data.comment);
this.setState({
comments: this.state.comments.unshift(data.comment),
});
}
} else if (isCommentChanged(res.op)) {
let data = res.data as CommentResponse;
this.setState({
comments: editCommentRes(data, this.state.comments),
});
} else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse;
this.setState({
comments: saveCommentRes(data, this.state.comments),
});
setupTippy();
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
this.setState({
comments: createCommentLikeRes(data, this.state.comments),
});
} else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse;
//make sure that this is the correct post in case we're in the wrong room
if (data.post.id === this.state.post.id) {
this.setState({
post: createPostLikeRes(data, this.state.post),
});
}
} else if (isPostChanged(res.op)) {
let data = res.data as PostResponse;
if (this.state.post.id === data.post.id) {
this.state.post = data.post;
this.setState(this.state);
setupTippy();
}
} else if (res.op == UserOperation.SavePost) {
let data = res.data as PostResponse;
if (this.state.post.id === data.post.id) {
this.state.post = data.post;
this.setState(this.state);
setupTippy();
}
} else if (res.op == UserOperation.EditCommunity) {
let data = res.data as CommunityResponse;
this.setState({
community: data.community,
post: update(this.state.post, {
community_id: { $set: data.community.id },
community_name: { $set: data.community.name },
}),
});
} else if (res.op == UserOperation.FollowCommunity) {
let data = res.data as CommunityResponse;
this.state.community.subscribed = data.community.subscribed;
this.state.community.number_of_subscribers =
data.community.number_of_subscribers;
this.setState(this.state);
} else if (res.op == UserOperation.BanFromCommunity) {
let data = res.data as BanFromCommunityResponse;
this.state.comments
.filter(c => c.creator_id == data.user.id)
.forEach(c => (c.banned_from_community = data.banned));
if (this.state.post.creator_id == data.user.id) {
this.state.post = update(this.state.post, {
banned_from_community: { $set: data.banned },
});
}
this.setState(this.state);
} else if (res.op == UserOperation.AddModToCommunity) {
let data = res.data as AddModToCommunityResponse;
this.state.moderators = data.moderators;
this.setState(this.state);
} else if (res.op == UserOperation.BanUser) {
let data = res.data as BanUserResponse;
this.state.comments
.filter(c => c.creator_id == data.user.id)
.forEach(c => (c.banned = data.banned));
if (this.state.post.creator_id == data.user.id) {
this.state.post = update(this.state.post, {
banned: { $set: data.banned },
});
}
this.setState(this.state);
} else if (res.op == UserOperation.AddAdmin) {
let data = res.data as AddAdminResponse;
this.state.siteRes.admins = data.admins;
this.setState(this.state);
} else if (res.op == UserOperation.AddSitemod) {
let data = res.data as AddSitemodResponse;
this.state.siteRes.sitemods = data.sitemods;
this.setState(this.state);
} else if (res.op == UserOperation.Search) {
let data = res.data as SearchResponse;
this.state.crossPosts = data.posts.filter(
p => p.id != Number(this.props.match.params.id)
);
if (this.state.crossPosts.length) {
this.state.post.duplicates = this.state.crossPosts;
}
this.setState(this.state);
}
}
}