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