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