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