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,
50 restoreScrollPosition,
63 import { CommentForm } from "../comment/comment-form";
64 import { CommentNodes } from "../comment/comment-nodes";
65 import { HtmlTags } from "../common/html-tags";
66 import { Icon, Spinner } from "../common/icon";
67 import { Sidebar } from "../community/sidebar";
68 import { PostListing } from "./post-listing";
70 const commentsShownInterval = 15;
73 postRes: GetPostResponse;
75 commentTree: CommentNodeI[];
77 commentSort: CommentSortType;
78 commentViewType: CommentViewType;
81 crossPosts: PostView[];
82 siteRes: GetSiteResponse;
83 commentSectionRef?: RefObject<HTMLDivElement>;
84 showSidebarMobile: boolean;
85 maxCommentsShown: number;
88 export class Post extends Component<any, PostState> {
89 private subscription: Subscription;
90 private isoData = setIsoData(this.context);
91 private commentScrollDebounced: () => void;
92 private emptyState: PostState = {
94 postId: getIdFromProps(this.props),
96 commentId: getCommentIdFromProps(this.props),
97 commentSort: CommentSortType.Hot,
98 commentViewType: CommentViewType.Tree,
102 siteRes: this.isoData.site_res,
103 commentSectionRef: null,
104 showSidebarMobile: false,
105 maxCommentsShown: commentsShownInterval,
108 constructor(props: any, context: any) {
109 super(props, context);
111 this.state = this.emptyState;
112 this.state.commentSectionRef = createRef();
114 this.parseMessage = this.parseMessage.bind(this);
115 this.subscription = wsSubscribe(this.parseMessage);
117 // Only fetch the data if coming from another route
118 if (this.isoData.path == this.context.router.route.match.url) {
119 this.state.postRes = this.isoData.routeData[0];
120 this.state.commentTree = buildCommentsTree(
121 this.state.postRes.comments,
122 this.state.commentSort
124 this.state.loading = false;
127 this.fetchCrossPosts();
128 if (this.state.commentId) {
129 this.scrollCommentIntoView();
132 if (this.checkScrollIntoCommentsParam) {
133 this.scrollIntoCommentSection();
142 let form: GetPost = {
143 id: this.state.postId,
144 auth: authField(false),
146 WebSocketService.Instance.send(wsClient.getPost(form));
150 if (this.state.postRes.post_view.post.url) {
152 q: this.state.postRes.post_view.post.url,
153 type_: SearchType.Url,
154 sort: SortType.TopAll,
155 listing_type: ListingType.All,
158 auth: authField(false),
160 WebSocketService.Instance.send(wsClient.search(form));
164 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
165 let pathSplit = req.path.split("/");
166 let promises: Promise<any>[] = [];
168 let id = Number(pathSplit[2]);
170 let postForm: GetPost = {
173 setOptionalAuth(postForm, req.auth);
175 promises.push(req.client.getPost(postForm));
180 componentWillUnmount() {
181 this.subscription.unsubscribe();
182 document.removeEventListener("scroll", this.commentScrollDebounced);
184 window.isoData.path = undefined;
185 saveScrollPosition(this.context);
188 componentDidMount() {
189 WebSocketService.Instance.send(
190 wsClient.postJoin({ post_id: this.state.postId })
192 autosize(document.querySelectorAll("textarea"));
194 this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
195 document.addEventListener("scroll", this.commentScrollDebounced);
198 componentDidUpdate(_lastProps: any) {
199 // Necessary if you are on a post and you click another post (same route)
200 if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
201 // TODO Couldnt get a refresh working. This does for now.
204 // let currentId = this.props.match.params.id;
205 // WebSocketService.Instance.getPost(currentId);
206 // this.context.refresh();
207 // this.context.router.history.push(_lastProps.location.pathname);
211 scrollCommentIntoView() {
212 let commentElement = document.getElementById(
213 `comment-${this.state.commentId}`
215 if (commentElement) {
216 commentElement.scrollIntoView();
217 commentElement.classList.add("mark");
218 this.state.scrolled = true;
219 this.markScrolledAsRead(this.state.commentId);
223 get checkScrollIntoCommentsParam() {
225 new URLSearchParams(this.props.location.search).get("scrollToComments")
229 scrollIntoCommentSection() {
230 this.state.commentSectionRef.current?.scrollIntoView();
233 // TODO this needs some re-work
234 markScrolledAsRead(commentId: number) {
235 let found = this.state.postRes.comments.find(
236 c => c.comment.id == commentId
238 let parent = this.state.postRes.comments.find(
239 c => found.comment.parent_id == c.comment.id
241 let parent_person_id = parent
243 : this.state.postRes.post_view.creator.id;
246 UserService.Instance.myUserInfo &&
247 UserService.Instance.myUserInfo.local_user_view.person.id ==
250 let form: MarkCommentAsRead = {
251 comment_id: found.comment.id,
255 WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
256 UserService.Instance.unreadInboxCountSub.next(
257 UserService.Instance.unreadInboxCountSub.value - 1
262 isBottom(el: Element) {
263 return el?.getBoundingClientRect().bottom <= window.innerHeight;
267 * Shows new comments when scrolling to the bottom of the comments div
269 trackCommentsBoxScrolling = () => {
270 const wrappedElement = document.getElementsByClassName("comments")[0];
271 if (wrappedElement && this.isBottom(wrappedElement)) {
272 this.state.maxCommentsShown += commentsShownInterval;
273 this.setState(this.state);
277 get documentTitle(): string {
278 return `${this.state.postRes.post_view.post.name} - ${this.state.siteRes.site_view.site.name}`;
281 get imageTag(): string {
282 let post = this.state.postRes.post_view.post;
284 post.thumbnail_url ||
285 (post.url ? (isImage(post.url) ? post.url : undefined) : undefined)
289 get descriptionTag(): string {
290 let body = this.state.postRes.post_view.post.body;
291 return body ? previewLines(body) : undefined;
295 let pv = this.state.postRes?.post_view;
297 <div class="container">
298 {this.state.loading ? (
304 <div class="col-12 col-md-8 mb-3">
306 title={this.documentTitle}
307 path={this.context.router.route.match.url}
308 image={this.imageTag}
309 description={this.descriptionTag}
313 duplicates={this.state.crossPosts}
316 moderators={this.state.postRes.moderators}
317 admins={this.state.siteRes.admins}
319 this.state.siteRes.site_view.site.enable_downvotes
321 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
323 <div ref={this.state.commentSectionRef} className="mb-2" />
325 postId={this.state.postId}
326 disabled={pv.post.locked}
328 <div class="d-block d-md-none">
330 class="btn btn-secondary d-inline-block mb-2 mr-3"
331 onClick={linkEvent(this, this.handleShowSidebarMobile)}
333 {i18n.t("sidebar")}{" "}
336 this.state.showSidebarMobile
340 classes="icon-inline"
343 {this.state.showSidebarMobile && this.sidebar()}
345 {this.state.postRes.comments.length > 0 && this.sortRadios()}
346 {this.state.commentViewType == CommentViewType.Tree &&
348 {this.state.commentViewType == CommentViewType.Chat &&
351 <div class="d-none d-md-block col-md-4">{this.sidebar()}</div>
361 <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
363 className={`btn btn-outline-secondary pointer ${
364 this.state.commentSort === CommentSortType.Hot && "active"
370 value={CommentSortType.Hot}
371 checked={this.state.commentSort === CommentSortType.Hot}
372 onChange={linkEvent(this, this.handleCommentSortChange)}
376 className={`btn btn-outline-secondary pointer ${
377 this.state.commentSort === CommentSortType.Top && "active"
383 value={CommentSortType.Top}
384 checked={this.state.commentSort === CommentSortType.Top}
385 onChange={linkEvent(this, this.handleCommentSortChange)}
389 className={`btn btn-outline-secondary pointer ${
390 this.state.commentSort === CommentSortType.New && "active"
396 value={CommentSortType.New}
397 checked={this.state.commentSort === CommentSortType.New}
398 onChange={linkEvent(this, this.handleCommentSortChange)}
402 className={`btn btn-outline-secondary pointer ${
403 this.state.commentSort === CommentSortType.Old && "active"
409 value={CommentSortType.Old}
410 checked={this.state.commentSort === CommentSortType.Old}
411 onChange={linkEvent(this, this.handleCommentSortChange)}
415 <div class="btn-group btn-group-toggle flex-wrap mb-2">
417 className={`btn btn-outline-secondary pointer ${
418 this.state.commentViewType === CommentViewType.Chat && "active"
424 value={CommentViewType.Chat}
425 checked={this.state.commentViewType === CommentViewType.Chat}
426 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
435 // These are already sorted by new
439 nodes={commentsToFlatNodes(this.state.postRes.comments)}
440 maxCommentsShown={this.state.maxCommentsShown}
442 locked={this.state.postRes.post_view.post.locked}
443 moderators={this.state.postRes.moderators}
444 admins={this.state.siteRes.admins}
445 postCreatorId={this.state.postRes.post_view.creator.id}
447 enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes}
457 community_view={this.state.postRes.community_view}
458 moderators={this.state.postRes.moderators}
459 admins={this.state.siteRes.admins}
460 online={this.state.postRes.online}
461 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
468 handleCommentSortChange(i: Post, event: any) {
469 i.state.commentSort = Number(event.target.value);
470 i.state.commentViewType = CommentViewType.Tree;
471 i.state.commentTree = buildCommentsTree(
472 i.state.postRes.comments,
478 handleCommentViewTypeChange(i: Post, event: any) {
479 i.state.commentViewType = Number(event.target.value);
480 i.state.commentSort = CommentSortType.New;
481 i.state.commentTree = buildCommentsTree(
482 i.state.postRes.comments,
488 handleShowSidebarMobile(i: Post) {
489 i.state.showSidebarMobile = !i.state.showSidebarMobile;
497 nodes={this.state.commentTree}
498 maxCommentsShown={this.state.maxCommentsShown}
499 locked={this.state.postRes.post_view.post.locked}
500 moderators={this.state.postRes.moderators}
501 admins={this.state.siteRes.admins}
502 postCreatorId={this.state.postRes.post_view.creator.id}
503 enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes}
509 parseMessage(msg: any) {
510 let op = wsUserOp(msg);
513 toast(i18n.t(msg.error), "danger");
515 } else if (msg.reconnect) {
516 let postId = Number(this.props.match.params.id);
517 WebSocketService.Instance.send(wsClient.postJoin({ post_id: postId }));
518 WebSocketService.Instance.send(
521 auth: authField(false),
524 } else if (op == UserOperation.GetPost) {
525 let data = wsJsonToRes<GetPostResponse>(msg).data;
526 this.state.postRes = data;
527 this.state.commentTree = buildCommentsTree(
528 this.state.postRes.comments,
529 this.state.commentSort
531 this.state.loading = false;
534 this.fetchCrossPosts();
535 this.setState(this.state);
537 if (!this.state.commentId) restoreScrollPosition(this.context);
539 if (this.checkScrollIntoCommentsParam) {
540 this.scrollIntoCommentSection();
543 if (this.state.commentId && !this.state.scrolled) {
544 this.scrollCommentIntoView();
546 } else if (op == UserOperation.CreateComment) {
547 let data = wsJsonToRes<CommentResponse>(msg).data;
549 // Don't get comments from the post room, if the creator is blocked
550 let creatorBlocked = UserService.Instance.myUserInfo?.person_blocks
551 .map(pb => pb.target.id)
552 .includes(data.comment_view.creator.id);
554 // Necessary since it might be a user reply, which has the recipients, to avoid double
555 if (data.recipient_ids.length == 0 && !creatorBlocked) {
556 this.state.postRes.comments.unshift(data.comment_view);
557 insertCommentIntoTree(this.state.commentTree, data.comment_view);
558 this.state.postRes.post_view.counts.comments++;
559 this.setState(this.state);
563 op == UserOperation.EditComment ||
564 op == UserOperation.DeleteComment ||
565 op == UserOperation.RemoveComment
567 let data = wsJsonToRes<CommentResponse>(msg).data;
568 editCommentRes(data.comment_view, this.state.postRes.comments);
569 this.setState(this.state);
570 } else if (op == UserOperation.SaveComment) {
571 let data = wsJsonToRes<CommentResponse>(msg).data;
572 saveCommentRes(data.comment_view, this.state.postRes.comments);
573 this.setState(this.state);
575 } else if (op == UserOperation.CreateCommentLike) {
576 let data = wsJsonToRes<CommentResponse>(msg).data;
577 createCommentLikeRes(data.comment_view, this.state.postRes.comments);
578 this.setState(this.state);
579 } else if (op == UserOperation.CreatePostLike) {
580 let data = wsJsonToRes<PostResponse>(msg).data;
581 createPostLikeRes(data.post_view, this.state.postRes.post_view);
582 this.setState(this.state);
584 op == UserOperation.EditPost ||
585 op == UserOperation.DeletePost ||
586 op == UserOperation.RemovePost ||
587 op == UserOperation.LockPost ||
588 op == UserOperation.StickyPost ||
589 op == UserOperation.SavePost
591 let data = wsJsonToRes<PostResponse>(msg).data;
592 this.state.postRes.post_view = data.post_view;
593 this.setState(this.state);
596 op == UserOperation.EditCommunity ||
597 op == UserOperation.DeleteCommunity ||
598 op == UserOperation.RemoveCommunity ||
599 op == UserOperation.FollowCommunity
601 let data = wsJsonToRes<CommunityResponse>(msg).data;
602 this.state.postRes.community_view = data.community_view;
603 this.state.postRes.post_view.community = data.community_view.community;
604 this.setState(this.state);
605 this.setState(this.state);
606 } else if (op == UserOperation.BanFromCommunity) {
607 let data = wsJsonToRes<BanFromCommunityResponse>(msg).data;
608 this.state.postRes.comments
609 .filter(c => c.creator.id == data.person_view.person.id)
610 .forEach(c => (c.creator_banned_from_community = data.banned));
612 this.state.postRes.post_view.creator.id == data.person_view.person.id
614 this.state.postRes.post_view.creator_banned_from_community =
617 this.setState(this.state);
618 } else if (op == UserOperation.AddModToCommunity) {
619 let data = wsJsonToRes<AddModToCommunityResponse>(msg).data;
620 this.state.postRes.moderators = data.moderators;
621 this.setState(this.state);
622 } else if (op == UserOperation.BanPerson) {
623 let data = wsJsonToRes<BanPersonResponse>(msg).data;
624 this.state.postRes.comments
625 .filter(c => c.creator.id == data.person_view.person.id)
626 .forEach(c => (c.creator.banned = data.banned));
628 this.state.postRes.post_view.creator.id == data.person_view.person.id
630 this.state.postRes.post_view.creator.banned = data.banned;
632 this.setState(this.state);
633 } else if (op == UserOperation.AddAdmin) {
634 let data = wsJsonToRes<AddAdminResponse>(msg).data;
635 this.state.siteRes.admins = data.admins;
636 this.setState(this.state);
637 } else if (op == UserOperation.Search) {
638 let data = wsJsonToRes<SearchResponse>(msg).data;
639 this.state.crossPosts = data.posts.filter(
640 p => p.post.id != Number(this.props.match.params.id)
642 this.setState(this.state);
643 } else if (op == UserOperation.LeaveAdmin) {
644 let data = wsJsonToRes<GetSiteResponse>(msg).data;
645 this.state.siteRes = data;
646 this.setState(this.state);
647 } else if (op == UserOperation.TransferCommunity) {
648 let data = wsJsonToRes<GetCommunityResponse>(msg).data;
649 this.state.postRes.community_view = data.community_view;
650 this.state.postRes.post_view.community = data.community_view.community;
651 this.state.postRes.moderators = data.moderators;
652 this.setState(this.state);
653 } else if (op == UserOperation.BlockPerson) {
654 let data = wsJsonToRes<BlockPersonResponse>(msg).data;
655 updatePersonBlock(data);
656 } else if (op == UserOperation.CreatePostReport) {
657 let data = wsJsonToRes<PostReportResponse>(msg).data;
659 toast(i18n.t("report_created"));
661 } else if (op == UserOperation.CreateCommentReport) {
662 let data = wsJsonToRes<CommentReportResponse>(msg).data;
664 toast(i18n.t("report_created"));