]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post.tsx
Don't render markdown for summaries. Fixes #658 (#659)
[lemmy-ui.git] / src / shared / components / post / post.tsx
1 import autosize from "autosize";
2 import { Component, createRef, linkEvent, RefObject } from "inferno";
3 import {
4   AddAdminResponse,
5   AddModToCommunityResponse,
6   BanFromCommunityResponse,
7   BanPersonResponse,
8   BlockPersonResponse,
9   CommentReportResponse,
10   CommentResponse,
11   CommunityResponse,
12   GetCommunityResponse,
13   GetPost,
14   GetPostResponse,
15   GetSiteResponse,
16   ListingType,
17   MarkCommentAsRead,
18   PostReportResponse,
19   PostResponse,
20   PostView,
21   Search,
22   SearchResponse,
23   SearchType,
24   SortType,
25   UserOperation,
26 } from "lemmy-js-client";
27 import { Subscription } from "rxjs";
28 import { i18n } from "../../i18next";
29 import {
30   CommentNode as CommentNodeI,
31   CommentSortType,
32   CommentViewType,
33   InitialFetchRequest,
34 } from "../../interfaces";
35 import { UserService, WebSocketService } from "../../services";
36 import {
37   authField,
38   buildCommentsTree,
39   commentsToFlatNodes,
40   createCommentLikeRes,
41   createPostLikeRes,
42   debounce,
43   editCommentRes,
44   getCommentIdFromProps,
45   getIdFromProps,
46   insertCommentIntoTree,
47   isBrowser,
48   isImage,
49   restoreScrollPosition,
50   saveCommentRes,
51   saveScrollPosition,
52   setIsoData,
53   setOptionalAuth,
54   setupTippy,
55   toast,
56   updatePersonBlock,
57   wsClient,
58   wsJsonToRes,
59   wsSubscribe,
60   wsUserOp,
61 } from "../../utils";
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";
68
69 const commentsShownInterval = 15;
70
71 interface PostState {
72   postRes: GetPostResponse;
73   postId: number;
74   commentTree: CommentNodeI[];
75   commentId?: number;
76   commentSort: CommentSortType;
77   commentViewType: CommentViewType;
78   scrolled?: boolean;
79   loading: boolean;
80   crossPosts: PostView[];
81   siteRes: GetSiteResponse;
82   commentSectionRef?: RefObject<HTMLDivElement>;
83   showSidebarMobile: boolean;
84   maxCommentsShown: number;
85 }
86
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 = {
92     postRes: null,
93     postId: getIdFromProps(this.props),
94     commentTree: [],
95     commentId: getCommentIdFromProps(this.props),
96     commentSort: CommentSortType.Hot,
97     commentViewType: CommentViewType.Tree,
98     scrolled: false,
99     loading: true,
100     crossPosts: [],
101     siteRes: this.isoData.site_res,
102     commentSectionRef: null,
103     showSidebarMobile: false,
104     maxCommentsShown: commentsShownInterval,
105   };
106
107   constructor(props: any, context: any) {
108     super(props, context);
109
110     this.state = this.emptyState;
111     this.state.commentSectionRef = createRef();
112
113     this.parseMessage = this.parseMessage.bind(this);
114     this.subscription = wsSubscribe(this.parseMessage);
115
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
122       );
123       this.state.loading = false;
124
125       if (isBrowser()) {
126         this.fetchCrossPosts();
127         if (this.state.commentId) {
128           this.scrollCommentIntoView();
129         }
130
131         if (this.checkScrollIntoCommentsParam) {
132           this.scrollIntoCommentSection();
133         }
134       }
135     } else {
136       this.fetchPost();
137     }
138   }
139
140   fetchPost() {
141     let form: GetPost = {
142       id: this.state.postId,
143       auth: authField(false),
144     };
145     WebSocketService.Instance.send(wsClient.getPost(form));
146   }
147
148   fetchCrossPosts() {
149     if (this.state.postRes.post_view.post.url) {
150       let form: Search = {
151         q: this.state.postRes.post_view.post.url,
152         type_: SearchType.Url,
153         sort: SortType.TopAll,
154         listing_type: ListingType.All,
155         page: 1,
156         limit: 6,
157         auth: authField(false),
158       };
159       WebSocketService.Instance.send(wsClient.search(form));
160     }
161   }
162
163   static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
164     let pathSplit = req.path.split("/");
165     let promises: Promise<any>[] = [];
166
167     let id = Number(pathSplit[2]);
168
169     let postForm: GetPost = {
170       id,
171     };
172     setOptionalAuth(postForm, req.auth);
173
174     promises.push(req.client.getPost(postForm));
175
176     return promises;
177   }
178
179   componentWillUnmount() {
180     this.subscription.unsubscribe();
181     document.removeEventListener("scroll", this.commentScrollDebounced);
182
183     window.isoData.path = undefined;
184     saveScrollPosition(this.context);
185   }
186
187   componentDidMount() {
188     WebSocketService.Instance.send(
189       wsClient.postJoin({ post_id: this.state.postId })
190     );
191     autosize(document.querySelectorAll("textarea"));
192
193     this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
194     document.addEventListener("scroll", this.commentScrollDebounced);
195   }
196
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.
201       location.reload();
202
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);
207     }
208   }
209
210   scrollCommentIntoView() {
211     let commentElement = document.getElementById(
212       `comment-${this.state.commentId}`
213     );
214     if (commentElement) {
215       commentElement.scrollIntoView();
216       commentElement.classList.add("mark");
217       this.state.scrolled = true;
218       this.markScrolledAsRead(this.state.commentId);
219     }
220   }
221
222   get checkScrollIntoCommentsParam() {
223     return Boolean(
224       new URLSearchParams(this.props.location.search).get("scrollToComments")
225     );
226   }
227
228   scrollIntoCommentSection() {
229     this.state.commentSectionRef.current?.scrollIntoView();
230   }
231
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
236     );
237     let parent = this.state.postRes.comments.find(
238       c => found.comment.parent_id == c.comment.id
239     );
240     let parent_person_id = parent
241       ? parent.creator.id
242       : this.state.postRes.post_view.creator.id;
243
244     if (
245       UserService.Instance.myUserInfo &&
246       UserService.Instance.myUserInfo.local_user_view.person.id ==
247         parent_person_id
248     ) {
249       let form: MarkCommentAsRead = {
250         comment_id: found.comment.id,
251         read: true,
252         auth: authField(),
253       };
254       WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
255       UserService.Instance.unreadInboxCountSub.next(
256         UserService.Instance.unreadInboxCountSub.value - 1
257       );
258     }
259   }
260
261   isBottom(el: Element) {
262     return el?.getBoundingClientRect().bottom <= window.innerHeight;
263   }
264
265   /**
266    * Shows new comments when scrolling to the bottom of the comments div
267    */
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);
273     }
274   };
275
276   get documentTitle(): string {
277     return `${this.state.postRes.post_view.post.name} - ${this.state.siteRes.site_view.site.name}`;
278   }
279
280   get imageTag(): string {
281     let post = this.state.postRes.post_view.post;
282     return (
283       post.thumbnail_url ||
284       (post.url ? (isImage(post.url) ? post.url : undefined) : undefined)
285     );
286   }
287
288   get descriptionTag(): string {
289     return this.state.postRes.post_view.post.body;
290   }
291
292   render() {
293     let pv = this.state.postRes?.post_view;
294     return (
295       <div class="container">
296         {this.state.loading ? (
297           <h5>
298             <Spinner large />
299           </h5>
300         ) : (
301           <div class="row">
302             <div class="col-12 col-md-8 mb-3">
303               <HtmlTags
304                 title={this.documentTitle}
305                 path={this.context.router.route.match.url}
306                 image={this.imageTag}
307                 description={this.descriptionTag}
308               />
309               <PostListing
310                 post_view={pv}
311                 duplicates={this.state.crossPosts}
312                 showBody
313                 showCommunity
314                 moderators={this.state.postRes.moderators}
315                 admins={this.state.siteRes.admins}
316                 enableDownvotes={
317                   this.state.siteRes.site_view.site.enable_downvotes
318                 }
319                 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
320               />
321               <div ref={this.state.commentSectionRef} className="mb-2" />
322               <CommentForm
323                 postId={this.state.postId}
324                 disabled={pv.post.locked}
325               />
326               <div class="d-block d-md-none">
327                 <button
328                   class="btn btn-secondary d-inline-block mb-2 mr-3"
329                   onClick={linkEvent(this, this.handleShowSidebarMobile)}
330                 >
331                   {i18n.t("sidebar")}{" "}
332                   <Icon
333                     icon={
334                       this.state.showSidebarMobile
335                         ? `minus-square`
336                         : `plus-square`
337                     }
338                     classes="icon-inline"
339                   />
340                 </button>
341                 {this.state.showSidebarMobile && this.sidebar()}
342               </div>
343               {this.state.postRes.comments.length > 0 && this.sortRadios()}
344               {this.state.commentViewType == CommentViewType.Tree &&
345                 this.commentsTree()}
346               {this.state.commentViewType == CommentViewType.Chat &&
347                 this.commentsFlat()}
348             </div>
349             <div class="d-none d-md-block col-md-4">{this.sidebar()}</div>
350           </div>
351         )}
352       </div>
353     );
354   }
355
356   sortRadios() {
357     return (
358       <>
359         <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
360           <label
361             className={`btn btn-outline-secondary pointer ${
362               this.state.commentSort === CommentSortType.Hot && "active"
363             }`}
364           >
365             {i18n.t("hot")}
366             <input
367               type="radio"
368               value={CommentSortType.Hot}
369               checked={this.state.commentSort === CommentSortType.Hot}
370               onChange={linkEvent(this, this.handleCommentSortChange)}
371             />
372           </label>
373           <label
374             className={`btn btn-outline-secondary pointer ${
375               this.state.commentSort === CommentSortType.Top && "active"
376             }`}
377           >
378             {i18n.t("top")}
379             <input
380               type="radio"
381               value={CommentSortType.Top}
382               checked={this.state.commentSort === CommentSortType.Top}
383               onChange={linkEvent(this, this.handleCommentSortChange)}
384             />
385           </label>
386           <label
387             className={`btn btn-outline-secondary pointer ${
388               this.state.commentSort === CommentSortType.New && "active"
389             }`}
390           >
391             {i18n.t("new")}
392             <input
393               type="radio"
394               value={CommentSortType.New}
395               checked={this.state.commentSort === CommentSortType.New}
396               onChange={linkEvent(this, this.handleCommentSortChange)}
397             />
398           </label>
399           <label
400             className={`btn btn-outline-secondary pointer ${
401               this.state.commentSort === CommentSortType.Old && "active"
402             }`}
403           >
404             {i18n.t("old")}
405             <input
406               type="radio"
407               value={CommentSortType.Old}
408               checked={this.state.commentSort === CommentSortType.Old}
409               onChange={linkEvent(this, this.handleCommentSortChange)}
410             />
411           </label>
412         </div>
413         <div class="btn-group btn-group-toggle flex-wrap mb-2">
414           <label
415             className={`btn btn-outline-secondary pointer ${
416               this.state.commentViewType === CommentViewType.Chat && "active"
417             }`}
418           >
419             {i18n.t("chat")}
420             <input
421               type="radio"
422               value={CommentViewType.Chat}
423               checked={this.state.commentViewType === CommentViewType.Chat}
424               onChange={linkEvent(this, this.handleCommentViewTypeChange)}
425             />
426           </label>
427         </div>
428       </>
429     );
430   }
431
432   commentsFlat() {
433     // These are already sorted by new
434     return (
435       <div>
436         <CommentNodes
437           nodes={commentsToFlatNodes(this.state.postRes.comments)}
438           maxCommentsShown={this.state.maxCommentsShown}
439           noIndent
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}
444           showContext
445           enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes}
446         />
447       </div>
448     );
449   }
450
451   sidebar() {
452     return (
453       <div class="mb-3">
454         <Sidebar
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}
460           showIcon
461         />
462       </div>
463     );
464   }
465
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,
471       i.state.commentSort
472     );
473     i.setState(i.state);
474   }
475
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,
481       i.state.commentSort
482     );
483     i.setState(i.state);
484   }
485
486   handleShowSidebarMobile(i: Post) {
487     i.state.showSidebarMobile = !i.state.showSidebarMobile;
488     i.setState(i.state);
489   }
490
491   commentsTree() {
492     return (
493       <div>
494         <CommentNodes
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}
502         />
503       </div>
504     );
505   }
506
507   parseMessage(msg: any) {
508     let op = wsUserOp(msg);
509     console.log(msg);
510     if (msg.error) {
511       toast(i18n.t(msg.error), "danger");
512       return;
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(
517         wsClient.getPost({
518           id: postId,
519           auth: authField(false),
520         })
521       );
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
528       );
529       this.state.loading = false;
530
531       // Get cross-posts
532       this.fetchCrossPosts();
533       this.setState(this.state);
534       setupTippy();
535       if (!this.state.commentId) restoreScrollPosition(this.context);
536
537       if (this.checkScrollIntoCommentsParam) {
538         this.scrollIntoCommentSection();
539       }
540
541       if (this.state.commentId && !this.state.scrolled) {
542         this.scrollCommentIntoView();
543       }
544     } else if (op == UserOperation.CreateComment) {
545       let data = wsJsonToRes<CommentResponse>(msg).data;
546
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);
551
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);
558         setupTippy();
559       }
560     } else if (
561       op == UserOperation.EditComment ||
562       op == UserOperation.DeleteComment ||
563       op == UserOperation.RemoveComment
564     ) {
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);
572       setupTippy();
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);
581     } else if (
582       op == UserOperation.EditPost ||
583       op == UserOperation.DeletePost ||
584       op == UserOperation.RemovePost ||
585       op == UserOperation.LockPost ||
586       op == UserOperation.StickyPost ||
587       op == UserOperation.SavePost
588     ) {
589       let data = wsJsonToRes<PostResponse>(msg).data;
590       this.state.postRes.post_view = data.post_view;
591       this.setState(this.state);
592       setupTippy();
593     } else if (
594       op == UserOperation.EditCommunity ||
595       op == UserOperation.DeleteCommunity ||
596       op == UserOperation.RemoveCommunity ||
597       op == UserOperation.FollowCommunity
598     ) {
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));
609       if (
610         this.state.postRes.post_view.creator.id == data.person_view.person.id
611       ) {
612         this.state.postRes.post_view.creator_banned_from_community =
613           data.banned;
614       }
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));
625       if (
626         this.state.postRes.post_view.creator.id == data.person_view.person.id
627       ) {
628         this.state.postRes.post_view.creator.banned = data.banned;
629       }
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)
639       );
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;
656       if (data) {
657         toast(i18n.t("report_created"));
658       }
659     } else if (op == UserOperation.CreateCommentReport) {
660       let data = wsJsonToRes<CommentReportResponse>(msg).data;
661       if (data) {
662         toast(i18n.t("report_created"));
663       }
664     }
665   }
666 }