]> Untitled Git - lemmy-ui.git/blob - src/shared/components/person/settings.tsx
d04504704fa67544aa196e5d1f086e5bc7143230
[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: boolean) {
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: boolean) {
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               isNew
330             />
331           </div>
332           <div className="mb-3">
333             <PasswordInput
334               id="verify-new-password"
335               value={this.state.changePasswordForm.new_password_verify}
336               onInput={linkEvent(this, this.handleNewPasswordVerifyChange)}
337               label={I18NextService.i18n.t("verify_password")}
338               isNew
339             />
340           </div>
341           <div className="mb-3">
342             <PasswordInput
343               id="user-old-password"
344               value={this.state.changePasswordForm.old_password}
345               onInput={linkEvent(this, this.handleOldPasswordChange)}
346               label={I18NextService.i18n.t("old_password")}
347             />
348           </div>
349           <div className="input-group mb-3">
350             <button
351               type="submit"
352               className="btn d-block btn-secondary me-4 w-100"
353             >
354               {this.state.changePasswordRes.state === "loading" ? (
355                 <Spinner />
356               ) : (
357                 capitalizeFirstLetter(I18NextService.i18n.t("save"))
358               )}
359             </button>
360           </div>
361         </form>
362       </>
363     );
364   }
365
366   blockUserCard() {
367     const { searchPersonLoading, searchPersonOptions } = this.state;
368
369     return (
370       <div>
371         <Filter
372           filterType="user"
373           loading={searchPersonLoading}
374           onChange={this.handleBlockPerson}
375           onSearch={this.handlePersonSearch}
376           options={searchPersonOptions}
377         />
378         {this.blockedUsersList()}
379       </div>
380     );
381   }
382
383   blockedUsersList() {
384     return (
385       <>
386         <h2 className="h5">{I18NextService.i18n.t("blocked_users")}</h2>
387         <ul className="list-unstyled mb-0">
388           {this.state.personBlocks.map(pb => (
389             <li key={pb.target.id}>
390               <span>
391                 <PersonListing person={pb.target} />
392                 <button
393                   className="btn btn-sm"
394                   onClick={linkEvent(
395                     { ctx: this, recipientId: pb.target.id },
396                     this.handleUnblockPerson,
397                   )}
398                   data-tippy-content={I18NextService.i18n.t("unblock_user")}
399                 >
400                   <Icon icon="x" classes="icon-inline" />
401                 </button>
402               </span>
403             </li>
404           ))}
405         </ul>
406       </>
407     );
408   }
409
410   blockCommunityCard() {
411     const { searchCommunityLoading, searchCommunityOptions } = this.state;
412
413     return (
414       <div>
415         <Filter
416           filterType="community"
417           loading={searchCommunityLoading}
418           onChange={this.handleBlockCommunity}
419           onSearch={this.handleCommunitySearch}
420           options={searchCommunityOptions}
421         />
422         {this.blockedCommunitiesList()}
423       </div>
424     );
425   }
426
427   blockedCommunitiesList() {
428     return (
429       <>
430         <h2 className="h5">{I18NextService.i18n.t("blocked_communities")}</h2>
431         <ul className="list-unstyled mb-0">
432           {this.state.communityBlocks.map(cb => (
433             <li key={cb.community.id}>
434               <span>
435                 <CommunityLink community={cb.community} />
436                 <button
437                   className="btn btn-sm"
438                   onClick={linkEvent(
439                     { ctx: this, communityId: cb.community.id },
440                     this.handleUnblockCommunity,
441                   )}
442                   data-tippy-content={I18NextService.i18n.t(
443                     "unblock_community",
444                   )}
445                 >
446                   <Icon icon="x" classes="icon-inline" />
447                 </button>
448               </span>
449             </li>
450           ))}
451         </ul>
452       </>
453     );
454   }
455
456   saveUserSettingsHtmlForm() {
457     const selectedLangs = this.state.saveUserSettingsForm.discussion_languages;
458
459     return (
460       <>
461         <h2 className="h5">{I18NextService.i18n.t("settings")}</h2>
462         <form onSubmit={linkEvent(this, this.handleSaveSettingsSubmit)}>
463           <div className="mb-3 row">
464             <label className="col-sm-3 col-form-label" htmlFor="display-name">
465               {I18NextService.i18n.t("display_name")}
466             </label>
467             <div className="col-sm-9">
468               <input
469                 id="display-name"
470                 type="text"
471                 className="form-control"
472                 placeholder={I18NextService.i18n.t("optional")}
473                 value={this.state.saveUserSettingsForm.display_name}
474                 onInput={linkEvent(this, this.handleDisplayNameChange)}
475                 pattern="^(?!@)(.+)$"
476                 minLength={3}
477               />
478             </div>
479           </div>
480           <div className="mb-3 row">
481             <label className="col-sm-3 col-form-label" htmlFor="user-bio">
482               {I18NextService.i18n.t("bio")}
483             </label>
484             <div className="col-sm-9">
485               <MarkdownTextArea
486                 initialContent={this.state.saveUserSettingsForm.bio}
487                 onContentChange={this.handleBioChange}
488                 maxLength={300}
489                 hideNavigationWarnings
490                 allLanguages={this.state.siteRes.all_languages}
491                 siteLanguages={this.state.siteRes.discussion_languages}
492               />
493             </div>
494           </div>
495           <div className="mb-3 row">
496             <label className="col-sm-3 col-form-label" htmlFor="user-email">
497               {I18NextService.i18n.t("email")}
498             </label>
499             <div className="col-sm-9">
500               <input
501                 type="email"
502                 id="user-email"
503                 className="form-control"
504                 placeholder={I18NextService.i18n.t("optional")}
505                 value={this.state.saveUserSettingsForm.email}
506                 onInput={linkEvent(this, this.handleEmailChange)}
507                 minLength={3}
508               />
509             </div>
510           </div>
511           <div className="mb-3 row">
512             <label className="col-sm-3 col-form-label" htmlFor="matrix-user-id">
513               <a href={elementUrl} rel={relTags}>
514                 {I18NextService.i18n.t("matrix_user_id")}
515               </a>
516             </label>
517             <div className="col-sm-9">
518               <input
519                 id="matrix-user-id"
520                 type="text"
521                 className="form-control"
522                 placeholder="@user:example.com"
523                 value={this.state.saveUserSettingsForm.matrix_user_id}
524                 onInput={linkEvent(this, this.handleMatrixUserIdChange)}
525                 pattern="^@[A-Za-z0-9._=-]+:[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"
526               />
527             </div>
528           </div>
529           <div className="mb-3 row">
530             <label className="col-sm-3 col-form-label">
531               {I18NextService.i18n.t("avatar")}
532             </label>
533             <div className="col-sm-9">
534               <ImageUploadForm
535                 uploadTitle={I18NextService.i18n.t("upload_avatar")}
536                 imageSrc={this.state.saveUserSettingsForm.avatar}
537                 onUpload={this.handleAvatarUpload}
538                 onRemove={this.handleAvatarRemove}
539                 rounded
540               />
541             </div>
542           </div>
543           <div className="mb-3 row">
544             <label className="col-sm-3 col-form-label">
545               {I18NextService.i18n.t("banner")}
546             </label>
547             <div className="col-sm-9">
548               <ImageUploadForm
549                 uploadTitle={I18NextService.i18n.t("upload_banner")}
550                 imageSrc={this.state.saveUserSettingsForm.banner}
551                 onUpload={this.handleBannerUpload}
552                 onRemove={this.handleBannerRemove}
553               />
554             </div>
555           </div>
556           <div className="mb-3 row">
557             <label className="col-sm-3 form-label" htmlFor="user-language">
558               {I18NextService.i18n.t("interface_language")}
559             </label>
560             <div className="col-sm-9">
561               <select
562                 id="user-language"
563                 value={this.state.saveUserSettingsForm.interface_language}
564                 onChange={linkEvent(this, this.handleInterfaceLangChange)}
565                 className="form-select d-inline-block w-auto"
566               >
567                 <option disabled aria-hidden="true">
568                   {I18NextService.i18n.t("interface_language")}
569                 </option>
570                 <option value="browser">
571                   {I18NextService.i18n.t("browser_default")}
572                 </option>
573                 <option disabled aria-hidden="true">
574                   â”€â”€
575                 </option>
576                 {languages
577                   .sort((a, b) => a.code.localeCompare(b.code))
578                   .map(lang => (
579                     <option key={lang.code} value={lang.code}>
580                       {lang.name}
581                     </option>
582                   ))}
583               </select>
584             </div>
585           </div>
586           <LanguageSelect
587             allLanguages={this.state.siteRes.all_languages}
588             siteLanguages={this.state.siteRes.discussion_languages}
589             selectedLanguageIds={selectedLangs}
590             multiple={true}
591             showLanguageWarning={true}
592             showAll={true}
593             showSite
594             onChange={this.handleDiscussionLanguageChange}
595           />
596           <div className="mb-3 row">
597             <label className="col-sm-3 col-form-label" htmlFor="user-theme">
598               {I18NextService.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="form-select d-inline-block w-auto"
606               >
607                 <option disabled aria-hidden="true">
608                   {I18NextService.i18n.t("theme")}
609                 </option>
610                 <option value="browser">
611                   {I18NextService.i18n.t("browser_default")}
612                 </option>
613                 <option value="browser-compact">
614                   {I18NextService.i18n.t("browser_default_compact")}
615                 </option>
616                 {this.state.themeList.map(theme => (
617                   <option key={theme} value={theme}>
618                     {theme}
619                   </option>
620                 ))}
621               </select>
622             </div>
623           </div>
624           <form className="mb-3 row">
625             <label className="col-sm-3 col-form-label">
626               {I18NextService.i18n.t("type")}
627             </label>
628             <div className="col-sm-9">
629               <ListingTypeSelect
630                 type_={
631                   this.state.saveUserSettingsForm.default_listing_type ??
632                   "Local"
633                 }
634                 showLocal={showLocal(this.isoData)}
635                 showSubscribed
636                 onChange={this.handleListingTypeChange}
637               />
638             </div>
639           </form>
640           <form className="mb-3 row">
641             <label className="col-sm-3 col-form-label">
642               {I18NextService.i18n.t("sort_type")}
643             </label>
644             <div className="col-sm-9">
645               <SortSelect
646                 sort={
647                   this.state.saveUserSettingsForm.default_sort_type ?? "Active"
648                 }
649                 onChange={this.handleSortTypeChange}
650               />
651             </div>
652           </form>
653           <div className="input-group mb-3">
654             <div className="form-check">
655               <input
656                 className="form-check-input"
657                 id="user-show-nsfw"
658                 type="checkbox"
659                 checked={this.state.saveUserSettingsForm.show_nsfw}
660                 onChange={linkEvent(this, this.handleShowNsfwChange)}
661               />
662               <label className="form-check-label" htmlFor="user-show-nsfw">
663                 {I18NextService.i18n.t("show_nsfw")}
664               </label>
665             </div>
666           </div>
667           <div className="input-group mb-3">
668             <div className="form-check">
669               <input
670                 className="form-check-input"
671                 id="user-show-scores"
672                 type="checkbox"
673                 checked={this.state.saveUserSettingsForm.show_scores}
674                 onChange={linkEvent(this, this.handleShowScoresChange)}
675               />
676               <label className="form-check-label" htmlFor="user-show-scores">
677                 {I18NextService.i18n.t("show_scores")}
678               </label>
679             </div>
680           </div>
681           <div className="input-group mb-3">
682             <div className="form-check">
683               <input
684                 className="form-check-input"
685                 id="user-show-avatars"
686                 type="checkbox"
687                 checked={this.state.saveUserSettingsForm.show_avatars}
688                 onChange={linkEvent(this, this.handleShowAvatarsChange)}
689               />
690               <label className="form-check-label" htmlFor="user-show-avatars">
691                 {I18NextService.i18n.t("show_avatars")}
692               </label>
693             </div>
694           </div>
695           <div className="input-group mb-3">
696             <div className="form-check">
697               <input
698                 className="form-check-input"
699                 id="user-bot-account"
700                 type="checkbox"
701                 checked={this.state.saveUserSettingsForm.bot_account}
702                 onChange={linkEvent(this, this.handleBotAccount)}
703               />
704               <label className="form-check-label" htmlFor="user-bot-account">
705                 {I18NextService.i18n.t("bot_account")}
706               </label>
707             </div>
708           </div>
709           <div className="input-group mb-3">
710             <div className="form-check">
711               <input
712                 className="form-check-input"
713                 id="user-show-bot-accounts"
714                 type="checkbox"
715                 checked={this.state.saveUserSettingsForm.show_bot_accounts}
716                 onChange={linkEvent(this, this.handleShowBotAccounts)}
717               />
718               <label
719                 className="form-check-label"
720                 htmlFor="user-show-bot-accounts"
721               >
722                 {I18NextService.i18n.t("show_bot_accounts")}
723               </label>
724             </div>
725           </div>
726           <div className="input-group mb-3">
727             <div className="form-check">
728               <input
729                 className="form-check-input"
730                 id="user-show-read-posts"
731                 type="checkbox"
732                 checked={this.state.saveUserSettingsForm.show_read_posts}
733                 onChange={linkEvent(this, this.handleReadPosts)}
734               />
735               <label
736                 className="form-check-label"
737                 htmlFor="user-show-read-posts"
738               >
739                 {I18NextService.i18n.t("show_read_posts")}
740               </label>
741             </div>
742           </div>
743           <div className="input-group mb-3">
744             <div className="form-check">
745               <input
746                 className="form-check-input"
747                 id="user-show-new-post-notifs"
748                 type="checkbox"
749                 checked={this.state.saveUserSettingsForm.show_new_post_notifs}
750                 onChange={linkEvent(this, this.handleShowNewPostNotifs)}
751               />
752               <label
753                 className="form-check-label"
754                 htmlFor="user-show-new-post-notifs"
755               >
756                 {I18NextService.i18n.t("show_new_post_notifs")}
757               </label>
758             </div>
759           </div>
760           <div className="input-group mb-3">
761             <div className="form-check">
762               <input
763                 className="form-check-input"
764                 id="user-send-notifications-to-email"
765                 type="checkbox"
766                 disabled={!this.state.saveUserSettingsForm.email}
767                 checked={
768                   this.state.saveUserSettingsForm.send_notifications_to_email
769                 }
770                 onChange={linkEvent(
771                   this,
772                   this.handleSendNotificationsToEmailChange,
773                 )}
774               />
775               <label
776                 className="form-check-label"
777                 htmlFor="user-send-notifications-to-email"
778               >
779                 {I18NextService.i18n.t("send_notifications_to_email")}
780               </label>
781             </div>
782           </div>
783           {this.totpSection()}
784           <div className="input-group mb-3">
785             <button type="submit" className="btn d-block btn-secondary me-4">
786               {this.state.saveRes.state === "loading" ? (
787                 <Spinner />
788               ) : (
789                 capitalizeFirstLetter(I18NextService.i18n.t("save"))
790               )}
791             </button>
792           </div>
793           <hr />
794           <form
795             className="mb-3"
796             onSubmit={linkEvent(this, this.handleDeleteAccount)}
797           >
798             <button
799               type="button"
800               className="btn d-block btn-danger"
801               onClick={linkEvent(
802                 this,
803                 this.handleDeleteAccountShowConfirmToggle,
804               )}
805             >
806               {I18NextService.i18n.t("delete_account")}
807             </button>
808             {this.state.deleteAccountShowConfirm && (
809               <>
810                 <label
811                   className="my-2 alert alert-danger d-block"
812                   role="alert"
813                   htmlFor="password-delete-account"
814                 >
815                   {I18NextService.i18n.t("delete_account_confirm")}
816                 </label>
817                 <PasswordInput
818                   id="password-delete-account"
819                   value={this.state.deleteAccountForm.password}
820                   onInput={linkEvent(
821                     this,
822                     this.handleDeleteAccountPasswordChange,
823                   )}
824                   className="my-2"
825                 />
826                 <button
827                   type="submit"
828                   className="btn btn-danger me-4"
829                   disabled={!this.state.deleteAccountForm.password}
830                 >
831                   {this.state.deleteAccountRes.state === "loading" ? (
832                     <Spinner />
833                   ) : (
834                     capitalizeFirstLetter(I18NextService.i18n.t("delete"))
835                   )}
836                 </button>
837                 <button
838                   className="btn btn-secondary"
839                   type="button"
840                   onClick={linkEvent(
841                     this,
842                     this.handleDeleteAccountShowConfirmToggle,
843                   )}
844                 >
845                   {I18NextService.i18n.t("cancel")}
846                 </button>
847               </>
848             )}
849           </form>
850         </form>
851       </>
852     );
853   }
854
855   totpSection() {
856     const totpUrl =
857       UserService.Instance.myUserInfo?.local_user_view.local_user.totp_2fa_url;
858
859     return (
860       <>
861         {!totpUrl && (
862           <div className="input-group mb-3">
863             <div className="form-check">
864               <input
865                 className="form-check-input"
866                 id="user-generate-totp"
867                 type="checkbox"
868                 checked={this.state.saveUserSettingsForm.generate_totp_2fa}
869                 onChange={linkEvent(this, this.handleGenerateTotp)}
870               />
871               <label className="form-check-label" htmlFor="user-generate-totp">
872                 {I18NextService.i18n.t("set_up_two_factor")}
873               </label>
874             </div>
875           </div>
876         )}
877
878         {totpUrl && (
879           <>
880             <div>
881               <a className="btn btn-secondary mb-2" href={totpUrl}>
882                 {I18NextService.i18n.t("two_factor_link")}
883               </a>
884             </div>
885             <div className="input-group mb-3">
886               <div className="form-check">
887                 <input
888                   className="form-check-input"
889                   id="user-remove-totp"
890                   type="checkbox"
891                   checked={
892                     this.state.saveUserSettingsForm.generate_totp_2fa === false
893                   }
894                   onChange={linkEvent(this, this.handleRemoveTotp)}
895                 />
896                 <label className="form-check-label" htmlFor="user-remove-totp">
897                   {I18NextService.i18n.t("remove_two_factor")}
898                 </label>
899               </div>
900             </div>
901           </>
902         )}
903       </>
904     );
905   }
906
907   handlePersonSearch = debounce(async (text: string) => {
908     this.setState({ searchPersonLoading: true });
909
910     const searchPersonOptions: Choice[] = [];
911
912     if (text.length > 0) {
913       searchPersonOptions.push(...(await fetchUsers(text)).map(personToChoice));
914     }
915
916     this.setState({
917       searchPersonLoading: false,
918       searchPersonOptions,
919     });
920   });
921
922   handleCommunitySearch = debounce(async (text: string) => {
923     this.setState({ searchCommunityLoading: true });
924
925     const searchCommunityOptions: Choice[] = [];
926
927     if (text.length > 0) {
928       searchCommunityOptions.push(
929         ...(await fetchCommunities(text)).map(communityToChoice),
930       );
931     }
932
933     this.setState({
934       searchCommunityLoading: false,
935       searchCommunityOptions,
936     });
937   });
938
939   async handleBlockPerson({ value }: Choice) {
940     if (value !== "0") {
941       const res = await HttpService.client.blockPerson({
942         person_id: Number(value),
943         block: true,
944         auth: myAuthRequired(),
945       });
946       this.personBlock(res);
947     }
948   }
949
950   async handleUnblockPerson({
951     ctx,
952     recipientId,
953   }: {
954     ctx: Settings;
955     recipientId: number;
956   }) {
957     const res = await HttpService.client.blockPerson({
958       person_id: recipientId,
959       block: false,
960       auth: myAuthRequired(),
961     });
962     ctx.personBlock(res);
963   }
964
965   async handleBlockCommunity({ value }: Choice) {
966     if (value !== "0") {
967       const res = await HttpService.client.blockCommunity({
968         community_id: Number(value),
969         block: true,
970         auth: myAuthRequired(),
971       });
972       this.communityBlock(res);
973     }
974   }
975
976   async handleUnblockCommunity(i: { ctx: Settings; communityId: number }) {
977     const auth = myAuth();
978     if (auth) {
979       const res = await HttpService.client.blockCommunity({
980         community_id: i.communityId,
981         block: false,
982         auth: myAuthRequired(),
983       });
984       i.ctx.communityBlock(res);
985     }
986   }
987
988   handleShowNsfwChange(i: Settings, event: any) {
989     i.setState(
990       s => ((s.saveUserSettingsForm.show_nsfw = event.target.checked), s),
991     );
992   }
993
994   handleShowAvatarsChange(i: Settings, event: any) {
995     const mui = UserService.Instance.myUserInfo;
996     if (mui) {
997       mui.local_user_view.local_user.show_avatars = event.target.checked;
998     }
999     i.setState(
1000       s => ((s.saveUserSettingsForm.show_avatars = event.target.checked), s),
1001     );
1002   }
1003
1004   handleBotAccount(i: Settings, event: any) {
1005     i.setState(
1006       s => ((s.saveUserSettingsForm.bot_account = event.target.checked), s),
1007     );
1008   }
1009
1010   handleShowBotAccounts(i: Settings, event: any) {
1011     i.setState(
1012       s => (
1013         (s.saveUserSettingsForm.show_bot_accounts = event.target.checked), s
1014       ),
1015     );
1016   }
1017
1018   handleReadPosts(i: Settings, event: any) {
1019     i.setState(
1020       s => ((s.saveUserSettingsForm.show_read_posts = event.target.checked), s),
1021     );
1022   }
1023
1024   handleShowNewPostNotifs(i: Settings, event: any) {
1025     i.setState(
1026       s => (
1027         (s.saveUserSettingsForm.show_new_post_notifs = event.target.checked), s
1028       ),
1029     );
1030   }
1031
1032   handleShowScoresChange(i: Settings, event: any) {
1033     const mui = UserService.Instance.myUserInfo;
1034     if (mui) {
1035       mui.local_user_view.local_user.show_scores = event.target.checked;
1036     }
1037     i.setState(
1038       s => ((s.saveUserSettingsForm.show_scores = event.target.checked), s),
1039     );
1040   }
1041
1042   handleGenerateTotp(i: Settings, event: any) {
1043     // Coerce false to undefined here, so it won't generate it.
1044     const checked: boolean | undefined = event.target.checked || undefined;
1045     if (checked) {
1046       toast(I18NextService.i18n.t("two_factor_setup_instructions"));
1047     }
1048     i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
1049   }
1050
1051   handleRemoveTotp(i: Settings, event: any) {
1052     // Coerce true to undefined here, so it won't generate it.
1053     const checked: boolean | undefined = !event.target.checked && undefined;
1054     i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
1055   }
1056
1057   handleSendNotificationsToEmailChange(i: Settings, event: any) {
1058     i.setState(
1059       s => (
1060         (s.saveUserSettingsForm.send_notifications_to_email =
1061           event.target.checked),
1062         s
1063       ),
1064     );
1065   }
1066
1067   handleThemeChange(i: Settings, event: any) {
1068     i.setState(s => ((s.saveUserSettingsForm.theme = event.target.value), s));
1069     setTheme(event.target.value, true);
1070   }
1071
1072   handleInterfaceLangChange(i: Settings, event: any) {
1073     const newLang = event.target.value ?? "browser";
1074     I18NextService.i18n.changeLanguage(
1075       newLang === "browser" ? navigator.languages : newLang,
1076     );
1077
1078     i.setState(
1079       s => (
1080         (s.saveUserSettingsForm.interface_language = event.target.value), s
1081       ),
1082     );
1083   }
1084
1085   handleDiscussionLanguageChange(val: number[]) {
1086     this.setState(
1087       s => ((s.saveUserSettingsForm.discussion_languages = val), s),
1088     );
1089   }
1090
1091   handleSortTypeChange(val: SortType) {
1092     this.setState(s => ((s.saveUserSettingsForm.default_sort_type = val), s));
1093   }
1094
1095   handleListingTypeChange(val: ListingType) {
1096     this.setState(
1097       s => ((s.saveUserSettingsForm.default_listing_type = val), s),
1098     );
1099   }
1100
1101   handleEmailChange(i: Settings, event: any) {
1102     i.setState(s => ((s.saveUserSettingsForm.email = event.target.value), s));
1103   }
1104
1105   handleBioChange(val: string) {
1106     this.setState(s => ((s.saveUserSettingsForm.bio = val), s));
1107   }
1108
1109   handleAvatarUpload(url: string) {
1110     this.setState(s => ((s.saveUserSettingsForm.avatar = url), s));
1111   }
1112
1113   handleAvatarRemove() {
1114     this.setState(s => ((s.saveUserSettingsForm.avatar = ""), s));
1115   }
1116
1117   handleBannerUpload(url: string) {
1118     this.setState(s => ((s.saveUserSettingsForm.banner = url), s));
1119   }
1120
1121   handleBannerRemove() {
1122     this.setState(s => ((s.saveUserSettingsForm.banner = ""), s));
1123   }
1124
1125   handleDisplayNameChange(i: Settings, event: any) {
1126     i.setState(
1127       s => ((s.saveUserSettingsForm.display_name = event.target.value), s),
1128     );
1129   }
1130
1131   handleMatrixUserIdChange(i: Settings, event: any) {
1132     i.setState(
1133       s => ((s.saveUserSettingsForm.matrix_user_id = event.target.value), s),
1134     );
1135   }
1136
1137   handleNewPasswordChange(i: Settings, event: any) {
1138     const newPass: string | undefined =
1139       event.target.value === "" ? undefined : event.target.value;
1140     i.setState(s => ((s.changePasswordForm.new_password = newPass), s));
1141   }
1142
1143   handleNewPasswordVerifyChange(i: Settings, event: any) {
1144     const newPassVerify: string | undefined =
1145       event.target.value === "" ? undefined : event.target.value;
1146     i.setState(
1147       s => ((s.changePasswordForm.new_password_verify = newPassVerify), s),
1148     );
1149   }
1150
1151   handleOldPasswordChange(i: Settings, event: any) {
1152     const oldPass: string | undefined =
1153       event.target.value === "" ? undefined : event.target.value;
1154     i.setState(s => ((s.changePasswordForm.old_password = oldPass), s));
1155   }
1156
1157   async handleSaveSettingsSubmit(i: Settings, event: any) {
1158     event.preventDefault();
1159     i.setState({ saveRes: { state: "loading" } });
1160
1161     const saveRes = await HttpService.client.saveUserSettings({
1162       ...i.state.saveUserSettingsForm,
1163       auth: myAuthRequired(),
1164     });
1165
1166     if (saveRes.state === "success") {
1167       UserService.Instance.login({
1168         res: saveRes.data,
1169         showToast: false,
1170       });
1171       toast(I18NextService.i18n.t("saved"));
1172       window.scrollTo(0, 0);
1173     }
1174
1175     i.setState({ saveRes });
1176   }
1177
1178   async handleChangePasswordSubmit(i: Settings, event: any) {
1179     event.preventDefault();
1180     const { new_password, new_password_verify, old_password } =
1181       i.state.changePasswordForm;
1182
1183     if (new_password && old_password && new_password_verify) {
1184       i.setState({ changePasswordRes: { state: "loading" } });
1185       const changePasswordRes = await HttpService.client.changePassword({
1186         new_password,
1187         new_password_verify,
1188         old_password,
1189         auth: myAuthRequired(),
1190       });
1191       if (changePasswordRes.state === "success") {
1192         UserService.Instance.login({
1193           res: changePasswordRes.data,
1194           showToast: false,
1195         });
1196         window.scrollTo(0, 0);
1197         toast(I18NextService.i18n.t("password_changed"));
1198       }
1199
1200       i.setState({ changePasswordRes });
1201     }
1202   }
1203
1204   handleDeleteAccountShowConfirmToggle(i: Settings) {
1205     i.setState({ deleteAccountShowConfirm: !i.state.deleteAccountShowConfirm });
1206   }
1207
1208   handleDeleteAccountPasswordChange(i: Settings, event: any) {
1209     i.setState(s => ((s.deleteAccountForm.password = event.target.value), s));
1210   }
1211
1212   async handleDeleteAccount(i: Settings, event: Event) {
1213     event.preventDefault();
1214     const password = i.state.deleteAccountForm.password;
1215     if (password) {
1216       i.setState({ deleteAccountRes: { state: "loading" } });
1217       const deleteAccountRes = await HttpService.client.deleteAccount({
1218         password,
1219         auth: myAuthRequired(),
1220       });
1221       if (deleteAccountRes.state === "success") {
1222         UserService.Instance.logout();
1223         this.context.router.history.replace("/");
1224       }
1225
1226       i.setState({ deleteAccountRes });
1227     }
1228   }
1229
1230   handleSwitchTab(i: { ctx: Settings; tab: string }) {
1231     i.ctx.setState({ currentTab: i.tab });
1232   }
1233
1234   personBlock(res: RequestState<BlockPersonResponse>) {
1235     if (res.state === "success") {
1236       updatePersonBlock(res.data);
1237       const mui = UserService.Instance.myUserInfo;
1238       if (mui) {
1239         this.setState({ personBlocks: mui.person_blocks });
1240       }
1241     }
1242   }
1243
1244   communityBlock(res: RequestState<BlockCommunityResponse>) {
1245     if (res.state === "success") {
1246       updateCommunityBlock(res.data);
1247       const mui = UserService.Instance.myUserInfo;
1248       if (mui) {
1249         this.setState({ communityBlocks: mui.community_blocks });
1250       }
1251     }
1252   }
1253 }