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