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