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