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