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