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