1 import autosize from "autosize";
2 import { Component, createRef, linkEvent, RefObject } from "inferno";
5 AddModToCommunityResponse,
6 BanFromCommunityResponse,
9 CommentNode as CommentNodeI,
10 CommentReportResponse,
32 } from "lemmy-js-client";
33 import { Subscription } from "rxjs";
34 import { i18n } from "../../i18next";
35 import { CommentViewType, InitialFetchRequest } from "../../interfaces";
36 import { UserService, WebSocketService } from "../../services";
47 getCommentIdFromProps,
51 insertCommentIntoTree,
55 restoreScrollPosition,
66 import { CommentForm } from "../comment/comment-form";
67 import { CommentNodes } from "../comment/comment-nodes";
68 import { HtmlTags } from "../common/html-tags";
69 import { Icon, Spinner } from "../common/icon";
70 import { Sidebar } from "../community/sidebar";
71 import { PostListing } from "./post-listing";
73 const commentsShownInterval = 15;
78 postRes?: GetPostResponse;
79 commentsRes?: GetCommentsResponse;
80 commentTree: CommentNodeI[];
81 commentSort: CommentSortType;
82 commentViewType: CommentViewType;
85 crossPosts?: PostView[];
86 siteRes: GetSiteResponse;
87 commentSectionRef?: RefObject<HTMLDivElement>;
88 showSidebarMobile: boolean;
89 maxCommentsShown: number;
92 export class Post extends Component<any, PostState> {
93 private subscription?: Subscription;
94 private isoData = setIsoData(this.context);
95 private commentScrollDebounced: () => void;
97 postId: getIdFromProps(this.props),
98 commentId: getCommentIdFromProps(this.props),
100 commentSort: CommentSortType[CommentSortType.Hot],
101 commentViewType: CommentViewType.Tree,
104 siteRes: this.isoData.site_res,
105 showSidebarMobile: false,
106 maxCommentsShown: commentsShownInterval,
109 constructor(props: any, context: any) {
110 super(props, context);
112 this.parseMessage = this.parseMessage.bind(this);
113 this.subscription = wsSubscribe(this.parseMessage);
115 this.state = { ...this.state, commentSectionRef: createRef() };
117 // Only fetch the data if coming from another route
118 if (this.isoData.path == this.context.router.route.match.url) {
121 postRes: this.isoData.routeData[0] as GetPostResponse,
122 commentsRes: this.isoData.routeData[1] as GetCommentsResponse,
125 if (this.state.commentsRes) {
128 commentTree: buildCommentsTree(
129 this.state.commentsRes.comments,
130 !!this.state.commentId
135 this.state = { ...this.state, loading: false };
138 if (this.state.postRes) {
139 WebSocketService.Instance.send(
140 wsClient.communityJoin({
141 community_id: this.state.postRes.community_view.community.id,
146 if (this.state.postId) {
147 WebSocketService.Instance.send(
148 wsClient.postJoin({ post_id: this.state.postId })
152 this.fetchCrossPosts();
154 if (this.checkScrollIntoCommentsParam) {
155 this.scrollIntoCommentSection();
164 this.setState({ commentsRes: undefined, postRes: undefined });
165 let auth = myAuth(false);
166 let postForm: GetPost = {
167 id: this.state.postId,
168 comment_id: this.state.commentId,
171 WebSocketService.Instance.send(wsClient.getPost(postForm));
173 let commentsForm: GetComments = {
174 post_id: this.state.postId,
175 parent_id: this.state.commentId,
176 max_depth: commentTreeMaxDepth,
177 sort: this.state.commentSort,
178 type_: ListingType.All,
182 WebSocketService.Instance.send(wsClient.getComments(commentsForm));
186 let q = this.state.postRes?.post_view.post.url;
190 type_: SearchType.Url,
191 sort: SortType.TopAll,
192 listing_type: ListingType.All,
194 limit: trendingFetchLimit,
197 WebSocketService.Instance.send(wsClient.search(form));
201 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
202 let pathSplit = req.path.split("/");
203 let promises: Promise<any>[] = [];
205 let pathType = pathSplit.at(1);
206 let id = pathSplit.at(2) ? Number(pathSplit.at(2)) : undefined;
209 let postForm: GetPost = {
213 let commentsForm: GetComments = {
214 max_depth: commentTreeMaxDepth,
215 sort: CommentSortType.Hot,
216 type_: ListingType.All,
221 // Set the correct id based on the path type
222 if (pathType == "post") {
224 commentsForm.post_id = id;
226 postForm.comment_id = id;
227 commentsForm.parent_id = id;
230 promises.push(req.client.getPost(postForm));
231 promises.push(req.client.getComments(commentsForm));
236 componentWillUnmount() {
237 this.subscription?.unsubscribe();
238 document.removeEventListener("scroll", this.commentScrollDebounced);
240 saveScrollPosition(this.context);
243 componentDidMount() {
244 autosize(document.querySelectorAll("textarea"));
246 this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
247 document.addEventListener("scroll", this.commentScrollDebounced);
250 componentDidUpdate(_lastProps: any) {
251 // Necessary if you are on a post and you click another post (same route)
252 if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
253 // TODO Couldnt get a refresh working. This does for now.
256 // let currentId = this.props.match.params.id;
257 // WebSocketService.Instance.getPost(currentId);
258 // this.context.refresh();
259 // this.context.router.history.push(_lastProps.location.pathname);
263 get checkScrollIntoCommentsParam() {
265 new URLSearchParams(this.props.location.search).get("scrollToComments")
269 scrollIntoCommentSection() {
270 this.state.commentSectionRef?.current?.scrollIntoView();
273 isBottom(el: Element): boolean {
274 return el?.getBoundingClientRect().bottom <= window.innerHeight;
278 * Shows new comments when scrolling to the bottom of the comments div
280 trackCommentsBoxScrolling = () => {
281 const wrappedElement = document.getElementsByClassName("comments")[0];
282 if (wrappedElement && this.isBottom(wrappedElement)) {
284 maxCommentsShown: this.state.maxCommentsShown + commentsShownInterval,
289 get documentTitle(): string {
290 let name_ = this.state.postRes?.post_view.post.name;
291 let siteName = this.state.siteRes.site_view.site.name;
292 return name_ ? `${name_} - ${siteName}` : "";
295 get imageTag(): string | undefined {
296 let post = this.state.postRes?.post_view.post;
297 let thumbnail = post?.thumbnail_url;
299 return thumbnail || (url && isImage(url) ? url : undefined);
303 let res = this.state.postRes;
304 let description = res?.post_view.post.body;
306 <div className="container-lg">
307 {this.state.loading ? (
313 <div className="row">
314 <div className="col-12 col-md-8 mb-3">
316 title={this.documentTitle}
317 path={this.context.router.route.match.url}
318 image={this.imageTag}
319 description={description}
322 post_view={res.post_view}
323 duplicates={this.state.crossPosts}
326 moderators={res.moderators}
327 admins={this.state.siteRes.admins}
328 enableDownvotes={enableDownvotes(this.state.siteRes)}
329 enableNsfw={enableNsfw(this.state.siteRes)}
330 allLanguages={this.state.siteRes.all_languages}
331 siteLanguages={this.state.siteRes.discussion_languages}
333 <div ref={this.state.commentSectionRef} className="mb-2" />
335 node={res.post_view.post.id}
336 disabled={res.post_view.post.locked}
337 allLanguages={this.state.siteRes.all_languages}
338 siteLanguages={this.state.siteRes.discussion_languages}
340 <div className="d-block d-md-none">
342 className="btn btn-secondary d-inline-block mb-2 mr-3"
343 onClick={linkEvent(this, this.handleShowSidebarMobile)}
345 {i18n.t("sidebar")}{" "}
348 this.state.showSidebarMobile
352 classes="icon-inline"
355 {this.state.showSidebarMobile && this.sidebar()}
358 {this.state.commentViewType == CommentViewType.Tree &&
360 {this.state.commentViewType == CommentViewType.Flat &&
363 <div className="d-none d-md-block col-md-4">{this.sidebar()}</div>
374 <div className="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
376 className={`btn btn-outline-secondary pointer ${
377 CommentSortType[this.state.commentSort] === CommentSortType.Hot &&
384 value={CommentSortType.Hot}
385 checked={this.state.commentSort === CommentSortType.Hot}
386 onChange={linkEvent(this, this.handleCommentSortChange)}
390 className={`btn btn-outline-secondary pointer ${
391 CommentSortType[this.state.commentSort] === CommentSortType.Top &&
398 value={CommentSortType.Top}
399 checked={this.state.commentSort === CommentSortType.Top}
400 onChange={linkEvent(this, this.handleCommentSortChange)}
404 className={`btn btn-outline-secondary pointer ${
405 CommentSortType[this.state.commentSort] === CommentSortType.New &&
412 value={CommentSortType.New}
413 checked={this.state.commentSort === CommentSortType.New}
414 onChange={linkEvent(this, this.handleCommentSortChange)}
418 className={`btn btn-outline-secondary pointer ${
419 CommentSortType[this.state.commentSort] === CommentSortType.Old &&
426 value={CommentSortType.Old}
427 checked={this.state.commentSort === CommentSortType.Old}
428 onChange={linkEvent(this, this.handleCommentSortChange)}
432 <div className="btn-group btn-group-toggle flex-wrap mb-2">
434 className={`btn btn-outline-secondary pointer ${
435 this.state.commentViewType === CommentViewType.Flat && "active"
441 value={CommentViewType.Flat}
442 checked={this.state.commentViewType === CommentViewType.Flat}
443 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
452 // These are already sorted by new
453 let commentsRes = this.state.commentsRes;
454 let postRes = this.state.postRes;
460 nodes={commentsToFlatNodes(commentsRes.comments)}
461 viewType={this.state.commentViewType}
462 maxCommentsShown={this.state.maxCommentsShown}
464 locked={postRes.post_view.post.locked}
465 moderators={postRes.moderators}
466 admins={this.state.siteRes.admins}
467 enableDownvotes={enableDownvotes(this.state.siteRes)}
469 allLanguages={this.state.siteRes.all_languages}
470 siteLanguages={this.state.siteRes.discussion_languages}
478 let res = this.state.postRes;
481 <div className="mb-3">
483 community_view={res.community_view}
484 moderators={res.moderators}
485 admins={this.state.siteRes.admins}
487 enableNsfw={enableNsfw(this.state.siteRes)}
489 allLanguages={this.state.siteRes.all_languages}
490 siteLanguages={this.state.siteRes.discussion_languages}
497 handleCommentSortChange(i: Post, event: any) {
499 commentSort: CommentSortType[event.target.value],
500 commentViewType: CommentViewType.Tree,
505 handleCommentViewTypeChange(i: Post, event: any) {
506 let comments = i.state.commentsRes?.comments;
509 commentViewType: Number(event.target.value),
510 commentSort: CommentSortType.New,
511 commentTree: buildCommentsTree(comments, !!i.state.commentId),
516 handleShowSidebarMobile(i: Post) {
517 i.setState({ showSidebarMobile: !i.state.showSidebarMobile });
520 handleViewPost(i: Post) {
521 let id = i.state.postRes?.post_view.post.id;
523 i.context.router.history.push(`/post/${id}`);
527 handleViewContext(i: Post) {
528 let parentId = getCommentParentId(
529 i.state.commentsRes?.comments?.at(0)?.comment
532 i.context.router.history.push(`/comment/${parentId}`);
537 let res = this.state.postRes;
538 let firstComment = this.state.commentTree.at(0)?.comment_view.comment;
539 let depth = getDepthFromComment(firstComment);
540 let showContextButton = depth ? depth > 0 : false;
545 {!!this.state.commentId && (
548 className="pl-0 d-block btn btn-link text-muted"
549 onClick={linkEvent(this, this.handleViewPost)}
551 {i18n.t("view_all_comments")} ➔
553 {showContextButton && (
555 className="pl-0 d-block btn btn-link text-muted"
556 onClick={linkEvent(this, this.handleViewContext)}
558 {i18n.t("show_context")} ➔
564 nodes={this.state.commentTree}
565 viewType={this.state.commentViewType}
566 maxCommentsShown={this.state.maxCommentsShown}
567 locked={res.post_view.post.locked}
568 moderators={res.moderators}
569 admins={this.state.siteRes.admins}
570 enableDownvotes={enableDownvotes(this.state.siteRes)}
571 allLanguages={this.state.siteRes.all_languages}
572 siteLanguages={this.state.siteRes.discussion_languages}
579 parseMessage(msg: any) {
580 let op = wsUserOp(msg);
583 toast(i18n.t(msg.error), "danger");
585 } else if (msg.reconnect) {
586 let post_id = this.state.postRes?.post_view.post.id;
588 WebSocketService.Instance.send(wsClient.postJoin({ post_id }));
589 WebSocketService.Instance.send(
596 } else if (op == UserOperation.GetPost) {
597 let data = wsJsonToRes<GetPostResponse>(msg);
598 this.setState({ postRes: data });
601 WebSocketService.Instance.send(
602 wsClient.postJoin({ post_id: data.post_view.post.id })
604 WebSocketService.Instance.send(
605 wsClient.communityJoin({
606 community_id: data.community_view.community.id,
611 // TODO move this into initial fetch and refetch
612 this.fetchCrossPosts();
614 if (!this.state.commentId) restoreScrollPosition(this.context);
616 if (this.checkScrollIntoCommentsParam) {
617 this.scrollIntoCommentSection();
619 } else if (op == UserOperation.GetComments) {
620 let data = wsJsonToRes<GetCommentsResponse>(msg);
621 // This section sets the comments res
622 let comments = this.state.commentsRes?.comments;
624 // You might need to append here, since this could be building more comments from a tree fetch
625 // Remove the first comment, since it is the parent
626 let newComments = data.comments;
628 comments.push(...newComments);
630 this.setState({ commentsRes: data });
633 let cComments = this.state.commentsRes?.comments ?? [];
635 commentTree: buildCommentsTree(cComments, !!this.state.commentId),
638 } else if (op == UserOperation.CreateComment) {
639 let data = wsJsonToRes<CommentResponse>(msg);
641 // Don't get comments from the post room, if the creator is blocked
642 let creatorBlocked = UserService.Instance.myUserInfo?.person_blocks
643 .map(pb => pb.target.id)
644 .includes(data.comment_view.creator.id);
646 // Necessary since it might be a user reply, which has the recipients, to avoid double
647 let postRes = this.state.postRes;
648 let commentsRes = this.state.commentsRes;
650 data.recipient_ids.length == 0 &&
655 commentsRes.comments.unshift(data.comment_view);
656 insertCommentIntoTree(
657 this.state.commentTree,
659 !!this.state.commentId
661 postRes.post_view.counts.comments++;
663 this.setState(this.state);
667 op == UserOperation.EditComment ||
668 op == UserOperation.DeleteComment ||
669 op == UserOperation.RemoveComment
671 let data = wsJsonToRes<CommentResponse>(msg);
672 editCommentRes(data.comment_view, this.state.commentsRes?.comments);
673 this.setState(this.state);
675 } else if (op == UserOperation.SaveComment) {
676 let data = wsJsonToRes<CommentResponse>(msg);
677 saveCommentRes(data.comment_view, this.state.commentsRes?.comments);
678 this.setState(this.state);
680 } else if (op == UserOperation.CreateCommentLike) {
681 let data = wsJsonToRes<CommentResponse>(msg);
682 createCommentLikeRes(data.comment_view, this.state.commentsRes?.comments);
683 this.setState(this.state);
684 } else if (op == UserOperation.CreatePostLike) {
685 let data = wsJsonToRes<PostResponse>(msg);
686 createPostLikeRes(data.post_view, this.state.postRes?.post_view);
687 this.setState(this.state);
689 op == UserOperation.EditPost ||
690 op == UserOperation.DeletePost ||
691 op == UserOperation.RemovePost ||
692 op == UserOperation.LockPost ||
693 op == UserOperation.FeaturePost ||
694 op == UserOperation.SavePost
696 let data = wsJsonToRes<PostResponse>(msg);
697 let pv = this.state.postRes?.post_view;
700 this.setState(this.state);
704 op == UserOperation.EditCommunity ||
705 op == UserOperation.DeleteCommunity ||
706 op == UserOperation.RemoveCommunity ||
707 op == UserOperation.FollowCommunity
709 let data = wsJsonToRes<CommunityResponse>(msg);
710 let res = this.state.postRes;
712 res.community_view = data.community_view;
713 res.post_view.community = data.community_view.community;
714 this.setState(this.state);
716 } else if (op == UserOperation.BanFromCommunity) {
717 let data = wsJsonToRes<BanFromCommunityResponse>(msg);
719 let postRes = this.state.postRes;
721 if (postRes.post_view.creator.id == data.person_view.person.id) {
722 postRes.post_view.creator_banned_from_community = data.banned;
726 this.state.commentsRes?.comments
727 .filter(c => c.creator.id == data.person_view.person.id)
728 .forEach(c => (c.creator_banned_from_community = data.banned));
729 this.setState(this.state);
730 } else if (op == UserOperation.AddModToCommunity) {
731 let data = wsJsonToRes<AddModToCommunityResponse>(msg);
732 let postRes = this.state.postRes;
734 postRes.moderators = data.moderators;
735 this.setState(this.state);
737 } else if (op == UserOperation.BanPerson) {
738 let data = wsJsonToRes<BanPersonResponse>(msg);
739 this.state.commentsRes?.comments
740 .filter(c => c.creator.id == data.person_view.person.id)
741 .forEach(c => (c.creator.banned = data.banned));
743 let postRes = this.state.postRes;
745 if (postRes.post_view.creator.id == data.person_view.person.id) {
746 postRes.post_view.creator.banned = data.banned;
749 this.setState(this.state);
750 } else if (op == UserOperation.AddAdmin) {
751 let data = wsJsonToRes<AddAdminResponse>(msg);
752 this.setState(s => ((s.siteRes.admins = data.admins), s));
753 } else if (op == UserOperation.Search) {
754 let data = wsJsonToRes<SearchResponse>(msg);
755 let xPosts = data.posts.filter(
756 p => p.post.id != Number(this.props.match.params.id)
758 this.setState({ crossPosts: xPosts.length > 0 ? xPosts : undefined });
759 } else if (op == UserOperation.LeaveAdmin) {
760 let data = wsJsonToRes<GetSiteResponse>(msg);
761 this.setState({ siteRes: data });
762 } else if (op == UserOperation.TransferCommunity) {
763 let data = wsJsonToRes<GetCommunityResponse>(msg);
764 let postRes = this.state.postRes;
766 postRes.community_view = data.community_view;
767 postRes.post_view.community = data.community_view.community;
768 postRes.moderators = data.moderators;
769 this.setState(this.state);
771 } else if (op == UserOperation.BlockPerson) {
772 let data = wsJsonToRes<BlockPersonResponse>(msg);
773 updatePersonBlock(data);
774 } else if (op == UserOperation.CreatePostReport) {
775 let data = wsJsonToRes<PostReportResponse>(msg);
777 toast(i18n.t("report_created"));
779 } else if (op == UserOperation.CreateCommentReport) {
780 let data = wsJsonToRes<CommentReportResponse>(msg);
782 toast(i18n.t("report_created"));
785 op == UserOperation.PurgePerson ||
786 op == UserOperation.PurgePost ||
787 op == UserOperation.PurgeComment ||
788 op == UserOperation.PurgeCommunity
790 let data = wsJsonToRes<PurgeItemResponse>(msg);
792 toast(i18n.t("purge_success"));
793 this.context.router.history.push(`/`);