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