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