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,
33 } from "lemmy-js-client";
34 import { Subscription } from "rxjs";
35 import { i18n } from "../../i18next";
36 import { CommentViewType, InitialFetchRequest } from "../../interfaces";
37 import { UserService, WebSocketService } from "../../services";
49 getCommentIdFromProps,
53 insertCommentIntoTree,
56 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 postId: Option<number>;
78 commentId: Option<number>;
79 postRes: Option<GetPostResponse>;
80 commentsRes: Option<GetCommentsResponse>;
81 commentTree: CommentNodeI[];
82 commentSort: CommentSortType;
83 commentViewType: CommentViewType;
86 crossPosts: Option<PostView[]>;
87 siteRes: GetSiteResponse;
88 commentSectionRef?: RefObject<HTMLDivElement>;
89 showSidebarMobile: boolean;
90 maxCommentsShown: number;
93 export class Post extends Component<any, PostState> {
94 private subscription: Subscription;
95 private isoData = setIsoData(
100 private commentScrollDebounced: () => void;
101 private emptyState: PostState = {
104 postId: getIdFromProps(this.props),
105 commentId: getCommentIdFromProps(this.props),
107 commentSort: CommentSortType[CommentSortType.Hot],
108 commentViewType: CommentViewType.Tree,
112 siteRes: this.isoData.site_res,
113 commentSectionRef: null,
114 showSidebarMobile: false,
115 maxCommentsShown: commentsShownInterval,
118 constructor(props: any, context: any) {
119 super(props, context);
121 this.state = this.emptyState;
122 this.state.commentSectionRef = createRef();
124 this.parseMessage = this.parseMessage.bind(this);
125 this.subscription = wsSubscribe(this.parseMessage);
127 // Only fetch the data if coming from another route
128 if (this.isoData.path == this.context.router.route.match.url) {
129 this.state.postRes = Some(this.isoData.routeData[0] as GetPostResponse);
130 this.state.commentsRes = Some(
131 this.isoData.routeData[1] as GetCommentsResponse
134 this.state.commentsRes.match({
136 this.state.commentTree = buildCommentsTree(
138 this.state.commentId.isSome()
143 this.state.loading = false;
146 WebSocketService.Instance.send(
147 wsClient.communityJoin({
149 this.state.postRes.unwrap().community_view.community.id,
153 this.state.postId.match({
155 WebSocketService.Instance.send(wsClient.postJoin({ post_id })),
159 this.fetchCrossPosts();
161 if (this.checkScrollIntoCommentsParam) {
162 this.scrollIntoCommentSection();
171 this.setState({ commentsRes: None });
172 let postForm = new GetPost({
173 id: this.state.postId,
174 comment_id: this.state.commentId,
175 auth: auth(false).ok(),
177 WebSocketService.Instance.send(wsClient.getPost(postForm));
179 let commentsForm = new GetComments({
180 post_id: this.state.postId,
181 parent_id: this.state.commentId,
182 max_depth: Some(commentTreeMaxDepth),
185 sort: Some(this.state.commentSort),
186 type_: Some(ListingType.All),
187 community_name: None,
189 saved_only: Some(false),
190 auth: auth(false).ok(),
192 WebSocketService.Instance.send(wsClient.getComments(commentsForm));
197 .andThen(r => r.post_view.post.url)
200 let form = new Search({
202 type_: Some(SearchType.Url),
203 sort: Some(SortType.TopAll),
204 listing_type: Some(ListingType.All),
206 limit: Some(trendingFetchLimit),
208 community_name: None,
210 auth: auth(false).ok(),
212 WebSocketService.Instance.send(wsClient.search(form));
218 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
219 let pathSplit = req.path.split("/");
220 let promises: Promise<any>[] = [];
222 let pathType = pathSplit[1];
223 let id = Number(pathSplit[2]);
225 let postForm = new GetPost({
231 let commentsForm = new GetComments({
234 max_depth: Some(commentTreeMaxDepth),
237 sort: Some(CommentSortType.Hot),
238 type_: Some(ListingType.All),
239 community_name: None,
241 saved_only: Some(false),
245 // Set the correct id based on the path type
246 if (pathType == "post") {
247 postForm.id = Some(id);
248 commentsForm.post_id = Some(id);
250 postForm.comment_id = Some(id);
251 commentsForm.parent_id = Some(id);
254 promises.push(req.client.getPost(postForm));
255 promises.push(req.client.getComments(commentsForm));
260 componentWillUnmount() {
261 this.subscription.unsubscribe();
262 document.removeEventListener("scroll", this.commentScrollDebounced);
264 saveScrollPosition(this.context);
267 componentDidMount() {
268 autosize(document.querySelectorAll("textarea"));
270 this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
271 document.addEventListener("scroll", this.commentScrollDebounced);
274 componentDidUpdate(_lastProps: any) {
275 // Necessary if you are on a post and you click another post (same route)
276 if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
277 // TODO Couldnt get a refresh working. This does for now.
280 // let currentId = this.props.match.params.id;
281 // WebSocketService.Instance.getPost(currentId);
282 // this.context.refresh();
283 // this.context.router.history.push(_lastProps.location.pathname);
287 get checkScrollIntoCommentsParam() {
289 new URLSearchParams(this.props.location.search).get("scrollToComments")
293 scrollIntoCommentSection() {
294 this.state.commentSectionRef.current?.scrollIntoView();
297 isBottom(el: Element): boolean {
298 return el?.getBoundingClientRect().bottom <= window.innerHeight;
302 * Shows new comments when scrolling to the bottom of the comments div
304 trackCommentsBoxScrolling = () => {
305 const wrappedElement = document.getElementsByClassName("comments")[0];
306 if (wrappedElement && this.isBottom(wrappedElement)) {
307 this.state.maxCommentsShown += commentsShownInterval;
308 this.setState(this.state);
312 get documentTitle(): string {
313 return this.state.postRes.match({
315 this.state.siteRes.site_view.match({
317 `${res.post_view.post.name} - ${siteView.site.name}`,
324 get imageTag(): Option<string> {
325 return this.state.postRes.match({
327 res.post_view.post.thumbnail_url.or(
328 res.post_view.post.url.match({
329 some: url => (isImage(url) ? Some(url) : None),
337 get descriptionTag(): Option<string> {
338 return this.state.postRes.andThen(r => r.post_view.post.body);
343 <div class="container">
344 {this.state.loading ? (
349 this.state.postRes.match({
352 <div class="col-12 col-md-8 mb-3">
354 title={this.documentTitle}
355 path={this.context.router.route.match.url}
356 image={this.imageTag}
357 description={this.descriptionTag}
360 post_view={res.post_view}
361 duplicates={this.state.crossPosts}
364 moderators={Some(res.moderators)}
365 admins={Some(this.state.siteRes.admins)}
366 enableDownvotes={enableDownvotes(this.state.siteRes)}
367 enableNsfw={enableNsfw(this.state.siteRes)}
369 <div ref={this.state.commentSectionRef} className="mb-2" />
371 node={Right(res.post_view.post.id)}
372 disabled={res.post_view.post.locked}
374 <div class="d-block d-md-none">
376 class="btn btn-secondary d-inline-block mb-2 mr-3"
377 onClick={linkEvent(this, this.handleShowSidebarMobile)}
379 {i18n.t("sidebar")}{" "}
382 this.state.showSidebarMobile
386 classes="icon-inline"
389 {this.state.showSidebarMobile && this.sidebar()}
392 {this.state.commentViewType == CommentViewType.Tree &&
394 {this.state.commentViewType == CommentViewType.Flat &&
397 <div class="d-none d-md-block col-md-4">{this.sidebar()}</div>
410 <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
412 className={`btn btn-outline-secondary pointer ${
413 CommentSortType[this.state.commentSort] === CommentSortType.Hot &&
420 value={CommentSortType.Hot}
421 checked={this.state.commentSort === CommentSortType.Hot}
422 onChange={linkEvent(this, this.handleCommentSortChange)}
426 className={`btn btn-outline-secondary pointer ${
427 CommentSortType[this.state.commentSort] === CommentSortType.Top &&
434 value={CommentSortType.Top}
435 checked={this.state.commentSort === CommentSortType.Top}
436 onChange={linkEvent(this, this.handleCommentSortChange)}
440 className={`btn btn-outline-secondary pointer ${
441 CommentSortType[this.state.commentSort] === CommentSortType.New &&
448 value={CommentSortType.New}
449 checked={this.state.commentSort === CommentSortType.New}
450 onChange={linkEvent(this, this.handleCommentSortChange)}
454 className={`btn btn-outline-secondary pointer ${
455 CommentSortType[this.state.commentSort] === CommentSortType.Old &&
462 value={CommentSortType.Old}
463 checked={this.state.commentSort === CommentSortType.Old}
464 onChange={linkEvent(this, this.handleCommentSortChange)}
468 <div class="btn-group btn-group-toggle flex-wrap mb-2">
470 className={`btn btn-outline-secondary pointer ${
471 this.state.commentViewType === CommentViewType.Flat && "active"
477 value={CommentViewType.Flat}
478 checked={this.state.commentViewType === CommentViewType.Flat}
479 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
488 // These are already sorted by new
489 return this.state.commentsRes.match({
491 this.state.postRes.match({
495 nodes={commentsToFlatNodes(commentsRes.comments)}
496 viewType={this.state.commentViewType}
497 maxCommentsShown={Some(this.state.maxCommentsShown)}
499 locked={postRes.post_view.post.locked}
500 moderators={Some(postRes.moderators)}
501 admins={Some(this.state.siteRes.admins)}
502 enableDownvotes={enableDownvotes(this.state.siteRes)}
514 return this.state.postRes.match({
518 community_view={res.community_view}
519 moderators={res.moderators}
520 admins={this.state.siteRes.admins}
522 enableNsfw={enableNsfw(this.state.siteRes)}
531 handleCommentSortChange(i: Post, event: any) {
532 i.state.commentSort = CommentSortType[event.target.value];
533 i.state.commentViewType = CommentViewType.Tree;
538 handleCommentViewTypeChange(i: Post, event: any) {
539 i.state.commentViewType = Number(event.target.value);
540 i.state.commentSort = CommentSortType.New;
541 i.state.commentTree = buildCommentsTree(
542 i.state.commentsRes.map(r => r.comments).unwrapOr([]),
543 i.state.commentId.isSome()
548 handleShowSidebarMobile(i: Post) {
549 i.state.showSidebarMobile = !i.state.showSidebarMobile;
553 handleViewPost(i: Post) {
554 i.state.postRes.match({
556 i.context.router.history.push(`/post/${res.post_view.post.id}`),
561 handleViewContext(i: Post) {
562 i.state.commentsRes.match({
564 i.context.router.history.push(
565 `/comment/${getCommentParentId(res.comments[0].comment).unwrap()}`
572 let showContextButton =
573 getDepthFromComment(this.state.commentTree[0].comment_view.comment) > 0;
575 return this.state.postRes.match({
578 {this.state.commentId.isSome() && (
581 class="pl-0 d-block btn btn-link text-muted"
582 onClick={linkEvent(this, this.handleViewPost)}
584 {i18n.t("view_all_comments")} âž”
586 {showContextButton && (
588 class="pl-0 d-block btn btn-link text-muted"
589 onClick={linkEvent(this, this.handleViewContext)}
591 {i18n.t("show_context")} âž”
597 nodes={this.state.commentTree}
598 viewType={this.state.commentViewType}
599 maxCommentsShown={Some(this.state.maxCommentsShown)}
600 locked={res.post_view.post.locked}
601 moderators={Some(res.moderators)}
602 admins={Some(this.state.siteRes.admins)}
603 enableDownvotes={enableDownvotes(this.state.siteRes)}
611 parseMessage(msg: any) {
612 let op = wsUserOp(msg);
615 toast(i18n.t(msg.error), "danger");
617 } else if (msg.reconnect) {
618 this.state.postRes.match({
620 let postId = res.post_view.post.id;
621 WebSocketService.Instance.send(
622 wsClient.postJoin({ post_id: postId })
624 WebSocketService.Instance.send(
628 auth: auth(false).ok(),
634 } else if (op == UserOperation.GetPost) {
635 let data = wsJsonToRes<GetPostResponse>(msg, GetPostResponse);
636 this.state.postRes = Some(data);
639 WebSocketService.Instance.send(
640 wsClient.postJoin({ post_id: data.post_view.post.id })
642 WebSocketService.Instance.send(
643 wsClient.communityJoin({
644 community_id: data.community_view.community.id,
649 // TODO move this into initial fetch and refetch
650 this.fetchCrossPosts();
651 this.setState(this.state);
653 if (this.state.commentId.isNone()) restoreScrollPosition(this.context);
655 if (this.checkScrollIntoCommentsParam) {
656 this.scrollIntoCommentSection();
658 } else if (op == UserOperation.GetComments) {
659 let data = wsJsonToRes<GetCommentsResponse>(msg, GetCommentsResponse);
660 // You might need to append here, since this could be building more comments from a tree fetch
661 this.state.commentsRes.match({
663 // Remove the first comment, since it is the parent
664 let newComments = data.comments;
666 res.comments.push(...newComments);
669 this.state.commentsRes = Some(data);
672 // this.state.commentsRes = Some(data);
673 this.state.commentTree = buildCommentsTree(
674 this.state.commentsRes.map(r => r.comments).unwrapOr([]),
675 this.state.commentId.isSome()
677 this.state.loading = false;
678 this.setState(this.state);
679 } else if (op == UserOperation.CreateComment) {
680 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
682 // Don't get comments from the post room, if the creator is blocked
683 let creatorBlocked = UserService.Instance.myUserInfo
684 .map(m => m.person_blocks)
686 .map(pb => pb.target.id)
687 .includes(data.comment_view.creator.id);
689 // Necessary since it might be a user reply, which has the recipients, to avoid double
690 if (data.recipient_ids.length == 0 && !creatorBlocked) {
691 this.state.postRes.match({
693 this.state.commentsRes.match({
694 some: commentsRes => {
695 commentsRes.comments.unshift(data.comment_view);
696 insertCommentIntoTree(
697 this.state.commentTree,
699 this.state.commentId.isSome()
701 postRes.post_view.counts.comments++;
707 this.setState(this.state);
711 op == UserOperation.EditComment ||
712 op == UserOperation.DeleteComment ||
713 op == UserOperation.RemoveComment
715 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
718 this.state.commentsRes.map(r => r.comments).unwrapOr([])
720 this.setState(this.state);
721 } else if (op == UserOperation.SaveComment) {
722 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
725 this.state.commentsRes.map(r => r.comments).unwrapOr([])
727 this.setState(this.state);
729 } else if (op == UserOperation.CreateCommentLike) {
730 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
731 createCommentLikeRes(
733 this.state.commentsRes.map(r => r.comments).unwrapOr([])
735 this.setState(this.state);
736 } else if (op == UserOperation.CreatePostLike) {
737 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
738 this.state.postRes.match({
739 some: res => createPostLikeRes(data.post_view, res.post_view),
742 this.setState(this.state);
744 op == UserOperation.EditPost ||
745 op == UserOperation.DeletePost ||
746 op == UserOperation.RemovePost ||
747 op == UserOperation.LockPost ||
748 op == UserOperation.StickyPost ||
749 op == UserOperation.SavePost
751 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
752 this.state.postRes.match({
753 some: res => (res.post_view = data.post_view),
756 this.setState(this.state);
759 op == UserOperation.EditCommunity ||
760 op == UserOperation.DeleteCommunity ||
761 op == UserOperation.RemoveCommunity ||
762 op == UserOperation.FollowCommunity
764 let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse);
765 this.state.postRes.match({
767 res.community_view = data.community_view;
768 res.post_view.community = data.community_view.community;
769 this.setState(this.state);
773 } else if (op == UserOperation.BanFromCommunity) {
774 let data = wsJsonToRes<BanFromCommunityResponse>(
776 BanFromCommunityResponse
778 this.state.postRes.match({
780 this.state.commentsRes.match({
781 some: commentsRes => {
783 .filter(c => c.creator.id == data.person_view.person.id)
784 .forEach(c => (c.creator_banned_from_community = data.banned));
785 if (postRes.post_view.creator.id == data.person_view.person.id) {
786 postRes.post_view.creator_banned_from_community = data.banned;
788 this.setState(this.state);
794 } else if (op == UserOperation.AddModToCommunity) {
795 let data = wsJsonToRes<AddModToCommunityResponse>(
797 AddModToCommunityResponse
799 this.state.postRes.match({
801 res.moderators = data.moderators;
802 this.setState(this.state);
806 } else if (op == UserOperation.BanPerson) {
807 let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
808 this.state.postRes.match({
810 this.state.commentsRes.match({
811 some: commentsRes => {
813 .filter(c => c.creator.id == data.person_view.person.id)
814 .forEach(c => (c.creator.banned = data.banned));
815 if (postRes.post_view.creator.id == data.person_view.person.id) {
816 postRes.post_view.creator.banned = data.banned;
818 this.setState(this.state);
824 } else if (op == UserOperation.AddAdmin) {
825 let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
826 this.state.siteRes.admins = data.admins;
827 this.setState(this.state);
828 } else if (op == UserOperation.Search) {
829 let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
830 let xPosts = data.posts.filter(
831 p => p.post.id != Number(this.props.match.params.id)
833 this.state.crossPosts = xPosts.length > 0 ? Some(xPosts) : None;
834 this.setState(this.state);
835 } else if (op == UserOperation.LeaveAdmin) {
836 let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse);
837 this.state.siteRes = data;
838 this.setState(this.state);
839 } else if (op == UserOperation.TransferCommunity) {
840 let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse);
841 this.state.postRes.match({
843 res.community_view = data.community_view;
844 res.post_view.community = data.community_view.community;
845 res.moderators = data.moderators;
846 this.setState(this.state);
850 } else if (op == UserOperation.BlockPerson) {
851 let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
852 updatePersonBlock(data);
853 } else if (op == UserOperation.CreatePostReport) {
854 let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse);
856 toast(i18n.t("report_created"));
858 } else if (op == UserOperation.CreateCommentReport) {
859 let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse);
861 toast(i18n.t("report_created"));
864 op == UserOperation.PurgePerson ||
865 op == UserOperation.PurgePost ||
866 op == UserOperation.PurgeComment ||
867 op == UserOperation.PurgeCommunity
869 let data = wsJsonToRes<PurgeItemResponse>(msg, PurgeItemResponse);
871 toast(i18n.t("purge_success"));
872 this.context.router.history.push(`/`);