]> Untitled Git - lemmy.git/blob - ui/src/components/main.tsx
Merge branch 'master' into dev
[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 {
37   wsJsonToRes,
38   repoUrl,
39   mdToHtml,
40   fetchLimit,
41   pictshareAvatarThumbnail,
42   showAvatars,
43   toast,
44   getListingTypeFromProps,
45   getPageFromProps,
46   getSortTypeFromProps,
47   getDataTypeFromProps,
48   editCommentRes,
49   saveCommentRes,
50   createCommentLikeRes,
51   createPostLikeFindRes,
52   editPostFindRes,
53   commentsToFlatNodes,
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-white" 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-white" 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 small font-weight-bold">
271                 <li className="list-inline-item">
272                   <span
273                     class="pointer"
274                     onClick={linkEvent(this, this.handleEditClick)}
275                   >
276                     {i18n.t('edit')}
277                   </span>
278                 </li>
279               </ul>
280             )}
281             <ul class="my-2 list-inline">
282               <li className="list-inline-item badge badge-secondary">
283                 {i18n.t('number_online', { count: this.state.siteRes.online })}
284               </li>
285               <li className="list-inline-item badge badge-secondary">
286                 {i18n.t('number_of_users', {
287                   count: this.state.siteRes.site.number_of_users,
288                 })}
289               </li>
290               <li className="list-inline-item badge badge-secondary">
291                 {i18n.t('number_of_communities', {
292                   count: this.state.siteRes.site.number_of_communities,
293                 })}
294               </li>
295               <li className="list-inline-item badge badge-secondary">
296                 {i18n.t('number_of_posts', {
297                   count: this.state.siteRes.site.number_of_posts,
298                 })}
299               </li>
300               <li className="list-inline-item badge badge-secondary">
301                 {i18n.t('number_of_comments', {
302                   count: this.state.siteRes.site.number_of_comments,
303                 })}
304               </li>
305               <li className="list-inline-item">
306                 <Link className="badge badge-secondary" to="/modlog">
307                   {i18n.t('modlog')}
308                 </Link>
309               </li>
310             </ul>
311             <ul class="mt-1 list-inline small mb-0">
312               <li class="list-inline-item">{i18n.t('admins')}:</li>
313               {this.state.siteRes.admins.map(admin => (
314                 <li class="list-inline-item">
315                   <Link class="text-info" to={`/u/${admin.name}`}>
316                     {admin.avatar && showAvatars() && (
317                       <img
318                         height="32"
319                         width="32"
320                         src={pictshareAvatarThumbnail(admin.avatar)}
321                         class="rounded-circle mr-1"
322                       />
323                     )}
324                     <span>{admin.name}</span>
325                   </Link>
326                 </li>
327               ))}
328             </ul>
329           </div>
330         </div>
331         {this.state.siteRes.site.description && (
332           <div class="card border-secondary mb-3">
333             <div class="card-body">
334               <div
335                 className="md-div"
336                 dangerouslySetInnerHTML={mdToHtml(
337                   this.state.siteRes.site.description
338                 )}
339               />
340             </div>
341           </div>
342         )}
343       </div>
344     );
345   }
346
347   landing() {
348     return (
349       <div class="card border-secondary">
350         <div class="card-body">
351           <h5>
352             {i18n.t('powered_by')}
353             <svg class="icon mx-2">
354               <use xlinkHref="#icon-mouse">#</use>
355             </svg>
356             <a href={repoUrl}>
357               Lemmy<sup>beta</sup>
358             </a>
359           </h5>
360           <p class="mb-0">
361             <T i18nKey="landing_0">
362               #
363               <a href="https://en.wikipedia.org/wiki/Social_network_aggregation">
364                 #
365               </a>
366               <a href="https://en.wikipedia.org/wiki/Fediverse">#</a>
367               <br></br>
368               <code>#</code>
369               <br></br>
370               <b>#</b>
371               <br></br>
372               <a href={repoUrl}>#</a>
373               <br></br>
374               <a href="https://www.rust-lang.org">#</a>
375               <a href="https://actix.rs/">#</a>
376               <a href="https://infernojs.org">#</a>
377               <a href="https://www.typescriptlang.org/">#</a>
378             </T>
379           </p>
380         </div>
381       </div>
382     );
383   }
384
385   posts() {
386     return (
387       <div class="main-content-wrapper">
388         {this.state.loading ? (
389           <h5>
390             <svg class="icon icon-spinner spin">
391               <use xlinkHref="#icon-spinner"></use>
392             </svg>
393           </h5>
394         ) : (
395           <div>
396             {this.selects()}
397             {this.listings()}
398             {this.paginator()}
399           </div>
400         )}
401       </div>
402     );
403   }
404
405   listings() {
406     return this.state.dataType == DataType.Post ? (
407       <PostListings posts={this.state.posts} showCommunity removeDuplicates />
408     ) : (
409       <CommentNodes
410         nodes={commentsToFlatNodes(this.state.comments)}
411         noIndent
412         showCommunity
413       />
414     );
415   }
416
417   selects() {
418     return (
419       <div className="mb-3">
420         <DataTypeSelect
421           type_={this.state.dataType}
422           onChange={this.handleDataTypeChange}
423         />
424         <span class="mx-2">
425           <ListingTypeSelect
426             type_={this.state.listingType}
427             onChange={this.handleListingTypeChange}
428           />
429         </span>
430         <span class="mr-2">
431           <SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
432         </span>
433         {this.state.listingType == ListingType.All && (
434           <a
435             href={`/feeds/all.xml?sort=${SortType[this.state.sort]}`}
436             target="_blank"
437           >
438             <svg class="icon mx-1 text-muted small">
439               <use xlinkHref="#icon-rss">#</use>
440             </svg>
441           </a>
442         )}
443         {UserService.Instance.user &&
444           this.state.listingType == ListingType.Subscribed && (
445             <a
446               href={`/feeds/front/${UserService.Instance.auth}.xml?sort=${
447                 SortType[this.state.sort]
448               }`}
449               target="_blank"
450             >
451               <svg class="icon mx-1 text-muted small">
452                 <use xlinkHref="#icon-rss">#</use>
453               </svg>
454             </a>
455           )}
456       </div>
457     );
458   }
459
460   paginator() {
461     return (
462       <div class="my-2">
463         {this.state.page > 1 && (
464           <button
465             class="btn btn-sm btn-secondary mr-1"
466             onClick={linkEvent(this, this.prevPage)}
467           >
468             {i18n.t('prev')}
469           </button>
470         )}
471         {this.state.posts.length == fetchLimit && (
472           <button
473             class="btn btn-sm btn-secondary"
474             onClick={linkEvent(this, this.nextPage)}
475           >
476             {i18n.t('next')}
477           </button>
478         )}
479       </div>
480     );
481   }
482
483   get canAdmin(): boolean {
484     return (
485       UserService.Instance.user &&
486       this.state.siteRes.admins
487         .map(a => a.id)
488         .includes(UserService.Instance.user.id)
489     );
490   }
491
492   handleEditClick(i: Main) {
493     i.state.showEditSite = true;
494     i.setState(i.state);
495   }
496
497   handleEditCancel() {
498     this.state.showEditSite = false;
499     this.setState(this.state);
500   }
501
502   nextPage(i: Main) {
503     i.state.page++;
504     i.state.loading = true;
505     i.setState(i.state);
506     i.updateUrl();
507     i.fetchData();
508     window.scrollTo(0, 0);
509   }
510
511   prevPage(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   handleSortChange(val: SortType) {
521     this.state.sort = val;
522     this.state.page = 1;
523     this.state.loading = true;
524     this.setState(this.state);
525     this.updateUrl();
526     this.fetchData();
527     window.scrollTo(0, 0);
528   }
529
530   handleListingTypeChange(val: ListingType) {
531     this.state.listingType = val;
532     this.state.page = 1;
533     this.state.loading = true;
534     this.setState(this.state);
535     this.updateUrl();
536     this.fetchData();
537     window.scrollTo(0, 0);
538   }
539
540   handleDataTypeChange(val: DataType) {
541     this.state.dataType = val;
542     this.state.page = 1;
543     this.state.loading = true;
544     this.setState(this.state);
545     this.updateUrl();
546     this.fetchData();
547     window.scrollTo(0, 0);
548   }
549
550   fetchData() {
551     if (this.state.dataType == DataType.Post) {
552       let getPostsForm: GetPostsForm = {
553         page: this.state.page,
554         limit: fetchLimit,
555         sort: SortType[this.state.sort],
556         type_: ListingType[this.state.listingType],
557       };
558       WebSocketService.Instance.getPosts(getPostsForm);
559     } else {
560       let getCommentsForm: GetCommentsForm = {
561         page: this.state.page,
562         limit: fetchLimit,
563         sort: SortType[this.state.sort],
564         type_: ListingType[this.state.listingType],
565       };
566       WebSocketService.Instance.getComments(getCommentsForm);
567     }
568   }
569
570   parseMessage(msg: WebSocketJsonResponse) {
571     console.log(msg);
572     let res = wsJsonToRes(msg);
573     if (msg.error) {
574       toast(i18n.t(msg.error), 'danger');
575       return;
576     } else if (msg.reconnect) {
577       this.fetchData();
578     } else if (res.op == UserOperation.GetFollowedCommunities) {
579       let data = res.data as GetFollowedCommunitiesResponse;
580       this.state.subscribedCommunities = data.communities;
581       this.setState(this.state);
582     } else if (res.op == UserOperation.ListCommunities) {
583       let data = res.data as ListCommunitiesResponse;
584       this.state.trendingCommunities = data.communities;
585       this.setState(this.state);
586     } else if (res.op == UserOperation.GetSite) {
587       let data = res.data as GetSiteResponse;
588
589       // This means it hasn't been set up yet
590       if (!data.site) {
591         this.context.router.history.push('/setup');
592       }
593       this.state.siteRes.admins = data.admins;
594       this.state.siteRes.site = data.site;
595       this.state.siteRes.banned = data.banned;
596       this.state.siteRes.online = data.online;
597       this.setState(this.state);
598       document.title = `${WebSocketService.Instance.site.name}`;
599     } else if (res.op == UserOperation.EditSite) {
600       let data = res.data as SiteResponse;
601       this.state.siteRes.site = data.site;
602       this.state.showEditSite = false;
603       this.setState(this.state);
604     } else if (res.op == UserOperation.GetPosts) {
605       let data = res.data as GetPostsResponse;
606       this.state.posts = data.posts;
607       this.state.loading = false;
608       this.setState(this.state);
609     } else if (res.op == UserOperation.CreatePost) {
610       let data = res.data as PostResponse;
611
612       // If you're on subscribed, only push it if you're subscribed.
613       if (this.state.listingType == ListingType.Subscribed) {
614         if (
615           this.state.subscribedCommunities
616             .map(c => c.community_id)
617             .includes(data.post.community_id)
618         ) {
619           this.state.posts.unshift(data.post);
620         }
621       } else {
622         this.state.posts.unshift(data.post);
623       }
624
625       this.setState(this.state);
626     } else if (res.op == UserOperation.EditPost) {
627       let data = res.data as PostResponse;
628       editPostFindRes(data, this.state.posts);
629       this.setState(this.state);
630     } else if (res.op == UserOperation.CreatePostLike) {
631       let data = res.data as PostResponse;
632       createPostLikeFindRes(data, this.state.posts);
633       this.setState(this.state);
634     } else if (res.op == UserOperation.AddAdmin) {
635       let data = res.data as AddAdminResponse;
636       this.state.siteRes.admins = data.admins;
637       this.setState(this.state);
638     } else if (res.op == UserOperation.BanUser) {
639       let data = res.data as BanUserResponse;
640       let found = this.state.siteRes.banned.find(u => (u.id = data.user.id));
641
642       // Remove the banned if its found in the list, and the action is an unban
643       if (found && !data.banned) {
644         this.state.siteRes.banned = this.state.siteRes.banned.filter(
645           i => i.id !== data.user.id
646         );
647       } else {
648         this.state.siteRes.banned.push(data.user);
649       }
650
651       this.state.posts
652         .filter(p => p.creator_id == data.user.id)
653         .forEach(p => (p.banned = data.banned));
654
655       this.setState(this.state);
656     } else if (res.op == UserOperation.GetComments) {
657       let data = res.data as GetCommentsResponse;
658       this.state.comments = data.comments;
659       this.state.loading = false;
660       this.setState(this.state);
661     } else if (res.op == UserOperation.EditComment) {
662       let data = res.data as CommentResponse;
663       editCommentRes(data, this.state.comments);
664       this.setState(this.state);
665     } else if (res.op == UserOperation.CreateComment) {
666       let data = res.data as CommentResponse;
667
668       // Necessary since it might be a user reply
669       if (data.recipient_ids.length == 0) {
670         // If you're on subscribed, only push it if you're subscribed.
671         if (this.state.listingType == ListingType.Subscribed) {
672           if (
673             this.state.subscribedCommunities
674               .map(c => c.community_id)
675               .includes(data.comment.community_id)
676           ) {
677             this.state.comments.unshift(data.comment);
678           }
679         } else {
680           this.state.comments.unshift(data.comment);
681         }
682         this.setState(this.state);
683       }
684     } else if (res.op == UserOperation.SaveComment) {
685       let data = res.data as CommentResponse;
686       saveCommentRes(data, this.state.comments);
687       this.setState(this.state);
688     } else if (res.op == UserOperation.CreateCommentLike) {
689       let data = res.data as CommentResponse;
690       createCommentLikeRes(data, this.state.comments);
691       this.setState(this.state);
692     }
693   }
694 }