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