]> Untitled Git - lemmy.git/blob - ui/src/components/post.tsx
Merge branch 'dev' into richardj-feature/frontend-a11y
[lemmy.git] / ui / src / components / post.tsx
1 import { Component, linkEvent } from 'inferno';
2 import { Subscription } from 'rxjs';
3 import { retryWhen, delay, take } from 'rxjs/operators';
4 import {
5   UserOperation,
6   Community,
7   Post as PostI,
8   GetPostResponse,
9   PostResponse,
10   Comment,
11   CommentForm as CommentFormI,
12   CommentResponse,
13   CommentSortType,
14   CreatePostLikeResponse,
15   CommunityUser,
16   CommunityResponse,
17   CommentNode as CommentNodeI,
18   BanFromCommunityResponse,
19   BanUserResponse,
20   AddModToCommunityResponse,
21   AddAdminResponse,
22   UserView,
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 { wsJsonToRes, hotRank, toast } from '../utils';
34 import { PostListing } from './post-listing';
35 import { PostListings } from './post-listings';
36 import { Sidebar } from './sidebar';
37 import { CommentForm } from './comment-form';
38 import { CommentNodes } from './comment-nodes';
39 import autosize from 'autosize';
40 import { i18n } from '../i18next';
41 import { T } from 'inferno-i18next';
42
43 interface PostState {
44   post: PostI;
45   comments: Array<Comment>;
46   commentSort: CommentSortType;
47   community: Community;
48   moderators: Array<CommunityUser>;
49   admins: Array<UserView>;
50   scrolled?: boolean;
51   scrolled_comment_id?: number;
52   loading: boolean;
53   crossPosts: Array<PostI>;
54 }
55
56 export class Post extends Component<any, PostState> {
57   private subscription: Subscription;
58   private emptyState: PostState = {
59     post: null,
60     comments: [],
61     commentSort: CommentSortType.Hot,
62     community: null,
63     moderators: [],
64     admins: [],
65     scrolled: false,
66     loading: true,
67     crossPosts: [],
68   };
69
70   constructor(props: any, context: any) {
71     super(props, context);
72
73     this.state = this.emptyState;
74
75     let postId = Number(this.props.match.params.id);
76     if (this.props.match.params.comment_id) {
77       this.state.scrolled_comment_id = this.props.match.params.comment_id;
78     }
79
80     this.subscription = WebSocketService.Instance.subject
81       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
82       .subscribe(
83         msg => this.parseMessage(msg),
84         err => console.error(err),
85         () => console.log('complete')
86       );
87
88     let form: GetPostForm = {
89       id: postId,
90     };
91     WebSocketService.Instance.getPost(form);
92   }
93
94   componentWillUnmount() {
95     this.subscription.unsubscribe();
96   }
97
98   componentDidMount() {
99     autosize(document.querySelectorAll('textarea'));
100   }
101
102   componentDidUpdate(_lastProps: any, lastState: PostState, _snapshot: any) {
103     if (
104       this.state.scrolled_comment_id &&
105       !this.state.scrolled &&
106       lastState.comments.length > 0
107     ) {
108       var elmnt = document.getElementById(
109         `comment-${this.state.scrolled_comment_id}`
110       );
111       elmnt.scrollIntoView();
112       elmnt.classList.add('mark');
113       this.state.scrolled = true;
114       this.markScrolledAsRead(this.state.scrolled_comment_id);
115     }
116
117     // Necessary if you are on a post and you click another post (same route)
118     if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
119       // Couldnt get a refresh working. This does for now.
120       location.reload();
121
122       // let currentId = this.props.match.params.id;
123       // WebSocketService.Instance.getPost(currentId);
124       // this.context.router.history.push('/sponsors');
125       // this.context.refresh();
126       // this.context.router.history.push(_lastProps.location.pathname);
127     }
128   }
129
130   markScrolledAsRead(commentId: number) {
131     let found = this.state.comments.find(c => c.id == commentId);
132     let parent = this.state.comments.find(c => found.parent_id == c.id);
133     let parent_user_id = parent
134       ? parent.creator_id
135       : this.state.post.creator_id;
136
137     if (
138       UserService.Instance.user &&
139       UserService.Instance.user.id == parent_user_id
140     ) {
141       let form: CommentFormI = {
142         content: found.content,
143         edit_id: found.id,
144         creator_id: found.creator_id,
145         post_id: found.post_id,
146         parent_id: found.parent_id,
147         read: true,
148         auth: null,
149       };
150       WebSocketService.Instance.editComment(form);
151     }
152   }
153
154   render() {
155     return (
156       <div class="container">
157         {this.state.loading ? (
158           <p class="text-center">
159             <svg class="icon icon-spinner spin">
160               <use xlinkHref="#icon-spinner"></use>
161             </svg>
162           </p>
163         ) : (
164           <div class="row">
165             <div class="col-12 col-md-8 mb-3">
166               <PostListing
167                 post={this.state.post}
168                 showBody
169                 showCommunity
170                 moderators={this.state.moderators}
171                 admins={this.state.admins}
172               />
173               {this.state.crossPosts.length > 0 && (
174                 <>
175                   <div class="my-1 text-muted small font-weight-bold">
176                     { i18n.t('cross_posts') }
177                   </div>
178                   <PostListings showCommunity posts={this.state.crossPosts} />
179                 </>
180               )}
181               <div className="mb-2" />
182               <CommentForm
183                 postId={this.state.post.id}
184                 disabled={this.state.post.locked}
185               />
186               {this.state.comments.length > 0 && this.sortRadios()}
187               {this.commentsTree()}
188             </div>
189             <div class="col-12 col-sm-12 col-md-4">
190               {this.state.comments.length > 0 && this.newComments()}
191               {this.sidebar()}
192             </div>
193           </div>
194         )}
195       </div>
196     );
197   }
198
199   sortRadios() {
200     return (
201       <div class="btn-group btn-group-toggle mb-3">
202         <label
203           className={`btn btn-sm btn-secondary pointer ${this.state
204             .commentSort === CommentSortType.Hot && 'active'}`}
205         >
206           {i18n.t('hot')}
207           <input
208             type="radio"
209             value={CommentSortType.Hot}
210             checked={this.state.commentSort === CommentSortType.Hot}
211             onChange={linkEvent(this, this.handleCommentSortChange)}
212           />
213         </label>
214         <label
215           className={`btn btn-sm btn-secondary pointer ${this.state
216             .commentSort === CommentSortType.Top && 'active'}`}
217         >
218           {i18n.t('top')}
219           <input
220             type="radio"
221             value={CommentSortType.Top}
222             checked={this.state.commentSort === CommentSortType.Top}
223             onChange={linkEvent(this, this.handleCommentSortChange)}
224           />
225         </label>
226         <label
227           className={`btn btn-sm btn-secondary pointer ${this.state
228             .commentSort === CommentSortType.New && 'active'}`}
229         >
230           {i18n.t('new')}
231           <input
232             type="radio"
233             value={CommentSortType.New}
234             checked={this.state.commentSort === CommentSortType.New}
235             onChange={linkEvent(this, this.handleCommentSortChange)}
236           />
237         </label>
238       </div>
239     );
240   }
241
242   newComments() {
243     return (
244       <div class="d-none d-md-block new-comments mb-3 card border-secondary">
245         <div class="card-body small">
246           <h6>
247             { i18n.t('recent_comments') }
248           </h6>
249           {this.state.comments.map(comment => (
250             <CommentNodes
251               nodes={[{ comment: comment }]}
252               noIndent
253               locked={this.state.post.locked}
254               moderators={this.state.moderators}
255               admins={this.state.admins}
256               postCreatorId={this.state.post.creator_id}
257             />
258           ))}
259         </div>
260       </div>
261     );
262   }
263
264   sidebar() {
265     return (
266       <div class="mb-3">
267         <Sidebar
268           community={this.state.community}
269           moderators={this.state.moderators}
270           admins={this.state.admins}
271         />
272       </div>
273     );
274   }
275
276   handleCommentSortChange(i: Post, event: any) {
277     i.state.commentSort = Number(event.target.value);
278     i.setState(i.state);
279   }
280
281   private buildCommentsTree(): Array<CommentNodeI> {
282     let map = new Map<number, CommentNodeI>();
283     for (let comment of this.state.comments) {
284       let node: CommentNodeI = {
285         comment: comment,
286         children: [],
287       };
288       map.set(comment.id, { ...node });
289     }
290     let tree: Array<CommentNodeI> = [];
291     for (let comment of this.state.comments) {
292       if (comment.parent_id) {
293         map.get(comment.parent_id).children.push(map.get(comment.id));
294       } else {
295         tree.push(map.get(comment.id));
296       }
297     }
298
299     this.sortTree(tree);
300
301     return tree;
302   }
303
304   sortTree(tree: Array<CommentNodeI>) {
305     // First, put removed and deleted comments at the bottom, then do your other sorts
306     if (this.state.commentSort == CommentSortType.Top) {
307       tree.sort(
308         (a, b) =>
309           +a.comment.removed - +b.comment.removed ||
310           +a.comment.deleted - +b.comment.deleted ||
311           b.comment.score - a.comment.score
312       );
313     } else if (this.state.commentSort == CommentSortType.New) {
314       tree.sort(
315         (a, b) =>
316           +a.comment.removed - +b.comment.removed ||
317           +a.comment.deleted - +b.comment.deleted ||
318           b.comment.published.localeCompare(a.comment.published)
319       );
320     } else if (this.state.commentSort == CommentSortType.Hot) {
321       tree.sort(
322         (a, b) =>
323           +a.comment.removed - +b.comment.removed ||
324           +a.comment.deleted - +b.comment.deleted ||
325           hotRank(b.comment) - hotRank(a.comment)
326       );
327     }
328
329     for (let node of tree) {
330       this.sortTree(node.children);
331     }
332   }
333
334   commentsTree() {
335     let nodes = this.buildCommentsTree();
336     return (
337       <div>
338         <CommentNodes
339           nodes={nodes}
340           locked={this.state.post.locked}
341           moderators={this.state.moderators}
342           admins={this.state.admins}
343           postCreatorId={this.state.post.creator_id}
344         />
345       </div>
346     );
347   }
348
349   parseMessage(msg: WebSocketJsonResponse) {
350     console.log(msg);
351     let res = wsJsonToRes(msg);
352     if (msg.error) {
353       toast(i18n.t(msg.error), 'danger');
354       return;
355     } else if (res.op == UserOperation.GetPost) {
356       let data = res.data as GetPostResponse;
357       this.state.post = data.post;
358       this.state.comments = data.comments;
359       this.state.community = data.community;
360       this.state.moderators = data.moderators;
361       this.state.admins = data.admins;
362       this.state.loading = false;
363       document.title = `${this.state.post.name} - ${WebSocketService.Instance.site.name}`;
364
365       // Get cross-posts
366       if (this.state.post.url) {
367         let form: SearchForm = {
368           q: this.state.post.url,
369           type_: SearchType[SearchType.Url],
370           sort: SortType[SortType.TopAll],
371           page: 1,
372           limit: 6,
373         };
374         WebSocketService.Instance.search(form);
375       }
376
377       this.setState(this.state);
378     } else if (res.op == UserOperation.CreateComment) {
379       let data = res.data as CommentResponse;
380       this.state.comments.unshift(data.comment);
381       this.setState(this.state);
382     } else if (res.op == UserOperation.EditComment) {
383       let data = res.data as CommentResponse;
384       let found = this.state.comments.find(c => c.id == data.comment.id);
385       found.content = data.comment.content;
386       found.updated = data.comment.updated;
387       found.removed = data.comment.removed;
388       found.deleted = data.comment.deleted;
389       found.upvotes = data.comment.upvotes;
390       found.downvotes = data.comment.downvotes;
391       found.score = data.comment.score;
392       found.read = data.comment.read;
393
394       this.setState(this.state);
395     } else if (res.op == UserOperation.SaveComment) {
396       let data = res.data as CommentResponse;
397       let found = this.state.comments.find(c => c.id == data.comment.id);
398       found.saved = data.comment.saved;
399       this.setState(this.state);
400     } else if (res.op == UserOperation.CreateCommentLike) {
401       let data = res.data as CommentResponse;
402       let found: Comment = this.state.comments.find(
403         c => c.id === data.comment.id
404       );
405       found.score = data.comment.score;
406       found.upvotes = data.comment.upvotes;
407       found.downvotes = data.comment.downvotes;
408       if (data.comment.my_vote !== null) {
409         found.my_vote = data.comment.my_vote;
410         found.upvoteLoading = false;
411         found.downvoteLoading = false;
412       }
413       this.setState(this.state);
414     } else if (res.op == UserOperation.CreatePostLike) {
415       let data = res.data as CreatePostLikeResponse;
416       this.state.post.my_vote = data.post.my_vote;
417       this.state.post.score = data.post.score;
418       this.state.post.upvotes = data.post.upvotes;
419       this.state.post.downvotes = data.post.downvotes;
420       this.setState(this.state);
421     } else if (res.op == UserOperation.EditPost) {
422       let data = res.data as PostResponse;
423       this.state.post = data.post;
424       this.setState(this.state);
425     } else if (res.op == UserOperation.SavePost) {
426       let data = res.data as PostResponse;
427       this.state.post = data.post;
428       this.setState(this.state);
429     } else if (res.op == UserOperation.EditCommunity) {
430       let data = res.data as CommunityResponse;
431       this.state.community = data.community;
432       this.state.post.community_id = data.community.id;
433       this.state.post.community_name = data.community.name;
434       this.setState(this.state);
435     } else if (res.op == UserOperation.FollowCommunity) {
436       let data = res.data as CommunityResponse;
437       this.state.community.subscribed = data.community.subscribed;
438       this.state.community.number_of_subscribers =
439         data.community.number_of_subscribers;
440       this.setState(this.state);
441     } else if (res.op == UserOperation.BanFromCommunity) {
442       let data = res.data as BanFromCommunityResponse;
443       this.state.comments
444         .filter(c => c.creator_id == data.user.id)
445         .forEach(c => (c.banned_from_community = data.banned));
446       if (this.state.post.creator_id == data.user.id) {
447         this.state.post.banned_from_community = data.banned;
448       }
449       this.setState(this.state);
450     } else if (res.op == UserOperation.AddModToCommunity) {
451       let data = res.data as AddModToCommunityResponse;
452       this.state.moderators = data.moderators;
453       this.setState(this.state);
454     } else if (res.op == UserOperation.BanUser) {
455       let data = res.data as BanUserResponse;
456       this.state.comments
457         .filter(c => c.creator_id == data.user.id)
458         .forEach(c => (c.banned = data.banned));
459       if (this.state.post.creator_id == data.user.id) {
460         this.state.post.banned = data.banned;
461       }
462       this.setState(this.state);
463     } else if (res.op == UserOperation.AddAdmin) {
464       let data = res.data as AddAdminResponse;
465       this.state.admins = data.admins;
466       this.setState(this.state);
467     } else if (res.op == UserOperation.Search) {
468       let data = res.data as SearchResponse;
469       this.state.crossPosts = data.posts.filter(
470         p => p.id != this.state.post.id
471       );
472       this.setState(this.state);
473     } else if (res.op == UserOperation.TransferSite) {
474       let data = res.data as GetSiteResponse;
475
476       this.state.admins = data.admins;
477       this.setState(this.state);
478     } else if (res.op == UserOperation.TransferCommunity) {
479       let data = res.data as GetCommunityResponse;
480       this.state.community = data.community;
481       this.state.moderators = data.moderators;
482       this.state.admins = data.admins;
483       this.setState(this.state);
484     }
485   }
486 }