]> Untitled Git - lemmy.git/blob - ui/src/components/user.tsx
Merge branch 'master' into email
[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   render() {
182     return (
183       <div class="container">
184         {this.state.loading ? (
185           <h5>
186             <svg class="icon icon-spinner spin">
187               <use xlinkHref="#icon-spinner"></use>
188             </svg>
189           </h5>
190         ) : (
191           <div class="row">
192             <div class="col-12 col-md-8">
193               <h5>/u/{this.state.user.name}</h5>
194               {this.selects()}
195               {this.state.view == View.Overview && this.overview()}
196               {this.state.view == View.Comments && this.comments()}
197               {this.state.view == View.Posts && this.posts()}
198               {this.state.view == View.Saved && this.overview()}
199               {this.paginator()}
200             </div>
201             <div class="col-12 col-md-4">
202               {this.userInfo()}
203               {this.isCurrentUser && this.userSettings()}
204               {this.moderates()}
205               {this.follows()}
206             </div>
207           </div>
208         )}
209       </div>
210     );
211   }
212
213   selects() {
214     return (
215       <div className="mb-2">
216         <select
217           value={this.state.view}
218           onChange={linkEvent(this, this.handleViewChange)}
219           class="custom-select custom-select-sm w-auto"
220         >
221           <option disabled>
222             <T i18nKey="view">#</T>
223           </option>
224           <option value={View.Overview}>
225             <T i18nKey="overview">#</T>
226           </option>
227           <option value={View.Comments}>
228             <T i18nKey="comments">#</T>
229           </option>
230           <option value={View.Posts}>
231             <T i18nKey="posts">#</T>
232           </option>
233           <option value={View.Saved}>
234             <T i18nKey="saved">#</T>
235           </option>
236         </select>
237         <span class="ml-2">
238           <SortSelect
239             sort={this.state.sort}
240             onChange={this.handleSortChange}
241             hideHot
242           />
243         </span>
244       </div>
245     );
246   }
247
248   overview() {
249     let combined: Array<{ type_: string; data: Comment | Post }> = [];
250     let comments = this.state.comments.map(e => {
251       return { type_: 'comments', data: e };
252     });
253     let posts = this.state.posts.map(e => {
254       return { type_: 'posts', data: e };
255     });
256
257     combined.push(...comments);
258     combined.push(...posts);
259
260     // Sort it
261     if (this.state.sort == SortType.New) {
262       combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
263     } else {
264       combined.sort((a, b) => b.data.score - a.data.score);
265     }
266
267     return (
268       <div>
269         {combined.map(i => (
270           <div>
271             {i.type_ == 'posts' ? (
272               <PostListing
273                 post={i.data as Post}
274                 admins={this.state.admins}
275                 showCommunity
276                 viewOnly
277               />
278             ) : (
279               <CommentNodes
280                 nodes={[{ comment: i.data as Comment }]}
281                 admins={this.state.admins}
282                 noIndent
283               />
284             )}
285           </div>
286         ))}
287       </div>
288     );
289   }
290
291   comments() {
292     return (
293       <div>
294         {this.state.comments.map(comment => (
295           <CommentNodes
296             nodes={[{ comment: comment }]}
297             admins={this.state.admins}
298             noIndent
299           />
300         ))}
301       </div>
302     );
303   }
304
305   posts() {
306     return (
307       <div>
308         {this.state.posts.map(post => (
309           <PostListing
310             post={post}
311             admins={this.state.admins}
312             showCommunity
313             viewOnly
314           />
315         ))}
316       </div>
317     );
318   }
319
320   userInfo() {
321     let user = this.state.user;
322     return (
323       <div>
324         <div class="card border-secondary mb-3">
325           <div class="card-body">
326             <h5>
327               <ul class="list-inline mb-0">
328                 <li className="list-inline-item">{user.name}</li>
329                 {user.banned && (
330                   <li className="list-inline-item badge badge-danger">
331                     <T i18nKey="banned">#</T>
332                   </li>
333                 )}
334               </ul>
335             </h5>
336             <div>
337               {i18n.t('joined')} <MomentTime data={user} />
338             </div>
339             <div class="table-responsive">
340               <table class="table table-bordered table-sm mt-2 mb-0">
341                 <tr>
342                   <td>
343                     <T
344                       i18nKey="number_of_points"
345                       interpolation={{ count: user.post_score }}
346                     >
347                       #
348                     </T>
349                   </td>
350                   <td>
351                     <T
352                       i18nKey="number_of_posts"
353                       interpolation={{ count: user.number_of_posts }}
354                     >
355                       #
356                     </T>
357                   </td>
358                 </tr>
359                 <tr>
360                   <td>
361                     <T
362                       i18nKey="number_of_points"
363                       interpolation={{ count: user.comment_score }}
364                     >
365                       #
366                     </T>
367                   </td>
368                   <td>
369                     <T
370                       i18nKey="number_of_comments"
371                       interpolation={{ count: user.number_of_comments }}
372                     >
373                       #
374                     </T>
375                   </td>
376                 </tr>
377               </table>
378             </div>
379             {this.isCurrentUser && (
380               <button
381                 class="btn btn-block btn-secondary mt-3"
382                 onClick={linkEvent(this, this.handleLogoutClick)}
383               >
384                 <T i18nKey="logout">#</T>
385               </button>
386             )}
387           </div>
388         </div>
389       </div>
390     );
391   }
392
393   userSettings() {
394     return (
395       <div>
396         <div class="card border-secondary mb-3">
397           <div class="card-body">
398             <h5>
399               <T i18nKey="settings">#</T>
400             </h5>
401             <form onSubmit={linkEvent(this, this.handleUserSettingsSubmit)}>
402               <div class="form-group">
403                 <div class="col-12">
404                   <label>
405                     <T i18nKey="theme">#</T>
406                   </label>
407                   <select
408                     value={this.state.userSettingsForm.theme}
409                     onChange={linkEvent(
410                       this,
411                       this.handleUserSettingsThemeChange
412                     )}
413                     class="ml-2 custom-select custom-select-sm w-auto"
414                   >
415                     <option disabled>
416                       <T i18nKey="theme">#</T>
417                     </option>
418                     {themes.map(theme => (
419                       <option value={theme}>{theme}</option>
420                     ))}
421                   </select>
422                 </div>
423               </div>
424               <form className="form-group">
425                 <div class="col-12">
426                   <label>
427                     <T i18nKey="sort_type" class="mr-2">
428                       #
429                     </T>
430                   </label>
431                   <ListingTypeSelect
432                     type_={this.state.userSettingsForm.default_listing_type}
433                     onChange={this.handleUserSettingsListingTypeChange}
434                   />
435                 </div>
436               </form>
437               <form className="form-group">
438                 <div class="col-12">
439                   <label>
440                     <T i18nKey="type" class="mr-2">
441                       #
442                     </T>
443                   </label>
444                   <SortSelect
445                     sort={this.state.userSettingsForm.default_sort_type}
446                     onChange={this.handleUserSettingsSortTypeChange}
447                   />
448                 </div>
449               </form>
450               <div class="form-group">
451                 <div class="col-12">
452                   <div class="form-check">
453                     <input
454                       class="form-check-input"
455                       type="checkbox"
456                       checked={this.state.userSettingsForm.show_nsfw}
457                       onChange={linkEvent(
458                         this,
459                         this.handleUserSettingsShowNsfwChange
460                       )}
461                     />
462                     <label class="form-check-label">
463                       <T i18nKey="show_nsfw">#</T>
464                     </label>
465                   </div>
466                 </div>
467               </div>
468               <div class="form-group">
469                 <div class="col-12">
470                   <button
471                     type="submit"
472                     class="btn btn-block btn-secondary mr-4"
473                   >
474                     {this.state.userSettingsLoading ? (
475                       <svg class="icon icon-spinner spin">
476                         <use xlinkHref="#icon-spinner"></use>
477                       </svg>
478                     ) : (
479                       capitalizeFirstLetter(i18n.t('save'))
480                     )}
481                   </button>
482                 </div>
483               </div>
484               <hr />
485               <div class="form-group mb-0">
486                 <div class="col-12">
487                   <button
488                     class="btn btn-block btn-danger"
489                     onClick={linkEvent(
490                       this,
491                       this.handleDeleteAccountShowConfirmToggle
492                     )}
493                   >
494                     <T i18nKey="delete_account">#</T>
495                   </button>
496                   {this.state.deleteAccountShowConfirm && (
497                     <>
498                       <div class="my-2 alert alert-danger" role="alert">
499                         <T i18nKey="delete_account_confirm">#</T>
500                       </div>
501                       <input
502                         type="password"
503                         value={this.state.deleteAccountForm.password}
504                         onInput={linkEvent(
505                           this,
506                           this.handleDeleteAccountPasswordChange
507                         )}
508                         class="form-control my-2"
509                       />
510                       <button
511                         class="btn btn-danger mr-4"
512                         disabled={!this.state.deleteAccountForm.password}
513                         onClick={linkEvent(this, this.handleDeleteAccount)}
514                       >
515                         {this.state.deleteAccountLoading ? (
516                           <svg class="icon icon-spinner spin">
517                             <use xlinkHref="#icon-spinner"></use>
518                           </svg>
519                         ) : (
520                           capitalizeFirstLetter(i18n.t('delete'))
521                         )}
522                       </button>
523                       <button
524                         class="btn btn-secondary"
525                         onClick={linkEvent(
526                           this,
527                           this.handleDeleteAccountShowConfirmToggle
528                         )}
529                       >
530                         <T i18nKey="cancel">#</T>
531                       </button>
532                     </>
533                   )}
534                 </div>
535               </div>
536             </form>
537           </div>
538         </div>
539       </div>
540     );
541   }
542
543   moderates() {
544     return (
545       <div>
546         {this.state.moderates.length > 0 && (
547           <div class="card border-secondary mb-3">
548             <div class="card-body">
549               <h5>
550                 <T i18nKey="moderates">#</T>
551               </h5>
552               <ul class="list-unstyled mb-0">
553                 {this.state.moderates.map(community => (
554                   <li>
555                     <Link to={`/c/${community.community_name}`}>
556                       {community.community_name}
557                     </Link>
558                   </li>
559                 ))}
560               </ul>
561             </div>
562           </div>
563         )}
564       </div>
565     );
566   }
567
568   follows() {
569     return (
570       <div>
571         {this.state.follows.length > 0 && (
572           <div class="card border-secondary mb-3">
573             <div class="card-body">
574               <h5>
575                 <T i18nKey="subscribed">#</T>
576               </h5>
577               <ul class="list-unstyled mb-0">
578                 {this.state.follows.map(community => (
579                   <li>
580                     <Link to={`/c/${community.community_name}`}>
581                       {community.community_name}
582                     </Link>
583                   </li>
584                 ))}
585               </ul>
586             </div>
587           </div>
588         )}
589       </div>
590     );
591   }
592
593   paginator() {
594     return (
595       <div class="my-2">
596         {this.state.page > 1 && (
597           <button
598             class="btn btn-sm btn-secondary mr-1"
599             onClick={linkEvent(this, this.prevPage)}
600           >
601             <T i18nKey="prev">#</T>
602           </button>
603         )}
604         <button
605           class="btn btn-sm btn-secondary"
606           onClick={linkEvent(this, this.nextPage)}
607         >
608           <T i18nKey="next">#</T>
609         </button>
610       </div>
611     );
612   }
613
614   updateUrl() {
615     let viewStr = View[this.state.view].toLowerCase();
616     let sortStr = SortType[this.state.sort].toLowerCase();
617     this.props.history.push(
618       `/u/${this.state.user.name}/view/${viewStr}/sort/${sortStr}/page/${this.state.page}`
619     );
620   }
621
622   nextPage(i: User) {
623     i.state.page++;
624     i.setState(i.state);
625     i.updateUrl();
626     i.refetch();
627   }
628
629   prevPage(i: User) {
630     i.state.page--;
631     i.setState(i.state);
632     i.updateUrl();
633     i.refetch();
634   }
635
636   refetch() {
637     let form: GetUserDetailsForm = {
638       user_id: this.state.user_id,
639       username: this.state.username,
640       sort: SortType[this.state.sort],
641       saved_only: this.state.view == View.Saved,
642       page: this.state.page,
643       limit: fetchLimit,
644     };
645     WebSocketService.Instance.getUserDetails(form);
646   }
647
648   handleSortChange(val: SortType) {
649     this.state.sort = val;
650     this.state.page = 1;
651     this.setState(this.state);
652     this.updateUrl();
653     this.refetch();
654   }
655
656   handleViewChange(i: User, event: any) {
657     i.state.view = Number(event.target.value);
658     i.state.page = 1;
659     i.setState(i.state);
660     i.updateUrl();
661     i.refetch();
662   }
663
664   handleUserSettingsShowNsfwChange(i: User, event: any) {
665     i.state.userSettingsForm.show_nsfw = event.target.checked;
666     i.setState(i.state);
667   }
668
669   handleUserSettingsThemeChange(i: User, event: any) {
670     i.state.userSettingsForm.theme = event.target.value;
671     setTheme(event.target.value);
672     i.setState(i.state);
673   }
674
675   handleUserSettingsSortTypeChange(val: SortType) {
676     this.state.userSettingsForm.default_sort_type = val;
677     this.setState(this.state);
678   }
679
680   handleUserSettingsListingTypeChange(val: ListingType) {
681     this.state.userSettingsForm.default_listing_type = val;
682     this.setState(this.state);
683   }
684
685   handleUserSettingsSubmit(i: User, event: any) {
686     event.preventDefault();
687     i.state.userSettingsLoading = true;
688     i.setState(i.state);
689
690     WebSocketService.Instance.saveUserSettings(i.state.userSettingsForm);
691   }
692
693   handleDeleteAccountShowConfirmToggle(i: User, event: any) {
694     event.preventDefault();
695     i.state.deleteAccountShowConfirm = !i.state.deleteAccountShowConfirm;
696     i.setState(i.state);
697   }
698
699   handleDeleteAccountPasswordChange(i: User, event: any) {
700     i.state.deleteAccountForm.password = event.target.value;
701     i.setState(i.state);
702   }
703
704   handleLogoutClick(i: User) {
705     UserService.Instance.logout();
706     i.context.router.history.push('/');
707   }
708
709   handleDeleteAccount(i: User, event: any) {
710     event.preventDefault();
711     i.state.deleteAccountLoading = true;
712     i.setState(i.state);
713
714     WebSocketService.Instance.deleteAccount(i.state.deleteAccountForm);
715   }
716
717   parseMessage(msg: any) {
718     console.log(msg);
719     let op: UserOperation = msgOp(msg);
720     if (msg.error) {
721       alert(i18n.t(msg.error));
722       this.state.deleteAccountLoading = false;
723       this.setState(this.state);
724       return;
725     } else if (op == UserOperation.GetUserDetails) {
726       let res: UserDetailsResponse = msg;
727       this.state.user = res.user;
728       this.state.comments = res.comments;
729       this.state.follows = res.follows;
730       this.state.moderates = res.moderates;
731       this.state.posts = res.posts;
732       this.state.admins = res.admins;
733       this.state.loading = false;
734       if (this.isCurrentUser) {
735         this.state.userSettingsForm.show_nsfw =
736           UserService.Instance.user.show_nsfw;
737         this.state.userSettingsForm.theme = UserService.Instance.user.theme
738           ? UserService.Instance.user.theme
739           : 'darkly';
740         this.state.userSettingsForm.default_sort_type =
741           UserService.Instance.user.default_sort_type;
742         this.state.userSettingsForm.default_listing_type =
743           UserService.Instance.user.default_listing_type;
744       }
745       document.title = `/u/${this.state.user.name} - ${WebSocketService.Instance.site.name}`;
746       window.scrollTo(0, 0);
747       this.setState(this.state);
748     } else if (op == UserOperation.EditComment) {
749       let res: CommentResponse = msg;
750
751       let found = this.state.comments.find(c => c.id == res.comment.id);
752       found.content = res.comment.content;
753       found.updated = res.comment.updated;
754       found.removed = res.comment.removed;
755       found.deleted = res.comment.deleted;
756       found.upvotes = res.comment.upvotes;
757       found.downvotes = res.comment.downvotes;
758       found.score = res.comment.score;
759
760       this.setState(this.state);
761     } else if (op == UserOperation.CreateComment) {
762       // let res: CommentResponse = msg;
763       alert(i18n.t('reply_sent'));
764       // this.state.comments.unshift(res.comment); // TODO do this right
765       // this.setState(this.state);
766     } else if (op == UserOperation.SaveComment) {
767       let res: CommentResponse = msg;
768       let found = this.state.comments.find(c => c.id == res.comment.id);
769       found.saved = res.comment.saved;
770       this.setState(this.state);
771     } else if (op == UserOperation.CreateCommentLike) {
772       let res: CommentResponse = msg;
773       let found: Comment = this.state.comments.find(
774         c => c.id === res.comment.id
775       );
776       found.score = res.comment.score;
777       found.upvotes = res.comment.upvotes;
778       found.downvotes = res.comment.downvotes;
779       if (res.comment.my_vote !== null) found.my_vote = res.comment.my_vote;
780       this.setState(this.state);
781     } else if (op == UserOperation.BanUser) {
782       let res: BanUserResponse = msg;
783       this.state.comments
784         .filter(c => c.creator_id == res.user.id)
785         .forEach(c => (c.banned = res.banned));
786       this.state.posts
787         .filter(c => c.creator_id == res.user.id)
788         .forEach(c => (c.banned = res.banned));
789       this.setState(this.state);
790     } else if (op == UserOperation.AddAdmin) {
791       let res: AddAdminResponse = msg;
792       this.state.admins = res.admins;
793       this.setState(this.state);
794     } else if (op == UserOperation.SaveUserSettings) {
795       this.state = this.emptyState;
796       this.state.userSettingsLoading = false;
797       this.setState(this.state);
798       let res: LoginResponse = msg;
799       UserService.Instance.login(res);
800     } else if (op == UserOperation.DeleteAccount) {
801       this.state.deleteAccountLoading = false;
802       this.state.deleteAccountShowConfirm = false;
803       this.setState(this.state);
804       this.context.router.history.push('/');
805     }
806   }
807 }