]> Untitled Git - lemmy.git/blob - ui/src/components/main.tsx
ui.components.main: fix duplicate requests
[lemmy.git] / ui / src / components / main.tsx
1 import { Component, linkEvent } from 'inferno';
2 import { Link } from 'inferno-router';
3 import { Subscription } from 'rxjs';
4 import { retryWhen, delay, take } from 'rxjs/operators';
5 import {
6   UserOperation,
7   CommunityUser,
8   GetFollowedCommunitiesResponse,
9   ListCommunitiesForm,
10   ListCommunitiesResponse,
11   Community,
12   SortType,
13   GetSiteResponse,
14   ListingType,
15   DataType,
16   SiteResponse,
17   GetPostsResponse,
18   PostResponse,
19   Post,
20   GetPostsForm,
21   Comment,
22   GetCommentsForm,
23   GetCommentsResponse,
24   CommentResponse,
25   AddAdminResponse,
26   BanUserResponse,
27   WebSocketJsonResponse,
28 } from '../interfaces';
29 import { WebSocketService, UserService } from '../services';
30 import { PostListings } from './post-listings';
31 import { CommentNodes } from './comment-nodes';
32 import { SortSelect } from './sort-select';
33 import { ListingTypeSelect } from './listing-type-select';
34 import { DataTypeSelect } from './data-type-select';
35 import { SiteForm } from './site-form';
36 import { UserListing } from './user-listing';
37 import { CommunityLink } from './community-link';
38 import {
39   wsJsonToRes,
40   repoUrl,
41   mdToHtml,
42   fetchLimit,
43   toast,
44   getListingTypeFromProps,
45   getPageFromProps,
46   getSortTypeFromProps,
47   getDataTypeFromProps,
48   editCommentRes,
49   saveCommentRes,
50   createCommentLikeRes,
51   createPostLikeFindRes,
52   editPostFindRes,
53   commentsToFlatNodes,
54   setupTippy,
55 } from '../utils';
56 import { i18n } from '../i18next';
57 import { T } from 'inferno-i18next';
58
59 interface MainState {
60   subscribedCommunities: Array<CommunityUser>;
61   trendingCommunities: Array<Community>;
62   siteRes: GetSiteResponse;
63   showEditSite: boolean;
64   loading: boolean;
65   posts: Array<Post>;
66   comments: Array<Comment>;
67   listingType: ListingType;
68   dataType: DataType;
69   sort: SortType;
70   page: number;
71 }
72
73 export class Main extends Component<any, MainState> {
74   private subscription: Subscription;
75   private emptyState: MainState = {
76     subscribedCommunities: [],
77     trendingCommunities: [],
78     siteRes: {
79       site: {
80         id: null,
81         name: null,
82         creator_id: null,
83         creator_name: null,
84         published: null,
85         number_of_users: null,
86         number_of_posts: null,
87         number_of_comments: null,
88         number_of_communities: null,
89         enable_downvotes: null,
90         open_registration: null,
91         enable_nsfw: null,
92       },
93       admins: [],
94       banned: [],
95       online: null,
96     },
97     showEditSite: false,
98     loading: true,
99     posts: [],
100     comments: [],
101     listingType: getListingTypeFromProps(this.props),
102     dataType: getDataTypeFromProps(this.props),
103     sort: getSortTypeFromProps(this.props),
104     page: getPageFromProps(this.props),
105   };
106
107   constructor(props: any, context: any) {
108     super(props, context);
109
110     this.state = this.emptyState;
111     this.handleEditCancel = this.handleEditCancel.bind(this);
112     this.handleSortChange = this.handleSortChange.bind(this);
113     this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
114     this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
115
116     this.subscription = WebSocketService.Instance.subject
117       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
118       .subscribe(
119         msg => this.parseMessage(msg),
120         err => console.error(err),
121         () => console.log('complete')
122       );
123
124     WebSocketService.Instance.getSite();
125
126     if (UserService.Instance.user) {
127       WebSocketService.Instance.getFollowedCommunities();
128     }
129
130     let listCommunitiesForm: ListCommunitiesForm = {
131       sort: SortType[SortType.Hot],
132       limit: 6,
133     };
134
135     WebSocketService.Instance.listCommunities(listCommunitiesForm);
136
137     this.fetchData();
138   }
139
140   componentWillUnmount() {
141     this.subscription.unsubscribe();
142   }
143
144   static getDerivedStateFromProps(props) {
145     return {
146       listingType: getListingTypeFromProps(props),
147       dataType: getDataTypeFromProps(props),
148       sort: getSortTypeFromProps(props),
149       page: getPageFromProps(props),
150     };
151   }
152
153   componentDidUpdate(_, lastState) {
154     if (
155       lastState.listingType !== this.state.listingType ||
156       lastState.dataType !== this.state.dataType ||
157       lastState.sort !== this.state.sort ||
158       lastState.page !== this.state.page
159     ) {
160       this.setState({ loading: true });
161       this.fetchData();
162     }
163   }
164
165   render() {
166     return (
167       <div class="container">
168         <div class="row">
169           <main role="main" class="col-12 col-md-8">
170             {this.posts()}
171           </main>
172           <aside class="col-12 col-md-4">{this.my_sidebar()}</aside>
173         </div>
174       </div>
175     );
176   }
177
178   my_sidebar() {
179     return (
180       <div>
181         {!this.state.loading && (
182           <div>
183             <div class="card border-secondary mb-3">
184               <div class="card-body">
185                 {this.trendingCommunities()}
186                 {UserService.Instance.user &&
187                   this.state.subscribedCommunities.length > 0 && (
188                     <div>
189                       <h5>
190                         <T i18nKey="subscribed_to_communities">
191                           #
192                           <Link class="text-body" to="/communities">
193                             #
194                           </Link>
195                         </T>
196                       </h5>
197                       <ul class="list-inline">
198                         {this.state.subscribedCommunities.map(community => (
199                           <li class="list-inline-item">
200                             <CommunityLink
201                               community={{
202                                 name: community.community_name,
203                                 id: community.community_id,
204                                 local: community.community_local,
205                                 actor_id: community.community_actor_id,
206                               }}
207                             />
208                           </li>
209                         ))}
210                       </ul>
211                     </div>
212                   )}
213                 <Link
214                   class="btn btn-sm btn-secondary btn-block"
215                   to="/create_community"
216                 >
217                   {i18n.t('create_a_community')}
218                 </Link>
219               </div>
220             </div>
221             {this.sidebar()}
222             {this.landing()}
223           </div>
224         )}
225       </div>
226     );
227   }
228
229   trendingCommunities() {
230     return (
231       <div>
232         <h5>
233           <T i18nKey="trending_communities">
234             #
235             <Link class="text-body" to="/communities">
236               #
237             </Link>
238           </T>
239         </h5>
240         <ul class="list-inline">
241           {this.state.trendingCommunities.map(community => (
242             <li class="list-inline-item">
243               <CommunityLink community={community} />
244             </li>
245           ))}
246         </ul>
247       </div>
248     );
249   }
250
251   sidebar() {
252     return (
253       <div>
254         {!this.state.showEditSite ? (
255           this.siteInfo()
256         ) : (
257           <SiteForm
258             site={this.state.siteRes.site}
259             onCancel={this.handleEditCancel}
260           />
261         )}
262       </div>
263     );
264   }
265
266   updateUrl(paramUpdates: {
267     listing_type?: string;
268     data_type?: string;
269     sort?: string;
270     page?: number;
271   }) {
272     const listingTypeStr =
273       paramUpdates.listing_type ||
274       ListingType[getListingTypeFromProps(this.props)].toLowerCase();
275     const dataTypeStr =
276       paramUpdates.data_type ||
277       DataType[getDataTypeFromProps(this.props)].toLowerCase();
278     const sortStr =
279       paramUpdates.sort ||
280       SortType[getSortTypeFromProps(this.props)].toLowerCase();
281     const page = paramUpdates.page || getPageFromProps(this.props);
282     this.props.history.push(
283       `/home/data_type/${dataTypeStr}/listing_type/${listingTypeStr}/sort/${sortStr}/page/${page}`
284     );
285   }
286
287   siteInfo() {
288     return (
289       <div>
290         <div class="card border-secondary mb-3">
291           <div class="card-body">
292             <h5 class="mb-0">{`${this.state.siteRes.site.name}`}</h5>
293             {this.canAdmin && (
294               <ul class="list-inline mb-1 text-muted font-weight-bold">
295                 <li className="list-inline-item-action">
296                   <span
297                     class="pointer"
298                     onClick={linkEvent(this, this.handleEditClick)}
299                     data-tippy-content={i18n.t('edit')}
300                   >
301                     <svg class="icon icon-inline">
302                       <use xlinkHref="#icon-edit"></use>
303                     </svg>
304                   </span>
305                 </li>
306               </ul>
307             )}
308             <ul class="my-2 list-inline">
309               {/*
310               <li className="list-inline-item badge badge-secondary">
311                 {i18n.t('number_online', { count: this.state.siteRes.online })}
312               </li>
313               */}
314               <li className="list-inline-item badge badge-secondary">
315                 {i18n.t('number_of_users', {
316                   count: this.state.siteRes.site.number_of_users,
317                 })}
318               </li>
319               <li className="list-inline-item badge badge-secondary">
320                 {i18n.t('number_of_communities', {
321                   count: this.state.siteRes.site.number_of_communities,
322                 })}
323               </li>
324               <li className="list-inline-item badge badge-secondary">
325                 {i18n.t('number_of_posts', {
326                   count: this.state.siteRes.site.number_of_posts,
327                 })}
328               </li>
329               <li className="list-inline-item badge badge-secondary">
330                 {i18n.t('number_of_comments', {
331                   count: this.state.siteRes.site.number_of_comments,
332                 })}
333               </li>
334               <li className="list-inline-item">
335                 <Link className="badge badge-secondary" to="/modlog">
336                   {i18n.t('modlog')}
337                 </Link>
338               </li>
339             </ul>
340             <ul class="mt-1 list-inline small mb-0">
341               <li class="list-inline-item">{i18n.t('admins')}:</li>
342               {this.state.siteRes.admins.map(admin => (
343                 <li class="list-inline-item">
344                   <UserListing
345                     user={{
346                       name: admin.name,
347                       avatar: admin.avatar,
348                       local: admin.local,
349                       actor_id: admin.actor_id,
350                       id: admin.id,
351                     }}
352                   />
353                 </li>
354               ))}
355             </ul>
356           </div>
357         </div>
358         {this.state.siteRes.site.description && (
359           <div class="card border-secondary mb-3">
360             <div class="card-body">
361               <div
362                 className="md-div"
363                 dangerouslySetInnerHTML={mdToHtml(
364                   this.state.siteRes.site.description
365                 )}
366               />
367             </div>
368           </div>
369         )}
370       </div>
371     );
372   }
373
374   landing() {
375     return (
376       <div class="card border-secondary">
377         <div class="card-body">
378           <h5>
379             {i18n.t('powered_by')}
380             <svg class="icon mx-2">
381               <use xlinkHref="#icon-mouse">#</use>
382             </svg>
383             <a href={repoUrl}>
384               Lemmy<sup>beta</sup>
385             </a>
386           </h5>
387           <p class="mb-0">
388             <T i18nKey="landing_0">
389               #
390               <a href="https://en.wikipedia.org/wiki/Social_network_aggregation">
391                 #
392               </a>
393               <a href="https://en.wikipedia.org/wiki/Fediverse">#</a>
394               <br class="big"></br>
395               <code>#</code>
396               <br></br>
397               <b>#</b>
398               <br class="big"></br>
399               <a href={repoUrl}>#</a>
400               <br class="big"></br>
401               <a href="https://www.rust-lang.org">#</a>
402               <a href="https://actix.rs/">#</a>
403               <a href="https://infernojs.org">#</a>
404               <a href="https://www.typescriptlang.org/">#</a>
405               <br class="big"></br>
406               <a href="https://github.com/LemmyNet/lemmy/graphs/contributors?type=a">
407                 #
408               </a>
409             </T>
410           </p>
411         </div>
412       </div>
413     );
414   }
415
416   posts() {
417     return (
418       <div class="main-content-wrapper">
419         {this.selects()}
420         {this.state.loading ? (
421           <h5>
422             <svg class="icon icon-spinner spin">
423               <use xlinkHref="#icon-spinner"></use>
424             </svg>
425           </h5>
426         ) : (
427           <div>
428             {this.listings()}
429             {this.paginator()}
430           </div>
431         )}
432       </div>
433     );
434   }
435
436   listings() {
437     return this.state.dataType == DataType.Post ? (
438       <PostListings
439         posts={this.state.posts}
440         showCommunity
441         removeDuplicates
442         sort={this.state.sort}
443         enableDownvotes={this.state.siteRes.site.enable_downvotes}
444         enableNsfw={this.state.siteRes.site.enable_nsfw}
445       />
446     ) : (
447       <CommentNodes
448         nodes={commentsToFlatNodes(this.state.comments)}
449         noIndent
450         showCommunity
451         sortType={this.state.sort}
452         showContext
453         enableDownvotes={this.state.siteRes.site.enable_downvotes}
454       />
455     );
456   }
457
458   selects() {
459     return (
460       <div className="mb-3">
461         <span class="mr-3">
462           <DataTypeSelect
463             type_={this.state.dataType}
464             onChange={this.handleDataTypeChange}
465           />
466         </span>
467         <span class="mr-3">
468           <ListingTypeSelect
469             type_={this.state.listingType}
470             onChange={this.handleListingTypeChange}
471           />
472         </span>
473         <span class="mr-2">
474           <SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
475         </span>
476         {this.state.listingType == ListingType.All && (
477           <a
478             href={`/feeds/all.xml?sort=${SortType[this.state.sort]}`}
479             target="_blank"
480             rel="noopener"
481             title="RSS"
482           >
483             <svg class="icon text-muted small">
484               <use xlinkHref="#icon-rss">#</use>
485             </svg>
486           </a>
487         )}
488         {UserService.Instance.user &&
489           this.state.listingType == ListingType.Subscribed && (
490             <a
491               href={`/feeds/front/${UserService.Instance.auth}.xml?sort=${
492                 SortType[this.state.sort]
493               }`}
494               target="_blank"
495               title="RSS"
496               rel="noopener"
497             >
498               <svg class="icon text-muted small">
499                 <use xlinkHref="#icon-rss">#</use>
500               </svg>
501             </a>
502           )}
503       </div>
504     );
505   }
506
507   paginator() {
508     return (
509       <div class="my-2">
510         {this.state.page > 1 && (
511           <button
512             class="btn btn-sm btn-secondary mr-1"
513             onClick={linkEvent(this, this.prevPage)}
514           >
515             {i18n.t('prev')}
516           </button>
517         )}
518         {this.state.posts.length > 0 && (
519           <button
520             class="btn btn-sm btn-secondary"
521             onClick={linkEvent(this, this.nextPage)}
522           >
523             {i18n.t('next')}
524           </button>
525         )}
526       </div>
527     );
528   }
529
530   get canAdmin(): boolean {
531     return (
532       UserService.Instance.user &&
533       this.state.siteRes.admins
534         .map(a => a.id)
535         .includes(UserService.Instance.user.id)
536     );
537   }
538
539   handleEditClick(i: Main) {
540     i.state.showEditSite = true;
541     i.setState(i.state);
542   }
543
544   handleEditCancel() {
545     this.state.showEditSite = false;
546     this.setState(this.state);
547   }
548
549   nextPage(i: Main) {
550     i.updateUrl({ page: this.state.page + 1 });
551     window.scrollTo(0, 0);
552   }
553
554   prevPage(i: Main) {
555     i.updateUrl({ page: this.state.page - 1 });
556     window.scrollTo(0, 0);
557   }
558
559   handleSortChange(val: SortType) {
560     this.updateUrl({ sort: SortType[val].toLowerCase(), page: 1 });
561     window.scrollTo(0, 0);
562   }
563
564   handleListingTypeChange(val: ListingType) {
565     this.updateUrl({ listing_type: ListingType[val].toLowerCase(), page: 1 });
566     window.scrollTo(0, 0);
567   }
568
569   handleDataTypeChange(val: DataType) {
570     this.updateUrl({ data_type: DataType[val].toLowerCase(), page: 1 });
571     window.scrollTo(0, 0);
572   }
573
574   fetchData() {
575     if (this.state.dataType == DataType.Post) {
576       let getPostsForm: GetPostsForm = {
577         page: this.state.page,
578         limit: fetchLimit,
579         sort: SortType[this.state.sort],
580         type_: ListingType[this.state.listingType],
581       };
582       WebSocketService.Instance.getPosts(getPostsForm);
583     } else {
584       let getCommentsForm: GetCommentsForm = {
585         page: this.state.page,
586         limit: fetchLimit,
587         sort: SortType[this.state.sort],
588         type_: ListingType[this.state.listingType],
589       };
590       WebSocketService.Instance.getComments(getCommentsForm);
591     }
592   }
593
594   parseMessage(msg: WebSocketJsonResponse) {
595     console.log(msg);
596     let res = wsJsonToRes(msg);
597     if (msg.error) {
598       toast(i18n.t(msg.error), 'danger');
599       return;
600     } else if (msg.reconnect) {
601       this.fetchData();
602     } else if (res.op == UserOperation.GetFollowedCommunities) {
603       let data = res.data as GetFollowedCommunitiesResponse;
604       this.state.subscribedCommunities = data.communities;
605       this.setState(this.state);
606     } else if (res.op == UserOperation.ListCommunities) {
607       let data = res.data as ListCommunitiesResponse;
608       this.state.trendingCommunities = data.communities;
609       this.setState(this.state);
610     } else if (res.op == UserOperation.GetSite) {
611       let data = res.data as GetSiteResponse;
612
613       // This means it hasn't been set up yet
614       if (!data.site) {
615         this.context.router.history.push('/setup');
616       }
617       this.state.siteRes.admins = data.admins;
618       this.state.siteRes.site = data.site;
619       this.state.siteRes.banned = data.banned;
620       this.state.siteRes.online = data.online;
621       this.setState(this.state);
622       document.title = `${this.state.siteRes.site.name}`;
623     } else if (res.op == UserOperation.EditSite) {
624       let data = res.data as SiteResponse;
625       this.state.siteRes.site = data.site;
626       this.state.showEditSite = false;
627       this.setState(this.state);
628       toast(i18n.t('site_saved'));
629     } else if (res.op == UserOperation.GetPosts) {
630       let data = res.data as GetPostsResponse;
631       this.state.posts = data.posts;
632       this.state.loading = false;
633       this.setState(this.state);
634       setupTippy();
635     } else if (res.op == UserOperation.CreatePost) {
636       let data = res.data as PostResponse;
637
638       // If you're on subscribed, only push it if you're subscribed.
639       if (this.state.listingType == ListingType.Subscribed) {
640         if (
641           this.state.subscribedCommunities
642             .map(c => c.community_id)
643             .includes(data.post.community_id)
644         ) {
645           this.state.posts.unshift(data.post);
646         }
647       } else {
648         // NSFW posts
649         let nsfw = data.post.nsfw || data.post.community_nsfw;
650
651         // Don't push the post if its nsfw, and don't have that setting on
652         if (
653           !nsfw ||
654           (nsfw &&
655             UserService.Instance.user &&
656             UserService.Instance.user.show_nsfw)
657         ) {
658           this.state.posts.unshift(data.post);
659         }
660       }
661       this.setState(this.state);
662     } else if (res.op == UserOperation.EditPost) {
663       let data = res.data as PostResponse;
664       editPostFindRes(data, this.state.posts);
665       this.setState(this.state);
666     } else if (res.op == UserOperation.CreatePostLike) {
667       let data = res.data as PostResponse;
668       createPostLikeFindRes(data, this.state.posts);
669       this.setState(this.state);
670     } else if (res.op == UserOperation.AddAdmin) {
671       let data = res.data as AddAdminResponse;
672       this.state.siteRes.admins = data.admins;
673       this.setState(this.state);
674     } else if (res.op == UserOperation.BanUser) {
675       let data = res.data as BanUserResponse;
676       let found = this.state.siteRes.banned.find(u => (u.id = data.user.id));
677
678       // Remove the banned if its found in the list, and the action is an unban
679       if (found && !data.banned) {
680         this.state.siteRes.banned = this.state.siteRes.banned.filter(
681           i => i.id !== data.user.id
682         );
683       } else {
684         this.state.siteRes.banned.push(data.user);
685       }
686
687       this.state.posts
688         .filter(p => p.creator_id == data.user.id)
689         .forEach(p => (p.banned = data.banned));
690
691       this.setState(this.state);
692     } else if (res.op == UserOperation.GetComments) {
693       let data = res.data as GetCommentsResponse;
694       this.state.comments = data.comments;
695       this.state.loading = false;
696       this.setState(this.state);
697     } else if (res.op == UserOperation.EditComment) {
698       let data = res.data as CommentResponse;
699       editCommentRes(data, this.state.comments);
700       this.setState(this.state);
701     } else if (res.op == UserOperation.CreateComment) {
702       let data = res.data as CommentResponse;
703
704       // Necessary since it might be a user reply
705       if (data.recipient_ids.length == 0) {
706         // If you're on subscribed, only push it if you're subscribed.
707         if (this.state.listingType == ListingType.Subscribed) {
708           if (
709             this.state.subscribedCommunities
710               .map(c => c.community_id)
711               .includes(data.comment.community_id)
712           ) {
713             this.state.comments.unshift(data.comment);
714           }
715         } else {
716           this.state.comments.unshift(data.comment);
717         }
718         this.setState(this.state);
719       }
720     } else if (res.op == UserOperation.SaveComment) {
721       let data = res.data as CommentResponse;
722       saveCommentRes(data, this.state.comments);
723       this.setState(this.state);
724     } else if (res.op == UserOperation.CreateCommentLike) {
725       let data = res.data as CommentResponse;
726       createCommentLikeRes(data, this.state.comments);
727       this.setState(this.state);
728     }
729   }
730 }