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