]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post.tsx
Adding post comment scrolling hack. Fixes #480 (#486)
[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   previewLines,
50   restoreScrollPosition,
51   saveCommentRes,
52   saveScrollPosition,
53   setIsoData,
54   setOptionalAuth,
55   setupTippy,
56   toast,
57   updatePersonBlock,
58   wsClient,
59   wsJsonToRes,
60   wsSubscribe,
61   wsUserOp,
62 } from "../../utils";
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";
69
70 const commentsShownInterval = 15;
71
72 interface PostState {
73   postRes: GetPostResponse;
74   postId: number;
75   commentTree: CommentNodeI[];
76   commentId?: number;
77   commentSort: CommentSortType;
78   commentViewType: CommentViewType;
79   scrolled?: boolean;
80   loading: boolean;
81   crossPosts: PostView[];
82   siteRes: GetSiteResponse;
83   commentSectionRef?: RefObject<HTMLDivElement>;
84   showSidebarMobile: boolean;
85   maxCommentsShown: number;
86 }
87
88 export class Post extends Component<any, PostState> {
89   private subscription: Subscription;
90   private isoData = setIsoData(this.context);
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.trackCommentsBoxScrolling);
182     window.isoData.path = undefined;
183     saveScrollPosition(this.context);
184   }
185
186   componentDidMount() {
187     WebSocketService.Instance.send(
188       wsClient.postJoin({ post_id: this.state.postId })
189     );
190     autosize(document.querySelectorAll("textarea"));
191
192     document.addEventListener(
193       "scroll",
194       debounce(this.trackCommentsBoxScrolling, 100)
195     );
196   }
197
198   componentDidUpdate(_lastProps: any, lastState: PostState) {
199     if (
200       this.state.commentId &&
201       !this.state.scrolled &&
202       lastState.postRes &&
203       lastState.postRes.comments.length > 0
204     ) {
205       this.scrollCommentIntoView();
206     }
207
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.
211       location.reload();
212
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);
217     }
218   }
219
220   scrollCommentIntoView() {
221     var elmnt = document.getElementById(`comment-${this.state.commentId}`);
222     elmnt.scrollIntoView();
223     elmnt.classList.add("mark");
224     this.state.scrolled = true;
225     this.markScrolledAsRead(this.state.commentId);
226   }
227
228   get checkScrollIntoCommentsParam() {
229     return Boolean(
230       new URLSearchParams(this.props.location.search).get("scrollToComments")
231     );
232   }
233
234   scrollIntoCommentSection() {
235     this.state.commentSectionRef.current?.scrollIntoView();
236   }
237
238   // TODO this needs some re-work
239   markScrolledAsRead(commentId: number) {
240     let found = this.state.postRes.comments.find(
241       c => c.comment.id == commentId
242     );
243     let parent = this.state.postRes.comments.find(
244       c => found.comment.parent_id == c.comment.id
245     );
246     let parent_person_id = parent
247       ? parent.creator.id
248       : this.state.postRes.post_view.creator.id;
249
250     if (
251       UserService.Instance.myUserInfo &&
252       UserService.Instance.myUserInfo.local_user_view.person.id ==
253         parent_person_id
254     ) {
255       let form: MarkCommentAsRead = {
256         comment_id: found.comment.id,
257         read: true,
258         auth: authField(),
259       };
260       WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
261       UserService.Instance.unreadInboxCountSub.next(
262         UserService.Instance.unreadInboxCountSub.value - 1
263       );
264     }
265   }
266
267   isBottom(el: Element) {
268     return el.getBoundingClientRect().bottom <= window.innerHeight;
269   }
270
271   /**
272    * Shows new comments when scrolling to the bottom of the comments div
273    */
274   trackCommentsBoxScrolling = () => {
275     const wrappedElement = document.getElementsByClassName("comments")[0];
276     if (this.isBottom(wrappedElement)) {
277       this.state.maxCommentsShown += commentsShownInterval;
278       this.setState(this.state);
279     }
280   };
281
282   get documentTitle(): string {
283     return `${this.state.postRes.post_view.post.name} - ${this.state.siteRes.site_view.site.name}`;
284   }
285
286   get imageTag(): string {
287     let post = this.state.postRes.post_view.post;
288     return (
289       post.thumbnail_url ||
290       (post.url ? (isImage(post.url) ? post.url : undefined) : undefined)
291     );
292   }
293
294   get descriptionTag(): string {
295     let body = this.state.postRes.post_view.post.body;
296     return body ? previewLines(body) : undefined;
297   }
298
299   render() {
300     let pv = this.state.postRes?.post_view;
301     return (
302       <div class="container">
303         {this.state.loading ? (
304           <h5>
305             <Spinner large />
306           </h5>
307         ) : (
308           <div class="row">
309             <div class="col-12 col-md-8 mb-3">
310               <HtmlTags
311                 title={this.documentTitle}
312                 path={this.context.router.route.match.url}
313                 image={this.imageTag}
314                 description={this.descriptionTag}
315               />
316               <PostListing
317                 post_view={pv}
318                 duplicates={this.state.crossPosts}
319                 showBody
320                 showCommunity
321                 moderators={this.state.postRes.moderators}
322                 admins={this.state.siteRes.admins}
323                 enableDownvotes={
324                   this.state.siteRes.site_view.site.enable_downvotes
325                 }
326                 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
327               />
328               <div ref={this.state.commentSectionRef} className="mb-2" />
329               <CommentForm
330                 postId={this.state.postId}
331                 disabled={pv.post.locked}
332               />
333               <div class="d-block d-md-none">
334                 <button
335                   class="btn btn-secondary d-inline-block mb-2 mr-3"
336                   onClick={linkEvent(this, this.handleShowSidebarMobile)}
337                 >
338                   {i18n.t("sidebar")}{" "}
339                   <Icon
340                     icon={
341                       this.state.showSidebarMobile
342                         ? `minus-square`
343                         : `plus-square`
344                     }
345                     classes="icon-inline"
346                   />
347                 </button>
348                 {this.state.showSidebarMobile && this.sidebar()}
349               </div>
350               {this.state.postRes.comments.length > 0 && this.sortRadios()}
351               {this.state.commentViewType == CommentViewType.Tree &&
352                 this.commentsTree()}
353               {this.state.commentViewType == CommentViewType.Chat &&
354                 this.commentsFlat()}
355             </div>
356             <div class="d-none d-md-block col-md-4">{this.sidebar()}</div>
357           </div>
358         )}
359       </div>
360     );
361   }
362
363   sortRadios() {
364     return (
365       <>
366         <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
367           <label
368             className={`btn btn-outline-secondary pointer ${
369               this.state.commentSort === CommentSortType.Hot && "active"
370             }`}
371           >
372             {i18n.t("hot")}
373             <input
374               type="radio"
375               value={CommentSortType.Hot}
376               checked={this.state.commentSort === CommentSortType.Hot}
377               onChange={linkEvent(this, this.handleCommentSortChange)}
378             />
379           </label>
380           <label
381             className={`btn btn-outline-secondary pointer ${
382               this.state.commentSort === CommentSortType.Top && "active"
383             }`}
384           >
385             {i18n.t("top")}
386             <input
387               type="radio"
388               value={CommentSortType.Top}
389               checked={this.state.commentSort === CommentSortType.Top}
390               onChange={linkEvent(this, this.handleCommentSortChange)}
391             />
392           </label>
393           <label
394             className={`btn btn-outline-secondary pointer ${
395               this.state.commentSort === CommentSortType.New && "active"
396             }`}
397           >
398             {i18n.t("new")}
399             <input
400               type="radio"
401               value={CommentSortType.New}
402               checked={this.state.commentSort === CommentSortType.New}
403               onChange={linkEvent(this, this.handleCommentSortChange)}
404             />
405           </label>
406           <label
407             className={`btn btn-outline-secondary pointer ${
408               this.state.commentSort === CommentSortType.Old && "active"
409             }`}
410           >
411             {i18n.t("old")}
412             <input
413               type="radio"
414               value={CommentSortType.Old}
415               checked={this.state.commentSort === CommentSortType.Old}
416               onChange={linkEvent(this, this.handleCommentSortChange)}
417             />
418           </label>
419         </div>
420         <div class="btn-group btn-group-toggle flex-wrap mb-2">
421           <label
422             className={`btn btn-outline-secondary pointer ${
423               this.state.commentViewType === CommentViewType.Chat && "active"
424             }`}
425           >
426             {i18n.t("chat")}
427             <input
428               type="radio"
429               value={CommentViewType.Chat}
430               checked={this.state.commentViewType === CommentViewType.Chat}
431               onChange={linkEvent(this, this.handleCommentViewTypeChange)}
432             />
433           </label>
434         </div>
435       </>
436     );
437   }
438
439   commentsFlat() {
440     // These are already sorted by new
441     return (
442       <div>
443         <CommentNodes
444           nodes={commentsToFlatNodes(this.state.postRes.comments)}
445           maxCommentsShown={this.state.maxCommentsShown}
446           noIndent
447           locked={this.state.postRes.post_view.post.locked}
448           moderators={this.state.postRes.moderators}
449           admins={this.state.siteRes.admins}
450           postCreatorId={this.state.postRes.post_view.creator.id}
451           showContext
452           enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes}
453         />
454       </div>
455     );
456   }
457
458   sidebar() {
459     return (
460       <div class="mb-3">
461         <Sidebar
462           community_view={this.state.postRes.community_view}
463           moderators={this.state.postRes.moderators}
464           admins={this.state.siteRes.admins}
465           online={this.state.postRes.online}
466           enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
467           showIcon
468         />
469       </div>
470     );
471   }
472
473   handleCommentSortChange(i: Post, event: any) {
474     i.state.commentSort = Number(event.target.value);
475     i.state.commentViewType = CommentViewType.Tree;
476     i.state.commentTree = buildCommentsTree(
477       i.state.postRes.comments,
478       i.state.commentSort
479     );
480     i.setState(i.state);
481   }
482
483   handleCommentViewTypeChange(i: Post, event: any) {
484     i.state.commentViewType = Number(event.target.value);
485     i.state.commentSort = CommentSortType.New;
486     i.state.commentTree = buildCommentsTree(
487       i.state.postRes.comments,
488       i.state.commentSort
489     );
490     i.setState(i.state);
491   }
492
493   handleShowSidebarMobile(i: Post) {
494     i.state.showSidebarMobile = !i.state.showSidebarMobile;
495     i.setState(i.state);
496   }
497
498   commentsTree() {
499     return (
500       <div>
501         <CommentNodes
502           nodes={this.state.commentTree}
503           maxCommentsShown={this.state.maxCommentsShown}
504           locked={this.state.postRes.post_view.post.locked}
505           moderators={this.state.postRes.moderators}
506           admins={this.state.siteRes.admins}
507           postCreatorId={this.state.postRes.post_view.creator.id}
508           enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes}
509         />
510       </div>
511     );
512   }
513
514   parseMessage(msg: any) {
515     let op = wsUserOp(msg);
516     console.log(msg);
517     if (msg.error) {
518       toast(i18n.t(msg.error), "danger");
519       return;
520     } else if (msg.reconnect) {
521       let postId = Number(this.props.match.params.id);
522       WebSocketService.Instance.send(wsClient.postJoin({ post_id: postId }));
523       WebSocketService.Instance.send(
524         wsClient.getPost({
525           id: postId,
526           auth: authField(false),
527         })
528       );
529     } else if (op == UserOperation.GetPost) {
530       let data = wsJsonToRes<GetPostResponse>(msg).data;
531       this.state.postRes = data;
532       this.state.commentTree = buildCommentsTree(
533         this.state.postRes.comments,
534         this.state.commentSort
535       );
536       this.state.loading = false;
537
538       // Get cross-posts
539       this.fetchCrossPosts();
540       this.setState(this.state);
541       setupTippy();
542       if (!this.state.commentId) restoreScrollPosition(this.context);
543
544       if (this.checkScrollIntoCommentsParam) {
545         this.scrollIntoCommentSection();
546       }
547     } else if (op == UserOperation.CreateComment) {
548       let data = wsJsonToRes<CommentResponse>(msg).data;
549
550       // Necessary since it might be a user reply, which has the recipients, to avoid double
551       if (data.recipient_ids.length == 0) {
552         this.state.postRes.comments.unshift(data.comment_view);
553         insertCommentIntoTree(this.state.commentTree, data.comment_view);
554         this.state.postRes.post_view.counts.comments++;
555         this.setState(this.state);
556         setupTippy();
557       }
558     } else if (
559       op == UserOperation.EditComment ||
560       op == UserOperation.DeleteComment ||
561       op == UserOperation.RemoveComment
562     ) {
563       let data = wsJsonToRes<CommentResponse>(msg).data;
564       editCommentRes(data.comment_view, this.state.postRes.comments);
565       this.setState(this.state);
566     } else if (op == UserOperation.SaveComment) {
567       let data = wsJsonToRes<CommentResponse>(msg).data;
568       saveCommentRes(data.comment_view, this.state.postRes.comments);
569       this.setState(this.state);
570       setupTippy();
571     } else if (op == UserOperation.CreateCommentLike) {
572       let data = wsJsonToRes<CommentResponse>(msg).data;
573       createCommentLikeRes(data.comment_view, this.state.postRes.comments);
574       this.setState(this.state);
575     } else if (op == UserOperation.CreatePostLike) {
576       let data = wsJsonToRes<PostResponse>(msg).data;
577       createPostLikeRes(data.post_view, this.state.postRes.post_view);
578       this.setState(this.state);
579     } else if (
580       op == UserOperation.EditPost ||
581       op == UserOperation.DeletePost ||
582       op == UserOperation.RemovePost ||
583       op == UserOperation.LockPost ||
584       op == UserOperation.StickyPost ||
585       op == UserOperation.SavePost
586     ) {
587       let data = wsJsonToRes<PostResponse>(msg).data;
588       this.state.postRes.post_view = data.post_view;
589       this.setState(this.state);
590       setupTippy();
591     } else if (
592       op == UserOperation.EditCommunity ||
593       op == UserOperation.DeleteCommunity ||
594       op == UserOperation.RemoveCommunity ||
595       op == UserOperation.FollowCommunity
596     ) {
597       let data = wsJsonToRes<CommunityResponse>(msg).data;
598       this.state.postRes.community_view = data.community_view;
599       this.state.postRes.post_view.community = data.community_view.community;
600       this.setState(this.state);
601       this.setState(this.state);
602     } else if (op == UserOperation.BanFromCommunity) {
603       let data = wsJsonToRes<BanFromCommunityResponse>(msg).data;
604       this.state.postRes.comments
605         .filter(c => c.creator.id == data.person_view.person.id)
606         .forEach(c => (c.creator_banned_from_community = data.banned));
607       if (
608         this.state.postRes.post_view.creator.id == data.person_view.person.id
609       ) {
610         this.state.postRes.post_view.creator_banned_from_community =
611           data.banned;
612       }
613       this.setState(this.state);
614     } else if (op == UserOperation.AddModToCommunity) {
615       let data = wsJsonToRes<AddModToCommunityResponse>(msg).data;
616       this.state.postRes.moderators = data.moderators;
617       this.setState(this.state);
618     } else if (op == UserOperation.BanPerson) {
619       let data = wsJsonToRes<BanPersonResponse>(msg).data;
620       this.state.postRes.comments
621         .filter(c => c.creator.id == data.person_view.person.id)
622         .forEach(c => (c.creator.banned = data.banned));
623       if (
624         this.state.postRes.post_view.creator.id == data.person_view.person.id
625       ) {
626         this.state.postRes.post_view.creator.banned = data.banned;
627       }
628       this.setState(this.state);
629     } else if (op == UserOperation.AddAdmin) {
630       let data = wsJsonToRes<AddAdminResponse>(msg).data;
631       this.state.siteRes.admins = data.admins;
632       this.setState(this.state);
633     } else if (op == UserOperation.Search) {
634       let data = wsJsonToRes<SearchResponse>(msg).data;
635       this.state.crossPosts = data.posts.filter(
636         p => p.post.id != Number(this.props.match.params.id)
637       );
638       this.setState(this.state);
639     } else if (op == UserOperation.TransferSite) {
640       let data = wsJsonToRes<GetSiteResponse>(msg).data;
641       this.state.siteRes = data;
642       this.setState(this.state);
643     } else if (op == UserOperation.TransferCommunity) {
644       let data = wsJsonToRes<GetCommunityResponse>(msg).data;
645       this.state.postRes.community_view = data.community_view;
646       this.state.postRes.post_view.community = data.community_view.community;
647       this.state.postRes.moderators = data.moderators;
648       this.setState(this.state);
649     } else if (op == UserOperation.BlockPerson) {
650       let data = wsJsonToRes<BlockPersonResponse>(msg).data;
651       updatePersonBlock(data);
652     } else if (op == UserOperation.CreatePostReport) {
653       let data = wsJsonToRes<PostReportResponse>(msg).data;
654       if (data) {
655         toast(i18n.t("report_created"));
656       }
657     } else if (op == UserOperation.CreateCommentReport) {
658       let data = wsJsonToRes<CommentReportResponse>(msg).data;
659       if (data) {
660         toast(i18n.t("report_created"));
661       }
662     }
663   }
664 }