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