]> Untitled Git - lemmy.git/blob - ui/src/components/main.tsx
Add a simple linked instances page. Fixes #1070 (#1071)
[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         {/*
389               <li className="list-inline-item badge badge-light">
390                 {i18n.t('number_online', { count: this.state.siteRes.online })}
391               </li>
392               */}
393         <li className="list-inline-item badge badge-light">
394           {i18n.t('number_of_users', {
395             count: this.state.siteRes.site.number_of_users,
396           })}
397         </li>
398         <li className="list-inline-item badge badge-light">
399           {i18n.t('number_of_communities', {
400             count: this.state.siteRes.site.number_of_communities,
401           })}
402         </li>
403         <li className="list-inline-item badge badge-light">
404           {i18n.t('number_of_posts', {
405             count: this.state.siteRes.site.number_of_posts,
406           })}
407         </li>
408         <li className="list-inline-item badge badge-light">
409           {i18n.t('number_of_comments', {
410             count: this.state.siteRes.site.number_of_comments,
411           })}
412         </li>
413         <li className="list-inline-item">
414           <Link className="badge badge-light" to="/modlog">
415             {i18n.t('modlog')}
416           </Link>
417         </li>
418       </ul>
419     );
420   }
421
422   adminButtons() {
423     return (
424       this.canAdmin && (
425         <ul class="list-inline mb-1 text-muted font-weight-bold">
426           <li className="list-inline-item-action">
427             <span
428               class="pointer"
429               onClick={linkEvent(this, this.handleEditClick)}
430               data-tippy-content={i18n.t('edit')}
431             >
432               <svg class="icon icon-inline">
433                 <use xlinkHref="#icon-edit"></use>
434               </svg>
435             </span>
436           </li>
437         </ul>
438       )
439     );
440   }
441
442   siteDescription() {
443     return (
444       <div
445         className="md-div"
446         dangerouslySetInnerHTML={mdToHtml(this.state.siteRes.site.description)}
447       />
448     );
449   }
450
451   landing() {
452     return (
453       <>
454         <h5>
455           {i18n.t('powered_by')}
456           <svg class="icon mx-2">
457             <use xlinkHref="#icon-mouse">#</use>
458           </svg>
459           <a href={repoUrl}>
460             Lemmy<sup>beta</sup>
461           </a>
462         </h5>
463         <p class="mb-0">
464           <T i18nKey="landing_0">
465             #
466             <a href="https://en.wikipedia.org/wiki/Social_network_aggregation">
467               #
468             </a>
469             <a href="https://en.wikipedia.org/wiki/Fediverse">#</a>
470             <br class="big"></br>
471             <code>#</code>
472             <br></br>
473             <b>#</b>
474             <br class="big"></br>
475             <a href={repoUrl}>#</a>
476             <br class="big"></br>
477             <a href="https://www.rust-lang.org">#</a>
478             <a href="https://actix.rs/">#</a>
479             <a href="https://infernojs.org">#</a>
480             <a href="https://www.typescriptlang.org/">#</a>
481             <br class="big"></br>
482             <a href="https://github.com/LemmyNet/lemmy/graphs/contributors?type=a">
483               #
484             </a>
485           </T>
486         </p>
487       </>
488     );
489   }
490
491   posts() {
492     return (
493       <div class="main-content-wrapper">
494         {this.state.loading ? (
495           <h5>
496             <svg class="icon icon-spinner spin">
497               <use xlinkHref="#icon-spinner"></use>
498             </svg>
499           </h5>
500         ) : (
501           <div>
502             {this.selects()}
503             {this.listings()}
504             {this.paginator()}
505           </div>
506         )}
507       </div>
508     );
509   }
510
511   listings() {
512     return this.state.dataType == DataType.Post ? (
513       <PostListings
514         posts={this.state.posts}
515         showCommunity
516         removeDuplicates
517         sort={this.state.sort}
518         enableDownvotes={this.state.siteRes.site.enable_downvotes}
519         enableNsfw={this.state.siteRes.site.enable_nsfw}
520       />
521     ) : (
522       <CommentNodes
523         nodes={commentsToFlatNodes(this.state.comments)}
524         noIndent
525         showCommunity
526         sortType={this.state.sort}
527         showContext
528         enableDownvotes={this.state.siteRes.site.enable_downvotes}
529       />
530     );
531   }
532
533   selects() {
534     return (
535       <div className="mb-3">
536         <span class="mr-3">
537           <DataTypeSelect
538             type_={this.state.dataType}
539             onChange={this.handleDataTypeChange}
540           />
541         </span>
542         <span class="mr-3">
543           <ListingTypeSelect
544             type_={this.state.listingType}
545             onChange={this.handleListingTypeChange}
546           />
547         </span>
548         <span class="mr-2">
549           <SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
550         </span>
551         {this.state.listingType == ListingType.All && (
552           <a
553             href={`/feeds/all.xml?sort=${SortType[this.state.sort]}`}
554             target="_blank"
555             rel="noopener"
556             title="RSS"
557           >
558             <svg class="icon text-muted small">
559               <use xlinkHref="#icon-rss">#</use>
560             </svg>
561           </a>
562         )}
563         {UserService.Instance.user &&
564           this.state.listingType == ListingType.Subscribed && (
565             <a
566               href={`/feeds/front/${UserService.Instance.auth}.xml?sort=${
567                 SortType[this.state.sort]
568               }`}
569               target="_blank"
570               title="RSS"
571               rel="noopener"
572             >
573               <svg class="icon text-muted small">
574                 <use xlinkHref="#icon-rss">#</use>
575               </svg>
576             </a>
577           )}
578       </div>
579     );
580   }
581
582   paginator() {
583     return (
584       <div class="my-2">
585         {this.state.page > 1 && (
586           <button
587             class="btn btn-secondary mr-1"
588             onClick={linkEvent(this, this.prevPage)}
589           >
590             {i18n.t('prev')}
591           </button>
592         )}
593         {this.state.posts.length > 0 && (
594           <button
595             class="btn btn-secondary"
596             onClick={linkEvent(this, this.nextPage)}
597           >
598             {i18n.t('next')}
599           </button>
600         )}
601       </div>
602     );
603   }
604
605   get canAdmin(): boolean {
606     return (
607       UserService.Instance.user &&
608       this.state.siteRes.admins
609         .map(a => a.id)
610         .includes(UserService.Instance.user.id)
611     );
612   }
613
614   handleEditClick(i: Main) {
615     i.state.showEditSite = true;
616     i.setState(i.state);
617   }
618
619   handleEditCancel() {
620     this.state.showEditSite = false;
621     this.setState(this.state);
622   }
623
624   nextPage(i: Main) {
625     i.updateUrl({ page: i.state.page + 1 });
626     window.scrollTo(0, 0);
627   }
628
629   prevPage(i: Main) {
630     i.updateUrl({ page: i.state.page - 1 });
631     window.scrollTo(0, 0);
632   }
633
634   handleSortChange(val: SortType) {
635     this.updateUrl({ sort: SortType[val].toLowerCase(), page: 1 });
636     window.scrollTo(0, 0);
637   }
638
639   handleListingTypeChange(val: ListingType) {
640     this.updateUrl({ listingType: ListingType[val].toLowerCase(), page: 1 });
641     window.scrollTo(0, 0);
642   }
643
644   handleDataTypeChange(val: DataType) {
645     this.updateUrl({ dataType: DataType[val].toLowerCase(), page: 1 });
646     window.scrollTo(0, 0);
647   }
648
649   fetchData() {
650     if (this.state.dataType == DataType.Post) {
651       let getPostsForm: GetPostsForm = {
652         page: this.state.page,
653         limit: fetchLimit,
654         sort: SortType[this.state.sort],
655         type_: ListingType[this.state.listingType],
656       };
657       WebSocketService.Instance.getPosts(getPostsForm);
658     } else {
659       let getCommentsForm: GetCommentsForm = {
660         page: this.state.page,
661         limit: fetchLimit,
662         sort: SortType[this.state.sort],
663         type_: ListingType[this.state.listingType],
664       };
665       WebSocketService.Instance.getComments(getCommentsForm);
666     }
667   }
668
669   parseMessage(msg: WebSocketJsonResponse) {
670     console.log(msg);
671     let res = wsJsonToRes(msg);
672     if (msg.error) {
673       toast(i18n.t(msg.error), 'danger');
674       return;
675     } else if (msg.reconnect) {
676       this.fetchData();
677     } else if (res.op == UserOperation.GetFollowedCommunities) {
678       let data = res.data as GetFollowedCommunitiesResponse;
679       this.state.subscribedCommunities = data.communities;
680       this.setState(this.state);
681     } else if (res.op == UserOperation.ListCommunities) {
682       let data = res.data as ListCommunitiesResponse;
683       this.state.trendingCommunities = data.communities;
684       this.setState(this.state);
685     } else if (res.op == UserOperation.GetSite) {
686       let data = res.data as GetSiteResponse;
687
688       // This means it hasn't been set up yet
689       if (!data.site) {
690         this.context.router.history.push('/setup');
691       }
692       this.state.siteRes.admins = data.admins;
693       this.state.siteRes.site = data.site;
694       this.state.siteRes.banned = data.banned;
695       this.state.siteRes.online = data.online;
696       this.setState(this.state);
697     } else if (res.op == UserOperation.EditSite) {
698       let data = res.data as SiteResponse;
699       this.state.siteRes.site = data.site;
700       this.state.showEditSite = false;
701       this.setState(this.state);
702       toast(i18n.t('site_saved'));
703     } else if (res.op == UserOperation.GetPosts) {
704       let data = res.data as GetPostsResponse;
705       this.state.posts = data.posts;
706       this.state.loading = false;
707       this.setState(this.state);
708       setupTippy();
709     } else if (res.op == UserOperation.CreatePost) {
710       let data = res.data as PostResponse;
711
712       // If you're on subscribed, only push it if you're subscribed.
713       if (this.state.listingType == ListingType.Subscribed) {
714         if (
715           this.state.subscribedCommunities
716             .map(c => c.community_id)
717             .includes(data.post.community_id)
718         ) {
719           this.state.posts.unshift(data.post);
720         }
721       } else {
722         // NSFW posts
723         let nsfw = data.post.nsfw || data.post.community_nsfw;
724
725         // Don't push the post if its nsfw, and don't have that setting on
726         if (
727           !nsfw ||
728           (nsfw &&
729             UserService.Instance.user &&
730             UserService.Instance.user.show_nsfw)
731         ) {
732           this.state.posts.unshift(data.post);
733         }
734       }
735       this.setState(this.state);
736     } else if (res.op == UserOperation.EditPost) {
737       let data = res.data as PostResponse;
738       editPostFindRes(data, this.state.posts);
739       this.setState(this.state);
740     } else if (res.op == UserOperation.CreatePostLike) {
741       let data = res.data as PostResponse;
742       createPostLikeFindRes(data, this.state.posts);
743       this.setState(this.state);
744     } else if (res.op == UserOperation.AddAdmin) {
745       let data = res.data as AddAdminResponse;
746       this.state.siteRes.admins = data.admins;
747       this.setState(this.state);
748     } else if (res.op == UserOperation.BanUser) {
749       let data = res.data as BanUserResponse;
750       let found = this.state.siteRes.banned.find(u => (u.id = data.user.id));
751
752       // Remove the banned if its found in the list, and the action is an unban
753       if (found && !data.banned) {
754         this.state.siteRes.banned = this.state.siteRes.banned.filter(
755           i => i.id !== data.user.id
756         );
757       } else {
758         this.state.siteRes.banned.push(data.user);
759       }
760
761       this.state.posts
762         .filter(p => p.creator_id == data.user.id)
763         .forEach(p => (p.banned = data.banned));
764
765       this.setState(this.state);
766     } else if (res.op == UserOperation.GetComments) {
767       let data = res.data as GetCommentsResponse;
768       this.state.comments = data.comments;
769       this.state.loading = false;
770       this.setState(this.state);
771     } else if (
772       res.op == UserOperation.EditComment ||
773       res.op == UserOperation.DeleteComment ||
774       res.op == UserOperation.RemoveComment
775     ) {
776       let data = res.data as CommentResponse;
777       editCommentRes(data, this.state.comments);
778       this.setState(this.state);
779     } else if (res.op == UserOperation.CreateComment) {
780       let data = res.data as CommentResponse;
781
782       // Necessary since it might be a user reply
783       if (data.recipient_ids.length == 0) {
784         // If you're on subscribed, only push it if you're subscribed.
785         if (this.state.listingType == ListingType.Subscribed) {
786           if (
787             this.state.subscribedCommunities
788               .map(c => c.community_id)
789               .includes(data.comment.community_id)
790           ) {
791             this.state.comments.unshift(data.comment);
792           }
793         } else {
794           this.state.comments.unshift(data.comment);
795         }
796         this.setState(this.state);
797       }
798     } else if (res.op == UserOperation.SaveComment) {
799       let data = res.data as CommentResponse;
800       saveCommentRes(data, this.state.comments);
801       this.setState(this.state);
802     } else if (res.op == UserOperation.CreateCommentLike) {
803       let data = res.data as CommentResponse;
804       createCommentLikeRes(data, this.state.comments);
805       this.setState(this.state);
806     }
807   }
808 }