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}
371 <div ref={this.state.commentSectionRef} className="mb-2" />
373 node={Right(res.post_view.post.id)}
374 disabled={res.post_view.post.locked}
375 allLanguages={this.state.siteRes.all_languages}
377 <div className="d-block d-md-none">
379 className="btn btn-secondary d-inline-block mb-2 mr-3"
380 onClick={linkEvent(this, this.handleShowSidebarMobile)}
382 {i18n.t("sidebar")}{" "}
385 this.state.showSidebarMobile
389 classes="icon-inline"
392 {this.state.showSidebarMobile && this.sidebar()}
395 {this.state.commentViewType == CommentViewType.Tree &&
397 {this.state.commentViewType == CommentViewType.Flat &&
400 <div className="d-none d-md-block col-md-4">
415 <div className="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
417 className={`btn btn-outline-secondary pointer ${
418 CommentSortType[this.state.commentSort] === CommentSortType.Hot &&
425 value={CommentSortType.Hot}
426 checked={this.state.commentSort === CommentSortType.Hot}
427 onChange={linkEvent(this, this.handleCommentSortChange)}
431 className={`btn btn-outline-secondary pointer ${
432 CommentSortType[this.state.commentSort] === CommentSortType.Top &&
439 value={CommentSortType.Top}
440 checked={this.state.commentSort === CommentSortType.Top}
441 onChange={linkEvent(this, this.handleCommentSortChange)}
445 className={`btn btn-outline-secondary pointer ${
446 CommentSortType[this.state.commentSort] === CommentSortType.New &&
453 value={CommentSortType.New}
454 checked={this.state.commentSort === CommentSortType.New}
455 onChange={linkEvent(this, this.handleCommentSortChange)}
459 className={`btn btn-outline-secondary pointer ${
460 CommentSortType[this.state.commentSort] === CommentSortType.Old &&
467 value={CommentSortType.Old}
468 checked={this.state.commentSort === CommentSortType.Old}
469 onChange={linkEvent(this, this.handleCommentSortChange)}
473 <div className="btn-group btn-group-toggle flex-wrap mb-2">
475 className={`btn btn-outline-secondary pointer ${
476 this.state.commentViewType === CommentViewType.Flat && "active"
482 value={CommentViewType.Flat}
483 checked={this.state.commentViewType === CommentViewType.Flat}
484 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
493 // These are already sorted by new
494 return this.state.commentsRes.match({
496 this.state.postRes.match({
500 nodes={commentsToFlatNodes(commentsRes.comments)}
501 viewType={this.state.commentViewType}
502 maxCommentsShown={Some(this.state.maxCommentsShown)}
504 locked={postRes.post_view.post.locked}
505 moderators={Some(postRes.moderators)}
506 admins={Some(this.state.siteRes.admins)}
507 enableDownvotes={enableDownvotes(this.state.siteRes)}
509 allLanguages={this.state.siteRes.all_languages}
520 return this.state.postRes.match({
522 <div className="mb-3">
524 community_view={res.community_view}
525 moderators={res.moderators}
526 admins={this.state.siteRes.admins}
528 enableNsfw={enableNsfw(this.state.siteRes)}
537 handleCommentSortChange(i: Post, event: any) {
539 commentSort: CommentSortType[event.target.value],
540 commentViewType: CommentViewType.Tree,
545 handleCommentViewTypeChange(i: Post, event: any) {
547 commentViewType: Number(event.target.value),
548 commentSort: CommentSortType.New,
549 commentTree: buildCommentsTree(
550 i.state.commentsRes.map(r => r.comments).unwrapOr([]),
551 i.state.commentId.isSome()
556 handleShowSidebarMobile(i: Post) {
557 i.setState({ showSidebarMobile: !i.state.showSidebarMobile });
560 handleViewPost(i: Post) {
561 i.state.postRes.match({
563 i.context.router.history.push(`/post/${res.post_view.post.id}`),
568 handleViewContext(i: Post) {
569 i.state.commentsRes.match({
571 i.context.router.history.push(
572 `/comment/${getCommentParentId(res.comments[0].comment).unwrap()}`
579 let showContextButton = toOption(this.state.commentTree[0]).match({
580 some: comment => getDepthFromComment(comment.comment_view.comment) > 0,
584 return this.state.postRes.match({
587 {this.state.commentId.isSome() && (
590 className="pl-0 d-block btn btn-link text-muted"
591 onClick={linkEvent(this, this.handleViewPost)}
593 {i18n.t("view_all_comments")} âž”
595 {showContextButton && (
597 className="pl-0 d-block btn btn-link text-muted"
598 onClick={linkEvent(this, this.handleViewContext)}
600 {i18n.t("show_context")} âž”
606 nodes={this.state.commentTree}
607 viewType={this.state.commentViewType}
608 maxCommentsShown={Some(this.state.maxCommentsShown)}
609 locked={res.post_view.post.locked}
610 moderators={Some(res.moderators)}
611 admins={Some(this.state.siteRes.admins)}
612 enableDownvotes={enableDownvotes(this.state.siteRes)}
613 allLanguages={this.state.siteRes.all_languages}
621 parseMessage(msg: any) {
622 let op = wsUserOp(msg);
625 toast(i18n.t(msg.error), "danger");
627 } else if (msg.reconnect) {
628 this.state.postRes.match({
630 let postId = res.post_view.post.id;
631 WebSocketService.Instance.send(
632 wsClient.postJoin({ post_id: postId })
634 WebSocketService.Instance.send(
638 auth: auth(false).ok(),
644 } else if (op == UserOperation.GetPost) {
645 let data = wsJsonToRes<GetPostResponse>(msg, GetPostResponse);
646 this.setState({ postRes: Some(data) });
649 WebSocketService.Instance.send(
650 wsClient.postJoin({ post_id: data.post_view.post.id })
652 WebSocketService.Instance.send(
653 wsClient.communityJoin({
654 community_id: data.community_view.community.id,
659 // TODO move this into initial fetch and refetch
660 this.fetchCrossPosts();
662 if (this.state.commentId.isNone()) restoreScrollPosition(this.context);
664 if (this.checkScrollIntoCommentsParam) {
665 this.scrollIntoCommentSection();
667 } else if (op == UserOperation.GetComments) {
668 let data = wsJsonToRes<GetCommentsResponse>(msg, GetCommentsResponse);
669 // You might need to append here, since this could be building more comments from a tree fetch
670 this.state.commentsRes.match({
672 // Remove the first comment, since it is the parent
673 let newComments = data.comments;
675 res.comments.push(...newComments);
677 none: () => this.setState({ commentsRes: Some(data) }),
679 // this.state.commentsRes = Some(data);
681 commentTree: buildCommentsTree(
682 this.state.commentsRes.map(r => r.comments).unwrapOr([]),
683 this.state.commentId.isSome()
687 this.setState(this.state);
688 } else if (op == UserOperation.CreateComment) {
689 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
691 // Don't get comments from the post room, if the creator is blocked
692 let creatorBlocked = UserService.Instance.myUserInfo
693 .map(m => m.person_blocks)
695 .map(pb => pb.target.id)
696 .includes(data.comment_view.creator.id);
698 // Necessary since it might be a user reply, which has the recipients, to avoid double
699 if (data.recipient_ids.length == 0 && !creatorBlocked) {
700 this.state.postRes.match({
702 this.state.commentsRes.match({
703 some: commentsRes => {
704 commentsRes.comments.unshift(data.comment_view);
705 insertCommentIntoTree(
706 this.state.commentTree,
708 this.state.commentId.isSome()
710 postRes.post_view.counts.comments++;
716 this.setState(this.state);
720 op == UserOperation.EditComment ||
721 op == UserOperation.DeleteComment ||
722 op == UserOperation.RemoveComment
724 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
727 this.state.commentsRes.map(r => r.comments).unwrapOr([])
729 this.setState(this.state);
731 } else if (op == UserOperation.SaveComment) {
732 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
735 this.state.commentsRes.map(r => r.comments).unwrapOr([])
737 this.setState(this.state);
739 } else if (op == UserOperation.CreateCommentLike) {
740 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
741 createCommentLikeRes(
743 this.state.commentsRes.map(r => r.comments).unwrapOr([])
745 this.setState(this.state);
746 } else if (op == UserOperation.CreatePostLike) {
747 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
748 this.state.postRes.match({
749 some: res => createPostLikeRes(data.post_view, res.post_view),
752 this.setState(this.state);
754 op == UserOperation.EditPost ||
755 op == UserOperation.DeletePost ||
756 op == UserOperation.RemovePost ||
757 op == UserOperation.LockPost ||
758 op == UserOperation.StickyPost ||
759 op == UserOperation.SavePost
761 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
762 this.state.postRes.match({
763 some: res => (res.post_view = data.post_view),
766 this.setState(this.state);
769 op == UserOperation.EditCommunity ||
770 op == UserOperation.DeleteCommunity ||
771 op == UserOperation.RemoveCommunity ||
772 op == UserOperation.FollowCommunity
774 let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse);
775 this.state.postRes.match({
777 res.community_view = data.community_view;
778 res.post_view.community = data.community_view.community;
779 this.setState(this.state);
783 } else if (op == UserOperation.BanFromCommunity) {
784 let data = wsJsonToRes<BanFromCommunityResponse>(
786 BanFromCommunityResponse
788 this.state.postRes.match({
790 this.state.commentsRes.match({
791 some: commentsRes => {
793 .filter(c => c.creator.id == data.person_view.person.id)
794 .forEach(c => (c.creator_banned_from_community = data.banned));
795 if (postRes.post_view.creator.id == data.person_view.person.id) {
796 postRes.post_view.creator_banned_from_community = data.banned;
798 this.setState(this.state);
804 } else if (op == UserOperation.AddModToCommunity) {
805 let data = wsJsonToRes<AddModToCommunityResponse>(
807 AddModToCommunityResponse
809 this.state.postRes.match({
811 res.moderators = data.moderators;
812 this.setState(this.state);
816 } else if (op == UserOperation.BanPerson) {
817 let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
818 this.state.postRes.match({
820 this.state.commentsRes.match({
821 some: commentsRes => {
823 .filter(c => c.creator.id == data.person_view.person.id)
824 .forEach(c => (c.creator.banned = data.banned));
825 if (postRes.post_view.creator.id == data.person_view.person.id) {
826 postRes.post_view.creator.banned = data.banned;
828 this.setState(this.state);
834 } else if (op == UserOperation.AddAdmin) {
835 let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
836 this.setState(s => ((s.siteRes.admins = data.admins), s));
837 } else if (op == UserOperation.Search) {
838 let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
839 let xPosts = data.posts.filter(
840 p => p.post.id != Number(this.props.match.params.id)
842 this.setState({ crossPosts: xPosts.length > 0 ? Some(xPosts) : None });
843 } else if (op == UserOperation.LeaveAdmin) {
844 let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse);
845 this.setState({ siteRes: data });
846 } else if (op == UserOperation.TransferCommunity) {
847 let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse);
848 this.state.postRes.match({
850 res.community_view = data.community_view;
851 res.post_view.community = data.community_view.community;
852 res.moderators = data.moderators;
853 this.setState(this.state);
857 } else if (op == UserOperation.BlockPerson) {
858 let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
859 updatePersonBlock(data);
860 } else if (op == UserOperation.CreatePostReport) {
861 let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse);
863 toast(i18n.t("report_created"));
865 } else if (op == UserOperation.CreateCommentReport) {
866 let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse);
868 toast(i18n.t("report_created"));
871 op == UserOperation.PurgePerson ||
872 op == UserOperation.PurgePost ||
873 op == UserOperation.PurgeComment ||
874 op == UserOperation.PurgeCommunity
876 let data = wsJsonToRes<PurgeItemResponse>(msg, PurgeItemResponse);
878 toast(i18n.t("purge_success"));
879 this.context.router.history.push(`/`);