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