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