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';
22 WebSocketJsonResponse,
23 } from '../interfaces';
24 import { WebSocketService, UserService } from '../services';
29 capitalizeFirstLetter,
38 createPostLikeFindRes,
42 import { PostListing } from './post-listing';
43 import { SortSelect } from './sort-select';
44 import { ListingTypeSelect } from './listing-type-select';
45 import { CommentNodes } from './comment-nodes';
46 import { MomentTime } from './moment-time';
47 import { i18n } from '../i18next';
60 follows: Array<CommunityUser>;
61 moderates: Array<CommunityUser>;
62 comments: Array<Comment>;
65 admins: Array<UserView>;
70 avatarLoading: boolean;
71 userSettingsForm: UserSettingsForm;
72 userSettingsLoading: boolean;
73 deleteAccountLoading: boolean;
74 deleteAccountShowConfirm: boolean;
75 deleteAccountForm: DeleteAccountForm;
78 export class User extends Component<any, UserState> {
79 private subscription: Subscription;
80 private emptyState: UserState = {
85 number_of_posts: null,
87 number_of_comments: null,
92 send_notifications_to_email: null,
102 avatarLoading: false,
103 view: this.getViewFromProps(this.props),
104 sort: this.getSortTypeFromProps(this.props),
105 page: this.getPageFromProps(this.props),
109 default_sort_type: null,
110 default_listing_type: null,
113 send_notifications_to_email: null,
116 userSettingsLoading: null,
117 deleteAccountLoading: null,
118 deleteAccountShowConfirm: false,
124 constructor(props: any, context: any) {
125 super(props, context);
127 this.state = this.emptyState;
128 this.handleSortChange = this.handleSortChange.bind(this);
129 this.handleUserSettingsSortTypeChange = this.handleUserSettingsSortTypeChange.bind(
132 this.handleUserSettingsListingTypeChange = this.handleUserSettingsListingTypeChange.bind(
136 this.state.user_id = Number(this.props.match.params.id);
137 this.state.username = this.props.match.params.username;
139 this.subscription = WebSocketService.Instance.subject
140 .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
142 msg => this.parseMessage(msg),
143 err => console.error(err),
144 () => console.log('complete')
150 get isCurrentUser() {
152 UserService.Instance.user &&
153 UserService.Instance.user.id == this.state.user.id
157 getViewFromProps(props: any): View {
158 return props.match.params.view
159 ? View[capitalizeFirstLetter(props.match.params.view)]
163 getSortTypeFromProps(props: any): SortType {
164 return props.match.params.sort
165 ? routeSortTypeToEnum(props.match.params.sort)
169 getPageFromProps(props: any): number {
170 return props.match.params.page ? Number(props.match.params.page) : 1;
173 componentWillUnmount() {
174 this.subscription.unsubscribe();
177 // Necessary for back button for some reason
178 componentWillReceiveProps(nextProps: any) {
180 nextProps.history.action == 'POP' ||
181 nextProps.history.action == 'PUSH'
183 this.state.view = this.getViewFromProps(nextProps);
184 this.state.sort = this.getSortTypeFromProps(nextProps);
185 this.state.page = this.getPageFromProps(nextProps);
186 this.setState(this.state);
191 componentDidUpdate(lastProps: any, _lastState: UserState, _snapshot: any) {
192 // Necessary if you are on a post and you click another post (same route)
194 lastProps.location.pathname.split('/')[2] !==
195 lastProps.history.location.pathname.split('/')[2]
197 // Couldnt get a refresh working. This does for now.
204 <div class="container">
205 {this.state.loading ? (
207 <svg class="icon icon-spinner spin">
208 <use xlinkHref="#icon-spinner"></use>
213 <div class="col-12 col-md-8">
215 {this.state.user.avatar && showAvatars() && (
219 src={this.state.user.avatar}
220 class="rounded-circle mr-2"
223 <span>/u/{this.state.user.name}</span>
226 {this.state.view == View.Overview && this.overview()}
227 {this.state.view == View.Comments && this.comments()}
228 {this.state.view == View.Posts && this.posts()}
229 {this.state.view == View.Saved && this.overview()}
232 <div class="col-12 col-md-4">
234 {this.isCurrentUser && this.userSettings()}
246 <div class="btn-group btn-group-toggle">
248 className={`btn btn-sm btn-secondary pointer btn-outline-light
249 ${this.state.view == View.Overview && 'active'}
254 value={View.Overview}
255 checked={this.state.view == View.Overview}
256 onChange={linkEvent(this, this.handleViewChange)}
261 className={`btn btn-sm btn-secondary pointer btn-outline-light
262 ${this.state.view == View.Comments && 'active'}
267 value={View.Comments}
268 checked={this.state.view == View.Comments}
269 onChange={linkEvent(this, this.handleViewChange)}
274 className={`btn btn-sm btn-secondary pointer btn-outline-light
275 ${this.state.view == View.Posts && 'active'}
281 checked={this.state.view == View.Posts}
282 onChange={linkEvent(this, this.handleViewChange)}
287 className={`btn btn-sm btn-secondary pointer btn-outline-light
288 ${this.state.view == View.Saved && 'active'}
294 checked={this.state.view == View.Saved}
295 onChange={linkEvent(this, this.handleViewChange)}
305 <div className="mb-2">
306 <span class="mr-3">{this.viewRadios()}</span>
308 sort={this.state.sort}
309 onChange={this.handleSortChange}
313 href={`/feeds/u/${this.state.username}.xml?sort=${
314 SortType[this.state.sort]
319 <svg class="icon mx-2 text-muted small">
320 <use xlinkHref="#icon-rss">#</use>
328 let combined: Array<{ type_: string; data: Comment | Post }> = [];
329 let comments = this.state.comments.map(e => {
330 return { type_: 'comments', data: e };
332 let posts = this.state.posts.map(e => {
333 return { type_: 'posts', data: e };
336 combined.push(...comments);
337 combined.push(...posts);
340 if (this.state.sort == SortType.New) {
341 combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
343 combined.sort((a, b) => b.data.score - a.data.score);
350 {i.type_ == 'posts' ? (
352 post={i.data as Post}
353 admins={this.state.admins}
358 nodes={[{ comment: i.data as Comment }]}
359 admins={this.state.admins}
374 nodes={commentsToFlatNodes(this.state.comments)}
375 admins={this.state.admins}
386 {this.state.posts.map(post => (
387 <PostListing post={post} admins={this.state.admins} showCommunity />
394 let user = this.state.user;
397 <div class="card border-secondary mb-3">
398 <div class="card-body">
400 <ul class="list-inline mb-0">
401 <li className="list-inline-item">{user.name}</li>
403 <li className="list-inline-item badge badge-danger">
410 {i18n.t('joined')} <MomentTime data={user} showAgo />
412 <div class="table-responsive mt-1">
413 <table class="table table-bordered table-sm mt-2 mb-0">
416 <td class="text-center" colSpan={2}>
417 {i18n.t('number_of_points', {
418 count: user.post_score + user.comment_score,
426 {i18n.t('number_of_points', { count: user.post_score })}
430 {i18n.t('number_of_posts', { count: user.number_of_posts })}
436 {i18n.t('number_of_points', { count: user.comment_score })}
440 {i18n.t('number_of_comments', {
441 count: user.number_of_comments,
447 {this.isCurrentUser ? (
449 class="btn btn-block btn-secondary mt-3"
450 onClick={linkEvent(this, this.handleLogoutClick)}
457 className={`btn btn-block btn-secondary mt-3 ${
458 !this.state.user.matrix_user_id && 'disabled'
461 href={`https://matrix.to/#/${this.state.user.matrix_user_id}`}
463 {i18n.t('send_secure_message')}
466 class="btn btn-block btn-secondary mt-3"
467 to={`/create_private_message?recipient_id=${this.state.user.id}`}
469 {i18n.t('send_message')}
482 <div class="card border-secondary mb-3">
483 <div class="card-body">
484 <h5>{i18n.t('settings')}</h5>
485 <form onSubmit={linkEvent(this, this.handleUserSettingsSubmit)}>
486 <div class="form-group">
487 <label>{i18n.t('avatar')}</label>
488 <form class="d-inline">
490 htmlFor="file-upload"
491 class="pointer ml-4 text-muted small font-weight-bold"
493 {!this.state.userSettingsForm.avatar ? (
494 <span class="btn btn-sm btn-secondary">
495 {i18n.t('upload_avatar')}
501 src={this.state.userSettingsForm.avatar}
502 class="rounded-circle"
509 accept="image/*,video/*"
512 disabled={!UserService.Instance.user}
513 onChange={linkEvent(this, this.handleImageUpload)}
517 <div class="form-group">
518 <label>{i18n.t('language')}</label>
520 value={this.state.userSettingsForm.lang}
521 onChange={linkEvent(this, this.handleUserSettingsLangChange)}
522 class="ml-2 custom-select custom-select-sm w-auto"
524 <option disabled>{i18n.t('language')}</option>
525 <option value="browser">{i18n.t('browser_default')}</option>
526 <option disabled>──</option>
527 {languages.map(lang => (
528 <option value={lang.code}>{lang.name}</option>
532 <div class="form-group">
533 <label>{i18n.t('theme')}</label>
535 value={this.state.userSettingsForm.theme}
536 onChange={linkEvent(this, this.handleUserSettingsThemeChange)}
537 class="ml-2 custom-select custom-select-sm w-auto"
539 <option disabled>{i18n.t('theme')}</option>
540 {themes.map(theme => (
541 <option value={theme}>{theme}</option>
545 <form className="form-group">
547 <div class="mr-2">{i18n.t('sort_type')}</div>
550 type_={this.state.userSettingsForm.default_listing_type}
551 onChange={this.handleUserSettingsListingTypeChange}
554 <form className="form-group">
556 <div class="mr-2">{i18n.t('type')}</div>
559 sort={this.state.userSettingsForm.default_sort_type}
560 onChange={this.handleUserSettingsSortTypeChange}
563 <div class="form-group row">
564 <label class="col-lg-3 col-form-label" htmlFor="user-email">
567 <div class="col-lg-9">
572 placeholder={i18n.t('optional')}
573 value={this.state.userSettingsForm.email}
576 this.handleUserSettingsEmailChange
582 <div class="form-group row">
583 <label class="col-lg-5 col-form-label">
584 <a href="https://about.riot.im/" target="_blank">
585 {i18n.t('matrix_user_id')}
588 <div class="col-lg-7">
592 placeholder="@user:example.com"
593 value={this.state.userSettingsForm.matrix_user_id}
596 this.handleUserSettingsMatrixUserIdChange
602 <div class="form-group row">
603 <label class="col-lg-5 col-form-label" htmlFor="user-password">
604 {i18n.t('new_password')}
606 <div class="col-lg-7">
611 value={this.state.userSettingsForm.new_password}
612 autoComplete="new-password"
615 this.handleUserSettingsNewPasswordChange
620 <div class="form-group row">
622 class="col-lg-5 col-form-label"
623 htmlFor="user-verify-password"
625 {i18n.t('verify_password')}
627 <div class="col-lg-7">
630 id="user-verify-password"
632 value={this.state.userSettingsForm.new_password_verify}
633 autoComplete="new-password"
636 this.handleUserSettingsNewPasswordVerifyChange
641 <div class="form-group row">
643 class="col-lg-5 col-form-label"
644 htmlFor="user-old-password"
646 {i18n.t('old_password')}
648 <div class="col-lg-7">
651 id="user-old-password"
653 value={this.state.userSettingsForm.old_password}
654 autoComplete="new-password"
657 this.handleUserSettingsOldPasswordChange
662 {WebSocketService.Instance.site.enable_nsfw && (
663 <div class="form-group">
664 <div class="form-check">
666 class="form-check-input"
669 checked={this.state.userSettingsForm.show_nsfw}
672 this.handleUserSettingsShowNsfwChange
675 <label class="form-check-label" htmlFor="user-show-nsfw">
676 {i18n.t('show_nsfw')}
681 <div class="form-group">
682 <div class="form-check">
684 class="form-check-input"
685 id="user-show-avatars"
687 checked={this.state.userSettingsForm.show_avatars}
690 this.handleUserSettingsShowAvatarsChange
693 <label class="form-check-label" htmlFor="user-show-avatars">
694 {i18n.t('show_avatars')}
698 <div class="form-group">
699 <div class="form-check">
701 class="form-check-input"
702 id="user-send-notifications-to-email"
704 disabled={!this.state.user.email}
706 this.state.userSettingsForm.send_notifications_to_email
710 this.handleUserSettingsSendNotificationsToEmailChange
714 class="form-check-label"
715 htmlFor="user-send-notifications-to-email"
717 {i18n.t('send_notifications_to_email')}
721 <div class="form-group">
722 <button type="submit" class="btn btn-block btn-secondary mr-4">
723 {this.state.userSettingsLoading ? (
724 <svg class="icon icon-spinner spin">
725 <use xlinkHref="#icon-spinner"></use>
728 capitalizeFirstLetter(i18n.t('save'))
733 <div class="form-group mb-0">
735 class="btn btn-block btn-danger"
738 this.handleDeleteAccountShowConfirmToggle
741 {i18n.t('delete_account')}
743 {this.state.deleteAccountShowConfirm && (
745 <div class="my-2 alert alert-danger" role="alert">
746 {i18n.t('delete_account_confirm')}
750 value={this.state.deleteAccountForm.password}
751 autoComplete="new-password"
754 this.handleDeleteAccountPasswordChange
756 class="form-control my-2"
759 class="btn btn-danger mr-4"
760 disabled={!this.state.deleteAccountForm.password}
761 onClick={linkEvent(this, this.handleDeleteAccount)}
763 {this.state.deleteAccountLoading ? (
764 <svg class="icon icon-spinner spin">
765 <use xlinkHref="#icon-spinner"></use>
768 capitalizeFirstLetter(i18n.t('delete'))
772 class="btn btn-secondary"
775 this.handleDeleteAccountShowConfirmToggle
793 {this.state.moderates.length > 0 && (
794 <div class="card border-secondary mb-3">
795 <div class="card-body">
796 <h5>{i18n.t('moderates')}</h5>
797 <ul class="list-unstyled mb-0">
798 {this.state.moderates.map(community => (
800 <Link to={`/c/${community.community_name}`}>
801 {community.community_name}
816 {this.state.follows.length > 0 && (
817 <div class="card border-secondary mb-3">
818 <div class="card-body">
819 <h5>{i18n.t('subscribed')}</h5>
820 <ul class="list-unstyled mb-0">
821 {this.state.follows.map(community => (
823 <Link to={`/c/${community.community_name}`}>
824 {community.community_name}
839 {this.state.page > 1 && (
841 class="btn btn-sm btn-secondary mr-1"
842 onClick={linkEvent(this, this.prevPage)}
848 class="btn btn-sm btn-secondary"
849 onClick={linkEvent(this, this.nextPage)}
858 let viewStr = View[this.state.view].toLowerCase();
859 let sortStr = SortType[this.state.sort].toLowerCase();
860 this.props.history.push(
861 `/u/${this.state.user.name}/view/${viewStr}/sort/${sortStr}/page/${this.state.page}`
880 let form: GetUserDetailsForm = {
881 user_id: this.state.user_id,
882 username: this.state.username,
883 sort: SortType[this.state.sort],
884 saved_only: this.state.view == View.Saved,
885 page: this.state.page,
888 WebSocketService.Instance.getUserDetails(form);
891 handleSortChange(val: SortType) {
892 this.state.sort = val;
894 this.setState(this.state);
899 handleViewChange(i: User, event: any) {
900 i.state.view = Number(event.target.value);
907 handleUserSettingsShowNsfwChange(i: User, event: any) {
908 i.state.userSettingsForm.show_nsfw = event.target.checked;
912 handleUserSettingsShowAvatarsChange(i: User, event: any) {
913 i.state.userSettingsForm.show_avatars = event.target.checked;
914 UserService.Instance.user.show_avatars = event.target.checked; // Just for instant updates
918 handleUserSettingsSendNotificationsToEmailChange(i: User, event: any) {
919 i.state.userSettingsForm.send_notifications_to_email = event.target.checked;
923 handleUserSettingsThemeChange(i: User, event: any) {
924 i.state.userSettingsForm.theme = event.target.value;
925 setTheme(event.target.value);
929 handleUserSettingsLangChange(i: User, event: any) {
930 i.state.userSettingsForm.lang = event.target.value;
931 i18n.changeLanguage(i.state.userSettingsForm.lang);
935 handleUserSettingsSortTypeChange(val: SortType) {
936 this.state.userSettingsForm.default_sort_type = val;
937 this.setState(this.state);
940 handleUserSettingsListingTypeChange(val: ListingType) {
941 this.state.userSettingsForm.default_listing_type = val;
942 this.setState(this.state);
945 handleUserSettingsEmailChange(i: User, event: any) {
946 i.state.userSettingsForm.email = event.target.value;
947 if (i.state.userSettingsForm.email == '' && !i.state.user.email) {
948 i.state.userSettingsForm.email = undefined;
953 handleUserSettingsMatrixUserIdChange(i: User, event: any) {
954 i.state.userSettingsForm.matrix_user_id = event.target.value;
956 i.state.userSettingsForm.matrix_user_id == '' &&
957 !i.state.user.matrix_user_id
959 i.state.userSettingsForm.matrix_user_id = undefined;
964 handleUserSettingsNewPasswordChange(i: User, event: any) {
965 i.state.userSettingsForm.new_password = event.target.value;
966 if (i.state.userSettingsForm.new_password == '') {
967 i.state.userSettingsForm.new_password = undefined;
972 handleUserSettingsNewPasswordVerifyChange(i: User, event: any) {
973 i.state.userSettingsForm.new_password_verify = event.target.value;
974 if (i.state.userSettingsForm.new_password_verify == '') {
975 i.state.userSettingsForm.new_password_verify = undefined;
980 handleUserSettingsOldPasswordChange(i: User, event: any) {
981 i.state.userSettingsForm.old_password = event.target.value;
982 if (i.state.userSettingsForm.old_password == '') {
983 i.state.userSettingsForm.old_password = undefined;
988 handleImageUpload(i: User, event: any) {
989 event.preventDefault();
990 let file = event.target.files[0];
991 const imageUploadUrl = `/pictrs/image`;
992 const formData = new FormData();
993 formData.append('images[]', file);
995 i.state.avatarLoading = true;
998 fetch(imageUploadUrl, {
1002 .then(res => res.json())
1004 console.log('pictrs upload:');
1006 if (res.msg == 'ok') {
1007 let hash = res.files[0].file;
1008 let url = `${window.location.origin}/pictrs/image/${hash}`;
1009 i.state.userSettingsForm.avatar = url;
1010 i.state.avatarLoading = false;
1011 i.setState(i.state);
1013 i.state.avatarLoading = false;
1014 i.setState(i.state);
1015 toast(JSON.stringify(res), 'danger');
1019 i.state.avatarLoading = false;
1020 i.setState(i.state);
1021 toast(error, 'danger');
1025 handleUserSettingsSubmit(i: User, event: any) {
1026 event.preventDefault();
1027 i.state.userSettingsLoading = true;
1028 i.setState(i.state);
1030 WebSocketService.Instance.saveUserSettings(i.state.userSettingsForm);
1033 handleDeleteAccountShowConfirmToggle(i: User, event: any) {
1034 event.preventDefault();
1035 i.state.deleteAccountShowConfirm = !i.state.deleteAccountShowConfirm;
1036 i.setState(i.state);
1039 handleDeleteAccountPasswordChange(i: User, event: any) {
1040 i.state.deleteAccountForm.password = event.target.value;
1041 i.setState(i.state);
1044 handleLogoutClick(i: User) {
1045 UserService.Instance.logout();
1046 i.context.router.history.push('/');
1049 handleDeleteAccount(i: User, event: any) {
1050 event.preventDefault();
1051 i.state.deleteAccountLoading = true;
1052 i.setState(i.state);
1054 WebSocketService.Instance.deleteAccount(i.state.deleteAccountForm);
1057 parseMessage(msg: WebSocketJsonResponse) {
1059 let res = wsJsonToRes(msg);
1061 toast(i18n.t(msg.error), 'danger');
1062 this.state.deleteAccountLoading = false;
1063 this.state.avatarLoading = false;
1064 this.state.userSettingsLoading = false;
1065 if (msg.error == 'couldnt_find_that_username_or_email') {
1066 this.context.router.history.push('/');
1068 this.setState(this.state);
1070 } else if (msg.reconnect) {
1072 } else if (res.op == UserOperation.GetUserDetails) {
1073 let data = res.data as UserDetailsResponse;
1074 this.state.user = data.user;
1075 this.state.comments = data.comments;
1076 this.state.follows = data.follows;
1077 this.state.moderates = data.moderates;
1078 this.state.posts = data.posts;
1079 this.state.admins = data.admins;
1080 this.state.loading = false;
1081 if (this.isCurrentUser) {
1082 this.state.userSettingsForm.show_nsfw =
1083 UserService.Instance.user.show_nsfw;
1084 this.state.userSettingsForm.theme = UserService.Instance.user.theme
1085 ? UserService.Instance.user.theme
1087 this.state.userSettingsForm.default_sort_type =
1088 UserService.Instance.user.default_sort_type;
1089 this.state.userSettingsForm.default_listing_type =
1090 UserService.Instance.user.default_listing_type;
1091 this.state.userSettingsForm.lang = UserService.Instance.user.lang;
1092 this.state.userSettingsForm.avatar = UserService.Instance.user.avatar;
1093 this.state.userSettingsForm.email = this.state.user.email;
1094 this.state.userSettingsForm.send_notifications_to_email = this.state.user.send_notifications_to_email;
1095 this.state.userSettingsForm.show_avatars =
1096 UserService.Instance.user.show_avatars;
1097 this.state.userSettingsForm.matrix_user_id = this.state.user.matrix_user_id;
1099 document.title = `/u/${this.state.user.name} - ${WebSocketService.Instance.site.name}`;
1100 window.scrollTo(0, 0);
1101 this.setState(this.state);
1103 } else if (res.op == UserOperation.EditComment) {
1104 let data = res.data as CommentResponse;
1105 editCommentRes(data, this.state.comments);
1106 this.setState(this.state);
1107 } else if (res.op == UserOperation.CreateComment) {
1108 let data = res.data as CommentResponse;
1110 UserService.Instance.user &&
1111 data.comment.creator_id == UserService.Instance.user.id
1113 toast(i18n.t('reply_sent'));
1115 } else if (res.op == UserOperation.SaveComment) {
1116 let data = res.data as CommentResponse;
1117 saveCommentRes(data, this.state.comments);
1118 this.setState(this.state);
1119 } else if (res.op == UserOperation.CreateCommentLike) {
1120 let data = res.data as CommentResponse;
1121 createCommentLikeRes(data, this.state.comments);
1122 this.setState(this.state);
1123 } else if (res.op == UserOperation.CreatePostLike) {
1124 let data = res.data as PostResponse;
1125 createPostLikeFindRes(data, this.state.posts);
1126 this.setState(this.state);
1127 } else if (res.op == UserOperation.BanUser) {
1128 let data = res.data as BanUserResponse;
1130 .filter(c => c.creator_id == data.user.id)
1131 .forEach(c => (c.banned = data.banned));
1133 .filter(c => c.creator_id == data.user.id)
1134 .forEach(c => (c.banned = data.banned));
1135 this.setState(this.state);
1136 } else if (res.op == UserOperation.AddAdmin) {
1137 let data = res.data as AddAdminResponse;
1138 this.state.admins = data.admins;
1139 this.setState(this.state);
1140 } else if (res.op == UserOperation.SaveUserSettings) {
1141 let data = res.data as LoginResponse;
1142 this.state = this.emptyState;
1143 this.state.userSettingsLoading = false;
1144 this.setState(this.state);
1145 UserService.Instance.login(data);
1146 } else if (res.op == UserOperation.DeleteAccount) {
1147 this.state.deleteAccountLoading = false;
1148 this.state.deleteAccountShowConfirm = false;
1149 this.setState(this.state);
1150 this.context.router.history.push('/');