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