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