]> Untitled Git - lemmy.git/blob - ui/src/components/user.tsx
Adding default sort / filter into user settings.
[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           </div>
380         </div>
381       </div>
382     );
383   }
384
385   userSettings() {
386     return (
387       <div>
388         <div class="card border-secondary mb-3">
389           <div class="card-body">
390             <h5>
391               <T i18nKey="settings">#</T>
392             </h5>
393             <form onSubmit={linkEvent(this, this.handleUserSettingsSubmit)}>
394               <div class="form-group">
395                 <div class="col-12">
396                   <label>
397                     <T i18nKey="theme">#</T>
398                   </label>
399                   <select
400                     value={this.state.userSettingsForm.theme}
401                     onChange={linkEvent(
402                       this,
403                       this.handleUserSettingsThemeChange
404                     )}
405                     class="ml-2 custom-select custom-select-sm w-auto"
406                   >
407                     <option disabled>
408                       <T i18nKey="theme">#</T>
409                     </option>
410                     {themes.map(theme => (
411                       <option value={theme}>{theme}</option>
412                     ))}
413                   </select>
414                 </div>
415               </div>
416               <form className="form-group">
417                 <div class="col-12">
418                   <label>
419                     <T i18nKey="sort_type" class="mr-2">
420                       #
421                     </T>
422                   </label>
423                   <ListingTypeSelect
424                     type_={this.state.userSettingsForm.default_listing_type}
425                     onChange={this.handleUserSettingsListingTypeChange}
426                   />
427                 </div>
428               </form>
429               <form className="form-group">
430                 <div class="col-12">
431                   <label>
432                     <T i18nKey="type" class="mr-2">
433                       #
434                     </T>
435                   </label>
436                   <SortSelect
437                     sort={this.state.userSettingsForm.default_sort_type}
438                     onChange={this.handleUserSettingsSortTypeChange}
439                   />
440                 </div>
441               </form>
442               <div class="form-group">
443                 <div class="col-12">
444                   <div class="form-check">
445                     <input
446                       class="form-check-input"
447                       type="checkbox"
448                       checked={this.state.userSettingsForm.show_nsfw}
449                       onChange={linkEvent(
450                         this,
451                         this.handleUserSettingsShowNsfwChange
452                       )}
453                     />
454                     <label class="form-check-label">
455                       <T i18nKey="show_nsfw">#</T>
456                     </label>
457                   </div>
458                 </div>
459               </div>
460               <div class="form-group">
461                 <div class="col-12">
462                   <button
463                     type="submit"
464                     class="btn btn-block btn-secondary mr-4"
465                   >
466                     {this.state.userSettingsLoading ? (
467                       <svg class="icon icon-spinner spin">
468                         <use xlinkHref="#icon-spinner"></use>
469                       </svg>
470                     ) : (
471                       capitalizeFirstLetter(i18n.t('save'))
472                     )}
473                   </button>
474                 </div>
475               </div>
476               <hr />
477               <div class="form-group mb-0">
478                 <div class="col-12">
479                   <button
480                     class="btn btn-block btn-danger"
481                     onClick={linkEvent(
482                       this,
483                       this.handleDeleteAccountShowConfirmToggle
484                     )}
485                   >
486                     <T i18nKey="delete_account">#</T>
487                   </button>
488                   {this.state.deleteAccountShowConfirm && (
489                     <>
490                       <div class="my-2 alert alert-danger" role="alert">
491                         <T i18nKey="delete_account_confirm">#</T>
492                       </div>
493                       <input
494                         type="password"
495                         value={this.state.deleteAccountForm.password}
496                         onInput={linkEvent(
497                           this,
498                           this.handleDeleteAccountPasswordChange
499                         )}
500                         class="form-control my-2"
501                       />
502                       <button
503                         class="btn btn-danger mr-4"
504                         disabled={!this.state.deleteAccountForm.password}
505                         onClick={linkEvent(this, this.handleDeleteAccount)}
506                       >
507                         {this.state.deleteAccountLoading ? (
508                           <svg class="icon icon-spinner spin">
509                             <use xlinkHref="#icon-spinner"></use>
510                           </svg>
511                         ) : (
512                           capitalizeFirstLetter(i18n.t('delete'))
513                         )}
514                       </button>
515                       <button
516                         class="btn btn-secondary"
517                         onClick={linkEvent(
518                           this,
519                           this.handleDeleteAccountShowConfirmToggle
520                         )}
521                       >
522                         <T i18nKey="cancel">#</T>
523                       </button>
524                     </>
525                   )}
526                 </div>
527               </div>
528             </form>
529           </div>
530         </div>
531       </div>
532     );
533   }
534
535   moderates() {
536     return (
537       <div>
538         {this.state.moderates.length > 0 && (
539           <div class="card border-secondary mb-3">
540             <div class="card-body">
541               <h5>
542                 <T i18nKey="moderates">#</T>
543               </h5>
544               <ul class="list-unstyled mb-0">
545                 {this.state.moderates.map(community => (
546                   <li>
547                     <Link to={`/c/${community.community_name}`}>
548                       {community.community_name}
549                     </Link>
550                   </li>
551                 ))}
552               </ul>
553             </div>
554           </div>
555         )}
556       </div>
557     );
558   }
559
560   follows() {
561     return (
562       <div>
563         {this.state.follows.length > 0 && (
564           <div class="card border-secondary mb-3">
565             <div class="card-body">
566               <h5>
567                 <T i18nKey="subscribed">#</T>
568               </h5>
569               <ul class="list-unstyled mb-0">
570                 {this.state.follows.map(community => (
571                   <li>
572                     <Link to={`/c/${community.community_name}`}>
573                       {community.community_name}
574                     </Link>
575                   </li>
576                 ))}
577               </ul>
578             </div>
579           </div>
580         )}
581       </div>
582     );
583   }
584
585   paginator() {
586     return (
587       <div class="my-2">
588         {this.state.page > 1 && (
589           <button
590             class="btn btn-sm btn-secondary mr-1"
591             onClick={linkEvent(this, this.prevPage)}
592           >
593             <T i18nKey="prev">#</T>
594           </button>
595         )}
596         <button
597           class="btn btn-sm btn-secondary"
598           onClick={linkEvent(this, this.nextPage)}
599         >
600           <T i18nKey="next">#</T>
601         </button>
602       </div>
603     );
604   }
605
606   updateUrl() {
607     let viewStr = View[this.state.view].toLowerCase();
608     let sortStr = SortType[this.state.sort].toLowerCase();
609     this.props.history.push(
610       `/u/${this.state.user.name}/view/${viewStr}/sort/${sortStr}/page/${this.state.page}`
611     );
612   }
613
614   nextPage(i: User) {
615     i.state.page++;
616     i.setState(i.state);
617     i.updateUrl();
618     i.refetch();
619   }
620
621   prevPage(i: User) {
622     i.state.page--;
623     i.setState(i.state);
624     i.updateUrl();
625     i.refetch();
626   }
627
628   refetch() {
629     let form: GetUserDetailsForm = {
630       user_id: this.state.user_id,
631       username: this.state.username,
632       sort: SortType[this.state.sort],
633       saved_only: this.state.view == View.Saved,
634       page: this.state.page,
635       limit: fetchLimit,
636     };
637     WebSocketService.Instance.getUserDetails(form);
638   }
639
640   handleSortChange(val: SortType) {
641     this.state.sort = val;
642     this.state.page = 1;
643     this.setState(this.state);
644     this.updateUrl();
645     this.refetch();
646   }
647
648   handleViewChange(i: User, event: any) {
649     i.state.view = Number(event.target.value);
650     i.state.page = 1;
651     i.setState(i.state);
652     i.updateUrl();
653     i.refetch();
654   }
655
656   handleUserSettingsShowNsfwChange(i: User, event: any) {
657     i.state.userSettingsForm.show_nsfw = event.target.checked;
658     i.setState(i.state);
659   }
660
661   handleUserSettingsThemeChange(i: User, event: any) {
662     i.state.userSettingsForm.theme = event.target.value;
663     setTheme(event.target.value);
664     i.setState(i.state);
665   }
666
667   handleUserSettingsSortTypeChange(val: SortType) {
668     this.state.userSettingsForm.default_sort_type = val;
669     this.setState(this.state);
670   }
671
672   handleUserSettingsListingTypeChange(val: ListingType) {
673     this.state.userSettingsForm.default_listing_type = val;
674     this.setState(this.state);
675   }
676
677   handleUserSettingsSubmit(i: User, event: any) {
678     event.preventDefault();
679     i.state.userSettingsLoading = true;
680     i.setState(i.state);
681
682     WebSocketService.Instance.saveUserSettings(i.state.userSettingsForm);
683   }
684
685   handleDeleteAccountShowConfirmToggle(i: User, event: any) {
686     event.preventDefault();
687     i.state.deleteAccountShowConfirm = !i.state.deleteAccountShowConfirm;
688     i.setState(i.state);
689   }
690
691   handleDeleteAccountPasswordChange(i: User, event: any) {
692     i.state.deleteAccountForm.password = event.target.value;
693     i.setState(i.state);
694   }
695
696   handleDeleteAccount(i: User, event: any) {
697     event.preventDefault();
698     i.state.deleteAccountLoading = true;
699     i.setState(i.state);
700
701     WebSocketService.Instance.deleteAccount(i.state.deleteAccountForm);
702   }
703
704   parseMessage(msg: any) {
705     console.log(msg);
706     let op: UserOperation = msgOp(msg);
707     if (msg.error) {
708       alert(i18n.t(msg.error));
709       this.state.deleteAccountLoading = false;
710       this.setState(this.state);
711       return;
712     } else if (op == UserOperation.GetUserDetails) {
713       let res: UserDetailsResponse = msg;
714       this.state.user = res.user;
715       this.state.comments = res.comments;
716       this.state.follows = res.follows;
717       this.state.moderates = res.moderates;
718       this.state.posts = res.posts;
719       this.state.admins = res.admins;
720       this.state.loading = false;
721       if (this.isCurrentUser) {
722         this.state.userSettingsForm.show_nsfw =
723           UserService.Instance.user.show_nsfw;
724         this.state.userSettingsForm.theme = UserService.Instance.user.theme
725           ? UserService.Instance.user.theme
726           : 'darkly';
727         this.state.userSettingsForm.default_sort_type =
728           UserService.Instance.user.default_sort_type;
729         this.state.userSettingsForm.default_listing_type =
730           UserService.Instance.user.default_listing_type;
731       }
732       document.title = `/u/${this.state.user.name} - ${WebSocketService.Instance.site.name}`;
733       window.scrollTo(0, 0);
734       this.setState(this.state);
735     } else if (op == UserOperation.EditComment) {
736       let res: CommentResponse = msg;
737
738       let found = this.state.comments.find(c => c.id == res.comment.id);
739       found.content = res.comment.content;
740       found.updated = res.comment.updated;
741       found.removed = res.comment.removed;
742       found.deleted = res.comment.deleted;
743       found.upvotes = res.comment.upvotes;
744       found.downvotes = res.comment.downvotes;
745       found.score = res.comment.score;
746
747       this.setState(this.state);
748     } else if (op == UserOperation.CreateComment) {
749       // let res: CommentResponse = msg;
750       alert(i18n.t('reply_sent'));
751       // this.state.comments.unshift(res.comment); // TODO do this right
752       // this.setState(this.state);
753     } else if (op == UserOperation.SaveComment) {
754       let res: CommentResponse = msg;
755       let found = this.state.comments.find(c => c.id == res.comment.id);
756       found.saved = res.comment.saved;
757       this.setState(this.state);
758     } else if (op == UserOperation.CreateCommentLike) {
759       let res: CommentResponse = msg;
760       let found: Comment = this.state.comments.find(
761         c => c.id === res.comment.id
762       );
763       found.score = res.comment.score;
764       found.upvotes = res.comment.upvotes;
765       found.downvotes = res.comment.downvotes;
766       if (res.comment.my_vote !== null) found.my_vote = res.comment.my_vote;
767       this.setState(this.state);
768     } else if (op == UserOperation.BanUser) {
769       let res: BanUserResponse = msg;
770       this.state.comments
771         .filter(c => c.creator_id == res.user.id)
772         .forEach(c => (c.banned = res.banned));
773       this.state.posts
774         .filter(c => c.creator_id == res.user.id)
775         .forEach(c => (c.banned = res.banned));
776       this.setState(this.state);
777     } else if (op == UserOperation.AddAdmin) {
778       let res: AddAdminResponse = msg;
779       this.state.admins = res.admins;
780       this.setState(this.state);
781     } else if (op == UserOperation.SaveUserSettings) {
782       this.state = this.emptyState;
783       this.state.userSettingsLoading = false;
784       this.setState(this.state);
785       let res: LoginResponse = msg;
786       UserService.Instance.login(res);
787     } else if (op == UserOperation.DeleteAccount) {
788       this.state.deleteAccountLoading = false;
789       this.state.deleteAccountShowConfirm = false;
790       this.setState(this.state);
791       this.context.router.history.push('/');
792     }
793   }
794 }