1 import autosize from "autosize";
2 import { Component, createRef, linkEvent, RefObject } from "inferno";
5 AddModToCommunityResponse,
6 BanFromCommunityResponse,
26 } from "lemmy-js-client";
27 import { Subscription } from "rxjs";
28 import { i18n } from "../../i18next";
30 CommentNode as CommentNodeI,
34 } from "../../interfaces";
35 import { UserService, WebSocketService } from "../../services";
44 getCommentIdFromProps,
46 insertCommentIntoTree,
49 restoreScrollPosition,
62 import { CommentForm } from "../comment/comment-form";
63 import { CommentNodes } from "../comment/comment-nodes";
64 import { HtmlTags } from "../common/html-tags";
65 import { Icon, Spinner } from "../common/icon";
66 import { Sidebar } from "../community/sidebar";
67 import { PostListing } from "./post-listing";
69 const commentsShownInterval = 15;
72 postRes: GetPostResponse;
74 commentTree: CommentNodeI[];
76 commentSort: CommentSortType;
77 commentViewType: CommentViewType;
80 crossPosts: PostView[];
81 siteRes: GetSiteResponse;
82 commentSectionRef?: RefObject<HTMLDivElement>;
83 showSidebarMobile: boolean;
84 maxCommentsShown: number;
87 export class Post extends Component<any, PostState> {
88 private subscription: Subscription;
89 private isoData = setIsoData(this.context);
90 private commentScrollDebounced: () => void;
91 private emptyState: PostState = {
93 postId: getIdFromProps(this.props),
95 commentId: getCommentIdFromProps(this.props),
96 commentSort: CommentSortType.Hot,
97 commentViewType: CommentViewType.Tree,
101 siteRes: this.isoData.site_res,
102 commentSectionRef: null,
103 showSidebarMobile: false,
104 maxCommentsShown: commentsShownInterval,
107 constructor(props: any, context: any) {
108 super(props, context);
110 this.state = this.emptyState;
111 this.state.commentSectionRef = createRef();
113 this.parseMessage = this.parseMessage.bind(this);
114 this.subscription = wsSubscribe(this.parseMessage);
116 // Only fetch the data if coming from another route
117 if (this.isoData.path == this.context.router.route.match.url) {
118 this.state.postRes = this.isoData.routeData[0];
119 this.state.commentTree = buildCommentsTree(
120 this.state.postRes.comments,
121 this.state.commentSort
123 this.state.loading = false;
126 this.fetchCrossPosts();
127 if (this.state.commentId) {
128 this.scrollCommentIntoView();
131 if (this.checkScrollIntoCommentsParam) {
132 this.scrollIntoCommentSection();
141 let form: GetPost = {
142 id: this.state.postId,
143 auth: authField(false),
145 WebSocketService.Instance.send(wsClient.getPost(form));
149 if (this.state.postRes.post_view.post.url) {
151 q: this.state.postRes.post_view.post.url,
152 type_: SearchType.Url,
153 sort: SortType.TopAll,
154 listing_type: ListingType.All,
157 auth: authField(false),
159 WebSocketService.Instance.send(wsClient.search(form));
163 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
164 let pathSplit = req.path.split("/");
165 let promises: Promise<any>[] = [];
167 let id = Number(pathSplit[2]);
169 let postForm: GetPost = {
172 setOptionalAuth(postForm, req.auth);
174 promises.push(req.client.getPost(postForm));
179 componentWillUnmount() {
180 this.subscription.unsubscribe();
181 document.removeEventListener("scroll", this.commentScrollDebounced);
183 window.isoData.path = undefined;
184 saveScrollPosition(this.context);
187 componentDidMount() {
188 WebSocketService.Instance.send(
189 wsClient.postJoin({ post_id: this.state.postId })
191 autosize(document.querySelectorAll("textarea"));
193 this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
194 document.addEventListener("scroll", this.commentScrollDebounced);
197 componentDidUpdate(_lastProps: any) {
198 // Necessary if you are on a post and you click another post (same route)
199 if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
200 // TODO Couldnt get a refresh working. This does for now.
203 // let currentId = this.props.match.params.id;
204 // WebSocketService.Instance.getPost(currentId);
205 // this.context.refresh();
206 // this.context.router.history.push(_lastProps.location.pathname);
210 scrollCommentIntoView() {
211 let commentElement = document.getElementById(
212 `comment-${this.state.commentId}`
214 if (commentElement) {
215 commentElement.scrollIntoView();
216 commentElement.classList.add("mark");
217 this.state.scrolled = true;
218 this.markScrolledAsRead(this.state.commentId);
222 get checkScrollIntoCommentsParam() {
224 new URLSearchParams(this.props.location.search).get("scrollToComments")
228 scrollIntoCommentSection() {
229 this.state.commentSectionRef.current?.scrollIntoView();
232 // TODO this needs some re-work
233 markScrolledAsRead(commentId: number) {
234 let found = this.state.postRes.comments.find(
235 c => c.comment.id == commentId
237 let parent = this.state.postRes.comments.find(
238 c => found.comment.parent_id == c.comment.id
240 let parent_person_id = parent
242 : this.state.postRes.post_view.creator.id;
245 UserService.Instance.myUserInfo &&
246 UserService.Instance.myUserInfo.local_user_view.person.id ==
249 let form: MarkCommentAsRead = {
250 comment_id: found.comment.id,
254 WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
255 UserService.Instance.unreadInboxCountSub.next(
256 UserService.Instance.unreadInboxCountSub.value - 1
261 isBottom(el: Element) {
262 return el?.getBoundingClientRect().bottom <= window.innerHeight;
266 * Shows new comments when scrolling to the bottom of the comments div
268 trackCommentsBoxScrolling = () => {
269 const wrappedElement = document.getElementsByClassName("comments")[0];
270 if (wrappedElement && this.isBottom(wrappedElement)) {
271 this.state.maxCommentsShown += commentsShownInterval;
272 this.setState(this.state);
276 get documentTitle(): string {
277 return `${this.state.postRes.post_view.post.name} - ${this.state.siteRes.site_view.site.name}`;
280 get imageTag(): string {
281 let post = this.state.postRes.post_view.post;
283 post.thumbnail_url ||
284 (post.url ? (isImage(post.url) ? post.url : undefined) : undefined)
288 get descriptionTag(): string {
289 return this.state.postRes.post_view.post.body;
293 let pv = this.state.postRes?.post_view;
295 <div class="container">
296 {this.state.loading ? (
302 <div class="col-12 col-md-8 mb-3">
304 title={this.documentTitle}
305 path={this.context.router.route.match.url}
306 image={this.imageTag}
307 description={this.descriptionTag}
311 duplicates={this.state.crossPosts}
314 moderators={this.state.postRes.moderators}
315 admins={this.state.siteRes.admins}
317 this.state.siteRes.site_view.site.enable_downvotes
319 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
321 <div ref={this.state.commentSectionRef} className="mb-2" />
323 postId={this.state.postId}
324 disabled={pv.post.locked}
326 <div class="d-block d-md-none">
328 class="btn btn-secondary d-inline-block mb-2 mr-3"
329 onClick={linkEvent(this, this.handleShowSidebarMobile)}
331 {i18n.t("sidebar")}{" "}
334 this.state.showSidebarMobile
338 classes="icon-inline"
341 {this.state.showSidebarMobile && this.sidebar()}
343 {this.state.postRes.comments.length > 0 && this.sortRadios()}
344 {this.state.commentViewType == CommentViewType.Tree &&
346 {this.state.commentViewType == CommentViewType.Chat &&
349 <div class="d-none d-md-block col-md-4">{this.sidebar()}</div>
359 <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
361 className={`btn btn-outline-secondary pointer ${
362 this.state.commentSort === CommentSortType.Hot && "active"
368 value={CommentSortType.Hot}
369 checked={this.state.commentSort === CommentSortType.Hot}
370 onChange={linkEvent(this, this.handleCommentSortChange)}
374 className={`btn btn-outline-secondary pointer ${
375 this.state.commentSort === CommentSortType.Top && "active"
381 value={CommentSortType.Top}
382 checked={this.state.commentSort === CommentSortType.Top}
383 onChange={linkEvent(this, this.handleCommentSortChange)}
387 className={`btn btn-outline-secondary pointer ${
388 this.state.commentSort === CommentSortType.New && "active"
394 value={CommentSortType.New}
395 checked={this.state.commentSort === CommentSortType.New}
396 onChange={linkEvent(this, this.handleCommentSortChange)}
400 className={`btn btn-outline-secondary pointer ${
401 this.state.commentSort === CommentSortType.Old && "active"
407 value={CommentSortType.Old}
408 checked={this.state.commentSort === CommentSortType.Old}
409 onChange={linkEvent(this, this.handleCommentSortChange)}
413 <div class="btn-group btn-group-toggle flex-wrap mb-2">
415 className={`btn btn-outline-secondary pointer ${
416 this.state.commentViewType === CommentViewType.Chat && "active"
422 value={CommentViewType.Chat}
423 checked={this.state.commentViewType === CommentViewType.Chat}
424 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
433 // These are already sorted by new
437 nodes={commentsToFlatNodes(this.state.postRes.comments)}
438 maxCommentsShown={this.state.maxCommentsShown}
440 locked={this.state.postRes.post_view.post.locked}
441 moderators={this.state.postRes.moderators}
442 admins={this.state.siteRes.admins}
443 postCreatorId={this.state.postRes.post_view.creator.id}
445 enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes}
455 community_view={this.state.postRes.community_view}
456 moderators={this.state.postRes.moderators}
457 admins={this.state.siteRes.admins}
458 online={this.state.postRes.online}
459 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
466 handleCommentSortChange(i: Post, event: any) {
467 i.state.commentSort = Number(event.target.value);
468 i.state.commentViewType = CommentViewType.Tree;
469 i.state.commentTree = buildCommentsTree(
470 i.state.postRes.comments,
476 handleCommentViewTypeChange(i: Post, event: any) {
477 i.state.commentViewType = Number(event.target.value);
478 i.state.commentSort = CommentSortType.New;
479 i.state.commentTree = buildCommentsTree(
480 i.state.postRes.comments,
486 handleShowSidebarMobile(i: Post) {
487 i.state.showSidebarMobile = !i.state.showSidebarMobile;
495 nodes={this.state.commentTree}
496 maxCommentsShown={this.state.maxCommentsShown}
497 locked={this.state.postRes.post_view.post.locked}
498 moderators={this.state.postRes.moderators}
499 admins={this.state.siteRes.admins}
500 postCreatorId={this.state.postRes.post_view.creator.id}
501 enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes}
507 parseMessage(msg: any) {
508 let op = wsUserOp(msg);
511 toast(i18n.t(msg.error), "danger");
513 } else if (msg.reconnect) {
514 let postId = Number(this.props.match.params.id);
515 WebSocketService.Instance.send(wsClient.postJoin({ post_id: postId }));
516 WebSocketService.Instance.send(
519 auth: authField(false),
522 } else if (op == UserOperation.GetPost) {
523 let data = wsJsonToRes<GetPostResponse>(msg).data;
524 this.state.postRes = data;
525 this.state.commentTree = buildCommentsTree(
526 this.state.postRes.comments,
527 this.state.commentSort
529 this.state.loading = false;
532 this.fetchCrossPosts();
533 this.setState(this.state);
535 if (!this.state.commentId) restoreScrollPosition(this.context);
537 if (this.checkScrollIntoCommentsParam) {
538 this.scrollIntoCommentSection();
541 if (this.state.commentId && !this.state.scrolled) {
542 this.scrollCommentIntoView();
544 } else if (op == UserOperation.CreateComment) {
545 let data = wsJsonToRes<CommentResponse>(msg).data;
547 // Don't get comments from the post room, if the creator is blocked
548 let creatorBlocked = UserService.Instance.myUserInfo?.person_blocks
549 .map(pb => pb.target.id)
550 .includes(data.comment_view.creator.id);
552 // Necessary since it might be a user reply, which has the recipients, to avoid double
553 if (data.recipient_ids.length == 0 && !creatorBlocked) {
554 this.state.postRes.comments.unshift(data.comment_view);
555 insertCommentIntoTree(this.state.commentTree, data.comment_view);
556 this.state.postRes.post_view.counts.comments++;
557 this.setState(this.state);
561 op == UserOperation.EditComment ||
562 op == UserOperation.DeleteComment ||
563 op == UserOperation.RemoveComment
565 let data = wsJsonToRes<CommentResponse>(msg).data;
566 editCommentRes(data.comment_view, this.state.postRes.comments);
567 this.setState(this.state);
568 } else if (op == UserOperation.SaveComment) {
569 let data = wsJsonToRes<CommentResponse>(msg).data;
570 saveCommentRes(data.comment_view, this.state.postRes.comments);
571 this.setState(this.state);
573 } else if (op == UserOperation.CreateCommentLike) {
574 let data = wsJsonToRes<CommentResponse>(msg).data;
575 createCommentLikeRes(data.comment_view, this.state.postRes.comments);
576 this.setState(this.state);
577 } else if (op == UserOperation.CreatePostLike) {
578 let data = wsJsonToRes<PostResponse>(msg).data;
579 createPostLikeRes(data.post_view, this.state.postRes.post_view);
580 this.setState(this.state);
582 op == UserOperation.EditPost ||
583 op == UserOperation.DeletePost ||
584 op == UserOperation.RemovePost ||
585 op == UserOperation.LockPost ||
586 op == UserOperation.StickyPost ||
587 op == UserOperation.SavePost
589 let data = wsJsonToRes<PostResponse>(msg).data;
590 this.state.postRes.post_view = data.post_view;
591 this.setState(this.state);
594 op == UserOperation.EditCommunity ||
595 op == UserOperation.DeleteCommunity ||
596 op == UserOperation.RemoveCommunity ||
597 op == UserOperation.FollowCommunity
599 let data = wsJsonToRes<CommunityResponse>(msg).data;
600 this.state.postRes.community_view = data.community_view;
601 this.state.postRes.post_view.community = data.community_view.community;
602 this.setState(this.state);
603 this.setState(this.state);
604 } else if (op == UserOperation.BanFromCommunity) {
605 let data = wsJsonToRes<BanFromCommunityResponse>(msg).data;
606 this.state.postRes.comments
607 .filter(c => c.creator.id == data.person_view.person.id)
608 .forEach(c => (c.creator_banned_from_community = data.banned));
610 this.state.postRes.post_view.creator.id == data.person_view.person.id
612 this.state.postRes.post_view.creator_banned_from_community =
615 this.setState(this.state);
616 } else if (op == UserOperation.AddModToCommunity) {
617 let data = wsJsonToRes<AddModToCommunityResponse>(msg).data;
618 this.state.postRes.moderators = data.moderators;
619 this.setState(this.state);
620 } else if (op == UserOperation.BanPerson) {
621 let data = wsJsonToRes<BanPersonResponse>(msg).data;
622 this.state.postRes.comments
623 .filter(c => c.creator.id == data.person_view.person.id)
624 .forEach(c => (c.creator.banned = data.banned));
626 this.state.postRes.post_view.creator.id == data.person_view.person.id
628 this.state.postRes.post_view.creator.banned = data.banned;
630 this.setState(this.state);
631 } else if (op == UserOperation.AddAdmin) {
632 let data = wsJsonToRes<AddAdminResponse>(msg).data;
633 this.state.siteRes.admins = data.admins;
634 this.setState(this.state);
635 } else if (op == UserOperation.Search) {
636 let data = wsJsonToRes<SearchResponse>(msg).data;
637 this.state.crossPosts = data.posts.filter(
638 p => p.post.id != Number(this.props.match.params.id)
640 this.setState(this.state);
641 } else if (op == UserOperation.LeaveAdmin) {
642 let data = wsJsonToRes<GetSiteResponse>(msg).data;
643 this.state.siteRes = data;
644 this.setState(this.state);
645 } else if (op == UserOperation.TransferCommunity) {
646 let data = wsJsonToRes<GetCommunityResponse>(msg).data;
647 this.state.postRes.community_view = data.community_view;
648 this.state.postRes.post_view.community = data.community_view.community;
649 this.state.postRes.moderators = data.moderators;
650 this.setState(this.state);
651 } else if (op == UserOperation.BlockPerson) {
652 let data = wsJsonToRes<BlockPersonResponse>(msg).data;
653 updatePersonBlock(data);
654 } else if (op == UserOperation.CreatePostReport) {
655 let data = wsJsonToRes<PostReportResponse>(msg).data;
657 toast(i18n.t("report_created"));
659 } else if (op == UserOperation.CreateCommentReport) {
660 let data = wsJsonToRes<CommentReportResponse>(msg).data;
662 toast(i18n.t("report_created"));