]> Untitled Git - lemmy.git/blob - ui/src/components/user.tsx
Merge branch 'dev'
[lemmy.git] / ui / src / components / user.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   Post,
8   Comment,
9   CommunityUser,
10   GetUserDetailsForm,
11   SortType,
12   ListingType,
13   UserDetailsResponse,
14   UserView,
15   CommentResponse,
16   UserSettingsForm,
17   LoginResponse,
18   BanUserResponse,
19   AddAdminResponse,
20   DeleteAccountForm,
21 } from '../interfaces';
22 import { WebSocketService, UserService } from '../services';
23 import {
24   msgOp,
25   fetchLimit,
26   routeSortTypeToEnum,
27   capitalizeFirstLetter,
28   themes,
29   setTheme,
30 } from '../utils';
31 import { PostListing } from './post-listing';
32 import { SortSelect } from './sort-select';
33 import { ListingTypeSelect } from './listing-type-select';
34 import { CommentNodes } from './comment-nodes';
35 import { MomentTime } from './moment-time';
36 import { i18n } from '../i18next';
37 import { T } from 'inferno-i18next';
38
39 enum View {
40   Overview,
41   Comments,
42   Posts,
43   Saved,
44 }
45
46 interface UserState {
47   user: UserView;
48   user_id: number;
49   username: string;
50   follows: Array<CommunityUser>;
51   moderates: Array<CommunityUser>;
52   comments: Array<Comment>;
53   posts: Array<Post>;
54   saved?: Array<Post>;
55   admins: Array<UserView>;
56   view: View;
57   sort: SortType;
58   page: number;
59   loading: boolean;
60   userSettingsForm: UserSettingsForm;
61   userSettingsLoading: boolean;
62   deleteAccountLoading: boolean;
63   deleteAccountShowConfirm: boolean;
64   deleteAccountForm: DeleteAccountForm;
65 }
66
67 export class User extends Component<any, UserState> {
68   private subscription: Subscription;
69   private emptyState: UserState = {
70     user: {
71       id: null,
72       name: null,
73       fedi_name: null,
74       published: null,
75       number_of_posts: null,
76       post_score: null,
77       number_of_comments: null,
78       comment_score: null,
79       banned: null,
80     },
81     user_id: null,
82     username: null,
83     follows: [],
84     moderates: [],
85     comments: [],
86     posts: [],
87     admins: [],
88     loading: true,
89     view: this.getViewFromProps(this.props),
90     sort: this.getSortTypeFromProps(this.props),
91     page: this.getPageFromProps(this.props),
92     userSettingsForm: {
93       show_nsfw: null,
94       theme: null,
95       default_sort_type: null,
96       default_listing_type: null,
97       auth: null,
98     },
99     userSettingsLoading: null,
100     deleteAccountLoading: null,
101     deleteAccountShowConfirm: false,
102     deleteAccountForm: {
103       password: null,
104     },
105   };
106
107   constructor(props: any, context: any) {
108     super(props, context);
109
110     this.state = this.emptyState;
111     this.handleSortChange = this.handleSortChange.bind(this);
112     this.handleUserSettingsSortTypeChange = this.handleUserSettingsSortTypeChange.bind(
113       this
114     );
115     this.handleUserSettingsListingTypeChange = this.handleUserSettingsListingTypeChange.bind(
116       this
117     );
118
119     this.state.user_id = Number(this.props.match.params.id);
120     this.state.username = this.props.match.params.username;
121
122     this.subscription = WebSocketService.Instance.subject
123       .pipe(
124         retryWhen(errors =>
125           errors.pipe(
126             delay(3000),
127             take(10)
128           )
129         )
130       )
131       .subscribe(
132         msg => this.parseMessage(msg),
133         err => console.error(err),
134         () => console.log('complete')
135       );
136
137     this.refetch();
138   }
139
140   get isCurrentUser() {
141     return (
142       UserService.Instance.user &&
143       UserService.Instance.user.id == this.state.user.id
144     );
145   }
146
147   getViewFromProps(props: any): View {
148     return props.match.params.view
149       ? View[capitalizeFirstLetter(props.match.params.view)]
150       : View.Overview;
151   }
152
153   getSortTypeFromProps(props: any): SortType {
154     return props.match.params.sort
155       ? routeSortTypeToEnum(props.match.params.sort)
156       : SortType.New;
157   }
158
159   getPageFromProps(props: any): number {
160     return props.match.params.page ? Number(props.match.params.page) : 1;
161   }
162
163   componentWillUnmount() {
164     this.subscription.unsubscribe();
165   }
166
167   // Necessary for back button for some reason
168   componentWillReceiveProps(nextProps: any) {
169     if (
170       nextProps.history.action == 'POP' ||
171       nextProps.history.action == 'PUSH'
172     ) {
173       this.state.view = this.getViewFromProps(nextProps);
174       this.state.sort = this.getSortTypeFromProps(nextProps);
175       this.state.page = this.getPageFromProps(nextProps);
176       this.setState(this.state);
177       this.refetch();
178     }
179   }
180
181   componentDidUpdate(lastProps: any, _lastState: UserState, _snapshot: any) {
182     // Necessary if you are on a post and you click another post (same route)
183     if (
184       lastProps.location.pathname.split('/')[2] !==
185       lastProps.history.location.pathname.split('/')[2]
186     ) {
187       // Couldnt get a refresh working. This does for now.
188       location.reload();
189     }
190   }
191
192   render() {
193     return (
194       <div class="container">
195         {this.state.loading ? (
196           <h5>
197             <svg class="icon icon-spinner spin">
198               <use xlinkHref="#icon-spinner"></use>
199             </svg>
200           </h5>
201         ) : (
202           <div class="row">
203             <div class="col-12 col-md-8">
204               <h5>/u/{this.state.user.name}</h5>
205               {this.selects()}
206               {this.state.view == View.Overview && this.overview()}
207               {this.state.view == View.Comments && this.comments()}
208               {this.state.view == View.Posts && this.posts()}
209               {this.state.view == View.Saved && this.overview()}
210               {this.paginator()}
211             </div>
212             <div class="col-12 col-md-4">
213               {this.userInfo()}
214               {this.isCurrentUser && this.userSettings()}
215               {this.moderates()}
216               {this.follows()}
217             </div>
218           </div>
219         )}
220       </div>
221     );
222   }
223
224   selects() {
225     return (
226       <div className="mb-2">
227         <select
228           value={this.state.view}
229           onChange={linkEvent(this, this.handleViewChange)}
230           class="custom-select custom-select-sm w-auto"
231         >
232           <option disabled>
233             <T i18nKey="view">#</T>
234           </option>
235           <option value={View.Overview}>
236             <T i18nKey="overview">#</T>
237           </option>
238           <option value={View.Comments}>
239             <T i18nKey="comments">#</T>
240           </option>
241           <option value={View.Posts}>
242             <T i18nKey="posts">#</T>
243           </option>
244           <option value={View.Saved}>
245             <T i18nKey="saved">#</T>
246           </option>
247         </select>
248         <span class="ml-2">
249           <SortSelect
250             sort={this.state.sort}
251             onChange={this.handleSortChange}
252             hideHot
253           />
254         </span>
255         <a
256           href={`/feeds/u/${this.state.username}.xml?sort=${
257             SortType[this.state.sort]
258           }`}
259           target="_blank"
260         >
261           <svg class="icon mx-2 text-muted small">
262             <use xlinkHref="#icon-rss">#</use>
263           </svg>
264         </a>
265       </div>
266     );
267   }
268
269   overview() {
270     let combined: Array<{ type_: string; data: Comment | Post }> = [];
271     let comments = this.state.comments.map(e => {
272       return { type_: 'comments', data: e };
273     });
274     let posts = this.state.posts.map(e => {
275       return { type_: 'posts', data: e };
276     });
277
278     combined.push(...comments);
279     combined.push(...posts);
280
281     // Sort it
282     if (this.state.sort == SortType.New) {
283       combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
284     } else {
285       combined.sort((a, b) => b.data.score - a.data.score);
286     }
287
288     return (
289       <div>
290         {combined.map(i => (
291           <div>
292             {i.type_ == 'posts' ? (
293               <PostListing
294                 post={i.data as Post}
295                 admins={this.state.admins}
296                 showCommunity
297                 viewOnly
298               />
299             ) : (
300               <CommentNodes
301                 nodes={[{ comment: i.data as Comment }]}
302                 admins={this.state.admins}
303                 noIndent
304               />
305             )}
306           </div>
307         ))}
308       </div>
309     );
310   }
311
312   comments() {
313     return (
314       <div>
315         {this.state.comments.map(comment => (
316           <CommentNodes
317             nodes={[{ comment: comment }]}
318             admins={this.state.admins}
319             noIndent
320           />
321         ))}
322       </div>
323     );
324   }
325
326   posts() {
327     return (
328       <div>
329         {this.state.posts.map(post => (
330           <PostListing
331             post={post}
332             admins={this.state.admins}
333             showCommunity
334             viewOnly
335           />
336         ))}
337       </div>
338     );
339   }
340
341   userInfo() {
342     let user = this.state.user;
343     return (
344       <div>
345         <div class="card border-secondary mb-3">
346           <div class="card-body">
347             <h5>
348               <ul class="list-inline mb-0">
349                 <li className="list-inline-item">{user.name}</li>
350                 {user.banned && (
351                   <li className="list-inline-item badge badge-danger">
352                     <T i18nKey="banned">#</T>
353                   </li>
354                 )}
355               </ul>
356             </h5>
357             <div>
358               {i18n.t('joined')} <MomentTime data={user} />
359             </div>
360             <div class="table-responsive">
361               <table class="table table-bordered table-sm mt-2 mb-0">
362                 <tr>
363                   <td>
364                     <T
365                       i18nKey="number_of_points"
366                       interpolation={{ count: user.post_score }}
367                     >
368                       #
369                     </T>
370                   </td>
371                   <td>
372                     <T
373                       i18nKey="number_of_posts"
374                       interpolation={{ count: user.number_of_posts }}
375                     >
376                       #
377                     </T>
378                   </td>
379                 </tr>
380                 <tr>
381                   <td>
382                     <T
383                       i18nKey="number_of_points"
384                       interpolation={{ count: user.comment_score }}
385                     >
386                       #
387                     </T>
388                   </td>
389                   <td>
390                     <T
391                       i18nKey="number_of_comments"
392                       interpolation={{ count: user.number_of_comments }}
393                     >
394                       #
395                     </T>
396                   </td>
397                 </tr>
398               </table>
399             </div>
400             {this.isCurrentUser && (
401               <button
402                 class="btn btn-block btn-secondary mt-3"
403                 onClick={linkEvent(this, this.handleLogoutClick)}
404               >
405                 <T i18nKey="logout">#</T>
406               </button>
407             )}
408           </div>
409         </div>
410       </div>
411     );
412   }
413
414   userSettings() {
415     return (
416       <div>
417         <div class="card border-secondary mb-3">
418           <div class="card-body">
419             <h5>
420               <T i18nKey="settings">#</T>
421             </h5>
422             <form onSubmit={linkEvent(this, this.handleUserSettingsSubmit)}>
423               <div class="form-group">
424                 <div class="col-12">
425                   <label>
426                     <T i18nKey="theme">#</T>
427                   </label>
428                   <select
429                     value={this.state.userSettingsForm.theme}
430                     onChange={linkEvent(
431                       this,
432                       this.handleUserSettingsThemeChange
433                     )}
434                     class="ml-2 custom-select custom-select-sm w-auto"
435                   >
436                     <option disabled>
437                       <T i18nKey="theme">#</T>
438                     </option>
439                     {themes.map(theme => (
440                       <option value={theme}>{theme}</option>
441                     ))}
442                   </select>
443                 </div>
444               </div>
445               <form className="form-group">
446                 <div class="col-12">
447                   <label>
448                     <T i18nKey="sort_type" class="mr-2">
449                       #
450                     </T>
451                   </label>
452                   <ListingTypeSelect
453                     type_={this.state.userSettingsForm.default_listing_type}
454                     onChange={this.handleUserSettingsListingTypeChange}
455                   />
456                 </div>
457               </form>
458               <form className="form-group">
459                 <div class="col-12">
460                   <label>
461                     <T i18nKey="type" class="mr-2">
462                       #
463                     </T>
464                   </label>
465                   <SortSelect
466                     sort={this.state.userSettingsForm.default_sort_type}
467                     onChange={this.handleUserSettingsSortTypeChange}
468                   />
469                 </div>
470               </form>
471               <div class="form-group">
472                 <div class="col-12">
473                   <div class="form-check">
474                     <input
475                       class="form-check-input"
476                       type="checkbox"
477                       checked={this.state.userSettingsForm.show_nsfw}
478                       onChange={linkEvent(
479                         this,
480                         this.handleUserSettingsShowNsfwChange
481                       )}
482                     />
483                     <label class="form-check-label">
484                       <T i18nKey="show_nsfw">#</T>
485                     </label>
486                   </div>
487                 </div>
488               </div>
489               <div class="form-group">
490                 <div class="col-12">
491                   <button
492                     type="submit"
493                     class="btn btn-block btn-secondary mr-4"
494                   >
495                     {this.state.userSettingsLoading ? (
496                       <svg class="icon icon-spinner spin">
497                         <use xlinkHref="#icon-spinner"></use>
498                       </svg>
499                     ) : (
500                       capitalizeFirstLetter(i18n.t('save'))
501                     )}
502                   </button>
503                 </div>
504               </div>
505               <hr />
506               <div class="form-group mb-0">
507                 <div class="col-12">
508                   <button
509                     class="btn btn-block btn-danger"
510                     onClick={linkEvent(
511                       this,
512                       this.handleDeleteAccountShowConfirmToggle
513                     )}
514                   >
515                     <T i18nKey="delete_account">#</T>
516                   </button>
517                   {this.state.deleteAccountShowConfirm && (
518                     <>
519                       <div class="my-2 alert alert-danger" role="alert">
520                         <T i18nKey="delete_account_confirm">#</T>
521                       </div>
522                       <input
523                         type="password"
524                         value={this.state.deleteAccountForm.password}
525                         onInput={linkEvent(
526                           this,
527                           this.handleDeleteAccountPasswordChange
528                         )}
529                         class="form-control my-2"
530                       />
531                       <button
532                         class="btn btn-danger mr-4"
533                         disabled={!this.state.deleteAccountForm.password}
534                         onClick={linkEvent(this, this.handleDeleteAccount)}
535                       >
536                         {this.state.deleteAccountLoading ? (
537                           <svg class="icon icon-spinner spin">
538                             <use xlinkHref="#icon-spinner"></use>
539                           </svg>
540                         ) : (
541                           capitalizeFirstLetter(i18n.t('delete'))
542                         )}
543                       </button>
544                       <button
545                         class="btn btn-secondary"
546                         onClick={linkEvent(
547                           this,
548                           this.handleDeleteAccountShowConfirmToggle
549                         )}
550                       >
551                         <T i18nKey="cancel">#</T>
552                       </button>
553                     </>
554                   )}
555                 </div>
556               </div>
557             </form>
558           </div>
559         </div>
560       </div>
561     );
562   }
563
564   moderates() {
565     return (
566       <div>
567         {this.state.moderates.length > 0 && (
568           <div class="card border-secondary mb-3">
569             <div class="card-body">
570               <h5>
571                 <T i18nKey="moderates">#</T>
572               </h5>
573               <ul class="list-unstyled mb-0">
574                 {this.state.moderates.map(community => (
575                   <li>
576                     <Link to={`/c/${community.community_name}`}>
577                       {community.community_name}
578                     </Link>
579                   </li>
580                 ))}
581               </ul>
582             </div>
583           </div>
584         )}
585       </div>
586     );
587   }
588
589   follows() {
590     return (
591       <div>
592         {this.state.follows.length > 0 && (
593           <div class="card border-secondary mb-3">
594             <div class="card-body">
595               <h5>
596                 <T i18nKey="subscribed">#</T>
597               </h5>
598               <ul class="list-unstyled mb-0">
599                 {this.state.follows.map(community => (
600                   <li>
601                     <Link to={`/c/${community.community_name}`}>
602                       {community.community_name}
603                     </Link>
604                   </li>
605                 ))}
606               </ul>
607             </div>
608           </div>
609         )}
610       </div>
611     );
612   }
613
614   paginator() {
615     return (
616       <div class="my-2">
617         {this.state.page > 1 && (
618           <button
619             class="btn btn-sm btn-secondary mr-1"
620             onClick={linkEvent(this, this.prevPage)}
621           >
622             <T i18nKey="prev">#</T>
623           </button>
624         )}
625         <button
626           class="btn btn-sm btn-secondary"
627           onClick={linkEvent(this, this.nextPage)}
628         >
629           <T i18nKey="next">#</T>
630         </button>
631       </div>
632     );
633   }
634
635   updateUrl() {
636     let viewStr = View[this.state.view].toLowerCase();
637     let sortStr = SortType[this.state.sort].toLowerCase();
638     this.props.history.push(
639       `/u/${this.state.user.name}/view/${viewStr}/sort/${sortStr}/page/${this.state.page}`
640     );
641   }
642
643   nextPage(i: User) {
644     i.state.page++;
645     i.setState(i.state);
646     i.updateUrl();
647     i.refetch();
648   }
649
650   prevPage(i: User) {
651     i.state.page--;
652     i.setState(i.state);
653     i.updateUrl();
654     i.refetch();
655   }
656
657   refetch() {
658     let form: GetUserDetailsForm = {
659       user_id: this.state.user_id,
660       username: this.state.username,
661       sort: SortType[this.state.sort],
662       saved_only: this.state.view == View.Saved,
663       page: this.state.page,
664       limit: fetchLimit,
665     };
666     WebSocketService.Instance.getUserDetails(form);
667   }
668
669   handleSortChange(val: SortType) {
670     this.state.sort = val;
671     this.state.page = 1;
672     this.setState(this.state);
673     this.updateUrl();
674     this.refetch();
675   }
676
677   handleViewChange(i: User, event: any) {
678     i.state.view = Number(event.target.value);
679     i.state.page = 1;
680     i.setState(i.state);
681     i.updateUrl();
682     i.refetch();
683   }
684
685   handleUserSettingsShowNsfwChange(i: User, event: any) {
686     i.state.userSettingsForm.show_nsfw = event.target.checked;
687     i.setState(i.state);
688   }
689
690   handleUserSettingsThemeChange(i: User, event: any) {
691     i.state.userSettingsForm.theme = event.target.value;
692     setTheme(event.target.value);
693     i.setState(i.state);
694   }
695
696   handleUserSettingsSortTypeChange(val: SortType) {
697     this.state.userSettingsForm.default_sort_type = val;
698     this.setState(this.state);
699   }
700
701   handleUserSettingsListingTypeChange(val: ListingType) {
702     this.state.userSettingsForm.default_listing_type = val;
703     this.setState(this.state);
704   }
705
706   handleUserSettingsSubmit(i: User, event: any) {
707     event.preventDefault();
708     i.state.userSettingsLoading = true;
709     i.setState(i.state);
710
711     WebSocketService.Instance.saveUserSettings(i.state.userSettingsForm);
712   }
713
714   handleDeleteAccountShowConfirmToggle(i: User, event: any) {
715     event.preventDefault();
716     i.state.deleteAccountShowConfirm = !i.state.deleteAccountShowConfirm;
717     i.setState(i.state);
718   }
719
720   handleDeleteAccountPasswordChange(i: User, event: any) {
721     i.state.deleteAccountForm.password = event.target.value;
722     i.setState(i.state);
723   }
724
725   handleLogoutClick(i: User) {
726     UserService.Instance.logout();
727     i.context.router.history.push('/');
728   }
729
730   handleDeleteAccount(i: User, event: any) {
731     event.preventDefault();
732     i.state.deleteAccountLoading = true;
733     i.setState(i.state);
734
735     WebSocketService.Instance.deleteAccount(i.state.deleteAccountForm);
736   }
737
738   parseMessage(msg: any) {
739     console.log(msg);
740     let op: UserOperation = msgOp(msg);
741     if (msg.error) {
742       alert(i18n.t(msg.error));
743       this.state.deleteAccountLoading = false;
744       this.setState(this.state);
745       return;
746     } else if (op == UserOperation.GetUserDetails) {
747       let res: UserDetailsResponse = msg;
748       this.state.user = res.user;
749       this.state.comments = res.comments;
750       this.state.follows = res.follows;
751       this.state.moderates = res.moderates;
752       this.state.posts = res.posts;
753       this.state.admins = res.admins;
754       this.state.loading = false;
755       if (this.isCurrentUser) {
756         this.state.userSettingsForm.show_nsfw =
757           UserService.Instance.user.show_nsfw;
758         this.state.userSettingsForm.theme = UserService.Instance.user.theme
759           ? UserService.Instance.user.theme
760           : 'darkly';
761         this.state.userSettingsForm.default_sort_type =
762           UserService.Instance.user.default_sort_type;
763         this.state.userSettingsForm.default_listing_type =
764           UserService.Instance.user.default_listing_type;
765       }
766       document.title = `/u/${this.state.user.name} - ${WebSocketService.Instance.site.name}`;
767       window.scrollTo(0, 0);
768       this.setState(this.state);
769     } else if (op == UserOperation.EditComment) {
770       let res: CommentResponse = msg;
771
772       let found = this.state.comments.find(c => c.id == res.comment.id);
773       found.content = res.comment.content;
774       found.updated = res.comment.updated;
775       found.removed = res.comment.removed;
776       found.deleted = res.comment.deleted;
777       found.upvotes = res.comment.upvotes;
778       found.downvotes = res.comment.downvotes;
779       found.score = res.comment.score;
780
781       this.setState(this.state);
782     } else if (op == UserOperation.CreateComment) {
783       // let res: CommentResponse = msg;
784       alert(i18n.t('reply_sent'));
785       // this.state.comments.unshift(res.comment); // TODO do this right
786       // this.setState(this.state);
787     } else if (op == UserOperation.SaveComment) {
788       let res: CommentResponse = msg;
789       let found = this.state.comments.find(c => c.id == res.comment.id);
790       found.saved = res.comment.saved;
791       this.setState(this.state);
792     } else if (op == UserOperation.CreateCommentLike) {
793       let res: CommentResponse = msg;
794       let found: Comment = this.state.comments.find(
795         c => c.id === res.comment.id
796       );
797       found.score = res.comment.score;
798       found.upvotes = res.comment.upvotes;
799       found.downvotes = res.comment.downvotes;
800       if (res.comment.my_vote !== null) found.my_vote = res.comment.my_vote;
801       this.setState(this.state);
802     } else if (op == UserOperation.BanUser) {
803       let res: BanUserResponse = msg;
804       this.state.comments
805         .filter(c => c.creator_id == res.user.id)
806         .forEach(c => (c.banned = res.banned));
807       this.state.posts
808         .filter(c => c.creator_id == res.user.id)
809         .forEach(c => (c.banned = res.banned));
810       this.setState(this.state);
811     } else if (op == UserOperation.AddAdmin) {
812       let res: AddAdminResponse = msg;
813       this.state.admins = res.admins;
814       this.setState(this.state);
815     } else if (op == UserOperation.SaveUserSettings) {
816       this.state = this.emptyState;
817       this.state.userSettingsLoading = false;
818       this.setState(this.state);
819       let res: LoginResponse = msg;
820       UserService.Instance.login(res);
821     } else if (op == UserOperation.DeleteAccount) {
822       this.state.deleteAccountLoading = false;
823       this.state.deleteAccountShowConfirm = false;
824       this.setState(this.state);
825       this.context.router.history.push('/');
826     }
827   }
828 }