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, lastState: PostState) {
200 this.state.commentId &&
201 !this.state.scrolled &&
203 lastState.postRes.comments.length > 0
205 this.scrollCommentIntoView();
208 // Necessary if you are on a post and you click another post (same route)
209 if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
210 // TODO Couldnt get a refresh working. This does for now.
213 // let currentId = this.props.match.params.id;
214 // WebSocketService.Instance.getPost(currentId);
215 // this.context.refresh();
216 // this.context.router.history.push(_lastProps.location.pathname);
220 scrollCommentIntoView() {
221 let commentElement = document.getElementById(
222 `comment-${this.state.commentId}`
224 if (commentElement) {
225 commentElement.scrollIntoView();
226 commentElement.classList.add("mark");
227 this.state.scrolled = true;
228 this.markScrolledAsRead(this.state.commentId);
232 get checkScrollIntoCommentsParam() {
234 new URLSearchParams(this.props.location.search).get("scrollToComments")
238 scrollIntoCommentSection() {
239 this.state.commentSectionRef.current?.scrollIntoView();
242 // TODO this needs some re-work
243 markScrolledAsRead(commentId: number) {
244 let found = this.state.postRes.comments.find(
245 c => c.comment.id == commentId
247 let parent = this.state.postRes.comments.find(
248 c => found.comment.parent_id == c.comment.id
250 let parent_person_id = parent
252 : this.state.postRes.post_view.creator.id;
255 UserService.Instance.myUserInfo &&
256 UserService.Instance.myUserInfo.local_user_view.person.id ==
259 let form: MarkCommentAsRead = {
260 comment_id: found.comment.id,
264 WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
265 UserService.Instance.unreadInboxCountSub.next(
266 UserService.Instance.unreadInboxCountSub.value - 1
271 isBottom(el: Element) {
272 return el?.getBoundingClientRect().bottom <= window.innerHeight;
276 * Shows new comments when scrolling to the bottom of the comments div
278 trackCommentsBoxScrolling = () => {
279 const wrappedElement = document.getElementsByClassName("comments")[0];
280 if (wrappedElement && this.isBottom(wrappedElement)) {
281 this.state.maxCommentsShown += commentsShownInterval;
282 this.setState(this.state);
286 get documentTitle(): string {
287 return `${this.state.postRes.post_view.post.name} - ${this.state.siteRes.site_view.site.name}`;
290 get imageTag(): string {
291 let post = this.state.postRes.post_view.post;
293 post.thumbnail_url ||
294 (post.url ? (isImage(post.url) ? post.url : undefined) : undefined)
298 get descriptionTag(): string {
299 let body = this.state.postRes.post_view.post.body;
300 return body ? previewLines(body) : undefined;
304 let pv = this.state.postRes?.post_view;
306 <div class="container">
307 {this.state.loading ? (
313 <div class="col-12 col-md-8 mb-3">
315 title={this.documentTitle}
316 path={this.context.router.route.match.url}
317 image={this.imageTag}
318 description={this.descriptionTag}
322 duplicates={this.state.crossPosts}
325 moderators={this.state.postRes.moderators}
326 admins={this.state.siteRes.admins}
328 this.state.siteRes.site_view.site.enable_downvotes
330 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
332 <div ref={this.state.commentSectionRef} className="mb-2" />
334 postId={this.state.postId}
335 disabled={pv.post.locked}
337 <div class="d-block d-md-none">
339 class="btn btn-secondary d-inline-block mb-2 mr-3"
340 onClick={linkEvent(this, this.handleShowSidebarMobile)}
342 {i18n.t("sidebar")}{" "}
345 this.state.showSidebarMobile
349 classes="icon-inline"
352 {this.state.showSidebarMobile && this.sidebar()}
354 {this.state.postRes.comments.length > 0 && this.sortRadios()}
355 {this.state.commentViewType == CommentViewType.Tree &&
357 {this.state.commentViewType == CommentViewType.Chat &&
360 <div class="d-none d-md-block col-md-4">{this.sidebar()}</div>
370 <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
372 className={`btn btn-outline-secondary pointer ${
373 this.state.commentSort === CommentSortType.Hot && "active"
379 value={CommentSortType.Hot}
380 checked={this.state.commentSort === CommentSortType.Hot}
381 onChange={linkEvent(this, this.handleCommentSortChange)}
385 className={`btn btn-outline-secondary pointer ${
386 this.state.commentSort === CommentSortType.Top && "active"
392 value={CommentSortType.Top}
393 checked={this.state.commentSort === CommentSortType.Top}
394 onChange={linkEvent(this, this.handleCommentSortChange)}
398 className={`btn btn-outline-secondary pointer ${
399 this.state.commentSort === CommentSortType.New && "active"
405 value={CommentSortType.New}
406 checked={this.state.commentSort === CommentSortType.New}
407 onChange={linkEvent(this, this.handleCommentSortChange)}
411 className={`btn btn-outline-secondary pointer ${
412 this.state.commentSort === CommentSortType.Old && "active"
418 value={CommentSortType.Old}
419 checked={this.state.commentSort === CommentSortType.Old}
420 onChange={linkEvent(this, this.handleCommentSortChange)}
424 <div class="btn-group btn-group-toggle flex-wrap mb-2">
426 className={`btn btn-outline-secondary pointer ${
427 this.state.commentViewType === CommentViewType.Chat && "active"
433 value={CommentViewType.Chat}
434 checked={this.state.commentViewType === CommentViewType.Chat}
435 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
444 // These are already sorted by new
448 nodes={commentsToFlatNodes(this.state.postRes.comments)}
449 maxCommentsShown={this.state.maxCommentsShown}
451 locked={this.state.postRes.post_view.post.locked}
452 moderators={this.state.postRes.moderators}
453 admins={this.state.siteRes.admins}
454 postCreatorId={this.state.postRes.post_view.creator.id}
456 enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes}
466 community_view={this.state.postRes.community_view}
467 moderators={this.state.postRes.moderators}
468 admins={this.state.siteRes.admins}
469 online={this.state.postRes.online}
470 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
477 handleCommentSortChange(i: Post, event: any) {
478 i.state.commentSort = Number(event.target.value);
479 i.state.commentViewType = CommentViewType.Tree;
480 i.state.commentTree = buildCommentsTree(
481 i.state.postRes.comments,
487 handleCommentViewTypeChange(i: Post, event: any) {
488 i.state.commentViewType = Number(event.target.value);
489 i.state.commentSort = CommentSortType.New;
490 i.state.commentTree = buildCommentsTree(
491 i.state.postRes.comments,
497 handleShowSidebarMobile(i: Post) {
498 i.state.showSidebarMobile = !i.state.showSidebarMobile;
506 nodes={this.state.commentTree}
507 maxCommentsShown={this.state.maxCommentsShown}
508 locked={this.state.postRes.post_view.post.locked}
509 moderators={this.state.postRes.moderators}
510 admins={this.state.siteRes.admins}
511 postCreatorId={this.state.postRes.post_view.creator.id}
512 enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes}
518 parseMessage(msg: any) {
519 let op = wsUserOp(msg);
522 toast(i18n.t(msg.error), "danger");
524 } else if (msg.reconnect) {
525 let postId = Number(this.props.match.params.id);
526 WebSocketService.Instance.send(wsClient.postJoin({ post_id: postId }));
527 WebSocketService.Instance.send(
530 auth: authField(false),
533 } else if (op == UserOperation.GetPost) {
534 let data = wsJsonToRes<GetPostResponse>(msg).data;
535 this.state.postRes = data;
536 this.state.commentTree = buildCommentsTree(
537 this.state.postRes.comments,
538 this.state.commentSort
540 this.state.loading = false;
543 this.fetchCrossPosts();
544 this.setState(this.state);
546 if (!this.state.commentId) restoreScrollPosition(this.context);
548 if (this.checkScrollIntoCommentsParam) {
549 this.scrollIntoCommentSection();
551 } else if (op == UserOperation.CreateComment) {
552 let data = wsJsonToRes<CommentResponse>(msg).data;
554 // Don't get comments from the post room, if the creator is blocked
555 let creatorBlocked = UserService.Instance.myUserInfo?.person_blocks
556 .map(pb => pb.target.id)
557 .includes(data.comment_view.creator.id);
559 // Necessary since it might be a user reply, which has the recipients, to avoid double
560 if (data.recipient_ids.length == 0 && !creatorBlocked) {
561 this.state.postRes.comments.unshift(data.comment_view);
562 insertCommentIntoTree(this.state.commentTree, data.comment_view);
563 this.state.postRes.post_view.counts.comments++;
564 this.setState(this.state);
568 op == UserOperation.EditComment ||
569 op == UserOperation.DeleteComment ||
570 op == UserOperation.RemoveComment
572 let data = wsJsonToRes<CommentResponse>(msg).data;
573 editCommentRes(data.comment_view, this.state.postRes.comments);
574 this.setState(this.state);
575 } else if (op == UserOperation.SaveComment) {
576 let data = wsJsonToRes<CommentResponse>(msg).data;
577 saveCommentRes(data.comment_view, this.state.postRes.comments);
578 this.setState(this.state);
580 } else if (op == UserOperation.CreateCommentLike) {
581 let data = wsJsonToRes<CommentResponse>(msg).data;
582 createCommentLikeRes(data.comment_view, this.state.postRes.comments);
583 this.setState(this.state);
584 } else if (op == UserOperation.CreatePostLike) {
585 let data = wsJsonToRes<PostResponse>(msg).data;
586 createPostLikeRes(data.post_view, this.state.postRes.post_view);
587 this.setState(this.state);
589 op == UserOperation.EditPost ||
590 op == UserOperation.DeletePost ||
591 op == UserOperation.RemovePost ||
592 op == UserOperation.LockPost ||
593 op == UserOperation.StickyPost ||
594 op == UserOperation.SavePost
596 let data = wsJsonToRes<PostResponse>(msg).data;
597 this.state.postRes.post_view = data.post_view;
598 this.setState(this.state);
601 op == UserOperation.EditCommunity ||
602 op == UserOperation.DeleteCommunity ||
603 op == UserOperation.RemoveCommunity ||
604 op == UserOperation.FollowCommunity
606 let data = wsJsonToRes<CommunityResponse>(msg).data;
607 this.state.postRes.community_view = data.community_view;
608 this.state.postRes.post_view.community = data.community_view.community;
609 this.setState(this.state);
610 this.setState(this.state);
611 } else if (op == UserOperation.BanFromCommunity) {
612 let data = wsJsonToRes<BanFromCommunityResponse>(msg).data;
613 this.state.postRes.comments
614 .filter(c => c.creator.id == data.person_view.person.id)
615 .forEach(c => (c.creator_banned_from_community = data.banned));
617 this.state.postRes.post_view.creator.id == data.person_view.person.id
619 this.state.postRes.post_view.creator_banned_from_community =
622 this.setState(this.state);
623 } else if (op == UserOperation.AddModToCommunity) {
624 let data = wsJsonToRes<AddModToCommunityResponse>(msg).data;
625 this.state.postRes.moderators = data.moderators;
626 this.setState(this.state);
627 } else if (op == UserOperation.BanPerson) {
628 let data = wsJsonToRes<BanPersonResponse>(msg).data;
629 this.state.postRes.comments
630 .filter(c => c.creator.id == data.person_view.person.id)
631 .forEach(c => (c.creator.banned = data.banned));
633 this.state.postRes.post_view.creator.id == data.person_view.person.id
635 this.state.postRes.post_view.creator.banned = data.banned;
637 this.setState(this.state);
638 } else if (op == UserOperation.AddAdmin) {
639 let data = wsJsonToRes<AddAdminResponse>(msg).data;
640 this.state.siteRes.admins = data.admins;
641 this.setState(this.state);
642 } else if (op == UserOperation.Search) {
643 let data = wsJsonToRes<SearchResponse>(msg).data;
644 this.state.crossPosts = data.posts.filter(
645 p => p.post.id != Number(this.props.match.params.id)
647 this.setState(this.state);
648 } else if (op == UserOperation.LeaveAdmin) {
649 let data = wsJsonToRes<GetSiteResponse>(msg).data;
650 this.state.siteRes = data;
651 this.setState(this.state);
652 } else if (op == UserOperation.TransferCommunity) {
653 let data = wsJsonToRes<GetCommunityResponse>(msg).data;
654 this.state.postRes.community_view = data.community_view;
655 this.state.postRes.post_view.community = data.community_view.community;
656 this.state.postRes.moderators = data.moderators;
657 this.setState(this.state);
658 } else if (op == UserOperation.BlockPerson) {
659 let data = wsJsonToRes<BlockPersonResponse>(msg).data;
660 updatePersonBlock(data);
661 } else if (op == UserOperation.CreatePostReport) {
662 let data = wsJsonToRes<PostReportResponse>(msg).data;
664 toast(i18n.t("report_created"));
666 } else if (op == UserOperation.CreateCommentReport) {
667 let data = wsJsonToRes<CommentReportResponse>(msg).data;
669 toast(i18n.t("report_created"));