1 import { None, Option, Right, Some } from "@sniptt/monads";
2 import autosize from "autosize";
3 import { Component, createRef, linkEvent, RefObject } from "inferno";
6 AddModToCommunityResponse,
7 BanFromCommunityResponse,
10 CommentNode as CommentNodeI,
11 CommentReportResponse,
34 } from "lemmy-js-client";
35 import { Subscription } from "rxjs";
36 import { i18n } from "../../i18next";
37 import { CommentViewType, InitialFetchRequest } from "../../interfaces";
38 import { UserService, WebSocketService } from "../../services";
50 getCommentIdFromProps,
54 insertCommentIntoTree,
57 restoreScrollPosition,
68 import { CommentForm } from "../comment/comment-form";
69 import { CommentNodes } from "../comment/comment-nodes";
70 import { HtmlTags } from "../common/html-tags";
71 import { Icon, Spinner } from "../common/icon";
72 import { Sidebar } from "../community/sidebar";
73 import { PostListing } from "./post-listing";
75 const commentsShownInterval = 15;
78 postId: Option<number>;
79 commentId: Option<number>;
80 postRes: Option<GetPostResponse>;
81 commentsRes: Option<GetCommentsResponse>;
82 commentTree: CommentNodeI[];
83 commentSort: CommentSortType;
84 commentViewType: CommentViewType;
87 crossPosts: Option<PostView[]>;
88 siteRes: GetSiteResponse;
89 commentSectionRef?: RefObject<HTMLDivElement>;
90 showSidebarMobile: boolean;
91 maxCommentsShown: number;
94 export class Post extends Component<any, PostState> {
95 private subscription: Subscription;
96 private isoData = setIsoData(
101 private commentScrollDebounced: () => void;
102 private emptyState: PostState = {
105 postId: getIdFromProps(this.props),
106 commentId: getCommentIdFromProps(this.props),
108 commentSort: CommentSortType[CommentSortType.Hot],
109 commentViewType: CommentViewType.Tree,
113 siteRes: this.isoData.site_res,
114 commentSectionRef: null,
115 showSidebarMobile: false,
116 maxCommentsShown: commentsShownInterval,
119 constructor(props: any, context: any) {
120 super(props, context);
122 this.state = this.emptyState;
124 this.parseMessage = this.parseMessage.bind(this);
125 this.subscription = wsSubscribe(this.parseMessage);
127 this.state = { ...this.state, commentSectionRef: createRef() };
129 // Only fetch the data if coming from another route
130 if (this.isoData.path == this.context.router.route.match.url) {
133 postRes: Some(this.isoData.routeData[0] as GetPostResponse),
134 commentsRes: Some(this.isoData.routeData[1] as GetCommentsResponse),
137 if (this.state.commentsRes.isSome()) {
140 commentTree: buildCommentsTree(
141 this.state.commentsRes.unwrap().comments,
142 this.state.commentId.isSome()
147 this.state = { ...this.state, loading: false };
150 WebSocketService.Instance.send(
151 wsClient.communityJoin({
153 this.state.postRes.unwrap().community_view.community.id,
157 this.state.postId.match({
159 WebSocketService.Instance.send(wsClient.postJoin({ post_id })),
163 this.fetchCrossPosts();
165 if (this.checkScrollIntoCommentsParam) {
166 this.scrollIntoCommentSection();
175 this.setState({ commentsRes: None });
176 let postForm = new GetPost({
177 id: this.state.postId,
178 comment_id: this.state.commentId,
179 auth: auth(false).ok(),
181 WebSocketService.Instance.send(wsClient.getPost(postForm));
183 let commentsForm = new GetComments({
184 post_id: this.state.postId,
185 parent_id: this.state.commentId,
186 max_depth: Some(commentTreeMaxDepth),
189 sort: Some(this.state.commentSort),
190 type_: Some(ListingType.All),
191 community_name: None,
193 saved_only: Some(false),
194 auth: auth(false).ok(),
196 WebSocketService.Instance.send(wsClient.getComments(commentsForm));
201 .andThen(r => r.post_view.post.url)
204 let form = new Search({
206 type_: Some(SearchType.Url),
207 sort: Some(SortType.TopAll),
208 listing_type: Some(ListingType.All),
210 limit: Some(trendingFetchLimit),
212 community_name: None,
214 auth: auth(false).ok(),
216 WebSocketService.Instance.send(wsClient.search(form));
222 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
223 let pathSplit = req.path.split("/");
224 let promises: Promise<any>[] = [];
226 let pathType = pathSplit[1];
227 let id = Number(pathSplit[2]);
229 let postForm = new GetPost({
235 let commentsForm = new GetComments({
238 max_depth: Some(commentTreeMaxDepth),
241 sort: Some(CommentSortType.Hot),
242 type_: Some(ListingType.All),
243 community_name: None,
245 saved_only: Some(false),
249 // Set the correct id based on the path type
250 if (pathType == "post") {
251 postForm.id = Some(id);
252 commentsForm.post_id = Some(id);
254 postForm.comment_id = Some(id);
255 commentsForm.parent_id = Some(id);
258 promises.push(req.client.getPost(postForm));
259 promises.push(req.client.getComments(commentsForm));
264 componentWillUnmount() {
265 this.subscription.unsubscribe();
266 document.removeEventListener("scroll", this.commentScrollDebounced);
268 saveScrollPosition(this.context);
271 componentDidMount() {
272 autosize(document.querySelectorAll("textarea"));
274 this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
275 document.addEventListener("scroll", this.commentScrollDebounced);
278 componentDidUpdate(_lastProps: any) {
279 // Necessary if you are on a post and you click another post (same route)
280 if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
281 // TODO Couldnt get a refresh working. This does for now.
284 // let currentId = this.props.match.params.id;
285 // WebSocketService.Instance.getPost(currentId);
286 // this.context.refresh();
287 // this.context.router.history.push(_lastProps.location.pathname);
291 get checkScrollIntoCommentsParam() {
293 new URLSearchParams(this.props.location.search).get("scrollToComments")
297 scrollIntoCommentSection() {
298 this.state.commentSectionRef.current?.scrollIntoView();
301 isBottom(el: Element): boolean {
302 return el?.getBoundingClientRect().bottom <= window.innerHeight;
306 * Shows new comments when scrolling to the bottom of the comments div
308 trackCommentsBoxScrolling = () => {
309 const wrappedElement = document.getElementsByClassName("comments")[0];
310 if (wrappedElement && this.isBottom(wrappedElement)) {
312 maxCommentsShown: this.state.maxCommentsShown + commentsShownInterval,
317 get documentTitle(): string {
318 return this.state.postRes.match({
320 this.state.siteRes.site_view.match({
322 `${res.post_view.post.name} - ${siteView.site.name}`,
329 get imageTag(): Option<string> {
330 return this.state.postRes.match({
332 res.post_view.post.thumbnail_url.or(
333 res.post_view.post.url.match({
334 some: url => (isImage(url) ? Some(url) : None),
342 get descriptionTag(): Option<string> {
343 return this.state.postRes.andThen(r => r.post_view.post.body);
348 <div className="container-lg">
349 {this.state.loading ? (
354 this.state.postRes.match({
356 <div className="row">
357 <div className="col-12 col-md-8 mb-3">
359 title={this.documentTitle}
360 path={this.context.router.route.match.url}
361 image={this.imageTag}
362 description={this.descriptionTag}
365 post_view={res.post_view}
366 duplicates={this.state.crossPosts}
369 moderators={Some(res.moderators)}
370 admins={Some(this.state.siteRes.admins)}
371 enableDownvotes={enableDownvotes(this.state.siteRes)}
372 enableNsfw={enableNsfw(this.state.siteRes)}
373 allLanguages={this.state.siteRes.all_languages}
375 <div ref={this.state.commentSectionRef} className="mb-2" />
377 node={Right(res.post_view.post.id)}
378 disabled={res.post_view.post.locked}
379 allLanguages={this.state.siteRes.all_languages}
381 <div className="d-block d-md-none">
383 className="btn btn-secondary d-inline-block mb-2 mr-3"
384 onClick={linkEvent(this, this.handleShowSidebarMobile)}
386 {i18n.t("sidebar")}{" "}
389 this.state.showSidebarMobile
393 classes="icon-inline"
396 {this.state.showSidebarMobile && this.sidebar()}
399 {this.state.commentViewType == CommentViewType.Tree &&
401 {this.state.commentViewType == CommentViewType.Flat &&
404 <div className="d-none d-md-block col-md-4">
419 <div className="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
421 className={`btn btn-outline-secondary pointer ${
422 CommentSortType[this.state.commentSort] === CommentSortType.Hot &&
429 value={CommentSortType.Hot}
430 checked={this.state.commentSort === CommentSortType.Hot}
431 onChange={linkEvent(this, this.handleCommentSortChange)}
435 className={`btn btn-outline-secondary pointer ${
436 CommentSortType[this.state.commentSort] === CommentSortType.Top &&
443 value={CommentSortType.Top}
444 checked={this.state.commentSort === CommentSortType.Top}
445 onChange={linkEvent(this, this.handleCommentSortChange)}
449 className={`btn btn-outline-secondary pointer ${
450 CommentSortType[this.state.commentSort] === CommentSortType.New &&
457 value={CommentSortType.New}
458 checked={this.state.commentSort === CommentSortType.New}
459 onChange={linkEvent(this, this.handleCommentSortChange)}
463 className={`btn btn-outline-secondary pointer ${
464 CommentSortType[this.state.commentSort] === CommentSortType.Old &&
471 value={CommentSortType.Old}
472 checked={this.state.commentSort === CommentSortType.Old}
473 onChange={linkEvent(this, this.handleCommentSortChange)}
477 <div className="btn-group btn-group-toggle flex-wrap mb-2">
479 className={`btn btn-outline-secondary pointer ${
480 this.state.commentViewType === CommentViewType.Flat && "active"
486 value={CommentViewType.Flat}
487 checked={this.state.commentViewType === CommentViewType.Flat}
488 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
497 // These are already sorted by new
498 return this.state.commentsRes.match({
500 this.state.postRes.match({
504 nodes={commentsToFlatNodes(commentsRes.comments)}
505 viewType={this.state.commentViewType}
506 maxCommentsShown={Some(this.state.maxCommentsShown)}
508 locked={postRes.post_view.post.locked}
509 moderators={Some(postRes.moderators)}
510 admins={Some(this.state.siteRes.admins)}
511 enableDownvotes={enableDownvotes(this.state.siteRes)}
513 allLanguages={this.state.siteRes.all_languages}
524 return this.state.postRes.match({
526 <div className="mb-3">
528 community_view={res.community_view}
529 moderators={res.moderators}
530 admins={this.state.siteRes.admins}
532 enableNsfw={enableNsfw(this.state.siteRes)}
541 handleCommentSortChange(i: Post, event: any) {
543 commentSort: CommentSortType[event.target.value],
544 commentViewType: CommentViewType.Tree,
549 handleCommentViewTypeChange(i: Post, event: any) {
551 commentViewType: Number(event.target.value),
552 commentSort: CommentSortType.New,
553 commentTree: buildCommentsTree(
554 i.state.commentsRes.map(r => r.comments).unwrapOr([]),
555 i.state.commentId.isSome()
560 handleShowSidebarMobile(i: Post) {
561 i.setState({ showSidebarMobile: !i.state.showSidebarMobile });
564 handleViewPost(i: Post) {
565 i.state.postRes.match({
567 i.context.router.history.push(`/post/${res.post_view.post.id}`),
572 handleViewContext(i: Post) {
573 i.state.commentsRes.match({
575 i.context.router.history.push(
576 `/comment/${getCommentParentId(res.comments[0].comment).unwrap()}`
583 let showContextButton = toOption(this.state.commentTree[0]).match({
584 some: comment => getDepthFromComment(comment.comment_view.comment) > 0,
588 return this.state.postRes.match({
591 {this.state.commentId.isSome() && (
594 className="pl-0 d-block btn btn-link text-muted"
595 onClick={linkEvent(this, this.handleViewPost)}
597 {i18n.t("view_all_comments")} âž”
599 {showContextButton && (
601 className="pl-0 d-block btn btn-link text-muted"
602 onClick={linkEvent(this, this.handleViewContext)}
604 {i18n.t("show_context")} âž”
610 nodes={this.state.commentTree}
611 viewType={this.state.commentViewType}
612 maxCommentsShown={Some(this.state.maxCommentsShown)}
613 locked={res.post_view.post.locked}
614 moderators={Some(res.moderators)}
615 admins={Some(this.state.siteRes.admins)}
616 enableDownvotes={enableDownvotes(this.state.siteRes)}
617 allLanguages={this.state.siteRes.all_languages}
625 parseMessage(msg: any) {
626 let op = wsUserOp(msg);
629 toast(i18n.t(msg.error), "danger");
631 } else if (msg.reconnect) {
632 this.state.postRes.match({
634 let postId = res.post_view.post.id;
635 WebSocketService.Instance.send(
636 wsClient.postJoin({ post_id: postId })
638 WebSocketService.Instance.send(
642 auth: auth(false).ok(),
648 } else if (op == UserOperation.GetPost) {
649 let data = wsJsonToRes<GetPostResponse>(msg, GetPostResponse);
650 this.setState({ postRes: Some(data) });
653 WebSocketService.Instance.send(
654 wsClient.postJoin({ post_id: data.post_view.post.id })
656 WebSocketService.Instance.send(
657 wsClient.communityJoin({
658 community_id: data.community_view.community.id,
663 // TODO move this into initial fetch and refetch
664 this.fetchCrossPosts();
666 if (this.state.commentId.isNone()) restoreScrollPosition(this.context);
668 if (this.checkScrollIntoCommentsParam) {
669 this.scrollIntoCommentSection();
671 } else if (op == UserOperation.GetComments) {
672 let data = wsJsonToRes<GetCommentsResponse>(msg, GetCommentsResponse);
673 // You might need to append here, since this could be building more comments from a tree fetch
674 this.state.commentsRes.match({
676 // Remove the first comment, since it is the parent
677 let newComments = data.comments;
679 res.comments.push(...newComments);
681 none: () => this.setState({ commentsRes: Some(data) }),
683 // this.state.commentsRes = Some(data);
685 commentTree: buildCommentsTree(
686 this.state.commentsRes.map(r => r.comments).unwrapOr([]),
687 this.state.commentId.isSome()
691 this.setState(this.state);
692 } else if (op == UserOperation.CreateComment) {
693 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
695 // Don't get comments from the post room, if the creator is blocked
696 let creatorBlocked = UserService.Instance.myUserInfo
697 .map(m => m.person_blocks)
699 .map(pb => pb.target.id)
700 .includes(data.comment_view.creator.id);
702 // Necessary since it might be a user reply, which has the recipients, to avoid double
703 if (data.recipient_ids.length == 0 && !creatorBlocked) {
704 this.state.postRes.match({
706 this.state.commentsRes.match({
707 some: commentsRes => {
708 commentsRes.comments.unshift(data.comment_view);
709 insertCommentIntoTree(
710 this.state.commentTree,
712 this.state.commentId.isSome()
714 postRes.post_view.counts.comments++;
720 this.setState(this.state);
724 op == UserOperation.EditComment ||
725 op == UserOperation.DeleteComment ||
726 op == UserOperation.RemoveComment
728 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
731 this.state.commentsRes.map(r => r.comments).unwrapOr([])
733 this.setState(this.state);
735 } else if (op == UserOperation.SaveComment) {
736 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
739 this.state.commentsRes.map(r => r.comments).unwrapOr([])
741 this.setState(this.state);
743 } else if (op == UserOperation.CreateCommentLike) {
744 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
745 createCommentLikeRes(
747 this.state.commentsRes.map(r => r.comments).unwrapOr([])
749 this.setState(this.state);
750 } else if (op == UserOperation.CreatePostLike) {
751 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
752 this.state.postRes.match({
753 some: res => createPostLikeRes(data.post_view, res.post_view),
756 this.setState(this.state);
758 op == UserOperation.EditPost ||
759 op == UserOperation.DeletePost ||
760 op == UserOperation.RemovePost ||
761 op == UserOperation.LockPost ||
762 op == UserOperation.StickyPost ||
763 op == UserOperation.SavePost
765 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
766 this.state.postRes.match({
767 some: res => (res.post_view = data.post_view),
770 this.setState(this.state);
773 op == UserOperation.EditCommunity ||
774 op == UserOperation.DeleteCommunity ||
775 op == UserOperation.RemoveCommunity ||
776 op == UserOperation.FollowCommunity
778 let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse);
779 this.state.postRes.match({
781 res.community_view = data.community_view;
782 res.post_view.community = data.community_view.community;
783 this.setState(this.state);
787 } else if (op == UserOperation.BanFromCommunity) {
788 let data = wsJsonToRes<BanFromCommunityResponse>(
790 BanFromCommunityResponse
792 this.state.postRes.match({
794 this.state.commentsRes.match({
795 some: commentsRes => {
797 .filter(c => c.creator.id == data.person_view.person.id)
798 .forEach(c => (c.creator_banned_from_community = data.banned));
799 if (postRes.post_view.creator.id == data.person_view.person.id) {
800 postRes.post_view.creator_banned_from_community = data.banned;
802 this.setState(this.state);
808 } else if (op == UserOperation.AddModToCommunity) {
809 let data = wsJsonToRes<AddModToCommunityResponse>(
811 AddModToCommunityResponse
813 this.state.postRes.match({
815 res.moderators = data.moderators;
816 this.setState(this.state);
820 } else if (op == UserOperation.BanPerson) {
821 let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
822 this.state.postRes.match({
824 this.state.commentsRes.match({
825 some: commentsRes => {
827 .filter(c => c.creator.id == data.person_view.person.id)
828 .forEach(c => (c.creator.banned = data.banned));
829 if (postRes.post_view.creator.id == data.person_view.person.id) {
830 postRes.post_view.creator.banned = data.banned;
832 this.setState(this.state);
838 } else if (op == UserOperation.AddAdmin) {
839 let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
840 this.setState(s => ((s.siteRes.admins = data.admins), s));
841 } else if (op == UserOperation.Search) {
842 let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
843 let xPosts = data.posts.filter(
844 p => p.post.id != Number(this.props.match.params.id)
846 this.setState({ crossPosts: xPosts.length > 0 ? Some(xPosts) : None });
847 } else if (op == UserOperation.LeaveAdmin) {
848 let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse);
849 this.setState({ siteRes: data });
850 } else if (op == UserOperation.TransferCommunity) {
851 let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse);
852 this.state.postRes.match({
854 res.community_view = data.community_view;
855 res.post_view.community = data.community_view.community;
856 res.moderators = data.moderators;
857 this.setState(this.state);
861 } else if (op == UserOperation.BlockPerson) {
862 let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
863 updatePersonBlock(data);
864 } else if (op == UserOperation.CreatePostReport) {
865 let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse);
867 toast(i18n.t("report_created"));
869 } else if (op == UserOperation.CreateCommentReport) {
870 let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse);
872 toast(i18n.t("report_created"));
875 op == UserOperation.PurgePerson ||
876 op == UserOperation.PurgePost ||
877 op == UserOperation.PurgeComment ||
878 op == UserOperation.PurgeCommunity
880 let data = wsJsonToRes<PurgeItemResponse>(msg, PurgeItemResponse);
882 toast(i18n.t("purge_success"));
883 this.context.router.history.push(`/`);