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 let auth = myAuth(false);
165 let postForm: GetPost = {
166 id: this.state.postId,
167 comment_id: this.state.commentId,
170 WebSocketService.Instance.send(wsClient.getPost(postForm));
172 let commentsForm: GetComments = {
173 post_id: this.state.postId,
174 parent_id: this.state.commentId,
175 max_depth: commentTreeMaxDepth,
176 sort: this.state.commentSort,
177 type_: ListingType.All,
181 WebSocketService.Instance.send(wsClient.getComments(commentsForm));
185 let q = this.state.postRes?.post_view.post.url;
189 type_: SearchType.Url,
190 sort: SortType.TopAll,
191 listing_type: ListingType.All,
193 limit: trendingFetchLimit,
196 WebSocketService.Instance.send(wsClient.search(form));
200 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
201 let pathSplit = req.path.split("/");
202 let promises: Promise<any>[] = [];
204 let pathType = pathSplit.at(1);
205 let id = pathSplit.at(2) ? Number(pathSplit.at(2)) : undefined;
208 let postForm: GetPost = {
212 let commentsForm: GetComments = {
213 max_depth: commentTreeMaxDepth,
214 sort: CommentSortType.Hot,
215 type_: ListingType.All,
220 // Set the correct id based on the path type
221 if (pathType == "post") {
223 commentsForm.post_id = id;
225 postForm.comment_id = id;
226 commentsForm.parent_id = id;
229 promises.push(req.client.getPost(postForm));
230 promises.push(req.client.getComments(commentsForm));
235 componentWillUnmount() {
236 this.subscription?.unsubscribe();
237 document.removeEventListener("scroll", this.commentScrollDebounced);
239 saveScrollPosition(this.context);
242 componentDidMount() {
243 autosize(document.querySelectorAll("textarea"));
245 this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
246 document.addEventListener("scroll", this.commentScrollDebounced);
249 componentDidUpdate(_lastProps: any) {
250 // Necessary if you are on a post and you click another post (same route)
251 if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
252 // TODO Couldnt get a refresh working. This does for now.
255 // let currentId = this.props.match.params.id;
256 // WebSocketService.Instance.getPost(currentId);
257 // this.context.refresh();
258 // this.context.router.history.push(_lastProps.location.pathname);
262 get checkScrollIntoCommentsParam() {
264 new URLSearchParams(this.props.location.search).get("scrollToComments")
268 scrollIntoCommentSection() {
269 this.state.commentSectionRef?.current?.scrollIntoView();
272 isBottom(el: Element): boolean {
273 return el?.getBoundingClientRect().bottom <= window.innerHeight;
277 * Shows new comments when scrolling to the bottom of the comments div
279 trackCommentsBoxScrolling = () => {
280 const wrappedElement = document.getElementsByClassName("comments")[0];
281 if (wrappedElement && this.isBottom(wrappedElement)) {
283 maxCommentsShown: this.state.maxCommentsShown + commentsShownInterval,
288 get documentTitle(): string {
289 let name_ = this.state.postRes?.post_view.post.name;
290 let siteName = this.state.siteRes.site_view.site.name;
291 return name_ ? `${name_} - ${siteName}` : "";
294 get imageTag(): string | undefined {
295 let post = this.state.postRes?.post_view.post;
296 let thumbnail = post?.thumbnail_url;
298 return thumbnail || (url && isImage(url) ? url : undefined);
302 let res = this.state.postRes;
303 let description = res?.post_view.post.body;
305 <div className="container-lg">
306 {this.state.loading ? (
312 <div className="row">
313 <div className="col-12 col-md-8 mb-3">
315 title={this.documentTitle}
316 path={this.context.router.route.match.url}
317 image={this.imageTag}
318 description={description}
321 post_view={res.post_view}
322 duplicates={this.state.crossPosts}
325 moderators={res.moderators}
326 admins={this.state.siteRes.admins}
327 enableDownvotes={enableDownvotes(this.state.siteRes)}
328 enableNsfw={enableNsfw(this.state.siteRes)}
329 allLanguages={this.state.siteRes.all_languages}
330 siteLanguages={this.state.siteRes.discussion_languages}
332 <div ref={this.state.commentSectionRef} className="mb-2" />
334 node={res.post_view.post.id}
335 disabled={res.post_view.post.locked}
336 allLanguages={this.state.siteRes.all_languages}
337 siteLanguages={this.state.siteRes.discussion_languages}
339 <div className="d-block d-md-none">
341 className="btn btn-secondary d-inline-block mb-2 mr-3"
342 onClick={linkEvent(this, this.handleShowSidebarMobile)}
344 {i18n.t("sidebar")}{" "}
347 this.state.showSidebarMobile
351 classes="icon-inline"
354 {this.state.showSidebarMobile && this.sidebar()}
357 {this.state.commentViewType == CommentViewType.Tree &&
359 {this.state.commentViewType == CommentViewType.Flat &&
362 <div className="d-none d-md-block col-md-4">{this.sidebar()}</div>
373 <div className="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
375 className={`btn btn-outline-secondary pointer ${
376 CommentSortType[this.state.commentSort] === CommentSortType.Hot &&
383 value={CommentSortType.Hot}
384 checked={this.state.commentSort === CommentSortType.Hot}
385 onChange={linkEvent(this, this.handleCommentSortChange)}
389 className={`btn btn-outline-secondary pointer ${
390 CommentSortType[this.state.commentSort] === CommentSortType.Top &&
397 value={CommentSortType.Top}
398 checked={this.state.commentSort === CommentSortType.Top}
399 onChange={linkEvent(this, this.handleCommentSortChange)}
403 className={`btn btn-outline-secondary pointer ${
404 CommentSortType[this.state.commentSort] === CommentSortType.New &&
411 value={CommentSortType.New}
412 checked={this.state.commentSort === CommentSortType.New}
413 onChange={linkEvent(this, this.handleCommentSortChange)}
417 className={`btn btn-outline-secondary pointer ${
418 CommentSortType[this.state.commentSort] === CommentSortType.Old &&
425 value={CommentSortType.Old}
426 checked={this.state.commentSort === CommentSortType.Old}
427 onChange={linkEvent(this, this.handleCommentSortChange)}
431 <div className="btn-group btn-group-toggle flex-wrap mb-2">
433 className={`btn btn-outline-secondary pointer ${
434 this.state.commentViewType === CommentViewType.Flat && "active"
440 value={CommentViewType.Flat}
441 checked={this.state.commentViewType === CommentViewType.Flat}
442 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
451 // These are already sorted by new
452 let commentsRes = this.state.commentsRes;
453 let postRes = this.state.postRes;
459 nodes={commentsToFlatNodes(commentsRes.comments)}
460 viewType={this.state.commentViewType}
461 maxCommentsShown={this.state.maxCommentsShown}
463 locked={postRes.post_view.post.locked}
464 moderators={postRes.moderators}
465 admins={this.state.siteRes.admins}
466 enableDownvotes={enableDownvotes(this.state.siteRes)}
468 allLanguages={this.state.siteRes.all_languages}
469 siteLanguages={this.state.siteRes.discussion_languages}
477 let res = this.state.postRes;
480 <div className="mb-3">
482 community_view={res.community_view}
483 moderators={res.moderators}
484 admins={this.state.siteRes.admins}
486 enableNsfw={enableNsfw(this.state.siteRes)}
488 allLanguages={this.state.siteRes.all_languages}
489 siteLanguages={this.state.siteRes.discussion_languages}
496 handleCommentSortChange(i: Post, event: any) {
498 commentSort: CommentSortType[event.target.value],
499 commentViewType: CommentViewType.Tree,
500 commentsRes: undefined,
506 handleCommentViewTypeChange(i: Post, event: any) {
507 let comments = i.state.commentsRes?.comments;
510 commentViewType: Number(event.target.value),
511 commentSort: CommentSortType.New,
512 commentTree: buildCommentsTree(comments, !!i.state.commentId),
517 handleShowSidebarMobile(i: Post) {
518 i.setState({ showSidebarMobile: !i.state.showSidebarMobile });
521 handleViewPost(i: Post) {
522 let id = i.state.postRes?.post_view.post.id;
524 i.context.router.history.push(`/post/${id}`);
528 handleViewContext(i: Post) {
529 let parentId = getCommentParentId(
530 i.state.commentsRes?.comments?.at(0)?.comment
533 i.context.router.history.push(`/comment/${parentId}`);
538 let res = this.state.postRes;
539 let firstComment = this.state.commentTree.at(0)?.comment_view.comment;
540 let depth = getDepthFromComment(firstComment);
541 let showContextButton = depth ? depth > 0 : false;
546 {!!this.state.commentId && (
549 className="pl-0 d-block btn btn-link text-muted"
550 onClick={linkEvent(this, this.handleViewPost)}
552 {i18n.t("view_all_comments")} âž”
554 {showContextButton && (
556 className="pl-0 d-block btn btn-link text-muted"
557 onClick={linkEvent(this, this.handleViewContext)}
559 {i18n.t("show_context")} âž”
565 nodes={this.state.commentTree}
566 viewType={this.state.commentViewType}
567 maxCommentsShown={this.state.maxCommentsShown}
568 locked={res.post_view.post.locked}
569 moderators={res.moderators}
570 admins={this.state.siteRes.admins}
571 enableDownvotes={enableDownvotes(this.state.siteRes)}
572 allLanguages={this.state.siteRes.all_languages}
573 siteLanguages={this.state.siteRes.discussion_languages}
580 parseMessage(msg: any) {
581 let op = wsUserOp(msg);
584 toast(i18n.t(msg.error), "danger");
586 } else if (msg.reconnect) {
587 let post_id = this.state.postRes?.post_view.post.id;
589 WebSocketService.Instance.send(wsClient.postJoin({ post_id }));
590 WebSocketService.Instance.send(
597 } else if (op == UserOperation.GetPost) {
598 let data = wsJsonToRes<GetPostResponse>(msg);
599 this.setState({ postRes: data });
602 WebSocketService.Instance.send(
603 wsClient.postJoin({ post_id: data.post_view.post.id })
605 WebSocketService.Instance.send(
606 wsClient.communityJoin({
607 community_id: data.community_view.community.id,
612 // TODO move this into initial fetch and refetch
613 this.fetchCrossPosts();
615 if (!this.state.commentId) restoreScrollPosition(this.context);
617 if (this.checkScrollIntoCommentsParam) {
618 this.scrollIntoCommentSection();
620 } else if (op == UserOperation.GetComments) {
621 let data = wsJsonToRes<GetCommentsResponse>(msg);
622 // This section sets the comments res
623 let comments = this.state.commentsRes?.comments;
625 // You might need to append here, since this could be building more comments from a tree fetch
626 // Remove the first comment, since it is the parent
627 let newComments = data.comments;
629 comments.push(...newComments);
631 this.setState({ commentsRes: data });
634 let cComments = this.state.commentsRes?.comments ?? [];
636 commentTree: buildCommentsTree(cComments, !!this.state.commentId),
639 } else if (op == UserOperation.CreateComment) {
640 let data = wsJsonToRes<CommentResponse>(msg);
642 // Don't get comments from the post room, if the creator is blocked
643 let creatorBlocked = UserService.Instance.myUserInfo?.person_blocks
644 .map(pb => pb.target.id)
645 .includes(data.comment_view.creator.id);
647 // Necessary since it might be a user reply, which has the recipients, to avoid double
648 let postRes = this.state.postRes;
649 let commentsRes = this.state.commentsRes;
651 data.recipient_ids.length == 0 &&
656 commentsRes.comments.unshift(data.comment_view);
657 insertCommentIntoTree(
658 this.state.commentTree,
660 !!this.state.commentId
662 postRes.post_view.counts.comments++;
664 this.setState(this.state);
668 op == UserOperation.EditComment ||
669 op == UserOperation.DeleteComment ||
670 op == UserOperation.RemoveComment
672 let data = wsJsonToRes<CommentResponse>(msg);
673 editCommentRes(data.comment_view, this.state.commentsRes?.comments);
674 this.setState(this.state);
676 } else if (op == UserOperation.SaveComment) {
677 let data = wsJsonToRes<CommentResponse>(msg);
678 saveCommentRes(data.comment_view, this.state.commentsRes?.comments);
679 this.setState(this.state);
681 } else if (op == UserOperation.CreateCommentLike) {
682 let data = wsJsonToRes<CommentResponse>(msg);
683 createCommentLikeRes(data.comment_view, this.state.commentsRes?.comments);
684 this.setState(this.state);
685 } else if (op == UserOperation.CreatePostLike) {
686 let data = wsJsonToRes<PostResponse>(msg);
687 createPostLikeRes(data.post_view, this.state.postRes?.post_view);
688 this.setState(this.state);
690 op == UserOperation.EditPost ||
691 op == UserOperation.DeletePost ||
692 op == UserOperation.RemovePost ||
693 op == UserOperation.LockPost ||
694 op == UserOperation.FeaturePost ||
695 op == UserOperation.SavePost
697 let data = wsJsonToRes<PostResponse>(msg);
698 let res = this.state.postRes;
700 res.post_view = data.post_view;
701 this.setState(this.state);
705 op == UserOperation.EditCommunity ||
706 op == UserOperation.DeleteCommunity ||
707 op == UserOperation.RemoveCommunity ||
708 op == UserOperation.FollowCommunity
710 let data = wsJsonToRes<CommunityResponse>(msg);
711 let res = this.state.postRes;
713 res.community_view = data.community_view;
714 res.post_view.community = data.community_view.community;
715 this.setState(this.state);
717 } else if (op == UserOperation.BanFromCommunity) {
718 let data = wsJsonToRes<BanFromCommunityResponse>(msg);
720 let res = this.state.postRes;
722 if (res.post_view.creator.id == data.person_view.person.id) {
723 res.post_view.creator_banned_from_community = data.banned;
727 this.state.commentsRes?.comments
728 .filter(c => c.creator.id == data.person_view.person.id)
729 .forEach(c => (c.creator_banned_from_community = data.banned));
730 this.setState(this.state);
731 } else if (op == UserOperation.AddModToCommunity) {
732 let data = wsJsonToRes<AddModToCommunityResponse>(msg);
733 let res = this.state.postRes;
735 res.moderators = data.moderators;
736 this.setState(this.state);
738 } else if (op == UserOperation.BanPerson) {
739 let data = wsJsonToRes<BanPersonResponse>(msg);
740 this.state.commentsRes?.comments
741 .filter(c => c.creator.id == data.person_view.person.id)
742 .forEach(c => (c.creator.banned = data.banned));
744 let res = this.state.postRes;
746 if (res.post_view.creator.id == data.person_view.person.id) {
747 res.post_view.creator.banned = data.banned;
750 this.setState(this.state);
751 } else if (op == UserOperation.AddAdmin) {
752 let data = wsJsonToRes<AddAdminResponse>(msg);
753 this.setState(s => ((s.siteRes.admins = data.admins), s));
754 } else if (op == UserOperation.Search) {
755 let data = wsJsonToRes<SearchResponse>(msg);
756 let xPosts = data.posts.filter(
757 p => p.post.id != Number(this.props.match.params.id)
759 this.setState({ crossPosts: xPosts.length > 0 ? xPosts : undefined });
760 } else if (op == UserOperation.LeaveAdmin) {
761 let data = wsJsonToRes<GetSiteResponse>(msg);
762 this.setState({ siteRes: data });
763 } else if (op == UserOperation.TransferCommunity) {
764 let data = wsJsonToRes<GetCommunityResponse>(msg);
765 let res = this.state.postRes;
767 res.community_view = data.community_view;
768 res.post_view.community = data.community_view.community;
769 res.moderators = data.moderators;
770 this.setState(this.state);
772 } else if (op == UserOperation.BlockPerson) {
773 let data = wsJsonToRes<BlockPersonResponse>(msg);
774 updatePersonBlock(data);
775 } else if (op == UserOperation.CreatePostReport) {
776 let data = wsJsonToRes<PostReportResponse>(msg);
778 toast(i18n.t("report_created"));
780 } else if (op == UserOperation.CreateCommentReport) {
781 let data = wsJsonToRes<CommentReportResponse>(msg);
783 toast(i18n.t("report_created"));
786 op == UserOperation.PurgePerson ||
787 op == UserOperation.PurgePost ||
788 op == UserOperation.PurgeComment ||
789 op == UserOperation.PurgeCommunity
791 let data = wsJsonToRes<PurgeItemResponse>(msg);
793 toast(i18n.t("purge_success"));
794 this.context.router.history.push(`/`);