1 import autosize from "autosize";
2 import { Component, createRef, linkEvent, RefObject } from "inferno";
5 AddModToCommunityResponse,
6 BanFromCommunityResponse,
28 } from "lemmy-js-client";
29 import { Subscription } from "rxjs";
30 import { i18n } from "../../i18next";
35 } from "../../interfaces";
36 import { UserService, WebSocketService } from "../../services";
47 getCommentIdFromProps,
51 insertCommentIntoTree,
55 restoreScrollPosition,
67 import { CommentForm } from "../comment/comment-form";
68 import { CommentNodes } from "../comment/comment-nodes";
69 import { HtmlTags } from "../common/html-tags";
70 import { Icon, Spinner } from "../common/icon";
71 import { Sidebar } from "../community/sidebar";
72 import { PostListing } from "./post-listing";
74 const commentsShownInterval = 15;
77 postResponse: GetPostResponse;
78 commentsResponse: GetCommentsResponse;
84 postRes?: GetPostResponse;
85 commentsRes?: GetCommentsResponse;
86 commentTree: CommentNodeI[];
87 commentSort: CommentSortType;
88 commentViewType: CommentViewType;
91 crossPosts?: PostView[];
92 siteRes: GetSiteResponse;
93 commentSectionRef?: RefObject<HTMLDivElement>;
94 showSidebarMobile: boolean;
95 maxCommentsShown: number;
98 export class Post extends Component<any, PostState> {
99 private subscription?: Subscription;
100 private isoData = setIsoData<PostData>(this.context);
101 private commentScrollDebounced: () => void;
103 postId: getIdFromProps(this.props),
104 commentId: getCommentIdFromProps(this.props),
107 commentViewType: CommentViewType.Tree,
110 siteRes: this.isoData.site_res,
111 showSidebarMobile: false,
112 maxCommentsShown: commentsShownInterval,
115 constructor(props: any, context: any) {
116 super(props, context);
118 this.parseMessage = this.parseMessage.bind(this);
119 this.subscription = wsSubscribe(this.parseMessage);
121 this.state = { ...this.state, commentSectionRef: createRef() };
123 // Only fetch the data if coming from another route
124 if (this.isoData.path === this.context.router.route.match.url) {
125 const { commentsResponse, postResponse } = this.isoData.routeData;
129 postRes: postResponse,
130 commentsRes: commentsResponse,
133 if (this.state.commentsRes) {
136 commentTree: buildCommentsTree(
137 this.state.commentsRes.comments,
138 !!this.state.commentId
143 this.state = { ...this.state, loading: false };
146 if (this.state.postRes) {
147 WebSocketService.Instance.send(
148 wsClient.communityJoin({
149 community_id: this.state.postRes.community_view.community.id,
154 if (this.state.postId) {
155 WebSocketService.Instance.send(
156 wsClient.postJoin({ post_id: this.state.postId })
160 this.fetchCrossPosts();
162 if (this.checkScrollIntoCommentsParam) {
163 this.scrollIntoCommentSection();
172 let auth = myAuth(false);
173 let postForm: GetPost = {
174 id: this.state.postId,
175 comment_id: this.state.commentId,
178 WebSocketService.Instance.send(wsClient.getPost(postForm));
180 let commentsForm: GetComments = {
181 post_id: this.state.postId,
182 parent_id: this.state.commentId,
183 max_depth: commentTreeMaxDepth,
184 sort: this.state.commentSort,
189 WebSocketService.Instance.send(wsClient.getComments(commentsForm));
193 let q = this.state.postRes?.post_view.post.url;
201 limit: trendingFetchLimit,
204 WebSocketService.Instance.send(wsClient.search(form));
208 static fetchInitialData(req: InitialFetchRequest): WithPromiseKeys<PostData> {
209 const pathSplit = req.path.split("/");
211 const pathType = pathSplit.at(1);
212 const id = pathSplit.at(2) ? Number(pathSplit.at(2)) : undefined;
213 const auth = req.auth;
215 const postForm: GetPost = {
219 const commentsForm: GetComments = {
220 max_depth: commentTreeMaxDepth,
227 // Set the correct id based on the path type
228 if (pathType === "post") {
230 commentsForm.post_id = id;
232 postForm.comment_id = id;
233 commentsForm.parent_id = id;
237 postResponse: req.client.getPost(postForm),
238 commentsResponse: req.client.getComments(commentsForm),
242 componentWillUnmount() {
243 this.subscription?.unsubscribe();
244 document.removeEventListener("scroll", this.commentScrollDebounced);
246 saveScrollPosition(this.context);
249 componentDidMount() {
250 autosize(document.querySelectorAll("textarea"));
252 this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
253 document.addEventListener("scroll", this.commentScrollDebounced);
256 componentDidUpdate(_lastProps: any) {
257 // Necessary if you are on a post and you click another post (same route)
258 if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
259 // TODO Couldnt get a refresh working. This does for now.
262 // let currentId = this.props.match.params.id;
263 // WebSocketService.Instance.getPost(currentId);
264 // this.context.refresh();
265 // this.context.router.history.push(_lastProps.location.pathname);
269 get checkScrollIntoCommentsParam() {
271 new URLSearchParams(this.props.location.search).get("scrollToComments")
275 scrollIntoCommentSection() {
276 this.state.commentSectionRef?.current?.scrollIntoView();
279 isBottom(el: Element): boolean {
280 return el?.getBoundingClientRect().bottom <= window.innerHeight;
284 * Shows new comments when scrolling to the bottom of the comments div
286 trackCommentsBoxScrolling = () => {
287 const wrappedElement = document.getElementsByClassName("comments")[0];
288 if (wrappedElement && this.isBottom(wrappedElement)) {
290 maxCommentsShown: this.state.maxCommentsShown + commentsShownInterval,
295 get documentTitle(): string {
296 let name_ = this.state.postRes?.post_view.post.name;
297 let siteName = this.state.siteRes.site_view.site.name;
298 return name_ ? `${name_} - ${siteName}` : "";
301 get imageTag(): string | undefined {
302 let post = this.state.postRes?.post_view.post;
303 let thumbnail = post?.thumbnail_url;
305 return thumbnail || (url && isImage(url) ? url : undefined);
309 let res = this.state.postRes;
310 let description = res?.post_view.post.body;
312 <div className="container-lg">
313 {this.state.loading ? (
319 <div className="row">
320 <div className="col-12 col-md-8 mb-3">
322 title={this.documentTitle}
323 path={this.context.router.route.match.url}
324 image={this.imageTag}
325 description={description}
328 post_view={res.post_view}
329 duplicates={this.state.crossPosts}
332 moderators={res.moderators}
333 admins={this.state.siteRes.admins}
334 enableDownvotes={enableDownvotes(this.state.siteRes)}
335 enableNsfw={enableNsfw(this.state.siteRes)}
336 allLanguages={this.state.siteRes.all_languages}
337 siteLanguages={this.state.siteRes.discussion_languages}
339 <div ref={this.state.commentSectionRef} className="mb-2" />
341 node={res.post_view.post.id}
342 disabled={res.post_view.post.locked}
343 allLanguages={this.state.siteRes.all_languages}
344 siteLanguages={this.state.siteRes.discussion_languages}
346 <div className="d-block d-md-none">
348 className="btn btn-secondary d-inline-block mb-2 mr-3"
349 onClick={linkEvent(this, this.handleShowSidebarMobile)}
351 {i18n.t("sidebar")}{" "}
354 this.state.showSidebarMobile
358 classes="icon-inline"
361 {this.state.showSidebarMobile && this.sidebar()}
364 {this.state.commentViewType == CommentViewType.Tree &&
366 {this.state.commentViewType == CommentViewType.Flat &&
369 <div className="d-none d-md-block col-md-4">{this.sidebar()}</div>
380 <div className="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
382 className={`btn btn-outline-secondary pointer ${
383 this.state.commentSort === "Hot" && "active"
390 checked={this.state.commentSort === "Hot"}
391 onChange={linkEvent(this, this.handleCommentSortChange)}
395 className={`btn btn-outline-secondary pointer ${
396 this.state.commentSort === "Top" && "active"
403 checked={this.state.commentSort === "Top"}
404 onChange={linkEvent(this, this.handleCommentSortChange)}
408 className={`btn btn-outline-secondary pointer ${
409 this.state.commentSort === "New" && "active"
416 checked={this.state.commentSort === "New"}
417 onChange={linkEvent(this, this.handleCommentSortChange)}
421 className={`btn btn-outline-secondary pointer ${
422 this.state.commentSort === "Old" && "active"
429 checked={this.state.commentSort === "Old"}
430 onChange={linkEvent(this, this.handleCommentSortChange)}
434 <div className="btn-group btn-group-toggle flex-wrap mb-2">
436 className={`btn btn-outline-secondary pointer ${
437 this.state.commentViewType === CommentViewType.Flat && "active"
443 value={CommentViewType.Flat}
444 checked={this.state.commentViewType === CommentViewType.Flat}
445 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
454 // These are already sorted by new
455 let commentsRes = this.state.commentsRes;
456 let postRes = this.state.postRes;
462 nodes={commentsToFlatNodes(commentsRes.comments)}
463 viewType={this.state.commentViewType}
464 maxCommentsShown={this.state.maxCommentsShown}
466 locked={postRes.post_view.post.locked}
467 moderators={postRes.moderators}
468 admins={this.state.siteRes.admins}
469 enableDownvotes={enableDownvotes(this.state.siteRes)}
471 allLanguages={this.state.siteRes.all_languages}
472 siteLanguages={this.state.siteRes.discussion_languages}
480 let res = this.state.postRes;
483 <div className="mb-3">
485 community_view={res.community_view}
486 moderators={res.moderators}
487 admins={this.state.siteRes.admins}
489 enableNsfw={enableNsfw(this.state.siteRes)}
491 allLanguages={this.state.siteRes.all_languages}
492 siteLanguages={this.state.siteRes.discussion_languages}
499 handleCommentSortChange(i: Post, event: any) {
501 commentSort: event.target.value as CommentSortType,
502 commentViewType: CommentViewType.Tree,
503 commentsRes: undefined,
509 handleCommentViewTypeChange(i: Post, event: any) {
510 let comments = i.state.commentsRes?.comments;
513 commentViewType: Number(event.target.value),
515 commentTree: buildCommentsTree(comments, !!i.state.commentId),
520 handleShowSidebarMobile(i: Post) {
521 i.setState({ showSidebarMobile: !i.state.showSidebarMobile });
524 handleViewPost(i: Post) {
525 let id = i.state.postRes?.post_view.post.id;
527 i.context.router.history.push(`/post/${id}`);
531 handleViewContext(i: Post) {
532 let parentId = getCommentParentId(
533 i.state.commentsRes?.comments?.at(0)?.comment
536 i.context.router.history.push(`/comment/${parentId}`);
541 let res = this.state.postRes;
542 let firstComment = this.state.commentTree.at(0)?.comment_view.comment;
543 let depth = getDepthFromComment(firstComment);
544 let showContextButton = depth ? depth > 0 : false;
549 {!!this.state.commentId && (
552 className="pl-0 d-block btn btn-link text-muted"
553 onClick={linkEvent(this, this.handleViewPost)}
555 {i18n.t("view_all_comments")} ➔
557 {showContextButton && (
559 className="pl-0 d-block btn btn-link text-muted"
560 onClick={linkEvent(this, this.handleViewContext)}
562 {i18n.t("show_context")} ➔
568 nodes={this.state.commentTree}
569 viewType={this.state.commentViewType}
570 maxCommentsShown={this.state.maxCommentsShown}
571 locked={res.post_view.post.locked}
572 moderators={res.moderators}
573 admins={this.state.siteRes.admins}
574 enableDownvotes={enableDownvotes(this.state.siteRes)}
575 allLanguages={this.state.siteRes.all_languages}
576 siteLanguages={this.state.siteRes.discussion_languages}
583 parseMessage(msg: any) {
584 let op = wsUserOp(msg);
587 toast(i18n.t(msg.error), "danger");
589 } else if (msg.reconnect) {
590 let post_id = this.state.postRes?.post_view.post.id;
592 WebSocketService.Instance.send(wsClient.postJoin({ post_id }));
593 WebSocketService.Instance.send(
600 } else if (op == UserOperation.GetPost) {
601 let data = wsJsonToRes<GetPostResponse>(msg);
602 this.setState({ postRes: data });
605 WebSocketService.Instance.send(
606 wsClient.postJoin({ post_id: data.post_view.post.id })
608 WebSocketService.Instance.send(
609 wsClient.communityJoin({
610 community_id: data.community_view.community.id,
615 // TODO move this into initial fetch and refetch
616 this.fetchCrossPosts();
618 if (!this.state.commentId) restoreScrollPosition(this.context);
620 if (this.checkScrollIntoCommentsParam) {
621 this.scrollIntoCommentSection();
623 } else if (op == UserOperation.GetComments) {
624 let data = wsJsonToRes<GetCommentsResponse>(msg);
625 // This section sets the comments res
626 let comments = this.state.commentsRes?.comments;
628 // You might need to append here, since this could be building more comments from a tree fetch
629 // Remove the first comment, since it is the parent
630 let newComments = data.comments;
632 comments.push(...newComments);
634 this.setState({ commentsRes: data });
637 let cComments = this.state.commentsRes?.comments ?? [];
639 commentTree: buildCommentsTree(cComments, !!this.state.commentId),
642 } else if (op == UserOperation.CreateComment) {
643 let data = wsJsonToRes<CommentResponse>(msg);
645 // Don't get comments from the post room, if the creator is blocked
646 let creatorBlocked = UserService.Instance.myUserInfo?.person_blocks
647 .map(pb => pb.target.id)
648 .includes(data.comment_view.creator.id);
650 // Necessary since it might be a user reply, which has the recipients, to avoid double
651 let postRes = this.state.postRes;
652 let commentsRes = this.state.commentsRes;
654 data.recipient_ids.length == 0 &&
657 data.comment_view.post.id == postRes.post_view.post.id &&
660 commentsRes.comments.unshift(data.comment_view);
661 insertCommentIntoTree(
662 this.state.commentTree,
664 !!this.state.commentId
666 postRes.post_view.counts.comments++;
668 this.setState(this.state);
672 op == UserOperation.EditComment ||
673 op == UserOperation.DeleteComment ||
674 op == UserOperation.RemoveComment
676 let data = wsJsonToRes<CommentResponse>(msg);
677 editCommentRes(data.comment_view, this.state.commentsRes?.comments);
678 this.setState(this.state);
680 } else if (op == UserOperation.SaveComment) {
681 let data = wsJsonToRes<CommentResponse>(msg);
682 saveCommentRes(data.comment_view, this.state.commentsRes?.comments);
683 this.setState(this.state);
685 } else if (op == UserOperation.CreateCommentLike) {
686 let data = wsJsonToRes<CommentResponse>(msg);
687 createCommentLikeRes(data.comment_view, this.state.commentsRes?.comments);
688 this.setState(this.state);
689 } else if (op == UserOperation.CreatePostLike) {
690 let data = wsJsonToRes<PostResponse>(msg);
691 createPostLikeRes(data.post_view, this.state.postRes?.post_view);
692 this.setState(this.state);
694 op == UserOperation.EditPost ||
695 op == UserOperation.DeletePost ||
696 op == UserOperation.RemovePost ||
697 op == UserOperation.LockPost ||
698 op == UserOperation.FeaturePost ||
699 op == UserOperation.SavePost
701 let data = wsJsonToRes<PostResponse>(msg);
702 let res = this.state.postRes;
704 res.post_view = data.post_view;
705 this.setState(this.state);
709 op == UserOperation.EditCommunity ||
710 op == UserOperation.DeleteCommunity ||
711 op == UserOperation.RemoveCommunity ||
712 op == UserOperation.FollowCommunity
714 let data = wsJsonToRes<CommunityResponse>(msg);
715 let res = this.state.postRes;
717 res.community_view = data.community_view;
718 res.post_view.community = data.community_view.community;
719 this.setState(this.state);
721 } else if (op == UserOperation.BanFromCommunity) {
722 let data = wsJsonToRes<BanFromCommunityResponse>(msg);
724 let res = this.state.postRes;
726 if (res.post_view.creator.id == data.person_view.person.id) {
727 res.post_view.creator_banned_from_community = data.banned;
731 this.state.commentsRes?.comments
732 .filter(c => c.creator.id == data.person_view.person.id)
733 .forEach(c => (c.creator_banned_from_community = data.banned));
734 this.setState(this.state);
735 } else if (op == UserOperation.AddModToCommunity) {
736 let data = wsJsonToRes<AddModToCommunityResponse>(msg);
737 let res = this.state.postRes;
739 res.moderators = data.moderators;
740 this.setState(this.state);
742 } else if (op == UserOperation.BanPerson) {
743 let data = wsJsonToRes<BanPersonResponse>(msg);
744 this.state.commentsRes?.comments
745 .filter(c => c.creator.id == data.person_view.person.id)
746 .forEach(c => (c.creator.banned = data.banned));
748 let res = this.state.postRes;
750 if (res.post_view.creator.id == data.person_view.person.id) {
751 res.post_view.creator.banned = data.banned;
754 this.setState(this.state);
755 } else if (op == UserOperation.AddAdmin) {
756 let data = wsJsonToRes<AddAdminResponse>(msg);
757 this.setState(s => ((s.siteRes.admins = data.admins), s));
758 } else if (op == UserOperation.Search) {
759 let data = wsJsonToRes<SearchResponse>(msg);
760 let xPosts = data.posts.filter(
761 p => p.post.ap_id != this.state.postRes?.post_view.post.ap_id
763 this.setState({ crossPosts: xPosts.length > 0 ? xPosts : undefined });
764 } else if (op == UserOperation.LeaveAdmin) {
765 let data = wsJsonToRes<GetSiteResponse>(msg);
766 this.setState({ siteRes: data });
767 } else if (op == UserOperation.TransferCommunity) {
768 let data = wsJsonToRes<GetCommunityResponse>(msg);
769 let res = this.state.postRes;
771 res.community_view = data.community_view;
772 res.post_view.community = data.community_view.community;
773 res.moderators = data.moderators;
774 this.setState(this.state);
776 } else if (op == UserOperation.BlockPerson) {
777 let data = wsJsonToRes<BlockPersonResponse>(msg);
778 updatePersonBlock(data);
779 } else if (op == UserOperation.CreatePostReport) {
780 let data = wsJsonToRes<PostReportResponse>(msg);
782 toast(i18n.t("report_created"));
784 } else if (op == UserOperation.CreateCommentReport) {
785 let data = wsJsonToRes<CommentReportResponse>(msg);
787 toast(i18n.t("report_created"));
790 op == UserOperation.PurgePerson ||
791 op == UserOperation.PurgePost ||
792 op == UserOperation.PurgeComment ||
793 op == UserOperation.PurgeCommunity
795 let data = wsJsonToRes<PurgeItemResponse>(msg);
797 toast(i18n.t("purge_success"));
798 this.context.router.history.push(`/`);