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