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;
123 this.state.commentSectionRef = createRef();
125 this.parseMessage = this.parseMessage.bind(this);
126 this.subscription = wsSubscribe(this.parseMessage);
128 // Only fetch the data if coming from another route
129 if (this.isoData.path == this.context.router.route.match.url) {
130 this.state.postRes = Some(this.isoData.routeData[0] as GetPostResponse);
131 this.state.commentsRes = Some(
132 this.isoData.routeData[1] as GetCommentsResponse
135 this.state.commentsRes.match({
137 this.state.commentTree = buildCommentsTree(
139 this.state.commentId.isSome()
144 this.state.loading = false;
147 WebSocketService.Instance.send(
148 wsClient.communityJoin({
150 this.state.postRes.unwrap().community_view.community.id,
154 this.state.postId.match({
156 WebSocketService.Instance.send(wsClient.postJoin({ post_id })),
160 this.fetchCrossPosts();
162 if (this.checkScrollIntoCommentsParam) {
163 this.scrollIntoCommentSection();
172 this.setState({ commentsRes: None });
173 let postForm = new GetPost({
174 id: this.state.postId,
175 comment_id: this.state.commentId,
176 auth: auth(false).ok(),
178 WebSocketService.Instance.send(wsClient.getPost(postForm));
180 let commentsForm = new GetComments({
181 post_id: this.state.postId,
182 parent_id: this.state.commentId,
183 max_depth: Some(commentTreeMaxDepth),
186 sort: Some(this.state.commentSort),
187 type_: Some(ListingType.All),
188 community_name: None,
190 saved_only: Some(false),
191 auth: auth(false).ok(),
193 WebSocketService.Instance.send(wsClient.getComments(commentsForm));
198 .andThen(r => r.post_view.post.url)
201 let form = new Search({
203 type_: Some(SearchType.Url),
204 sort: Some(SortType.TopAll),
205 listing_type: Some(ListingType.All),
207 limit: Some(trendingFetchLimit),
209 community_name: None,
211 auth: auth(false).ok(),
213 WebSocketService.Instance.send(wsClient.search(form));
219 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
220 let pathSplit = req.path.split("/");
221 let promises: Promise<any>[] = [];
223 let pathType = pathSplit[1];
224 let id = Number(pathSplit[2]);
226 let postForm = new GetPost({
232 let commentsForm = new GetComments({
235 max_depth: Some(commentTreeMaxDepth),
238 sort: Some(CommentSortType.Hot),
239 type_: Some(ListingType.All),
240 community_name: None,
242 saved_only: Some(false),
246 // Set the correct id based on the path type
247 if (pathType == "post") {
248 postForm.id = Some(id);
249 commentsForm.post_id = Some(id);
251 postForm.comment_id = Some(id);
252 commentsForm.parent_id = Some(id);
255 promises.push(req.client.getPost(postForm));
256 promises.push(req.client.getComments(commentsForm));
261 componentWillUnmount() {
262 this.subscription.unsubscribe();
263 document.removeEventListener("scroll", this.commentScrollDebounced);
265 saveScrollPosition(this.context);
268 componentDidMount() {
269 autosize(document.querySelectorAll("textarea"));
271 this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
272 document.addEventListener("scroll", this.commentScrollDebounced);
275 componentDidUpdate(_lastProps: any) {
276 // Necessary if you are on a post and you click another post (same route)
277 if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
278 // TODO Couldnt get a refresh working. This does for now.
281 // let currentId = this.props.match.params.id;
282 // WebSocketService.Instance.getPost(currentId);
283 // this.context.refresh();
284 // this.context.router.history.push(_lastProps.location.pathname);
288 get checkScrollIntoCommentsParam() {
290 new URLSearchParams(this.props.location.search).get("scrollToComments")
294 scrollIntoCommentSection() {
295 this.state.commentSectionRef.current?.scrollIntoView();
298 isBottom(el: Element): boolean {
299 return el?.getBoundingClientRect().bottom <= window.innerHeight;
303 * Shows new comments when scrolling to the bottom of the comments div
305 trackCommentsBoxScrolling = () => {
306 const wrappedElement = document.getElementsByClassName("comments")[0];
307 if (wrappedElement && this.isBottom(wrappedElement)) {
308 this.state.maxCommentsShown += commentsShownInterval;
309 this.setState(this.state);
313 get documentTitle(): string {
314 return this.state.postRes.match({
316 this.state.siteRes.site_view.match({
318 `${res.post_view.post.name} - ${siteView.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 class="container">
345 {this.state.loading ? (
350 this.state.postRes.match({
353 <div class="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)}
370 <div ref={this.state.commentSectionRef} className="mb-2" />
372 node={Right(res.post_view.post.id)}
373 disabled={res.post_view.post.locked}
375 <div class="d-block d-md-none">
377 class="btn btn-secondary d-inline-block mb-2 mr-3"
378 onClick={linkEvent(this, this.handleShowSidebarMobile)}
380 {i18n.t("sidebar")}{" "}
383 this.state.showSidebarMobile
387 classes="icon-inline"
390 {this.state.showSidebarMobile && this.sidebar()}
393 {this.state.commentViewType == CommentViewType.Tree &&
395 {this.state.commentViewType == CommentViewType.Flat &&
398 <div class="d-none d-md-block col-md-4">{this.sidebar()}</div>
411 <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
413 className={`btn btn-outline-secondary pointer ${
414 CommentSortType[this.state.commentSort] === CommentSortType.Hot &&
421 value={CommentSortType.Hot}
422 checked={this.state.commentSort === CommentSortType.Hot}
423 onChange={linkEvent(this, this.handleCommentSortChange)}
427 className={`btn btn-outline-secondary pointer ${
428 CommentSortType[this.state.commentSort] === CommentSortType.Top &&
435 value={CommentSortType.Top}
436 checked={this.state.commentSort === CommentSortType.Top}
437 onChange={linkEvent(this, this.handleCommentSortChange)}
441 className={`btn btn-outline-secondary pointer ${
442 CommentSortType[this.state.commentSort] === CommentSortType.New &&
449 value={CommentSortType.New}
450 checked={this.state.commentSort === CommentSortType.New}
451 onChange={linkEvent(this, this.handleCommentSortChange)}
455 className={`btn btn-outline-secondary pointer ${
456 CommentSortType[this.state.commentSort] === CommentSortType.Old &&
463 value={CommentSortType.Old}
464 checked={this.state.commentSort === CommentSortType.Old}
465 onChange={linkEvent(this, this.handleCommentSortChange)}
469 <div class="btn-group btn-group-toggle flex-wrap mb-2">
471 className={`btn btn-outline-secondary pointer ${
472 this.state.commentViewType === CommentViewType.Flat && "active"
478 value={CommentViewType.Flat}
479 checked={this.state.commentViewType === CommentViewType.Flat}
480 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
489 // These are already sorted by new
490 return this.state.commentsRes.match({
492 this.state.postRes.match({
496 nodes={commentsToFlatNodes(commentsRes.comments)}
497 viewType={this.state.commentViewType}
498 maxCommentsShown={Some(this.state.maxCommentsShown)}
500 locked={postRes.post_view.post.locked}
501 moderators={Some(postRes.moderators)}
502 admins={Some(this.state.siteRes.admins)}
503 enableDownvotes={enableDownvotes(this.state.siteRes)}
515 return this.state.postRes.match({
519 community_view={res.community_view}
520 moderators={res.moderators}
521 admins={this.state.siteRes.admins}
523 enableNsfw={enableNsfw(this.state.siteRes)}
532 handleCommentSortChange(i: Post, event: any) {
533 i.state.commentSort = CommentSortType[event.target.value];
534 i.state.commentViewType = CommentViewType.Tree;
539 handleCommentViewTypeChange(i: Post, event: any) {
540 i.state.commentViewType = Number(event.target.value);
541 i.state.commentSort = CommentSortType.New;
542 i.state.commentTree = buildCommentsTree(
543 i.state.commentsRes.map(r => r.comments).unwrapOr([]),
544 i.state.commentId.isSome()
549 handleShowSidebarMobile(i: Post) {
550 i.state.showSidebarMobile = !i.state.showSidebarMobile;
554 handleViewPost(i: Post) {
555 i.state.postRes.match({
557 i.context.router.history.push(`/post/${res.post_view.post.id}`),
562 handleViewContext(i: Post) {
563 i.state.commentsRes.match({
565 i.context.router.history.push(
566 `/comment/${getCommentParentId(res.comments[0].comment).unwrap()}`
573 let showContextButton = toOption(this.state.commentTree[0]).match({
574 some: comment => getDepthFromComment(comment.comment_view.comment) > 0,
578 return this.state.postRes.match({
581 {this.state.commentId.isSome() && (
584 class="pl-0 d-block btn btn-link text-muted"
585 onClick={linkEvent(this, this.handleViewPost)}
587 {i18n.t("view_all_comments")} âž”
589 {showContextButton && (
591 class="pl-0 d-block btn btn-link text-muted"
592 onClick={linkEvent(this, this.handleViewContext)}
594 {i18n.t("show_context")} âž”
600 nodes={this.state.commentTree}
601 viewType={this.state.commentViewType}
602 maxCommentsShown={Some(this.state.maxCommentsShown)}
603 locked={res.post_view.post.locked}
604 moderators={Some(res.moderators)}
605 admins={Some(this.state.siteRes.admins)}
606 enableDownvotes={enableDownvotes(this.state.siteRes)}
614 parseMessage(msg: any) {
615 let op = wsUserOp(msg);
618 toast(i18n.t(msg.error), "danger");
620 } else if (msg.reconnect) {
621 this.state.postRes.match({
623 let postId = res.post_view.post.id;
624 WebSocketService.Instance.send(
625 wsClient.postJoin({ post_id: postId })
627 WebSocketService.Instance.send(
631 auth: auth(false).ok(),
637 } else if (op == UserOperation.GetPost) {
638 let data = wsJsonToRes<GetPostResponse>(msg, GetPostResponse);
639 this.state.postRes = Some(data);
642 WebSocketService.Instance.send(
643 wsClient.postJoin({ post_id: data.post_view.post.id })
645 WebSocketService.Instance.send(
646 wsClient.communityJoin({
647 community_id: data.community_view.community.id,
652 // TODO move this into initial fetch and refetch
653 this.fetchCrossPosts();
654 this.setState(this.state);
656 if (this.state.commentId.isNone()) restoreScrollPosition(this.context);
658 if (this.checkScrollIntoCommentsParam) {
659 this.scrollIntoCommentSection();
661 } else if (op == UserOperation.GetComments) {
662 let data = wsJsonToRes<GetCommentsResponse>(msg, GetCommentsResponse);
663 // You might need to append here, since this could be building more comments from a tree fetch
664 this.state.commentsRes.match({
666 // Remove the first comment, since it is the parent
667 let newComments = data.comments;
669 res.comments.push(...newComments);
672 this.state.commentsRes = Some(data);
675 // this.state.commentsRes = Some(data);
676 this.state.commentTree = buildCommentsTree(
677 this.state.commentsRes.map(r => r.comments).unwrapOr([]),
678 this.state.commentId.isSome()
680 this.state.loading = false;
681 this.setState(this.state);
682 } else if (op == UserOperation.CreateComment) {
683 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
685 // Don't get comments from the post room, if the creator is blocked
686 let creatorBlocked = UserService.Instance.myUserInfo
687 .map(m => m.person_blocks)
689 .map(pb => pb.target.id)
690 .includes(data.comment_view.creator.id);
692 // Necessary since it might be a user reply, which has the recipients, to avoid double
693 if (data.recipient_ids.length == 0 && !creatorBlocked) {
694 this.state.postRes.match({
696 this.state.commentsRes.match({
697 some: commentsRes => {
698 commentsRes.comments.unshift(data.comment_view);
699 insertCommentIntoTree(
700 this.state.commentTree,
702 this.state.commentId.isSome()
704 postRes.post_view.counts.comments++;
710 this.setState(this.state);
714 op == UserOperation.EditComment ||
715 op == UserOperation.DeleteComment ||
716 op == UserOperation.RemoveComment
718 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
721 this.state.commentsRes.map(r => r.comments).unwrapOr([])
723 this.setState(this.state);
725 } else if (op == UserOperation.SaveComment) {
726 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
729 this.state.commentsRes.map(r => r.comments).unwrapOr([])
731 this.setState(this.state);
733 } else if (op == UserOperation.CreateCommentLike) {
734 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
735 createCommentLikeRes(
737 this.state.commentsRes.map(r => r.comments).unwrapOr([])
739 this.setState(this.state);
740 } else if (op == UserOperation.CreatePostLike) {
741 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
742 this.state.postRes.match({
743 some: res => createPostLikeRes(data.post_view, res.post_view),
746 this.setState(this.state);
748 op == UserOperation.EditPost ||
749 op == UserOperation.DeletePost ||
750 op == UserOperation.RemovePost ||
751 op == UserOperation.LockPost ||
752 op == UserOperation.StickyPost ||
753 op == UserOperation.SavePost
755 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
756 this.state.postRes.match({
757 some: res => (res.post_view = data.post_view),
760 this.setState(this.state);
763 op == UserOperation.EditCommunity ||
764 op == UserOperation.DeleteCommunity ||
765 op == UserOperation.RemoveCommunity ||
766 op == UserOperation.FollowCommunity
768 let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse);
769 this.state.postRes.match({
771 res.community_view = data.community_view;
772 res.post_view.community = data.community_view.community;
773 this.setState(this.state);
777 } else if (op == UserOperation.BanFromCommunity) {
778 let data = wsJsonToRes<BanFromCommunityResponse>(
780 BanFromCommunityResponse
782 this.state.postRes.match({
784 this.state.commentsRes.match({
785 some: commentsRes => {
787 .filter(c => c.creator.id == data.person_view.person.id)
788 .forEach(c => (c.creator_banned_from_community = data.banned));
789 if (postRes.post_view.creator.id == data.person_view.person.id) {
790 postRes.post_view.creator_banned_from_community = data.banned;
792 this.setState(this.state);
798 } else if (op == UserOperation.AddModToCommunity) {
799 let data = wsJsonToRes<AddModToCommunityResponse>(
801 AddModToCommunityResponse
803 this.state.postRes.match({
805 res.moderators = data.moderators;
806 this.setState(this.state);
810 } else if (op == UserOperation.BanPerson) {
811 let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
812 this.state.postRes.match({
814 this.state.commentsRes.match({
815 some: commentsRes => {
817 .filter(c => c.creator.id == data.person_view.person.id)
818 .forEach(c => (c.creator.banned = data.banned));
819 if (postRes.post_view.creator.id == data.person_view.person.id) {
820 postRes.post_view.creator.banned = data.banned;
822 this.setState(this.state);
828 } else if (op == UserOperation.AddAdmin) {
829 let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
830 this.state.siteRes.admins = data.admins;
831 this.setState(this.state);
832 } else if (op == UserOperation.Search) {
833 let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
834 let xPosts = data.posts.filter(
835 p => p.post.id != Number(this.props.match.params.id)
837 this.state.crossPosts = xPosts.length > 0 ? Some(xPosts) : None;
838 this.setState(this.state);
839 } else if (op == UserOperation.LeaveAdmin) {
840 let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse);
841 this.state.siteRes = data;
842 this.setState(this.state);
843 } else if (op == UserOperation.TransferCommunity) {
844 let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse);
845 this.state.postRes.match({
847 res.community_view = data.community_view;
848 res.post_view.community = data.community_view.community;
849 res.moderators = data.moderators;
850 this.setState(this.state);
854 } else if (op == UserOperation.BlockPerson) {
855 let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
856 updatePersonBlock(data);
857 } else if (op == UserOperation.CreatePostReport) {
858 let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse);
860 toast(i18n.t("report_created"));
862 } else if (op == UserOperation.CreateCommentReport) {
863 let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse);
865 toast(i18n.t("report_created"));
868 op == UserOperation.PurgePerson ||
869 op == UserOperation.PurgePost ||
870 op == UserOperation.PurgeComment ||
871 op == UserOperation.PurgeCommunity
873 let data = wsJsonToRes<PurgeItemResponse>(msg, PurgeItemResponse);
875 toast(i18n.t("purge_success"));
876 this.context.router.history.push(`/`);