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