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 `${res.post_view.post.name} - ${this.state.siteRes.site_view.site.name}`,
325 get imageTag(): Option<string> {
326 return this.state.postRes.match({
328 res.post_view.post.thumbnail_url.or(
329 res.post_view.post.url.match({
330 some: url => (isImage(url) ? Some(url) : None),
338 get descriptionTag(): Option<string> {
339 return this.state.postRes.andThen(r => r.post_view.post.body);
344 <div className="container-lg">
345 {this.state.loading ? (
350 this.state.postRes.match({
352 <div className="row">
353 <div className="col-12 col-md-8 mb-3">
355 title={this.documentTitle}
356 path={this.context.router.route.match.url}
357 image={this.imageTag}
358 description={this.descriptionTag}
361 post_view={res.post_view}
362 duplicates={this.state.crossPosts}
365 moderators={Some(res.moderators)}
366 admins={Some(this.state.siteRes.admins)}
367 enableDownvotes={enableDownvotes(this.state.siteRes)}
368 enableNsfw={enableNsfw(this.state.siteRes)}
369 allLanguages={this.state.siteRes.all_languages}
370 siteLanguages={this.state.siteRes.discussion_languages}
372 <div ref={this.state.commentSectionRef} className="mb-2" />
374 node={Right(res.post_view.post.id)}
375 disabled={res.post_view.post.locked}
376 allLanguages={this.state.siteRes.all_languages}
377 siteLanguages={this.state.siteRes.discussion_languages}
379 <div className="d-block d-md-none">
381 className="btn btn-secondary d-inline-block mb-2 mr-3"
382 onClick={linkEvent(this, this.handleShowSidebarMobile)}
384 {i18n.t("sidebar")}{" "}
387 this.state.showSidebarMobile
391 classes="icon-inline"
394 {this.state.showSidebarMobile && this.sidebar()}
397 {this.state.commentViewType == CommentViewType.Tree &&
399 {this.state.commentViewType == CommentViewType.Flat &&
402 <div className="d-none d-md-block col-md-4">
417 <div className="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
419 className={`btn btn-outline-secondary pointer ${
420 CommentSortType[this.state.commentSort] === CommentSortType.Hot &&
427 value={CommentSortType.Hot}
428 checked={this.state.commentSort === CommentSortType.Hot}
429 onChange={linkEvent(this, this.handleCommentSortChange)}
433 className={`btn btn-outline-secondary pointer ${
434 CommentSortType[this.state.commentSort] === CommentSortType.Top &&
441 value={CommentSortType.Top}
442 checked={this.state.commentSort === CommentSortType.Top}
443 onChange={linkEvent(this, this.handleCommentSortChange)}
447 className={`btn btn-outline-secondary pointer ${
448 CommentSortType[this.state.commentSort] === CommentSortType.New &&
455 value={CommentSortType.New}
456 checked={this.state.commentSort === CommentSortType.New}
457 onChange={linkEvent(this, this.handleCommentSortChange)}
461 className={`btn btn-outline-secondary pointer ${
462 CommentSortType[this.state.commentSort] === CommentSortType.Old &&
469 value={CommentSortType.Old}
470 checked={this.state.commentSort === CommentSortType.Old}
471 onChange={linkEvent(this, this.handleCommentSortChange)}
475 <div className="btn-group btn-group-toggle flex-wrap mb-2">
477 className={`btn btn-outline-secondary pointer ${
478 this.state.commentViewType === CommentViewType.Flat && "active"
484 value={CommentViewType.Flat}
485 checked={this.state.commentViewType === CommentViewType.Flat}
486 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
495 // These are already sorted by new
496 return this.state.commentsRes.match({
498 this.state.postRes.match({
502 nodes={commentsToFlatNodes(commentsRes.comments)}
503 viewType={this.state.commentViewType}
504 maxCommentsShown={Some(this.state.maxCommentsShown)}
506 locked={postRes.post_view.post.locked}
507 moderators={Some(postRes.moderators)}
508 admins={Some(this.state.siteRes.admins)}
509 enableDownvotes={enableDownvotes(this.state.siteRes)}
511 allLanguages={this.state.siteRes.all_languages}
512 siteLanguages={this.state.siteRes.discussion_languages}
523 return this.state.postRes.match({
525 <div className="mb-3">
527 community_view={res.community_view}
528 moderators={res.moderators}
529 admins={this.state.siteRes.admins}
531 enableNsfw={enableNsfw(this.state.siteRes)}
533 allLanguages={this.state.siteRes.all_languages}
534 siteLanguages={this.state.siteRes.discussion_languages}
535 communityLanguages={None}
543 handleCommentSortChange(i: Post, event: any) {
545 commentSort: CommentSortType[event.target.value],
546 commentViewType: CommentViewType.Tree,
551 handleCommentViewTypeChange(i: Post, event: any) {
553 commentViewType: Number(event.target.value),
554 commentSort: CommentSortType.New,
555 commentTree: buildCommentsTree(
556 i.state.commentsRes.map(r => r.comments).unwrapOr([]),
557 i.state.commentId.isSome()
562 handleShowSidebarMobile(i: Post) {
563 i.setState({ showSidebarMobile: !i.state.showSidebarMobile });
566 handleViewPost(i: Post) {
567 i.state.postRes.match({
569 i.context.router.history.push(`/post/${res.post_view.post.id}`),
574 handleViewContext(i: Post) {
575 i.state.commentsRes.match({
577 i.context.router.history.push(
578 `/comment/${getCommentParentId(res.comments[0].comment).unwrap()}`
585 let showContextButton = toOption(this.state.commentTree[0]).match({
586 some: comment => getDepthFromComment(comment.comment_view.comment) > 0,
590 return this.state.postRes.match({
593 {this.state.commentId.isSome() && (
596 className="pl-0 d-block btn btn-link text-muted"
597 onClick={linkEvent(this, this.handleViewPost)}
599 {i18n.t("view_all_comments")} âž”
601 {showContextButton && (
603 className="pl-0 d-block btn btn-link text-muted"
604 onClick={linkEvent(this, this.handleViewContext)}
606 {i18n.t("show_context")} âž”
612 nodes={this.state.commentTree}
613 viewType={this.state.commentViewType}
614 maxCommentsShown={Some(this.state.maxCommentsShown)}
615 locked={res.post_view.post.locked}
616 moderators={Some(res.moderators)}
617 admins={Some(this.state.siteRes.admins)}
618 enableDownvotes={enableDownvotes(this.state.siteRes)}
619 allLanguages={this.state.siteRes.all_languages}
620 siteLanguages={this.state.siteRes.discussion_languages}
628 parseMessage(msg: any) {
629 let op = wsUserOp(msg);
632 toast(i18n.t(msg.error), "danger");
634 } else if (msg.reconnect) {
635 this.state.postRes.match({
637 let postId = res.post_view.post.id;
638 WebSocketService.Instance.send(
639 wsClient.postJoin({ post_id: postId })
641 WebSocketService.Instance.send(
645 auth: auth(false).ok(),
651 } else if (op == UserOperation.GetPost) {
652 let data = wsJsonToRes<GetPostResponse>(msg, GetPostResponse);
653 this.setState({ postRes: Some(data) });
656 WebSocketService.Instance.send(
657 wsClient.postJoin({ post_id: data.post_view.post.id })
659 WebSocketService.Instance.send(
660 wsClient.communityJoin({
661 community_id: data.community_view.community.id,
666 // TODO move this into initial fetch and refetch
667 this.fetchCrossPosts();
669 if (this.state.commentId.isNone()) restoreScrollPosition(this.context);
671 if (this.checkScrollIntoCommentsParam) {
672 this.scrollIntoCommentSection();
674 } else if (op == UserOperation.GetComments) {
675 let data = wsJsonToRes<GetCommentsResponse>(msg, GetCommentsResponse);
676 // You might need to append here, since this could be building more comments from a tree fetch
677 this.state.commentsRes.match({
679 // Remove the first comment, since it is the parent
680 let newComments = data.comments;
682 res.comments.push(...newComments);
684 none: () => this.setState({ commentsRes: Some(data) }),
686 // this.state.commentsRes = Some(data);
688 commentTree: buildCommentsTree(
689 this.state.commentsRes.map(r => r.comments).unwrapOr([]),
690 this.state.commentId.isSome()
694 this.setState(this.state);
695 } else if (op == UserOperation.CreateComment) {
696 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
698 // Don't get comments from the post room, if the creator is blocked
699 let creatorBlocked = UserService.Instance.myUserInfo
700 .map(m => m.person_blocks)
702 .map(pb => pb.target.id)
703 .includes(data.comment_view.creator.id);
705 // Necessary since it might be a user reply, which has the recipients, to avoid double
706 if (data.recipient_ids.length == 0 && !creatorBlocked) {
707 this.state.postRes.match({
709 this.state.commentsRes.match({
710 some: commentsRes => {
711 commentsRes.comments.unshift(data.comment_view);
712 insertCommentIntoTree(
713 this.state.commentTree,
715 this.state.commentId.isSome()
717 postRes.post_view.counts.comments++;
723 this.setState(this.state);
727 op == UserOperation.EditComment ||
728 op == UserOperation.DeleteComment ||
729 op == UserOperation.RemoveComment
731 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
734 this.state.commentsRes.map(r => r.comments).unwrapOr([])
736 this.setState(this.state);
738 } else if (op == UserOperation.SaveComment) {
739 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
742 this.state.commentsRes.map(r => r.comments).unwrapOr([])
744 this.setState(this.state);
746 } else if (op == UserOperation.CreateCommentLike) {
747 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
748 createCommentLikeRes(
750 this.state.commentsRes.map(r => r.comments).unwrapOr([])
752 this.setState(this.state);
753 } else if (op == UserOperation.CreatePostLike) {
754 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
755 this.state.postRes.match({
756 some: res => createPostLikeRes(data.post_view, res.post_view),
759 this.setState(this.state);
761 op == UserOperation.EditPost ||
762 op == UserOperation.DeletePost ||
763 op == UserOperation.RemovePost ||
764 op == UserOperation.LockPost ||
765 op == UserOperation.FeaturePost ||
766 op == UserOperation.SavePost
768 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
769 this.state.postRes.match({
770 some: res => (res.post_view = data.post_view),
773 this.setState(this.state);
776 op == UserOperation.EditCommunity ||
777 op == UserOperation.DeleteCommunity ||
778 op == UserOperation.RemoveCommunity ||
779 op == UserOperation.FollowCommunity
781 let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse);
782 this.state.postRes.match({
784 res.community_view = data.community_view;
785 res.post_view.community = data.community_view.community;
786 this.setState(this.state);
790 } else if (op == UserOperation.BanFromCommunity) {
791 let data = wsJsonToRes<BanFromCommunityResponse>(
793 BanFromCommunityResponse
795 this.state.postRes.match({
797 this.state.commentsRes.match({
798 some: commentsRes => {
800 .filter(c => c.creator.id == data.person_view.person.id)
801 .forEach(c => (c.creator_banned_from_community = data.banned));
802 if (postRes.post_view.creator.id == data.person_view.person.id) {
803 postRes.post_view.creator_banned_from_community = data.banned;
805 this.setState(this.state);
811 } else if (op == UserOperation.AddModToCommunity) {
812 let data = wsJsonToRes<AddModToCommunityResponse>(
814 AddModToCommunityResponse
816 this.state.postRes.match({
818 res.moderators = data.moderators;
819 this.setState(this.state);
823 } else if (op == UserOperation.BanPerson) {
824 let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
825 this.state.postRes.match({
827 this.state.commentsRes.match({
828 some: commentsRes => {
830 .filter(c => c.creator.id == data.person_view.person.id)
831 .forEach(c => (c.creator.banned = data.banned));
832 if (postRes.post_view.creator.id == data.person_view.person.id) {
833 postRes.post_view.creator.banned = data.banned;
835 this.setState(this.state);
841 } else if (op == UserOperation.AddAdmin) {
842 let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
843 this.setState(s => ((s.siteRes.admins = data.admins), s));
844 } else if (op == UserOperation.Search) {
845 let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
846 let xPosts = data.posts.filter(
847 p => p.post.id != Number(this.props.match.params.id)
849 this.setState({ crossPosts: xPosts.length > 0 ? Some(xPosts) : None });
850 } else if (op == UserOperation.LeaveAdmin) {
851 let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse);
852 this.setState({ siteRes: data });
853 } else if (op == UserOperation.TransferCommunity) {
854 let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse);
855 this.state.postRes.match({
857 res.community_view = data.community_view;
858 res.post_view.community = data.community_view.community;
859 res.moderators = data.moderators;
860 this.setState(this.state);
864 } else if (op == UserOperation.BlockPerson) {
865 let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
866 updatePersonBlock(data);
867 } else if (op == UserOperation.CreatePostReport) {
868 let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse);
870 toast(i18n.t("report_created"));
872 } else if (op == UserOperation.CreateCommentReport) {
873 let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse);
875 toast(i18n.t("report_created"));
878 op == UserOperation.PurgePerson ||
879 op == UserOperation.PurgePost ||
880 op == UserOperation.PurgeComment ||
881 op == UserOperation.PurgeCommunity
883 let data = wsJsonToRes<PurgeItemResponse>(msg, PurgeItemResponse);
885 toast(i18n.t("purge_success"));
886 this.context.router.history.push(`/`);