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