]> Untitled Git - lemmy.git/blob - ui/src/components/main.tsx
Combine duplicate front page posts. Fixes #284
[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   SiteResponse,
16   GetPostsResponse,
17   PostResponse,
18   Post,
19   GetPostsForm,
20   AddAdminResponse,
21   BanUserResponse,
22   WebSocketJsonResponse,
23 } from '../interfaces';
24 import { WebSocketService, UserService } from '../services';
25 import { PostListings } from './post-listings';
26 import { SortSelect } from './sort-select';
27 import { ListingTypeSelect } from './listing-type-select';
28 import { SiteForm } from './site-form';
29 import {
30   wsJsonToRes,
31   repoUrl,
32   mdToHtml,
33   fetchLimit,
34   routeSortTypeToEnum,
35   routeListingTypeToEnum,
36   pictshareAvatarThumbnail,
37   showAvatars,
38   toast,
39 } from '../utils';
40 import { i18n } from '../i18next';
41 import { T } from 'inferno-i18next';
42
43 interface MainState {
44   subscribedCommunities: Array<CommunityUser>;
45   trendingCommunities: Array<Community>;
46   siteRes: GetSiteResponse;
47   showEditSite: boolean;
48   loading: boolean;
49   posts: Array<Post>;
50   type_: ListingType;
51   sort: SortType;
52   page: number;
53 }
54
55 export class Main extends Component<any, MainState> {
56   private subscription: Subscription;
57   private emptyState: MainState = {
58     subscribedCommunities: [],
59     trendingCommunities: [],
60     siteRes: {
61       site: {
62         id: null,
63         name: null,
64         creator_id: null,
65         creator_name: null,
66         published: null,
67         number_of_users: null,
68         number_of_posts: null,
69         number_of_comments: null,
70         number_of_communities: null,
71         enable_downvotes: null,
72         open_registration: null,
73         enable_nsfw: null,
74       },
75       admins: [],
76       banned: [],
77       online: null,
78     },
79     showEditSite: false,
80     loading: true,
81     posts: [],
82     type_: this.getListingTypeFromProps(this.props),
83     sort: this.getSortTypeFromProps(this.props),
84     page: this.getPageFromProps(this.props),
85   };
86
87   getListingTypeFromProps(props: any): ListingType {
88     return props.match.params.type
89       ? routeListingTypeToEnum(props.match.params.type)
90       : UserService.Instance.user
91       ? UserService.Instance.user.default_listing_type
92       : ListingType.All;
93   }
94
95   getSortTypeFromProps(props: any): SortType {
96     return props.match.params.sort
97       ? routeSortTypeToEnum(props.match.params.sort)
98       : UserService.Instance.user
99       ? UserService.Instance.user.default_sort_type
100       : SortType.Hot;
101   }
102
103   getPageFromProps(props: any): number {
104     return props.match.params.page ? Number(props.match.params.page) : 1;
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.handleTypeChange = this.handleTypeChange.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.fetchPosts();
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.type_ = this.getListingTypeFromProps(nextProps);
150       this.state.sort = this.getSortTypeFromProps(nextProps);
151       this.state.page = this.getPageFromProps(nextProps);
152       this.setState(this.state);
153       this.fetchPosts();
154     }
155   }
156
157   render() {
158     return (
159       <div class="container">
160         <div class="row">
161           <main role="main" class="col-12 col-md-8">
162             {this.posts()}
163           </main>
164           <aside class="col-12 col-md-4">{this.my_sidebar()}</aside>
165         </div>
166       </div>
167     );
168   }
169
170   my_sidebar() {
171     return (
172       <div>
173         {!this.state.loading && (
174           <div>
175             <div class="card border-secondary mb-3">
176               <div class="card-body">
177                 {this.trendingCommunities()}
178                 {UserService.Instance.user &&
179                   this.state.subscribedCommunities.length > 0 && (
180                     <div>
181                       <h5>
182                         <T i18nKey="subscribed_to_communities">
183                           #
184                           <Link class="text-white" to="/communities">
185                             #
186                           </Link>
187                         </T>
188                       </h5>
189                       <ul class="list-inline">
190                         {this.state.subscribedCommunities.map(community => (
191                           <li class="list-inline-item">
192                             <Link to={`/c/${community.community_name}`}>
193                               {community.community_name}
194                             </Link>
195                           </li>
196                         ))}
197                       </ul>
198                     </div>
199                   )}
200                 <Link
201                   class="btn btn-sm btn-secondary btn-block"
202                   to="/create_community"
203                 >
204                   {i18n.t('create_a_community')}
205                 </Link>
206               </div>
207             </div>
208             {this.sidebar()}
209             {this.landing()}
210           </div>
211         )}
212       </div>
213     );
214   }
215
216   trendingCommunities() {
217     return (
218       <div>
219         <h5>
220           <T i18nKey="trending_communities">
221             #
222             <Link class="text-white" to="/communities">
223               #
224             </Link>
225           </T>
226         </h5>
227         <ul class="list-inline">
228           {this.state.trendingCommunities.map(community => (
229             <li class="list-inline-item">
230               <Link to={`/c/${community.name}`}>{community.name}</Link>
231             </li>
232           ))}
233         </ul>
234       </div>
235     );
236   }
237
238   sidebar() {
239     return (
240       <div>
241         {!this.state.showEditSite ? (
242           this.siteInfo()
243         ) : (
244           <SiteForm
245             site={this.state.siteRes.site}
246             onCancel={this.handleEditCancel}
247           />
248         )}
249       </div>
250     );
251   }
252
253   updateUrl() {
254     let typeStr = ListingType[this.state.type_].toLowerCase();
255     let sortStr = SortType[this.state.sort].toLowerCase();
256     this.props.history.push(
257       `/home/type/${typeStr}/sort/${sortStr}/page/${this.state.page}`
258     );
259   }
260
261   siteInfo() {
262     return (
263       <div>
264         <div class="card border-secondary mb-3">
265           <div class="card-body">
266             <h5 class="mb-0">{`${this.state.siteRes.site.name}`}</h5>
267             {this.canAdmin && (
268               <ul class="list-inline mb-1 text-muted small font-weight-bold">
269                 <li className="list-inline-item">
270                   <span
271                     class="pointer"
272                     onClick={linkEvent(this, this.handleEditClick)}
273                   >
274                     {i18n.t('edit')}
275                   </span>
276                 </li>
277               </ul>
278             )}
279             <ul class="my-2 list-inline">
280               <li className="list-inline-item badge badge-secondary">
281                 {i18n.t('number_online', { count: this.state.siteRes.online })}
282               </li>
283               <li className="list-inline-item badge badge-secondary">
284                 {i18n.t('number_of_users', {
285                   count: this.state.siteRes.site.number_of_users,
286                 })}
287               </li>
288               <li className="list-inline-item badge badge-secondary">
289                 {i18n.t('number_of_communities', {
290                   count: this.state.siteRes.site.number_of_communities,
291                 })}
292               </li>
293               <li className="list-inline-item badge badge-secondary">
294                 {i18n.t('number_of_posts', {
295                   count: this.state.siteRes.site.number_of_posts,
296                 })}
297               </li>
298               <li className="list-inline-item badge badge-secondary">
299                 {i18n.t('number_of_comments', {
300                   count: this.state.siteRes.site.number_of_comments,
301                 })}
302               </li>
303               <li className="list-inline-item">
304                 <Link className="badge badge-secondary" to="/modlog">
305                   {i18n.t('modlog')}
306                 </Link>
307               </li>
308             </ul>
309             <ul class="mt-1 list-inline small mb-0">
310               <li class="list-inline-item">{i18n.t('admins')}:</li>
311               {this.state.siteRes.admins.map(admin => (
312                 <li class="list-inline-item">
313                   <Link class="text-info" to={`/u/${admin.name}`}>
314                     {admin.avatar && showAvatars() && (
315                       <img
316                         height="32"
317                         width="32"
318                         src={pictshareAvatarThumbnail(admin.avatar)}
319                         class="rounded-circle mr-1"
320                       />
321                     )}
322                     <span>{admin.name}</span>
323                   </Link>
324                 </li>
325               ))}
326             </ul>
327           </div>
328         </div>
329         {this.state.siteRes.site.description && (
330           <div class="card border-secondary mb-3">
331             <div class="card-body">
332               <div
333                 className="md-div"
334                 dangerouslySetInnerHTML={mdToHtml(
335                   this.state.siteRes.site.description
336                 )}
337               />
338             </div>
339           </div>
340         )}
341       </div>
342     );
343   }
344
345   landing() {
346     return (
347       <div class="card border-secondary">
348         <div class="card-body">
349           <h5>
350             {i18n.t('powered_by')}
351             <svg class="icon mx-2">
352               <use xlinkHref="#icon-mouse">#</use>
353             </svg>
354             <a href={repoUrl}>
355               Lemmy<sup>beta</sup>
356             </a>
357           </h5>
358           <p class="mb-0">
359             <T i18nKey="landing_0">
360               #
361               <a href="https://en.wikipedia.org/wiki/Social_network_aggregation">
362                 #
363               </a>
364               <a href="https://en.wikipedia.org/wiki/Fediverse">#</a>
365               <br></br>
366               <code>#</code>
367               <br></br>
368               <b>#</b>
369               <br></br>
370               <a href={repoUrl}>#</a>
371               <br></br>
372               <a href="https://www.rust-lang.org">#</a>
373               <a href="https://actix.rs/">#</a>
374               <a href="https://infernojs.org">#</a>
375               <a href="https://www.typescriptlang.org/">#</a>
376             </T>
377           </p>
378         </div>
379       </div>
380     );
381   }
382
383   posts() {
384     return (
385       <div class="main-content-wrapper">
386         {this.state.loading ? (
387           <h5>
388             <svg class="icon icon-spinner spin">
389               <use xlinkHref="#icon-spinner"></use>
390             </svg>
391           </h5>
392         ) : (
393           <div>
394             {this.selects()}
395             <PostListings
396               posts={this.state.posts}
397               showCommunity
398               removeDuplicates
399             />
400             {this.paginator()}
401           </div>
402         )}
403       </div>
404     );
405   }
406
407   selects() {
408     return (
409       <div className="mb-3">
410         <ListingTypeSelect
411           type_={this.state.type_}
412           onChange={this.handleTypeChange}
413         />
414         <span class="mx-2">
415           <SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
416         </span>
417         {this.state.type_ == ListingType.All && (
418           <a
419             href={`/feeds/all.xml?sort=${SortType[this.state.sort]}`}
420             target="_blank"
421           >
422             <svg class="icon mx-1 text-muted small">
423               <use xlinkHref="#icon-rss">#</use>
424             </svg>
425           </a>
426         )}
427         {UserService.Instance.user &&
428           this.state.type_ == ListingType.Subscribed && (
429             <a
430               href={`/feeds/front/${UserService.Instance.auth}.xml?sort=${
431                 SortType[this.state.sort]
432               }`}
433               target="_blank"
434             >
435               <svg class="icon mx-1 text-muted small">
436                 <use xlinkHref="#icon-rss">#</use>
437               </svg>
438             </a>
439           )}
440       </div>
441     );
442   }
443
444   paginator() {
445     return (
446       <div class="my-2">
447         {this.state.page > 1 && (
448           <button
449             class="btn btn-sm btn-secondary mr-1"
450             onClick={linkEvent(this, this.prevPage)}
451           >
452             {i18n.t('prev')}
453           </button>
454         )}
455         {this.state.posts.length == fetchLimit && (
456           <button
457             class="btn btn-sm btn-secondary"
458             onClick={linkEvent(this, this.nextPage)}
459           >
460             {i18n.t('next')}
461           </button>
462         )}
463       </div>
464     );
465   }
466
467   get canAdmin(): boolean {
468     return (
469       UserService.Instance.user &&
470       this.state.siteRes.admins
471         .map(a => a.id)
472         .includes(UserService.Instance.user.id)
473     );
474   }
475
476   handleEditClick(i: Main) {
477     i.state.showEditSite = true;
478     i.setState(i.state);
479   }
480
481   handleEditCancel() {
482     this.state.showEditSite = false;
483     this.setState(this.state);
484   }
485
486   nextPage(i: Main) {
487     i.state.page++;
488     i.state.loading = true;
489     i.setState(i.state);
490     i.updateUrl();
491     i.fetchPosts();
492     window.scrollTo(0, 0);
493   }
494
495   prevPage(i: Main) {
496     i.state.page--;
497     i.state.loading = true;
498     i.setState(i.state);
499     i.updateUrl();
500     i.fetchPosts();
501     window.scrollTo(0, 0);
502   }
503
504   handleSortChange(val: SortType) {
505     this.state.sort = val;
506     this.state.page = 1;
507     this.state.loading = true;
508     this.setState(this.state);
509     this.updateUrl();
510     this.fetchPosts();
511     window.scrollTo(0, 0);
512   }
513
514   handleTypeChange(val: ListingType) {
515     this.state.type_ = val;
516     this.state.page = 1;
517     this.state.loading = true;
518     this.setState(this.state);
519     this.updateUrl();
520     this.fetchPosts();
521     window.scrollTo(0, 0);
522   }
523
524   fetchPosts() {
525     let getPostsForm: GetPostsForm = {
526       page: this.state.page,
527       limit: fetchLimit,
528       sort: SortType[this.state.sort],
529       type_: ListingType[this.state.type_],
530     };
531     WebSocketService.Instance.getPosts(getPostsForm);
532   }
533
534   parseMessage(msg: WebSocketJsonResponse) {
535     console.log(msg);
536     let res = wsJsonToRes(msg);
537     if (msg.error) {
538       toast(i18n.t(msg.error), 'danger');
539       return;
540     } else if (res.op == UserOperation.GetFollowedCommunities) {
541       let data = res.data as GetFollowedCommunitiesResponse;
542       this.state.subscribedCommunities = data.communities;
543       this.setState(this.state);
544     } else if (res.op == UserOperation.ListCommunities) {
545       let data = res.data as ListCommunitiesResponse;
546       this.state.trendingCommunities = data.communities;
547       this.setState(this.state);
548     } else if (res.op == UserOperation.GetSite) {
549       let data = res.data as GetSiteResponse;
550
551       // This means it hasn't been set up yet
552       if (!data.site) {
553         this.context.router.history.push('/setup');
554       }
555       this.state.siteRes.admins = data.admins;
556       this.state.siteRes.site = data.site;
557       this.state.siteRes.banned = data.banned;
558       this.state.siteRes.online = data.online;
559       this.setState(this.state);
560       document.title = `${WebSocketService.Instance.site.name}`;
561     } else if (res.op == UserOperation.EditSite) {
562       let data = res.data as SiteResponse;
563       this.state.siteRes.site = data.site;
564       this.state.showEditSite = false;
565       this.setState(this.state);
566     } else if (res.op == UserOperation.GetPosts) {
567       let data = res.data as GetPostsResponse;
568       this.state.posts = data.posts;
569       this.state.loading = false;
570       this.setState(this.state);
571     } else if (res.op == UserOperation.CreatePost) {
572       let data = res.data as PostResponse;
573
574       // If you're on subscribed, only push it if you're subscribed.
575       if (this.state.type_ == ListingType.Subscribed) {
576         if (
577           this.state.subscribedCommunities
578             .map(c => c.community_id)
579             .includes(data.post.community_id)
580         ) {
581           this.state.posts.unshift(data.post);
582         }
583       } else {
584         this.state.posts.unshift(data.post);
585       }
586
587       this.setState(this.state);
588     } else if (res.op == UserOperation.EditPost) {
589       let data = res.data as PostResponse;
590       let found = this.state.posts.find(c => c.id == data.post.id);
591
592       found.url = data.post.url;
593       found.name = data.post.name;
594       found.nsfw = data.post.nsfw;
595
596       this.setState(this.state);
597     } else if (res.op == UserOperation.CreatePostLike) {
598       let data = res.data as PostResponse;
599       let found = this.state.posts.find(c => c.id == data.post.id);
600
601       found.score = data.post.score;
602       found.upvotes = data.post.upvotes;
603       found.downvotes = data.post.downvotes;
604       if (data.post.my_vote !== null) {
605         found.my_vote = data.post.my_vote;
606         found.upvoteLoading = false;
607         found.downvoteLoading = false;
608       }
609
610       this.setState(this.state);
611     } else if (res.op == UserOperation.AddAdmin) {
612       let data = res.data as AddAdminResponse;
613       this.state.siteRes.admins = data.admins;
614       this.setState(this.state);
615     } else if (res.op == UserOperation.BanUser) {
616       let data = res.data as BanUserResponse;
617       let found = this.state.siteRes.banned.find(u => (u.id = data.user.id));
618
619       // Remove the banned if its found in the list, and the action is an unban
620       if (found && !data.banned) {
621         this.state.siteRes.banned = this.state.siteRes.banned.filter(
622           i => i.id !== data.user.id
623         );
624       } else {
625         this.state.siteRes.banned.push(data.user);
626       }
627
628       this.state.posts
629         .filter(p => p.creator_id == data.user.id)
630         .forEach(p => (p.banned = data.banned));
631
632       this.setState(this.state);
633     }
634   }
635 }