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