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