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