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