]> Untitled Git - lemmy-ui.git/blob - src/shared/components/person/settings.tsx
Merge branch 'main' into fix/fix-post-title-element
[lemmy-ui.git] / src / shared / components / person / settings.tsx
1 import { NoOptionI18nKeys } from "i18next";
2 import { Component, linkEvent } from "inferno";
3 import {
4   BlockCommunityResponse,
5   BlockPersonResponse,
6   CommunityBlockView,
7   DeleteAccountResponse,
8   GetSiteResponse,
9   ListingType,
10   LoginResponse,
11   PersonBlockView,
12   SortType,
13 } from "lemmy-js-client";
14 import { i18n, languages } from "../../i18next";
15 import { UserService } from "../../services";
16 import { HttpService, RequestState } from "../../services/HttpService";
17 import {
18   Choice,
19   capitalizeFirstLetter,
20   communityToChoice,
21   debounce,
22   elementUrl,
23   emDash,
24   fetchCommunities,
25   fetchThemeList,
26   fetchUsers,
27   myAuth,
28   myAuthRequired,
29   personToChoice,
30   relTags,
31   setIsoData,
32   setTheme,
33   setupTippy,
34   showLocal,
35   toast,
36   updateCommunityBlock,
37   updatePersonBlock,
38 } from "../../utils";
39 import { HtmlTags } from "../common/html-tags";
40 import { Icon, Spinner } from "../common/icon";
41 import { ImageUploadForm } from "../common/image-upload-form";
42 import { LanguageSelect } from "../common/language-select";
43 import { ListingTypeSelect } from "../common/listing-type-select";
44 import { MarkdownTextArea } from "../common/markdown-textarea";
45 import { SearchableSelect } from "../common/searchable-select";
46 import { SortSelect } from "../common/sort-select";
47 import Tabs from "../common/tabs";
48 import { CommunityLink } from "../community/community-link";
49 import { PersonListing } from "./person-listing";
50
51 interface SettingsState {
52   saveRes: RequestState<LoginResponse>;
53   changePasswordRes: RequestState<LoginResponse>;
54   deleteAccountRes: RequestState<DeleteAccountResponse>;
55   // TODO redo these forms
56   saveUserSettingsForm: {
57     show_nsfw?: boolean;
58     theme?: string;
59     default_sort_type?: SortType;
60     default_listing_type?: ListingType;
61     interface_language?: string;
62     avatar?: string;
63     banner?: string;
64     display_name?: string;
65     email?: string;
66     bio?: string;
67     matrix_user_id?: string;
68     show_avatars?: boolean;
69     show_scores?: boolean;
70     send_notifications_to_email?: boolean;
71     bot_account?: boolean;
72     show_bot_accounts?: boolean;
73     show_read_posts?: boolean;
74     show_new_post_notifs?: boolean;
75     discussion_languages?: number[];
76     generate_totp_2fa?: boolean;
77   };
78   changePasswordForm: {
79     new_password?: string;
80     new_password_verify?: string;
81     old_password?: string;
82   };
83   deleteAccountForm: {
84     password?: string;
85   };
86   personBlocks: PersonBlockView[];
87   communityBlocks: CommunityBlockView[];
88   currentTab: string;
89   themeList: string[];
90   deleteAccountShowConfirm: boolean;
91   siteRes: GetSiteResponse;
92   searchCommunityLoading: boolean;
93   searchCommunityOptions: Choice[];
94   searchPersonLoading: boolean;
95   searchPersonOptions: Choice[];
96 }
97
98 type FilterType = "user" | "community";
99
100 const Filter = ({
101   filterType,
102   options,
103   onChange,
104   onSearch,
105   loading,
106 }: {
107   filterType: FilterType;
108   options: Choice[];
109   onSearch: (text: string) => void;
110   onChange: (choice: Choice) => void;
111   loading: boolean;
112 }) => (
113   <div className="form-group row">
114     <label
115       className="col-md-4 col-form-label"
116       htmlFor={`block-${filterType}-filter`}
117     >
118       {i18n.t(`block_${filterType}` as NoOptionI18nKeys)}
119     </label>
120     <div className="col-md-8">
121       <SearchableSelect
122         id={`block-${filterType}-filter`}
123         options={[
124           { label: emDash, value: "0", disabled: true } as Choice,
125         ].concat(options)}
126         loading={loading}
127         onChange={onChange}
128         onSearch={onSearch}
129       />
130     </div>
131   </div>
132 );
133
134 export class Settings extends Component<any, SettingsState> {
135   private isoData = setIsoData(this.context);
136   state: SettingsState = {
137     saveRes: { state: "empty" },
138     deleteAccountRes: { state: "empty" },
139     changePasswordRes: { state: "empty" },
140     saveUserSettingsForm: {},
141     changePasswordForm: {},
142     deleteAccountShowConfirm: false,
143     deleteAccountForm: {},
144     personBlocks: [],
145     communityBlocks: [],
146     currentTab: "settings",
147     siteRes: this.isoData.site_res,
148     themeList: [],
149     searchCommunityLoading: false,
150     searchCommunityOptions: [],
151     searchPersonLoading: false,
152     searchPersonOptions: [],
153   };
154
155   constructor(props: any, context: any) {
156     super(props, context);
157
158     this.handleSortTypeChange = this.handleSortTypeChange.bind(this);
159     this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
160     this.handleBioChange = this.handleBioChange.bind(this);
161     this.handleDiscussionLanguageChange =
162       this.handleDiscussionLanguageChange.bind(this);
163
164     this.handleAvatarUpload = this.handleAvatarUpload.bind(this);
165     this.handleAvatarRemove = this.handleAvatarRemove.bind(this);
166
167     this.handleBannerUpload = this.handleBannerUpload.bind(this);
168     this.handleBannerRemove = this.handleBannerRemove.bind(this);
169     this.userSettings = this.userSettings.bind(this);
170     this.blockCards = this.blockCards.bind(this);
171
172     this.handleBlockPerson = this.handleBlockPerson.bind(this);
173     this.handleBlockCommunity = this.handleBlockCommunity.bind(this);
174
175     const mui = UserService.Instance.myUserInfo;
176     if (mui) {
177       const {
178         local_user: {
179           show_nsfw,
180           theme,
181           default_sort_type,
182           default_listing_type,
183           interface_language,
184           show_avatars,
185           show_bot_accounts,
186           show_scores,
187           show_read_posts,
188           show_new_post_notifs,
189           send_notifications_to_email,
190           email,
191         },
192         person: {
193           avatar,
194           banner,
195           display_name,
196           bot_account,
197           bio,
198           matrix_user_id,
199         },
200       } = mui.local_user_view;
201
202       this.state = {
203         ...this.state,
204         personBlocks: mui.person_blocks,
205         communityBlocks: mui.community_blocks,
206         saveUserSettingsForm: {
207           ...this.state.saveUserSettingsForm,
208           show_nsfw,
209           theme: theme ?? "browser",
210           default_sort_type,
211           default_listing_type,
212           interface_language,
213           discussion_languages: mui.discussion_languages,
214           avatar,
215           banner,
216           display_name,
217           show_avatars,
218           bot_account,
219           show_bot_accounts,
220           show_scores,
221           show_read_posts,
222           show_new_post_notifs,
223           email,
224           bio,
225           send_notifications_to_email,
226           matrix_user_id,
227         },
228       };
229     }
230   }
231
232   async componentDidMount() {
233     setupTippy();
234     this.setState({ themeList: await fetchThemeList() });
235   }
236
237   get documentTitle(): string {
238     return i18n.t("settings");
239   }
240
241   render() {
242     return (
243       <div className="container-lg">
244         <HtmlTags
245           title={this.documentTitle}
246           path={this.context.router.route.match.url}
247           description={this.documentTitle}
248           image={this.state.saveUserSettingsForm.avatar}
249         />
250         <Tabs
251           tabs={[
252             {
253               key: "settings",
254               label: i18n.t("settings"),
255               getNode: this.userSettings,
256             },
257             {
258               key: "blocks",
259               label: i18n.t("blocks"),
260               getNode: this.blockCards,
261             },
262           ]}
263         />
264       </div>
265     );
266   }
267
268   userSettings() {
269     return (
270       <div className="row">
271         <div className="col-12 col-md-6">
272           <div className="card border-secondary mb-3">
273             <div className="card-body">{this.saveUserSettingsHtmlForm()}</div>
274           </div>
275         </div>
276         <div className="col-12 col-md-6">
277           <div className="card border-secondary mb-3">
278             <div className="card-body">{this.changePasswordHtmlForm()}</div>
279           </div>
280         </div>
281       </div>
282     );
283   }
284
285   blockCards() {
286     return (
287       <div className="row">
288         <div className="col-12 col-md-6">
289           <div className="card border-secondary mb-3">
290             <div className="card-body">{this.blockUserCard()}</div>
291           </div>
292         </div>
293         <div className="col-12 col-md-6">
294           <div className="card border-secondary mb-3">
295             <div className="card-body">{this.blockCommunityCard()}</div>
296           </div>
297         </div>
298       </div>
299     );
300   }
301
302   changePasswordHtmlForm() {
303     return (
304       <>
305         <h5>{i18n.t("change_password")}</h5>
306         <form onSubmit={linkEvent(this, this.handleChangePasswordSubmit)}>
307           <div className="form-group row">
308             <label className="col-sm-5 col-form-label" htmlFor="user-password">
309               {i18n.t("new_password")}
310             </label>
311             <div className="col-sm-7">
312               <input
313                 type="password"
314                 id="user-password"
315                 className="form-control"
316                 value={this.state.changePasswordForm.new_password}
317                 autoComplete="new-password"
318                 maxLength={60}
319                 onInput={linkEvent(this, this.handleNewPasswordChange)}
320               />
321             </div>
322           </div>
323           <div className="form-group row">
324             <label
325               className="col-sm-5 col-form-label"
326               htmlFor="user-verify-password"
327             >
328               {i18n.t("verify_password")}
329             </label>
330             <div className="col-sm-7">
331               <input
332                 type="password"
333                 id="user-verify-password"
334                 className="form-control"
335                 value={this.state.changePasswordForm.new_password_verify}
336                 autoComplete="new-password"
337                 maxLength={60}
338                 onInput={linkEvent(this, this.handleNewPasswordVerifyChange)}
339               />
340             </div>
341           </div>
342           <div className="form-group row">
343             <label
344               className="col-sm-5 col-form-label"
345               htmlFor="user-old-password"
346             >
347               {i18n.t("old_password")}
348             </label>
349             <div className="col-sm-7">
350               <input
351                 type="password"
352                 id="user-old-password"
353                 className="form-control"
354                 value={this.state.changePasswordForm.old_password}
355                 autoComplete="new-password"
356                 maxLength={60}
357                 onInput={linkEvent(this, this.handleOldPasswordChange)}
358               />
359             </div>
360           </div>
361           <div className="form-group">
362             <button type="submit" className="btn btn-block btn-secondary mr-4">
363               {this.state.changePasswordRes.state === "loading" ? (
364                 <Spinner />
365               ) : (
366                 capitalizeFirstLetter(i18n.t("save"))
367               )}
368             </button>
369           </div>
370         </form>
371       </>
372     );
373   }
374
375   blockUserCard() {
376     const { searchPersonLoading, searchPersonOptions } = this.state;
377
378     return (
379       <div>
380         <Filter
381           filterType="user"
382           loading={searchPersonLoading}
383           onChange={this.handleBlockPerson}
384           onSearch={this.handlePersonSearch}
385           options={searchPersonOptions}
386         />
387         {this.blockedUsersList()}
388       </div>
389     );
390   }
391
392   blockedUsersList() {
393     return (
394       <>
395         <h5>{i18n.t("blocked_users")}</h5>
396         <ul className="list-unstyled mb-0">
397           {this.state.personBlocks.map(pb => (
398             <li key={pb.target.id}>
399               <span>
400                 <PersonListing person={pb.target} />
401                 <button
402                   className="btn btn-sm"
403                   onClick={linkEvent(
404                     { ctx: this, recipientId: pb.target.id },
405                     this.handleUnblockPerson
406                   )}
407                   data-tippy-content={i18n.t("unblock_user")}
408                 >
409                   <Icon icon="x" classes="icon-inline" />
410                 </button>
411               </span>
412             </li>
413           ))}
414         </ul>
415       </>
416     );
417   }
418
419   blockCommunityCard() {
420     const { searchCommunityLoading, searchCommunityOptions } = this.state;
421
422     return (
423       <div>
424         <Filter
425           filterType="community"
426           loading={searchCommunityLoading}
427           onChange={this.handleBlockCommunity}
428           onSearch={this.handleCommunitySearch}
429           options={searchCommunityOptions}
430         />
431         {this.blockedCommunitiesList()}
432       </div>
433     );
434   }
435
436   blockedCommunitiesList() {
437     return (
438       <>
439         <h5>{i18n.t("blocked_communities")}</h5>
440         <ul className="list-unstyled mb-0">
441           {this.state.communityBlocks.map(cb => (
442             <li key={cb.community.id}>
443               <span>
444                 <CommunityLink community={cb.community} />
445                 <button
446                   className="btn btn-sm"
447                   onClick={linkEvent(
448                     { ctx: this, communityId: cb.community.id },
449                     this.handleUnblockCommunity
450                   )}
451                   data-tippy-content={i18n.t("unblock_community")}
452                 >
453                   <Icon icon="x" classes="icon-inline" />
454                 </button>
455               </span>
456             </li>
457           ))}
458         </ul>
459       </>
460     );
461   }
462
463   saveUserSettingsHtmlForm() {
464     const selectedLangs = this.state.saveUserSettingsForm.discussion_languages;
465
466     return (
467       <>
468         <h5>{i18n.t("settings")}</h5>
469         <form onSubmit={linkEvent(this, this.handleSaveSettingsSubmit)}>
470           <div className="form-group row">
471             <label className="col-sm-5 col-form-label" htmlFor="display-name">
472               {i18n.t("display_name")}
473             </label>
474             <div className="col-sm-7">
475               <input
476                 id="display-name"
477                 type="text"
478                 className="form-control"
479                 placeholder={i18n.t("optional")}
480                 value={this.state.saveUserSettingsForm.display_name}
481                 onInput={linkEvent(this, this.handleDisplayNameChange)}
482                 pattern="^(?!@)(.+)$"
483                 minLength={3}
484               />
485             </div>
486           </div>
487           <div className="form-group row">
488             <label className="col-sm-3 col-form-label" htmlFor="user-bio">
489               {i18n.t("bio")}
490             </label>
491             <div className="col-sm-9">
492               <MarkdownTextArea
493                 initialContent={this.state.saveUserSettingsForm.bio}
494                 onContentChange={this.handleBioChange}
495                 maxLength={300}
496                 hideNavigationWarnings
497                 allLanguages={this.state.siteRes.all_languages}
498                 siteLanguages={this.state.siteRes.discussion_languages}
499               />
500             </div>
501           </div>
502           <div className="form-group row">
503             <label className="col-sm-3 col-form-label" htmlFor="user-email">
504               {i18n.t("email")}
505             </label>
506             <div className="col-sm-9">
507               <input
508                 type="email"
509                 id="user-email"
510                 className="form-control"
511                 placeholder={i18n.t("optional")}
512                 value={this.state.saveUserSettingsForm.email}
513                 onInput={linkEvent(this, this.handleEmailChange)}
514                 minLength={3}
515               />
516             </div>
517           </div>
518           <div className="form-group row">
519             <label className="col-sm-5 col-form-label" htmlFor="matrix-user-id">
520               <a href={elementUrl} rel={relTags}>
521                 {i18n.t("matrix_user_id")}
522               </a>
523             </label>
524             <div className="col-sm-7">
525               <input
526                 id="matrix-user-id"
527                 type="text"
528                 className="form-control"
529                 placeholder="@user:example.com"
530                 value={this.state.saveUserSettingsForm.matrix_user_id}
531                 onInput={linkEvent(this, this.handleMatrixUserIdChange)}
532                 pattern="^@[A-Za-z0-9._=-]+:[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"
533               />
534             </div>
535           </div>
536           <div className="form-group row">
537             <label className="col-sm-3">{i18n.t("avatar")}</label>
538             <div className="col-sm-9">
539               <ImageUploadForm
540                 uploadTitle={i18n.t("upload_avatar")}
541                 imageSrc={this.state.saveUserSettingsForm.avatar}
542                 onUpload={this.handleAvatarUpload}
543                 onRemove={this.handleAvatarRemove}
544                 rounded
545               />
546             </div>
547           </div>
548           <div className="form-group row">
549             <label className="col-sm-3">{i18n.t("banner")}</label>
550             <div className="col-sm-9">
551               <ImageUploadForm
552                 uploadTitle={i18n.t("upload_banner")}
553                 imageSrc={this.state.saveUserSettingsForm.banner}
554                 onUpload={this.handleBannerUpload}
555                 onRemove={this.handleBannerRemove}
556               />
557             </div>
558           </div>
559           <div className="form-group row">
560             <label className="col-sm-3" htmlFor="user-language">
561               {i18n.t("interface_language")}
562             </label>
563             <div className="col-sm-9">
564               <select
565                 id="user-language"
566                 value={this.state.saveUserSettingsForm.interface_language}
567                 onChange={linkEvent(this, this.handleInterfaceLangChange)}
568                 className="custom-select w-auto"
569               >
570                 <option disabled aria-hidden="true">
571                   {i18n.t("interface_language")}
572                 </option>
573                 <option value="browser">{i18n.t("browser_default")}</option>
574                 <option disabled aria-hidden="true">
575                   â”€â”€
576                 </option>
577                 {languages
578                   .sort((a, b) => a.code.localeCompare(b.code))
579                   .map(lang => (
580                     <option key={lang.code} value={lang.code}>
581                       {lang.name}
582                     </option>
583                   ))}
584               </select>
585             </div>
586           </div>
587           <LanguageSelect
588             allLanguages={this.state.siteRes.all_languages}
589             siteLanguages={this.state.siteRes.discussion_languages}
590             selectedLanguageIds={selectedLangs}
591             multiple={true}
592             showLanguageWarning={true}
593             showSite
594             onChange={this.handleDiscussionLanguageChange}
595           />
596           <div className="form-group row">
597             <label className="col-sm-3" htmlFor="user-theme">
598               {i18n.t("theme")}
599             </label>
600             <div className="col-sm-9">
601               <select
602                 id="user-theme"
603                 value={this.state.saveUserSettingsForm.theme}
604                 onChange={linkEvent(this, this.handleThemeChange)}
605                 className="custom-select w-auto"
606               >
607                 <option disabled aria-hidden="true">
608                   {i18n.t("theme")}
609                 </option>
610                 <option value="browser">{i18n.t("browser_default")}</option>
611                 {this.state.themeList.map(theme => (
612                   <option key={theme} value={theme}>
613                     {theme}
614                   </option>
615                 ))}
616               </select>
617             </div>
618           </div>
619           <form className="form-group row">
620             <label className="col-sm-3">{i18n.t("type")}</label>
621             <div className="col-sm-9">
622               <ListingTypeSelect
623                 type_={
624                   this.state.saveUserSettingsForm.default_listing_type ??
625                   "Local"
626                 }
627                 showLocal={showLocal(this.isoData)}
628                 showSubscribed
629                 onChange={this.handleListingTypeChange}
630               />
631             </div>
632           </form>
633           <form className="form-group row">
634             <label className="col-sm-3">{i18n.t("sort_type")}</label>
635             <div className="col-sm-9">
636               <SortSelect
637                 sort={
638                   this.state.saveUserSettingsForm.default_sort_type ?? "Active"
639                 }
640                 onChange={this.handleSortTypeChange}
641               />
642             </div>
643           </form>
644           <div className="form-group">
645             <div className="form-check">
646               <input
647                 className="form-check-input"
648                 id="user-show-nsfw"
649                 type="checkbox"
650                 checked={this.state.saveUserSettingsForm.show_nsfw}
651                 onChange={linkEvent(this, this.handleShowNsfwChange)}
652               />
653               <label className="form-check-label" htmlFor="user-show-nsfw">
654                 {i18n.t("show_nsfw")}
655               </label>
656             </div>
657           </div>
658           <div className="form-group">
659             <div className="form-check">
660               <input
661                 className="form-check-input"
662                 id="user-show-scores"
663                 type="checkbox"
664                 checked={this.state.saveUserSettingsForm.show_scores}
665                 onChange={linkEvent(this, this.handleShowScoresChange)}
666               />
667               <label className="form-check-label" htmlFor="user-show-scores">
668                 {i18n.t("show_scores")}
669               </label>
670             </div>
671           </div>
672           <div className="form-group">
673             <div className="form-check">
674               <input
675                 className="form-check-input"
676                 id="user-show-avatars"
677                 type="checkbox"
678                 checked={this.state.saveUserSettingsForm.show_avatars}
679                 onChange={linkEvent(this, this.handleShowAvatarsChange)}
680               />
681               <label className="form-check-label" htmlFor="user-show-avatars">
682                 {i18n.t("show_avatars")}
683               </label>
684             </div>
685           </div>
686           <div className="form-group">
687             <div className="form-check">
688               <input
689                 className="form-check-input"
690                 id="user-bot-account"
691                 type="checkbox"
692                 checked={this.state.saveUserSettingsForm.bot_account}
693                 onChange={linkEvent(this, this.handleBotAccount)}
694               />
695               <label className="form-check-label" htmlFor="user-bot-account">
696                 {i18n.t("bot_account")}
697               </label>
698             </div>
699           </div>
700           <div className="form-group">
701             <div className="form-check">
702               <input
703                 className="form-check-input"
704                 id="user-show-bot-accounts"
705                 type="checkbox"
706                 checked={this.state.saveUserSettingsForm.show_bot_accounts}
707                 onChange={linkEvent(this, this.handleShowBotAccounts)}
708               />
709               <label
710                 className="form-check-label"
711                 htmlFor="user-show-bot-accounts"
712               >
713                 {i18n.t("show_bot_accounts")}
714               </label>
715             </div>
716           </div>
717           <div className="form-group">
718             <div className="form-check">
719               <input
720                 className="form-check-input"
721                 id="user-show-read-posts"
722                 type="checkbox"
723                 checked={this.state.saveUserSettingsForm.show_read_posts}
724                 onChange={linkEvent(this, this.handleReadPosts)}
725               />
726               <label
727                 className="form-check-label"
728                 htmlFor="user-show-read-posts"
729               >
730                 {i18n.t("show_read_posts")}
731               </label>
732             </div>
733           </div>
734           <div className="form-group">
735             <div className="form-check">
736               <input
737                 className="form-check-input"
738                 id="user-show-new-post-notifs"
739                 type="checkbox"
740                 checked={this.state.saveUserSettingsForm.show_new_post_notifs}
741                 onChange={linkEvent(this, this.handleShowNewPostNotifs)}
742               />
743               <label
744                 className="form-check-label"
745                 htmlFor="user-show-new-post-notifs"
746               >
747                 {i18n.t("show_new_post_notifs")}
748               </label>
749             </div>
750           </div>
751           <div className="form-group">
752             <div className="form-check">
753               <input
754                 className="form-check-input"
755                 id="user-send-notifications-to-email"
756                 type="checkbox"
757                 disabled={!this.state.saveUserSettingsForm.email}
758                 checked={
759                   this.state.saveUserSettingsForm.send_notifications_to_email
760                 }
761                 onChange={linkEvent(
762                   this,
763                   this.handleSendNotificationsToEmailChange
764                 )}
765               />
766               <label
767                 className="form-check-label"
768                 htmlFor="user-send-notifications-to-email"
769               >
770                 {i18n.t("send_notifications_to_email")}
771               </label>
772             </div>
773           </div>
774           {this.totpSection()}
775           <div className="form-group">
776             <button type="submit" className="btn btn-block btn-secondary mr-4">
777               {this.state.saveRes.state === "loading" ? (
778                 <Spinner />
779               ) : (
780                 capitalizeFirstLetter(i18n.t("save"))
781               )}
782             </button>
783           </div>
784           <hr />
785           <div className="form-group">
786             <button
787               className="btn btn-block btn-danger"
788               onClick={linkEvent(
789                 this,
790                 this.handleDeleteAccountShowConfirmToggle
791               )}
792             >
793               {i18n.t("delete_account")}
794             </button>
795             {this.state.deleteAccountShowConfirm && (
796               <>
797                 <div className="my-2 alert alert-danger" role="alert">
798                   {i18n.t("delete_account_confirm")}
799                 </div>
800                 <input
801                   type="password"
802                   value={this.state.deleteAccountForm.password}
803                   autoComplete="new-password"
804                   maxLength={60}
805                   onInput={linkEvent(
806                     this,
807                     this.handleDeleteAccountPasswordChange
808                   )}
809                   className="form-control my-2"
810                 />
811                 <button
812                   className="btn btn-danger mr-4"
813                   disabled={!this.state.deleteAccountForm.password}
814                   onClick={linkEvent(this, this.handleDeleteAccount)}
815                 >
816                   {this.state.deleteAccountRes.state === "loading" ? (
817                     <Spinner />
818                   ) : (
819                     capitalizeFirstLetter(i18n.t("delete"))
820                   )}
821                 </button>
822                 <button
823                   className="btn btn-secondary"
824                   onClick={linkEvent(
825                     this,
826                     this.handleDeleteAccountShowConfirmToggle
827                   )}
828                 >
829                   {i18n.t("cancel")}
830                 </button>
831               </>
832             )}
833           </div>
834         </form>
835       </>
836     );
837   }
838
839   totpSection() {
840     const totpUrl =
841       UserService.Instance.myUserInfo?.local_user_view.local_user.totp_2fa_url;
842
843     return (
844       <>
845         {!totpUrl && (
846           <div className="form-group">
847             <div className="form-check">
848               <input
849                 className="form-check-input"
850                 id="user-generate-totp"
851                 type="checkbox"
852                 checked={this.state.saveUserSettingsForm.generate_totp_2fa}
853                 onChange={linkEvent(this, this.handleGenerateTotp)}
854               />
855               <label className="form-check-label" htmlFor="user-generate-totp">
856                 {i18n.t("set_up_two_factor")}
857               </label>
858             </div>
859           </div>
860         )}
861
862         {totpUrl && (
863           <>
864             <div>
865               <a className="btn btn-secondary mb-2" href={totpUrl}>
866                 {i18n.t("two_factor_link")}
867               </a>
868             </div>
869             <div className="form-group">
870               <div className="form-check">
871                 <input
872                   className="form-check-input"
873                   id="user-remove-totp"
874                   type="checkbox"
875                   checked={
876                     this.state.saveUserSettingsForm.generate_totp_2fa == false
877                   }
878                   onChange={linkEvent(this, this.handleRemoveTotp)}
879                 />
880                 <label className="form-check-label" htmlFor="user-remove-totp">
881                   {i18n.t("remove_two_factor")}
882                 </label>
883               </div>
884             </div>
885           </>
886         )}
887       </>
888     );
889   }
890
891   handlePersonSearch = debounce(async (text: string) => {
892     this.setState({ searchPersonLoading: true });
893
894     const searchPersonOptions: Choice[] = [];
895
896     if (text.length > 0) {
897       searchPersonOptions.push(...(await fetchUsers(text)).map(personToChoice));
898     }
899
900     this.setState({
901       searchPersonLoading: false,
902       searchPersonOptions,
903     });
904   });
905
906   handleCommunitySearch = debounce(async (text: string) => {
907     this.setState({ searchCommunityLoading: true });
908
909     const searchCommunityOptions: Choice[] = [];
910
911     if (text.length > 0) {
912       searchCommunityOptions.push(
913         ...(await fetchCommunities(text)).map(communityToChoice)
914       );
915     }
916
917     this.setState({
918       searchCommunityLoading: false,
919       searchCommunityOptions,
920     });
921   });
922
923   async handleBlockPerson({ value }: Choice) {
924     if (value !== "0") {
925       const res = await HttpService.client.blockPerson({
926         person_id: Number(value),
927         block: true,
928         auth: myAuthRequired(),
929       });
930       this.personBlock(res);
931     }
932   }
933
934   async handleUnblockPerson({
935     ctx,
936     recipientId,
937   }: {
938     ctx: Settings;
939     recipientId: number;
940   }) {
941     const res = await HttpService.client.blockPerson({
942       person_id: recipientId,
943       block: false,
944       auth: myAuthRequired(),
945     });
946     ctx.personBlock(res);
947   }
948
949   async handleBlockCommunity({ value }: Choice) {
950     if (value !== "0") {
951       const res = await HttpService.client.blockCommunity({
952         community_id: Number(value),
953         block: true,
954         auth: myAuthRequired(),
955       });
956       this.communityBlock(res);
957     }
958   }
959
960   async handleUnblockCommunity(i: { ctx: Settings; communityId: number }) {
961     const auth = myAuth();
962     if (auth) {
963       const res = await HttpService.client.blockCommunity({
964         community_id: i.communityId,
965         block: false,
966         auth: myAuthRequired(),
967       });
968       i.ctx.communityBlock(res);
969     }
970   }
971
972   handleShowNsfwChange(i: Settings, event: any) {
973     i.setState(
974       s => ((s.saveUserSettingsForm.show_nsfw = event.target.checked), s)
975     );
976   }
977
978   handleShowAvatarsChange(i: Settings, event: any) {
979     const mui = UserService.Instance.myUserInfo;
980     if (mui) {
981       mui.local_user_view.local_user.show_avatars = event.target.checked;
982     }
983     i.setState(
984       s => ((s.saveUserSettingsForm.show_avatars = event.target.checked), s)
985     );
986   }
987
988   handleBotAccount(i: Settings, event: any) {
989     i.setState(
990       s => ((s.saveUserSettingsForm.bot_account = event.target.checked), s)
991     );
992   }
993
994   handleShowBotAccounts(i: Settings, event: any) {
995     i.setState(
996       s => (
997         (s.saveUserSettingsForm.show_bot_accounts = event.target.checked), s
998       )
999     );
1000   }
1001
1002   handleReadPosts(i: Settings, event: any) {
1003     i.setState(
1004       s => ((s.saveUserSettingsForm.show_read_posts = event.target.checked), s)
1005     );
1006   }
1007
1008   handleShowNewPostNotifs(i: Settings, event: any) {
1009     i.setState(
1010       s => (
1011         (s.saveUserSettingsForm.show_new_post_notifs = event.target.checked), s
1012       )
1013     );
1014   }
1015
1016   handleShowScoresChange(i: Settings, event: any) {
1017     const mui = UserService.Instance.myUserInfo;
1018     if (mui) {
1019       mui.local_user_view.local_user.show_scores = event.target.checked;
1020     }
1021     i.setState(
1022       s => ((s.saveUserSettingsForm.show_scores = event.target.checked), s)
1023     );
1024   }
1025
1026   handleGenerateTotp(i: Settings, event: any) {
1027     // Coerce false to undefined here, so it won't generate it.
1028     const checked: boolean | undefined = event.target.checked || undefined;
1029     if (checked) {
1030       toast(i18n.t("two_factor_setup_instructions"));
1031     }
1032     i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
1033   }
1034
1035   handleRemoveTotp(i: Settings, event: any) {
1036     // Coerce true to undefined here, so it won't generate it.
1037     const checked: boolean | undefined = !event.target.checked && undefined;
1038     i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
1039   }
1040
1041   handleSendNotificationsToEmailChange(i: Settings, event: any) {
1042     i.setState(
1043       s => (
1044         (s.saveUserSettingsForm.send_notifications_to_email =
1045           event.target.checked),
1046         s
1047       )
1048     );
1049   }
1050
1051   handleThemeChange(i: Settings, event: any) {
1052     i.setState(s => ((s.saveUserSettingsForm.theme = event.target.value), s));
1053     setTheme(event.target.value, true);
1054   }
1055
1056   handleInterfaceLangChange(i: Settings, event: any) {
1057     const newLang = event.target.value ?? "browser";
1058     i18n.changeLanguage(newLang === "browser" ? navigator.languages : newLang);
1059
1060     i.setState(
1061       s => ((s.saveUserSettingsForm.interface_language = event.target.value), s)
1062     );
1063   }
1064
1065   handleDiscussionLanguageChange(val: number[]) {
1066     this.setState(
1067       s => ((s.saveUserSettingsForm.discussion_languages = val), s)
1068     );
1069   }
1070
1071   handleSortTypeChange(val: SortType) {
1072     this.setState(s => ((s.saveUserSettingsForm.default_sort_type = val), s));
1073   }
1074
1075   handleListingTypeChange(val: ListingType) {
1076     this.setState(
1077       s => ((s.saveUserSettingsForm.default_listing_type = val), s)
1078     );
1079   }
1080
1081   handleEmailChange(i: Settings, event: any) {
1082     i.setState(s => ((s.saveUserSettingsForm.email = event.target.value), s));
1083   }
1084
1085   handleBioChange(val: string) {
1086     this.setState(s => ((s.saveUserSettingsForm.bio = val), s));
1087   }
1088
1089   handleAvatarUpload(url: string) {
1090     this.setState(s => ((s.saveUserSettingsForm.avatar = url), s));
1091   }
1092
1093   handleAvatarRemove() {
1094     this.setState(s => ((s.saveUserSettingsForm.avatar = ""), s));
1095   }
1096
1097   handleBannerUpload(url: string) {
1098     this.setState(s => ((s.saveUserSettingsForm.banner = url), s));
1099   }
1100
1101   handleBannerRemove() {
1102     this.setState(s => ((s.saveUserSettingsForm.banner = ""), s));
1103   }
1104
1105   handleDisplayNameChange(i: Settings, event: any) {
1106     i.setState(
1107       s => ((s.saveUserSettingsForm.display_name = event.target.value), s)
1108     );
1109   }
1110
1111   handleMatrixUserIdChange(i: Settings, event: any) {
1112     i.setState(
1113       s => ((s.saveUserSettingsForm.matrix_user_id = event.target.value), s)
1114     );
1115   }
1116
1117   handleNewPasswordChange(i: Settings, event: any) {
1118     const newPass: string | undefined =
1119       event.target.value == "" ? undefined : event.target.value;
1120     i.setState(s => ((s.changePasswordForm.new_password = newPass), s));
1121   }
1122
1123   handleNewPasswordVerifyChange(i: Settings, event: any) {
1124     const newPassVerify: string | undefined =
1125       event.target.value == "" ? undefined : event.target.value;
1126     i.setState(
1127       s => ((s.changePasswordForm.new_password_verify = newPassVerify), s)
1128     );
1129   }
1130
1131   handleOldPasswordChange(i: Settings, event: any) {
1132     const oldPass: string | undefined =
1133       event.target.value == "" ? undefined : event.target.value;
1134     i.setState(s => ((s.changePasswordForm.old_password = oldPass), s));
1135   }
1136
1137   async handleSaveSettingsSubmit(i: Settings, event: any) {
1138     event.preventDefault();
1139     i.setState({ saveRes: { state: "loading" } });
1140
1141     const saveRes = await HttpService.client.saveUserSettings({
1142       ...i.state.saveUserSettingsForm,
1143       auth: myAuthRequired(),
1144     });
1145     if (saveRes.state === "success") {
1146       UserService.Instance.login(saveRes.data);
1147       location.reload();
1148       toast(i18n.t("saved"));
1149       window.scrollTo(0, 0);
1150     }
1151
1152     i.setState({ saveRes });
1153   }
1154
1155   async handleChangePasswordSubmit(i: Settings, event: any) {
1156     event.preventDefault();
1157     const { new_password, new_password_verify, old_password } =
1158       i.state.changePasswordForm;
1159
1160     if (new_password && old_password && new_password_verify) {
1161       i.setState({ changePasswordRes: { state: "loading" } });
1162       const changePasswordRes = await HttpService.client.changePassword({
1163         new_password,
1164         new_password_verify,
1165         old_password,
1166         auth: myAuthRequired(),
1167       });
1168       if (changePasswordRes.state === "success") {
1169         UserService.Instance.login(changePasswordRes.data);
1170         window.scrollTo(0, 0);
1171         toast(i18n.t("password_changed"));
1172       }
1173
1174       i.setState({ changePasswordRes });
1175     }
1176   }
1177
1178   handleDeleteAccountShowConfirmToggle(i: Settings) {
1179     i.setState({ deleteAccountShowConfirm: !i.state.deleteAccountShowConfirm });
1180   }
1181
1182   handleDeleteAccountPasswordChange(i: Settings, event: any) {
1183     i.setState(s => ((s.deleteAccountForm.password = event.target.value), s));
1184   }
1185
1186   async handleDeleteAccount(i: Settings) {
1187     const password = i.state.deleteAccountForm.password;
1188     if (password) {
1189       i.setState({ deleteAccountRes: { state: "loading" } });
1190       const deleteAccountRes = await HttpService.client.deleteAccount({
1191         password,
1192         auth: myAuthRequired(),
1193       });
1194       if (deleteAccountRes.state === "success") {
1195         UserService.Instance.logout();
1196         this.context.router.history.replace("/");
1197       }
1198
1199       i.setState({ deleteAccountRes });
1200     }
1201   }
1202
1203   handleSwitchTab(i: { ctx: Settings; tab: string }) {
1204     i.ctx.setState({ currentTab: i.tab });
1205   }
1206
1207   personBlock(res: RequestState<BlockPersonResponse>) {
1208     if (res.state === "success") {
1209       updatePersonBlock(res.data);
1210       const mui = UserService.Instance.myUserInfo;
1211       if (mui) {
1212         this.setState({ personBlocks: mui.person_blocks });
1213       }
1214     }
1215   }
1216
1217   communityBlock(res: RequestState<BlockCommunityResponse>) {
1218     if (res.state === "success") {
1219       updateCommunityBlock(res.data);
1220       const mui = UserService.Instance.myUserInfo;
1221       if (mui) {
1222         this.setState({ communityBlocks: mui.community_blocks });
1223       }
1224     }
1225   }
1226 }