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 CommentReportResponse,
29 } from "lemmy-js-client";
30 import { Subscription } from "rxjs";
31 import { i18n } from "../../i18next";
33 CommentNode as CommentNodeI,
37 } from "../../interfaces";
38 import { UserService, WebSocketService } from "../../services";
49 getCommentIdFromProps,
51 insertCommentIntoTree,
54 restoreScrollPosition,
65 import { CommentForm } from "../comment/comment-form";
66 import { CommentNodes } from "../comment/comment-nodes";
67 import { HtmlTags } from "../common/html-tags";
68 import { Icon, Spinner } from "../common/icon";
69 import { Sidebar } from "../community/sidebar";
70 import { PostListing } from "./post-listing";
72 const commentsShownInterval = 15;
75 postRes: Option<GetPostResponse>;
77 commentTree: CommentNodeI[];
79 commentSort: CommentSortType;
80 commentViewType: CommentViewType;
83 crossPosts: Option<PostView[]>;
84 siteRes: GetSiteResponse;
85 commentSectionRef?: RefObject<HTMLDivElement>;
86 showSidebarMobile: boolean;
87 maxCommentsShown: number;
90 export class Post extends Component<any, PostState> {
91 private subscription: Subscription;
92 private isoData = setIsoData(this.context, GetPostResponse);
93 private commentScrollDebounced: () => void;
94 private emptyState: PostState = {
96 postId: getIdFromProps(this.props),
98 commentId: getCommentIdFromProps(this.props),
99 commentSort: CommentSortType.Hot,
100 commentViewType: CommentViewType.Tree,
104 siteRes: this.isoData.site_res,
105 commentSectionRef: null,
106 showSidebarMobile: false,
107 maxCommentsShown: commentsShownInterval,
110 constructor(props: any, context: any) {
111 super(props, context);
113 this.state = this.emptyState;
114 this.state.commentSectionRef = createRef();
116 this.parseMessage = this.parseMessage.bind(this);
117 this.subscription = wsSubscribe(this.parseMessage);
119 // Only fetch the data if coming from another route
120 if (this.isoData.path == this.context.router.route.match.url) {
121 this.state.postRes = Some(this.isoData.routeData[0] as GetPostResponse);
122 this.state.commentTree = buildCommentsTree(
123 this.state.postRes.unwrap().comments,
124 this.state.commentSort
126 this.state.loading = false;
129 WebSocketService.Instance.send(
130 wsClient.communityJoin({
132 this.state.postRes.unwrap().community_view.community.id,
135 WebSocketService.Instance.send(
136 wsClient.postJoin({ post_id: this.state.postId })
139 this.fetchCrossPosts();
140 if (this.state.commentId) {
141 this.scrollCommentIntoView();
144 if (this.checkScrollIntoCommentsParam) {
145 this.scrollIntoCommentSection();
154 let form = new GetPost({
155 id: this.state.postId,
156 auth: auth(false).ok(),
158 WebSocketService.Instance.send(wsClient.getPost(form));
163 .andThen(r => r.post_view.post.url)
166 let form = new Search({
168 type_: Some(SearchType.Url),
169 sort: Some(SortType.TopAll),
170 listing_type: Some(ListingType.All),
172 limit: Some(trendingFetchLimit),
174 community_name: None,
176 auth: auth(false).ok(),
178 WebSocketService.Instance.send(wsClient.search(form));
184 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
185 let pathSplit = req.path.split("/");
187 let id = Number(pathSplit[2]);
189 let postForm = new GetPost({
194 return [req.client.getPost(postForm)];
197 componentWillUnmount() {
198 this.subscription.unsubscribe();
199 document.removeEventListener("scroll", this.commentScrollDebounced);
201 saveScrollPosition(this.context);
204 componentDidMount() {
205 autosize(document.querySelectorAll("textarea"));
207 this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
208 document.addEventListener("scroll", this.commentScrollDebounced);
211 componentDidUpdate(_lastProps: any) {
212 // Necessary if you are on a post and you click another post (same route)
213 if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
214 // TODO Couldnt get a refresh working. This does for now.
217 // let currentId = this.props.match.params.id;
218 // WebSocketService.Instance.getPost(currentId);
219 // this.context.refresh();
220 // this.context.router.history.push(_lastProps.location.pathname);
224 scrollCommentIntoView() {
225 let commentElement = document.getElementById(
226 `comment-${this.state.commentId}`
228 if (commentElement) {
229 commentElement.scrollIntoView();
230 commentElement.classList.add("mark");
231 this.state.scrolled = true;
232 this.markScrolledAsRead(this.state.commentId);
236 get checkScrollIntoCommentsParam() {
238 new URLSearchParams(this.props.location.search).get("scrollToComments")
242 scrollIntoCommentSection() {
243 this.state.commentSectionRef.current?.scrollIntoView();
246 // TODO this needs some re-work
247 markScrolledAsRead(commentId: number) {
248 this.state.postRes.match({
250 let found = res.comments.find(c => c.comment.id == commentId);
251 let parent = res.comments.find(
252 c => found.comment.parent_id.unwrapOr(0) == c.comment.id
254 let parent_person_id = parent
256 : res.post_view.creator.id;
258 UserService.Instance.myUserInfo.match({
260 if (mui.local_user_view.person.id == parent_person_id) {
261 let form = new MarkCommentAsRead({
262 comment_id: found.comment.id,
264 auth: auth().unwrap(),
266 WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
267 UserService.Instance.unreadInboxCountSub.next(
268 UserService.Instance.unreadInboxCountSub.value - 1
279 isBottom(el: Element): boolean {
280 return el?.getBoundingClientRect().bottom <= window.innerHeight;
284 * Shows new comments when scrolling to the bottom of the comments div
286 trackCommentsBoxScrolling = () => {
287 const wrappedElement = document.getElementsByClassName("comments")[0];
288 if (wrappedElement && this.isBottom(wrappedElement)) {
289 this.state.maxCommentsShown += commentsShownInterval;
290 this.setState(this.state);
294 get documentTitle(): string {
295 return this.state.postRes.match({
297 this.state.siteRes.site_view.match({
299 `${res.post_view.post.name} - ${siteView.site.name}`,
306 get imageTag(): Option<string> {
307 return this.state.postRes.match({
309 res.post_view.post.thumbnail_url.or(
310 res.post_view.post.url.match({
311 some: url => (isImage(url) ? Some(url) : None),
319 get descriptionTag(): Option<string> {
320 return this.state.postRes.andThen(r => r.post_view.post.body);
325 <div class="container">
326 {this.state.loading ? (
331 this.state.postRes.match({
334 <div class="col-12 col-md-8 mb-3">
336 title={this.documentTitle}
337 path={this.context.router.route.match.url}
338 image={this.imageTag}
339 description={this.descriptionTag}
342 post_view={res.post_view}
343 duplicates={this.state.crossPosts}
346 moderators={Some(res.moderators)}
347 admins={Some(this.state.siteRes.admins)}
348 enableDownvotes={enableDownvotes(this.state.siteRes)}
349 enableNsfw={enableNsfw(this.state.siteRes)}
351 <div ref={this.state.commentSectionRef} className="mb-2" />
353 node={Right(this.state.postId)}
354 disabled={res.post_view.post.locked}
356 <div class="d-block d-md-none">
358 class="btn btn-secondary d-inline-block mb-2 mr-3"
359 onClick={linkEvent(this, this.handleShowSidebarMobile)}
361 {i18n.t("sidebar")}{" "}
364 this.state.showSidebarMobile
368 classes="icon-inline"
371 {this.state.showSidebarMobile && this.sidebar()}
373 {res.comments.length > 0 && this.sortRadios()}
374 {this.state.commentViewType == CommentViewType.Tree &&
376 {this.state.commentViewType == CommentViewType.Chat &&
379 <div class="d-none d-md-block col-md-4">{this.sidebar()}</div>
392 <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
394 className={`btn btn-outline-secondary pointer ${
395 this.state.commentSort === CommentSortType.Hot && "active"
401 value={CommentSortType.Hot}
402 checked={this.state.commentSort === CommentSortType.Hot}
403 onChange={linkEvent(this, this.handleCommentSortChange)}
407 className={`btn btn-outline-secondary pointer ${
408 this.state.commentSort === CommentSortType.Top && "active"
414 value={CommentSortType.Top}
415 checked={this.state.commentSort === CommentSortType.Top}
416 onChange={linkEvent(this, this.handleCommentSortChange)}
420 className={`btn btn-outline-secondary pointer ${
421 this.state.commentSort === CommentSortType.New && "active"
427 value={CommentSortType.New}
428 checked={this.state.commentSort === CommentSortType.New}
429 onChange={linkEvent(this, this.handleCommentSortChange)}
433 className={`btn btn-outline-secondary pointer ${
434 this.state.commentSort === CommentSortType.Old && "active"
440 value={CommentSortType.Old}
441 checked={this.state.commentSort === CommentSortType.Old}
442 onChange={linkEvent(this, this.handleCommentSortChange)}
446 <div class="btn-group btn-group-toggle flex-wrap mb-2">
448 className={`btn btn-outline-secondary pointer ${
449 this.state.commentViewType === CommentViewType.Chat && "active"
455 value={CommentViewType.Chat}
456 checked={this.state.commentViewType === CommentViewType.Chat}
457 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
466 // These are already sorted by new
467 return this.state.postRes.match({
471 nodes={commentsToFlatNodes(res.comments)}
472 maxCommentsShown={Some(this.state.maxCommentsShown)}
474 locked={res.post_view.post.locked}
475 moderators={Some(res.moderators)}
476 admins={Some(this.state.siteRes.admins)}
477 enableDownvotes={enableDownvotes(this.state.siteRes)}
487 return this.state.postRes.match({
491 community_view={res.community_view}
492 moderators={res.moderators}
493 admins={this.state.siteRes.admins}
495 enableNsfw={enableNsfw(this.state.siteRes)}
504 handleCommentSortChange(i: Post, event: any) {
505 i.state.commentSort = Number(event.target.value);
506 i.state.commentViewType = CommentViewType.Tree;
507 i.state.commentTree = buildCommentsTree(
508 i.state.postRes.map(r => r.comments).unwrapOr([]),
514 handleCommentViewTypeChange(i: Post, event: any) {
515 i.state.commentViewType = Number(event.target.value);
516 i.state.commentSort = CommentSortType.New;
517 i.state.commentTree = buildCommentsTree(
518 i.state.postRes.map(r => r.comments).unwrapOr([]),
524 handleShowSidebarMobile(i: Post) {
525 i.state.showSidebarMobile = !i.state.showSidebarMobile;
530 return this.state.postRes.match({
534 nodes={this.state.commentTree}
535 maxCommentsShown={Some(this.state.maxCommentsShown)}
536 locked={res.post_view.post.locked}
537 moderators={Some(res.moderators)}
538 admins={Some(this.state.siteRes.admins)}
539 enableDownvotes={enableDownvotes(this.state.siteRes)}
547 parseMessage(msg: any) {
548 let op = wsUserOp(msg);
551 toast(i18n.t(msg.error), "danger");
553 } else if (msg.reconnect) {
554 let postId = Number(this.props.match.params.id);
555 WebSocketService.Instance.send(wsClient.postJoin({ post_id: postId }));
556 WebSocketService.Instance.send(
559 auth: auth(false).ok(),
562 } else if (op == UserOperation.GetPost) {
563 let data = wsJsonToRes<GetPostResponse>(msg, GetPostResponse);
564 this.state.postRes = Some(data);
566 this.state.commentTree = buildCommentsTree(
567 this.state.postRes.map(r => r.comments).unwrapOr([]),
568 this.state.commentSort
570 this.state.loading = false;
573 WebSocketService.Instance.send(
574 wsClient.postJoin({ post_id: this.state.postId })
576 WebSocketService.Instance.send(
577 wsClient.communityJoin({
578 community_id: data.community_view.community.id,
583 this.fetchCrossPosts();
584 this.setState(this.state);
586 if (!this.state.commentId) restoreScrollPosition(this.context);
588 if (this.checkScrollIntoCommentsParam) {
589 this.scrollIntoCommentSection();
592 if (this.state.commentId && !this.state.scrolled) {
593 this.scrollCommentIntoView();
595 } else if (op == UserOperation.CreateComment) {
596 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
598 // Don't get comments from the post room, if the creator is blocked
599 let creatorBlocked = UserService.Instance.myUserInfo
600 .map(m => m.person_blocks)
602 .map(pb => pb.target.id)
603 .includes(data.comment_view.creator.id);
605 // Necessary since it might be a user reply, which has the recipients, to avoid double
606 if (data.recipient_ids.length == 0 && !creatorBlocked) {
607 this.state.postRes.match({
609 res.comments.unshift(data.comment_view);
610 insertCommentIntoTree(this.state.commentTree, data.comment_view);
611 res.post_view.counts.comments++;
615 this.setState(this.state);
619 op == UserOperation.EditComment ||
620 op == UserOperation.DeleteComment ||
621 op == UserOperation.RemoveComment
623 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
626 this.state.postRes.map(r => r.comments).unwrapOr([])
628 this.setState(this.state);
629 } else if (op == UserOperation.SaveComment) {
630 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
633 this.state.postRes.map(r => r.comments).unwrapOr([])
635 this.setState(this.state);
637 } else if (op == UserOperation.CreateCommentLike) {
638 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
639 createCommentLikeRes(
641 this.state.postRes.map(r => r.comments).unwrapOr([])
643 this.setState(this.state);
644 } else if (op == UserOperation.CreatePostLike) {
645 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
646 this.state.postRes.match({
647 some: res => createPostLikeRes(data.post_view, res.post_view),
650 this.setState(this.state);
652 op == UserOperation.EditPost ||
653 op == UserOperation.DeletePost ||
654 op == UserOperation.RemovePost ||
655 op == UserOperation.LockPost ||
656 op == UserOperation.StickyPost ||
657 op == UserOperation.SavePost
659 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
660 this.state.postRes.match({
661 some: res => (res.post_view = data.post_view),
664 this.setState(this.state);
667 op == UserOperation.EditCommunity ||
668 op == UserOperation.DeleteCommunity ||
669 op == UserOperation.RemoveCommunity ||
670 op == UserOperation.FollowCommunity
672 let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse);
673 this.state.postRes.match({
675 res.community_view = data.community_view;
676 res.post_view.community = data.community_view.community;
677 this.setState(this.state);
681 } else if (op == UserOperation.BanFromCommunity) {
682 let data = wsJsonToRes<BanFromCommunityResponse>(
684 BanFromCommunityResponse
686 this.state.postRes.match({
689 .filter(c => c.creator.id == data.person_view.person.id)
690 .forEach(c => (c.creator_banned_from_community = data.banned));
691 if (res.post_view.creator.id == data.person_view.person.id) {
692 res.post_view.creator_banned_from_community = data.banned;
694 this.setState(this.state);
698 } else if (op == UserOperation.AddModToCommunity) {
699 let data = wsJsonToRes<AddModToCommunityResponse>(
701 AddModToCommunityResponse
703 this.state.postRes.match({
705 res.moderators = data.moderators;
706 this.setState(this.state);
710 } else if (op == UserOperation.BanPerson) {
711 let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
712 this.state.postRes.match({
715 .filter(c => c.creator.id == data.person_view.person.id)
716 .forEach(c => (c.creator.banned = data.banned));
717 if (res.post_view.creator.id == data.person_view.person.id) {
718 res.post_view.creator.banned = data.banned;
720 this.setState(this.state);
724 } else if (op == UserOperation.AddAdmin) {
725 let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
726 this.state.siteRes.admins = data.admins;
727 this.setState(this.state);
728 } else if (op == UserOperation.Search) {
729 let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
730 let xPosts = data.posts.filter(
731 p => p.post.id != Number(this.props.match.params.id)
733 this.state.crossPosts = xPosts.length > 0 ? Some(xPosts) : None;
734 this.setState(this.state);
735 } else if (op == UserOperation.LeaveAdmin) {
736 let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse);
737 this.state.siteRes = data;
738 this.setState(this.state);
739 } else if (op == UserOperation.TransferCommunity) {
740 let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse);
741 this.state.postRes.match({
743 res.community_view = data.community_view;
744 res.post_view.community = data.community_view.community;
745 res.moderators = data.moderators;
746 this.setState(this.state);
750 } else if (op == UserOperation.BlockPerson) {
751 let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
752 updatePersonBlock(data);
753 } else if (op == UserOperation.CreatePostReport) {
754 let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse);
756 toast(i18n.t("report_created"));
758 } else if (op == UserOperation.CreateCommentReport) {
759 let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse);
761 toast(i18n.t("report_created"));