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