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,
30 } from "lemmy-js-client";
31 import { Subscription } from "rxjs";
32 import { i18n } from "../../i18next";
34 CommentNode as CommentNodeI,
38 } from "../../interfaces";
39 import { UserService, WebSocketService } from "../../services";
50 getCommentIdFromProps,
52 insertCommentIntoTree,
55 restoreScrollPosition,
66 import { CommentForm } from "../comment/comment-form";
67 import { CommentNodes } from "../comment/comment-nodes";
68 import { HtmlTags } from "../common/html-tags";
69 import { Icon, Spinner } from "../common/icon";
70 import { Sidebar } from "../community/sidebar";
71 import { PostListing } from "./post-listing";
73 const commentsShownInterval = 15;
76 postRes: Option<GetPostResponse>;
78 commentTree: CommentNodeI[];
80 commentSort: CommentSortType;
81 commentViewType: CommentViewType;
84 crossPosts: Option<PostView[]>;
85 siteRes: GetSiteResponse;
86 commentSectionRef?: RefObject<HTMLDivElement>;
87 showSidebarMobile: boolean;
88 maxCommentsShown: number;
91 export class Post extends Component<any, PostState> {
92 private subscription: Subscription;
93 private isoData = setIsoData(this.context, GetPostResponse);
94 private commentScrollDebounced: () => void;
95 private emptyState: PostState = {
97 postId: getIdFromProps(this.props),
99 commentId: getCommentIdFromProps(this.props),
100 commentSort: CommentSortType.Hot,
101 commentViewType: CommentViewType.Tree,
105 siteRes: this.isoData.site_res,
106 commentSectionRef: null,
107 showSidebarMobile: false,
108 maxCommentsShown: commentsShownInterval,
111 constructor(props: any, context: any) {
112 super(props, context);
114 this.state = this.emptyState;
115 this.state.commentSectionRef = createRef();
117 this.parseMessage = this.parseMessage.bind(this);
118 this.subscription = wsSubscribe(this.parseMessage);
120 // Only fetch the data if coming from another route
121 if (this.isoData.path == this.context.router.route.match.url) {
122 this.state.postRes = Some(this.isoData.routeData[0] as GetPostResponse);
123 this.state.commentTree = buildCommentsTree(
124 this.state.postRes.unwrap().comments,
125 this.state.commentSort
127 this.state.loading = false;
130 WebSocketService.Instance.send(
131 wsClient.communityJoin({
133 this.state.postRes.unwrap().community_view.community.id,
136 WebSocketService.Instance.send(
137 wsClient.postJoin({ post_id: this.state.postId })
140 this.fetchCrossPosts();
141 if (this.state.commentId) {
142 this.scrollCommentIntoView();
145 if (this.checkScrollIntoCommentsParam) {
146 this.scrollIntoCommentSection();
155 let form = new GetPost({
156 id: this.state.postId,
157 auth: auth(false).ok(),
159 WebSocketService.Instance.send(wsClient.getPost(form));
164 .andThen(r => r.post_view.post.url)
167 let form = new Search({
169 type_: Some(SearchType.Url),
170 sort: Some(SortType.TopAll),
171 listing_type: Some(ListingType.All),
173 limit: Some(trendingFetchLimit),
175 community_name: None,
177 auth: auth(false).ok(),
179 WebSocketService.Instance.send(wsClient.search(form));
185 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
186 let pathSplit = req.path.split("/");
188 let id = Number(pathSplit[2]);
190 let postForm = new GetPost({
195 return [req.client.getPost(postForm)];
198 componentWillUnmount() {
199 this.subscription.unsubscribe();
200 document.removeEventListener("scroll", this.commentScrollDebounced);
202 saveScrollPosition(this.context);
205 componentDidMount() {
206 autosize(document.querySelectorAll("textarea"));
208 this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
209 document.addEventListener("scroll", this.commentScrollDebounced);
212 componentDidUpdate(_lastProps: any) {
213 // Necessary if you are on a post and you click another post (same route)
214 if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
215 // TODO Couldnt get a refresh working. This does for now.
218 // let currentId = this.props.match.params.id;
219 // WebSocketService.Instance.getPost(currentId);
220 // this.context.refresh();
221 // this.context.router.history.push(_lastProps.location.pathname);
225 scrollCommentIntoView() {
226 let commentElement = document.getElementById(
227 `comment-${this.state.commentId}`
229 if (commentElement) {
230 commentElement.scrollIntoView();
231 commentElement.classList.add("mark");
232 this.state.scrolled = true;
233 this.markScrolledAsRead(this.state.commentId);
237 get checkScrollIntoCommentsParam() {
239 new URLSearchParams(this.props.location.search).get("scrollToComments")
243 scrollIntoCommentSection() {
244 this.state.commentSectionRef.current?.scrollIntoView();
247 // TODO this needs some re-work
248 markScrolledAsRead(commentId: number) {
249 this.state.postRes.match({
251 let found = res.comments.find(c => c.comment.id == commentId);
252 let parent = res.comments.find(
253 c => found.comment.parent_id.unwrapOr(0) == c.comment.id
255 let parent_person_id = parent
257 : res.post_view.creator.id;
259 UserService.Instance.myUserInfo.match({
261 if (mui.local_user_view.person.id == parent_person_id) {
262 let form = new MarkCommentAsRead({
263 comment_id: found.comment.id,
265 auth: auth().unwrap(),
267 WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
268 UserService.Instance.unreadInboxCountSub.next(
269 UserService.Instance.unreadInboxCountSub.value - 1
280 isBottom(el: Element): boolean {
281 return el?.getBoundingClientRect().bottom <= window.innerHeight;
285 * Shows new comments when scrolling to the bottom of the comments div
287 trackCommentsBoxScrolling = () => {
288 const wrappedElement = document.getElementsByClassName("comments")[0];
289 if (wrappedElement && this.isBottom(wrappedElement)) {
290 this.state.maxCommentsShown += commentsShownInterval;
291 this.setState(this.state);
295 get documentTitle(): string {
296 return this.state.postRes.match({
298 this.state.siteRes.site_view.match({
300 `${res.post_view.post.name} - ${siteView.site.name}`,
307 get imageTag(): Option<string> {
308 return this.state.postRes.match({
310 res.post_view.post.thumbnail_url.or(
311 res.post_view.post.url.match({
312 some: url => (isImage(url) ? Some(url) : None),
320 get descriptionTag(): Option<string> {
321 return this.state.postRes.andThen(r => r.post_view.post.body);
326 <div class="container">
327 {this.state.loading ? (
332 this.state.postRes.match({
335 <div class="col-12 col-md-8 mb-3">
337 title={this.documentTitle}
338 path={this.context.router.route.match.url}
339 image={this.imageTag}
340 description={this.descriptionTag}
343 post_view={res.post_view}
344 duplicates={this.state.crossPosts}
347 moderators={Some(res.moderators)}
348 admins={Some(this.state.siteRes.admins)}
349 enableDownvotes={enableDownvotes(this.state.siteRes)}
350 enableNsfw={enableNsfw(this.state.siteRes)}
352 <div ref={this.state.commentSectionRef} className="mb-2" />
354 node={Right(this.state.postId)}
355 disabled={res.post_view.post.locked}
357 <div class="d-block d-md-none">
359 class="btn btn-secondary d-inline-block mb-2 mr-3"
360 onClick={linkEvent(this, this.handleShowSidebarMobile)}
362 {i18n.t("sidebar")}{" "}
365 this.state.showSidebarMobile
369 classes="icon-inline"
372 {this.state.showSidebarMobile && this.sidebar()}
374 {res.comments.length > 0 && this.sortRadios()}
375 {this.state.commentViewType == CommentViewType.Tree &&
377 {this.state.commentViewType == CommentViewType.Chat &&
380 <div class="d-none d-md-block col-md-4">{this.sidebar()}</div>
393 <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
395 className={`btn btn-outline-secondary pointer ${
396 this.state.commentSort === CommentSortType.Hot && "active"
402 value={CommentSortType.Hot}
403 checked={this.state.commentSort === CommentSortType.Hot}
404 onChange={linkEvent(this, this.handleCommentSortChange)}
408 className={`btn btn-outline-secondary pointer ${
409 this.state.commentSort === CommentSortType.Top && "active"
415 value={CommentSortType.Top}
416 checked={this.state.commentSort === CommentSortType.Top}
417 onChange={linkEvent(this, this.handleCommentSortChange)}
421 className={`btn btn-outline-secondary pointer ${
422 this.state.commentSort === CommentSortType.New && "active"
428 value={CommentSortType.New}
429 checked={this.state.commentSort === CommentSortType.New}
430 onChange={linkEvent(this, this.handleCommentSortChange)}
434 className={`btn btn-outline-secondary pointer ${
435 this.state.commentSort === CommentSortType.Old && "active"
441 value={CommentSortType.Old}
442 checked={this.state.commentSort === CommentSortType.Old}
443 onChange={linkEvent(this, this.handleCommentSortChange)}
447 <div class="btn-group btn-group-toggle flex-wrap mb-2">
449 className={`btn btn-outline-secondary pointer ${
450 this.state.commentViewType === CommentViewType.Chat && "active"
456 value={CommentViewType.Chat}
457 checked={this.state.commentViewType === CommentViewType.Chat}
458 onChange={linkEvent(this, this.handleCommentViewTypeChange)}
467 // These are already sorted by new
468 return this.state.postRes.match({
472 nodes={commentsToFlatNodes(res.comments)}
473 maxCommentsShown={Some(this.state.maxCommentsShown)}
475 locked={res.post_view.post.locked}
476 moderators={Some(res.moderators)}
477 admins={Some(this.state.siteRes.admins)}
478 enableDownvotes={enableDownvotes(this.state.siteRes)}
488 return this.state.postRes.match({
492 community_view={res.community_view}
493 moderators={res.moderators}
494 admins={this.state.siteRes.admins}
496 enableNsfw={enableNsfw(this.state.siteRes)}
505 handleCommentSortChange(i: Post, event: any) {
506 i.state.commentSort = Number(event.target.value);
507 i.state.commentViewType = CommentViewType.Tree;
508 i.state.commentTree = buildCommentsTree(
509 i.state.postRes.map(r => r.comments).unwrapOr([]),
515 handleCommentViewTypeChange(i: Post, event: any) {
516 i.state.commentViewType = Number(event.target.value);
517 i.state.commentSort = CommentSortType.New;
518 i.state.commentTree = buildCommentsTree(
519 i.state.postRes.map(r => r.comments).unwrapOr([]),
525 handleShowSidebarMobile(i: Post) {
526 i.state.showSidebarMobile = !i.state.showSidebarMobile;
531 return this.state.postRes.match({
535 nodes={this.state.commentTree}
536 maxCommentsShown={Some(this.state.maxCommentsShown)}
537 locked={res.post_view.post.locked}
538 moderators={Some(res.moderators)}
539 admins={Some(this.state.siteRes.admins)}
540 enableDownvotes={enableDownvotes(this.state.siteRes)}
548 parseMessage(msg: any) {
549 let op = wsUserOp(msg);
552 toast(i18n.t(msg.error), "danger");
554 } else if (msg.reconnect) {
555 let postId = Number(this.props.match.params.id);
556 WebSocketService.Instance.send(wsClient.postJoin({ post_id: postId }));
557 WebSocketService.Instance.send(
560 auth: auth(false).ok(),
563 } else if (op == UserOperation.GetPost) {
564 let data = wsJsonToRes<GetPostResponse>(msg, GetPostResponse);
565 this.state.postRes = Some(data);
567 this.state.commentTree = buildCommentsTree(
568 this.state.postRes.map(r => r.comments).unwrapOr([]),
569 this.state.commentSort
571 this.state.loading = false;
574 WebSocketService.Instance.send(
575 wsClient.postJoin({ post_id: this.state.postId })
577 WebSocketService.Instance.send(
578 wsClient.communityJoin({
579 community_id: data.community_view.community.id,
584 this.fetchCrossPosts();
585 this.setState(this.state);
587 if (!this.state.commentId) restoreScrollPosition(this.context);
589 if (this.checkScrollIntoCommentsParam) {
590 this.scrollIntoCommentSection();
593 if (this.state.commentId && !this.state.scrolled) {
594 this.scrollCommentIntoView();
596 } else if (op == UserOperation.CreateComment) {
597 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
599 // Don't get comments from the post room, if the creator is blocked
600 let creatorBlocked = UserService.Instance.myUserInfo
601 .map(m => m.person_blocks)
603 .map(pb => pb.target.id)
604 .includes(data.comment_view.creator.id);
606 // Necessary since it might be a user reply, which has the recipients, to avoid double
607 if (data.recipient_ids.length == 0 && !creatorBlocked) {
608 this.state.postRes.match({
610 res.comments.unshift(data.comment_view);
611 insertCommentIntoTree(this.state.commentTree, data.comment_view);
612 res.post_view.counts.comments++;
616 this.setState(this.state);
620 op == UserOperation.EditComment ||
621 op == UserOperation.DeleteComment ||
622 op == UserOperation.RemoveComment
624 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
627 this.state.postRes.map(r => r.comments).unwrapOr([])
629 this.setState(this.state);
630 } else if (op == UserOperation.SaveComment) {
631 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
634 this.state.postRes.map(r => r.comments).unwrapOr([])
636 this.setState(this.state);
638 } else if (op == UserOperation.CreateCommentLike) {
639 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
640 createCommentLikeRes(
642 this.state.postRes.map(r => r.comments).unwrapOr([])
644 this.setState(this.state);
645 } else if (op == UserOperation.CreatePostLike) {
646 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
647 this.state.postRes.match({
648 some: res => createPostLikeRes(data.post_view, res.post_view),
651 this.setState(this.state);
653 op == UserOperation.EditPost ||
654 op == UserOperation.DeletePost ||
655 op == UserOperation.RemovePost ||
656 op == UserOperation.LockPost ||
657 op == UserOperation.StickyPost ||
658 op == UserOperation.SavePost
660 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
661 this.state.postRes.match({
662 some: res => (res.post_view = data.post_view),
665 this.setState(this.state);
668 op == UserOperation.EditCommunity ||
669 op == UserOperation.DeleteCommunity ||
670 op == UserOperation.RemoveCommunity ||
671 op == UserOperation.FollowCommunity
673 let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse);
674 this.state.postRes.match({
676 res.community_view = data.community_view;
677 res.post_view.community = data.community_view.community;
678 this.setState(this.state);
682 } else if (op == UserOperation.BanFromCommunity) {
683 let data = wsJsonToRes<BanFromCommunityResponse>(
685 BanFromCommunityResponse
687 this.state.postRes.match({
690 .filter(c => c.creator.id == data.person_view.person.id)
691 .forEach(c => (c.creator_banned_from_community = data.banned));
692 if (res.post_view.creator.id == data.person_view.person.id) {
693 res.post_view.creator_banned_from_community = data.banned;
695 this.setState(this.state);
699 } else if (op == UserOperation.AddModToCommunity) {
700 let data = wsJsonToRes<AddModToCommunityResponse>(
702 AddModToCommunityResponse
704 this.state.postRes.match({
706 res.moderators = data.moderators;
707 this.setState(this.state);
711 } else if (op == UserOperation.BanPerson) {
712 let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
713 this.state.postRes.match({
716 .filter(c => c.creator.id == data.person_view.person.id)
717 .forEach(c => (c.creator.banned = data.banned));
718 if (res.post_view.creator.id == data.person_view.person.id) {
719 res.post_view.creator.banned = data.banned;
721 this.setState(this.state);
725 } else if (op == UserOperation.AddAdmin) {
726 let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
727 this.state.siteRes.admins = data.admins;
728 this.setState(this.state);
729 } else if (op == UserOperation.Search) {
730 let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
731 let xPosts = data.posts.filter(
732 p => p.post.id != Number(this.props.match.params.id)
734 this.state.crossPosts = xPosts.length > 0 ? Some(xPosts) : None;
735 this.setState(this.state);
736 } else if (op == UserOperation.LeaveAdmin) {
737 let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse);
738 this.state.siteRes = data;
739 this.setState(this.state);
740 } else if (op == UserOperation.TransferCommunity) {
741 let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse);
742 this.state.postRes.match({
744 res.community_view = data.community_view;
745 res.post_view.community = data.community_view.community;
746 res.moderators = data.moderators;
747 this.setState(this.state);
751 } else if (op == UserOperation.BlockPerson) {
752 let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
753 updatePersonBlock(data);
754 } else if (op == UserOperation.CreatePostReport) {
755 let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse);
757 toast(i18n.t("report_created"));
759 } else if (op == UserOperation.CreateCommentReport) {
760 let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse);
762 toast(i18n.t("report_created"));
765 op == UserOperation.PurgePerson ||
766 op == UserOperation.PurgePost ||
767 op == UserOperation.PurgeComment ||
768 op == UserOperation.PurgeCommunity
770 let data = wsJsonToRes<PurgeItemResponse>(msg, PurgeItemResponse);
772 toast(i18n.t("purge_success"));
773 this.context.router.history.push(`/`);