]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post.tsx
Removing save and read config hjson. Fixes #695 (#696)
[lemmy-ui.git] / src / shared / components / post / post.tsx
1 import { None, Option, Right, Some } from "@sniptt/monads";
2 import autosize from "autosize";
3 import { Component, createRef, linkEvent, RefObject } from "inferno";
4 import {
5   AddAdminResponse,
6   AddModToCommunityResponse,
7   BanFromCommunityResponse,
8   BanPersonResponse,
9   BlockPersonResponse,
10   CommentReportResponse,
11   CommentResponse,
12   CommunityResponse,
13   GetCommunityResponse,
14   GetPost,
15   GetPostResponse,
16   GetSiteResponse,
17   ListingType,
18   MarkCommentAsRead,
19   PostReportResponse,
20   PostResponse,
21   PostView,
22   Search,
23   SearchResponse,
24   SearchType,
25   SortType,
26   UserOperation,
27   wsJsonToRes,
28   wsUserOp,
29 } from "lemmy-js-client";
30 import { Subscription } from "rxjs";
31 import { i18n } from "../../i18next";
32 import {
33   CommentNode as CommentNodeI,
34   CommentSortType,
35   CommentViewType,
36   InitialFetchRequest,
37 } from "../../interfaces";
38 import { UserService, WebSocketService } from "../../services";
39 import {
40   auth,
41   buildCommentsTree,
42   commentsToFlatNodes,
43   createCommentLikeRes,
44   createPostLikeRes,
45   debounce,
46   editCommentRes,
47   enableDownvotes,
48   enableNsfw,
49   getCommentIdFromProps,
50   getIdFromProps,
51   insertCommentIntoTree,
52   isBrowser,
53   isImage,
54   restoreScrollPosition,
55   saveCommentRes,
56   saveScrollPosition,
57   setIsoData,
58   setupTippy,
59   toast,
60   trendingFetchLimit,
61   updatePersonBlock,
62   wsClient,
63   wsSubscribe,
64 } from "../../utils";
65 import { CommentForm } from "../comment/comment-form";
66 import { CommentNodes } from "../comment/comment-nodes";
67 import { HtmlTags } from "../common/html-tags";
68 import { Icon, Spinner } from "../common/icon";
69 import { Sidebar } from "../community/sidebar";
70 import { PostListing } from "./post-listing";
71
72 const commentsShownInterval = 15;
73
74 interface PostState {
75   postRes: Option<GetPostResponse>;
76   postId: number;
77   commentTree: CommentNodeI[];
78   commentId?: number;
79   commentSort: CommentSortType;
80   commentViewType: CommentViewType;
81   scrolled?: boolean;
82   loading: boolean;
83   crossPosts: Option<PostView[]>;
84   siteRes: GetSiteResponse;
85   commentSectionRef?: RefObject<HTMLDivElement>;
86   showSidebarMobile: boolean;
87   maxCommentsShown: number;
88 }
89
90 export class Post extends Component<any, PostState> {
91   private subscription: Subscription;
92   private isoData = setIsoData(this.context, GetPostResponse);
93   private commentScrollDebounced: () => void;
94   private emptyState: PostState = {
95     postRes: None,
96     postId: getIdFromProps(this.props),
97     commentTree: [],
98     commentId: getCommentIdFromProps(this.props),
99     commentSort: CommentSortType.Hot,
100     commentViewType: CommentViewType.Tree,
101     scrolled: false,
102     loading: true,
103     crossPosts: None,
104     siteRes: this.isoData.site_res,
105     commentSectionRef: null,
106     showSidebarMobile: false,
107     maxCommentsShown: commentsShownInterval,
108   };
109
110   constructor(props: any, context: any) {
111     super(props, context);
112
113     this.state = this.emptyState;
114     this.state.commentSectionRef = createRef();
115
116     this.parseMessage = this.parseMessage.bind(this);
117     this.subscription = wsSubscribe(this.parseMessage);
118
119     // Only fetch the data if coming from another route
120     if (this.isoData.path == this.context.router.route.match.url) {
121       this.state.postRes = Some(this.isoData.routeData[0] as GetPostResponse);
122       this.state.commentTree = buildCommentsTree(
123         this.state.postRes.unwrap().comments,
124         this.state.commentSort
125       );
126       this.state.loading = false;
127
128       if (isBrowser()) {
129         WebSocketService.Instance.send(
130           wsClient.communityJoin({
131             community_id:
132               this.state.postRes.unwrap().community_view.community.id,
133           })
134         );
135         WebSocketService.Instance.send(
136           wsClient.postJoin({ post_id: this.state.postId })
137         );
138
139         this.fetchCrossPosts();
140         if (this.state.commentId) {
141           this.scrollCommentIntoView();
142         }
143
144         if (this.checkScrollIntoCommentsParam) {
145           this.scrollIntoCommentSection();
146         }
147       }
148     } else {
149       this.fetchPost();
150     }
151   }
152
153   fetchPost() {
154     let form = new GetPost({
155       id: this.state.postId,
156       auth: auth(false).ok(),
157     });
158     WebSocketService.Instance.send(wsClient.getPost(form));
159   }
160
161   fetchCrossPosts() {
162     this.state.postRes
163       .andThen(r => r.post_view.post.url)
164       .match({
165         some: url => {
166           let form = new Search({
167             q: url,
168             type_: Some(SearchType.Url),
169             sort: Some(SortType.TopAll),
170             listing_type: Some(ListingType.All),
171             page: Some(1),
172             limit: Some(trendingFetchLimit),
173             community_id: None,
174             community_name: None,
175             creator_id: None,
176             auth: auth(false).ok(),
177           });
178           WebSocketService.Instance.send(wsClient.search(form));
179         },
180         none: void 0,
181       });
182   }
183
184   static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
185     let pathSplit = req.path.split("/");
186
187     let id = Number(pathSplit[2]);
188
189     let postForm = new GetPost({
190       id,
191       auth: req.auth,
192     });
193
194     return [req.client.getPost(postForm)];
195   }
196
197   componentWillUnmount() {
198     this.subscription.unsubscribe();
199     document.removeEventListener("scroll", this.commentScrollDebounced);
200
201     saveScrollPosition(this.context);
202   }
203
204   componentDidMount() {
205     autosize(document.querySelectorAll("textarea"));
206
207     this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
208     document.addEventListener("scroll", this.commentScrollDebounced);
209   }
210
211   componentDidUpdate(_lastProps: any) {
212     // Necessary if you are on a post and you click another post (same route)
213     if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
214       // TODO Couldnt get a refresh working. This does for now.
215       location.reload();
216
217       // let currentId = this.props.match.params.id;
218       // WebSocketService.Instance.getPost(currentId);
219       // this.context.refresh();
220       // this.context.router.history.push(_lastProps.location.pathname);
221     }
222   }
223
224   scrollCommentIntoView() {
225     let commentElement = document.getElementById(
226       `comment-${this.state.commentId}`
227     );
228     if (commentElement) {
229       commentElement.scrollIntoView();
230       commentElement.classList.add("mark");
231       this.state.scrolled = true;
232       this.markScrolledAsRead(this.state.commentId);
233     }
234   }
235
236   get checkScrollIntoCommentsParam() {
237     return Boolean(
238       new URLSearchParams(this.props.location.search).get("scrollToComments")
239     );
240   }
241
242   scrollIntoCommentSection() {
243     this.state.commentSectionRef.current?.scrollIntoView();
244   }
245
246   // TODO this needs some re-work
247   markScrolledAsRead(commentId: number) {
248     this.state.postRes.match({
249       some: res => {
250         let found = res.comments.find(c => c.comment.id == commentId);
251         let parent = res.comments.find(
252           c => found.comment.parent_id.unwrapOr(0) == c.comment.id
253         );
254         let parent_person_id = parent
255           ? parent.creator.id
256           : res.post_view.creator.id;
257
258         UserService.Instance.myUserInfo.match({
259           some: mui => {
260             if (mui.local_user_view.person.id == parent_person_id) {
261               let form = new MarkCommentAsRead({
262                 comment_id: found.comment.id,
263                 read: true,
264                 auth: auth().unwrap(),
265               });
266               WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
267               UserService.Instance.unreadInboxCountSub.next(
268                 UserService.Instance.unreadInboxCountSub.value - 1
269               );
270             }
271           },
272           none: void 0,
273         });
274       },
275       none: void 0,
276     });
277   }
278
279   isBottom(el: Element): boolean {
280     return el?.getBoundingClientRect().bottom <= window.innerHeight;
281   }
282
283   /**
284    * Shows new comments when scrolling to the bottom of the comments div
285    */
286   trackCommentsBoxScrolling = () => {
287     const wrappedElement = document.getElementsByClassName("comments")[0];
288     if (wrappedElement && this.isBottom(wrappedElement)) {
289       this.state.maxCommentsShown += commentsShownInterval;
290       this.setState(this.state);
291     }
292   };
293
294   get documentTitle(): string {
295     return this.state.postRes.match({
296       some: res =>
297         this.state.siteRes.site_view.match({
298           some: siteView =>
299             `${res.post_view.post.name} - ${siteView.site.name}`,
300           none: "",
301         }),
302       none: "",
303     });
304   }
305
306   get imageTag(): Option<string> {
307     return this.state.postRes.match({
308       some: res =>
309         res.post_view.post.thumbnail_url.or(
310           res.post_view.post.url.match({
311             some: url => (isImage(url) ? Some(url) : None),
312             none: None,
313           })
314         ),
315       none: None,
316     });
317   }
318
319   get descriptionTag(): Option<string> {
320     return this.state.postRes.andThen(r => r.post_view.post.body);
321   }
322
323   render() {
324     return (
325       <div class="container">
326         {this.state.loading ? (
327           <h5>
328             <Spinner large />
329           </h5>
330         ) : (
331           this.state.postRes.match({
332             some: res => (
333               <div class="row">
334                 <div class="col-12 col-md-8 mb-3">
335                   <HtmlTags
336                     title={this.documentTitle}
337                     path={this.context.router.route.match.url}
338                     image={this.imageTag}
339                     description={this.descriptionTag}
340                   />
341                   <PostListing
342                     post_view={res.post_view}
343                     duplicates={this.state.crossPosts}
344                     showBody
345                     showCommunity
346                     moderators={Some(res.moderators)}
347                     admins={Some(this.state.siteRes.admins)}
348                     enableDownvotes={enableDownvotes(this.state.siteRes)}
349                     enableNsfw={enableNsfw(this.state.siteRes)}
350                   />
351                   <div ref={this.state.commentSectionRef} className="mb-2" />
352                   <CommentForm
353                     node={Right(this.state.postId)}
354                     disabled={res.post_view.post.locked}
355                   />
356                   <div class="d-block d-md-none">
357                     <button
358                       class="btn btn-secondary d-inline-block mb-2 mr-3"
359                       onClick={linkEvent(this, this.handleShowSidebarMobile)}
360                     >
361                       {i18n.t("sidebar")}{" "}
362                       <Icon
363                         icon={
364                           this.state.showSidebarMobile
365                             ? `minus-square`
366                             : `plus-square`
367                         }
368                         classes="icon-inline"
369                       />
370                     </button>
371                     {this.state.showSidebarMobile && this.sidebar()}
372                   </div>
373                   {res.comments.length > 0 && this.sortRadios()}
374                   {this.state.commentViewType == CommentViewType.Tree &&
375                     this.commentsTree()}
376                   {this.state.commentViewType == CommentViewType.Chat &&
377                     this.commentsFlat()}
378                 </div>
379                 <div class="d-none d-md-block col-md-4">{this.sidebar()}</div>
380               </div>
381             ),
382             none: <></>,
383           })
384         )}
385       </div>
386     );
387   }
388
389   sortRadios() {
390     return (
391       <>
392         <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
393           <label
394             className={`btn btn-outline-secondary pointer ${
395               this.state.commentSort === CommentSortType.Hot && "active"
396             }`}
397           >
398             {i18n.t("hot")}
399             <input
400               type="radio"
401               value={CommentSortType.Hot}
402               checked={this.state.commentSort === CommentSortType.Hot}
403               onChange={linkEvent(this, this.handleCommentSortChange)}
404             />
405           </label>
406           <label
407             className={`btn btn-outline-secondary pointer ${
408               this.state.commentSort === CommentSortType.Top && "active"
409             }`}
410           >
411             {i18n.t("top")}
412             <input
413               type="radio"
414               value={CommentSortType.Top}
415               checked={this.state.commentSort === CommentSortType.Top}
416               onChange={linkEvent(this, this.handleCommentSortChange)}
417             />
418           </label>
419           <label
420             className={`btn btn-outline-secondary pointer ${
421               this.state.commentSort === CommentSortType.New && "active"
422             }`}
423           >
424             {i18n.t("new")}
425             <input
426               type="radio"
427               value={CommentSortType.New}
428               checked={this.state.commentSort === CommentSortType.New}
429               onChange={linkEvent(this, this.handleCommentSortChange)}
430             />
431           </label>
432           <label
433             className={`btn btn-outline-secondary pointer ${
434               this.state.commentSort === CommentSortType.Old && "active"
435             }`}
436           >
437             {i18n.t("old")}
438             <input
439               type="radio"
440               value={CommentSortType.Old}
441               checked={this.state.commentSort === CommentSortType.Old}
442               onChange={linkEvent(this, this.handleCommentSortChange)}
443             />
444           </label>
445         </div>
446         <div class="btn-group btn-group-toggle flex-wrap mb-2">
447           <label
448             className={`btn btn-outline-secondary pointer ${
449               this.state.commentViewType === CommentViewType.Chat && "active"
450             }`}
451           >
452             {i18n.t("chat")}
453             <input
454               type="radio"
455               value={CommentViewType.Chat}
456               checked={this.state.commentViewType === CommentViewType.Chat}
457               onChange={linkEvent(this, this.handleCommentViewTypeChange)}
458             />
459           </label>
460         </div>
461       </>
462     );
463   }
464
465   commentsFlat() {
466     // These are already sorted by new
467     return this.state.postRes.match({
468       some: res => (
469         <div>
470           <CommentNodes
471             nodes={commentsToFlatNodes(res.comments)}
472             maxCommentsShown={Some(this.state.maxCommentsShown)}
473             noIndent
474             locked={res.post_view.post.locked}
475             moderators={Some(res.moderators)}
476             admins={Some(this.state.siteRes.admins)}
477             enableDownvotes={enableDownvotes(this.state.siteRes)}
478             showContext
479           />
480         </div>
481       ),
482       none: <></>,
483     });
484   }
485
486   sidebar() {
487     return this.state.postRes.match({
488       some: res => (
489         <div class="mb-3">
490           <Sidebar
491             community_view={res.community_view}
492             moderators={res.moderators}
493             admins={this.state.siteRes.admins}
494             online={res.online}
495             enableNsfw={enableNsfw(this.state.siteRes)}
496             showIcon
497           />
498         </div>
499       ),
500       none: <></>,
501     });
502   }
503
504   handleCommentSortChange(i: Post, event: any) {
505     i.state.commentSort = Number(event.target.value);
506     i.state.commentViewType = CommentViewType.Tree;
507     i.state.commentTree = buildCommentsTree(
508       i.state.postRes.map(r => r.comments).unwrapOr([]),
509       i.state.commentSort
510     );
511     i.setState(i.state);
512   }
513
514   handleCommentViewTypeChange(i: Post, event: any) {
515     i.state.commentViewType = Number(event.target.value);
516     i.state.commentSort = CommentSortType.New;
517     i.state.commentTree = buildCommentsTree(
518       i.state.postRes.map(r => r.comments).unwrapOr([]),
519       i.state.commentSort
520     );
521     i.setState(i.state);
522   }
523
524   handleShowSidebarMobile(i: Post) {
525     i.state.showSidebarMobile = !i.state.showSidebarMobile;
526     i.setState(i.state);
527   }
528
529   commentsTree() {
530     return this.state.postRes.match({
531       some: res => (
532         <div>
533           <CommentNodes
534             nodes={this.state.commentTree}
535             maxCommentsShown={Some(this.state.maxCommentsShown)}
536             locked={res.post_view.post.locked}
537             moderators={Some(res.moderators)}
538             admins={Some(this.state.siteRes.admins)}
539             enableDownvotes={enableDownvotes(this.state.siteRes)}
540           />
541         </div>
542       ),
543       none: <></>,
544     });
545   }
546
547   parseMessage(msg: any) {
548     let op = wsUserOp(msg);
549     console.log(msg);
550     if (msg.error) {
551       toast(i18n.t(msg.error), "danger");
552       return;
553     } else if (msg.reconnect) {
554       let postId = Number(this.props.match.params.id);
555       WebSocketService.Instance.send(wsClient.postJoin({ post_id: postId }));
556       WebSocketService.Instance.send(
557         wsClient.getPost({
558           id: postId,
559           auth: auth(false).ok(),
560         })
561       );
562     } else if (op == UserOperation.GetPost) {
563       let data = wsJsonToRes<GetPostResponse>(msg, GetPostResponse);
564       this.state.postRes = Some(data);
565
566       this.state.commentTree = buildCommentsTree(
567         this.state.postRes.map(r => r.comments).unwrapOr([]),
568         this.state.commentSort
569       );
570       this.state.loading = false;
571
572       // join the rooms
573       WebSocketService.Instance.send(
574         wsClient.postJoin({ post_id: this.state.postId })
575       );
576       WebSocketService.Instance.send(
577         wsClient.communityJoin({
578           community_id: data.community_view.community.id,
579         })
580       );
581
582       // Get cross-posts
583       this.fetchCrossPosts();
584       this.setState(this.state);
585       setupTippy();
586       if (!this.state.commentId) restoreScrollPosition(this.context);
587
588       if (this.checkScrollIntoCommentsParam) {
589         this.scrollIntoCommentSection();
590       }
591
592       if (this.state.commentId && !this.state.scrolled) {
593         this.scrollCommentIntoView();
594       }
595     } else if (op == UserOperation.CreateComment) {
596       let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
597
598       // Don't get comments from the post room, if the creator is blocked
599       let creatorBlocked = UserService.Instance.myUserInfo
600         .map(m => m.person_blocks)
601         .unwrapOr([])
602         .map(pb => pb.target.id)
603         .includes(data.comment_view.creator.id);
604
605       // Necessary since it might be a user reply, which has the recipients, to avoid double
606       if (data.recipient_ids.length == 0 && !creatorBlocked) {
607         this.state.postRes.match({
608           some: res => {
609             res.comments.unshift(data.comment_view);
610             insertCommentIntoTree(this.state.commentTree, data.comment_view);
611             res.post_view.counts.comments++;
612           },
613           none: void 0,
614         });
615         this.setState(this.state);
616         setupTippy();
617       }
618     } else if (
619       op == UserOperation.EditComment ||
620       op == UserOperation.DeleteComment ||
621       op == UserOperation.RemoveComment
622     ) {
623       let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
624       editCommentRes(
625         data.comment_view,
626         this.state.postRes.map(r => r.comments).unwrapOr([])
627       );
628       this.setState(this.state);
629     } else if (op == UserOperation.SaveComment) {
630       let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
631       saveCommentRes(
632         data.comment_view,
633         this.state.postRes.map(r => r.comments).unwrapOr([])
634       );
635       this.setState(this.state);
636       setupTippy();
637     } else if (op == UserOperation.CreateCommentLike) {
638       let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
639       createCommentLikeRes(
640         data.comment_view,
641         this.state.postRes.map(r => r.comments).unwrapOr([])
642       );
643       this.setState(this.state);
644     } else if (op == UserOperation.CreatePostLike) {
645       let data = wsJsonToRes<PostResponse>(msg, PostResponse);
646       this.state.postRes.match({
647         some: res => createPostLikeRes(data.post_view, res.post_view),
648         none: void 0,
649       });
650       this.setState(this.state);
651     } else if (
652       op == UserOperation.EditPost ||
653       op == UserOperation.DeletePost ||
654       op == UserOperation.RemovePost ||
655       op == UserOperation.LockPost ||
656       op == UserOperation.StickyPost ||
657       op == UserOperation.SavePost
658     ) {
659       let data = wsJsonToRes<PostResponse>(msg, PostResponse);
660       this.state.postRes.match({
661         some: res => (res.post_view = data.post_view),
662         none: void 0,
663       });
664       this.setState(this.state);
665       setupTippy();
666     } else if (
667       op == UserOperation.EditCommunity ||
668       op == UserOperation.DeleteCommunity ||
669       op == UserOperation.RemoveCommunity ||
670       op == UserOperation.FollowCommunity
671     ) {
672       let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse);
673       this.state.postRes.match({
674         some: res => {
675           res.community_view = data.community_view;
676           res.post_view.community = data.community_view.community;
677           this.setState(this.state);
678         },
679         none: void 0,
680       });
681     } else if (op == UserOperation.BanFromCommunity) {
682       let data = wsJsonToRes<BanFromCommunityResponse>(
683         msg,
684         BanFromCommunityResponse
685       );
686       this.state.postRes.match({
687         some: res => {
688           res.comments
689             .filter(c => c.creator.id == data.person_view.person.id)
690             .forEach(c => (c.creator_banned_from_community = data.banned));
691           if (res.post_view.creator.id == data.person_view.person.id) {
692             res.post_view.creator_banned_from_community = data.banned;
693           }
694           this.setState(this.state);
695         },
696         none: void 0,
697       });
698     } else if (op == UserOperation.AddModToCommunity) {
699       let data = wsJsonToRes<AddModToCommunityResponse>(
700         msg,
701         AddModToCommunityResponse
702       );
703       this.state.postRes.match({
704         some: res => {
705           res.moderators = data.moderators;
706           this.setState(this.state);
707         },
708         none: void 0,
709       });
710     } else if (op == UserOperation.BanPerson) {
711       let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
712       this.state.postRes.match({
713         some: res => {
714           res.comments
715             .filter(c => c.creator.id == data.person_view.person.id)
716             .forEach(c => (c.creator.banned = data.banned));
717           if (res.post_view.creator.id == data.person_view.person.id) {
718             res.post_view.creator.banned = data.banned;
719           }
720           this.setState(this.state);
721         },
722         none: void 0,
723       });
724     } else if (op == UserOperation.AddAdmin) {
725       let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
726       this.state.siteRes.admins = data.admins;
727       this.setState(this.state);
728     } else if (op == UserOperation.Search) {
729       let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
730       let xPosts = data.posts.filter(
731         p => p.post.id != Number(this.props.match.params.id)
732       );
733       this.state.crossPosts = xPosts.length > 0 ? Some(xPosts) : None;
734       this.setState(this.state);
735     } else if (op == UserOperation.LeaveAdmin) {
736       let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse);
737       this.state.siteRes = data;
738       this.setState(this.state);
739     } else if (op == UserOperation.TransferCommunity) {
740       let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse);
741       this.state.postRes.match({
742         some: res => {
743           res.community_view = data.community_view;
744           res.post_view.community = data.community_view.community;
745           res.moderators = data.moderators;
746           this.setState(this.state);
747         },
748         none: void 0,
749       });
750     } else if (op == UserOperation.BlockPerson) {
751       let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
752       updatePersonBlock(data);
753     } else if (op == UserOperation.CreatePostReport) {
754       let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse);
755       if (data) {
756         toast(i18n.t("report_created"));
757       }
758     } else if (op == UserOperation.CreateCommentReport) {
759       let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse);
760       if (data) {
761         toast(i18n.t("report_created"));
762       }
763     }
764   }
765 }