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