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