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