]> Untitled Git - lemmy-ui.git/blob - src/shared/components/person/settings.tsx
Merge branch 'main' of github.com:L3v3L/lemmy-ui into main
[lemmy-ui.git] / src / shared / components / person / settings.tsx
1 import { debounce } from "@utils/helpers";
2 import { NoOptionI18nKeys } from "i18next";
3 import { Component, linkEvent } from "inferno";
4 import {
5   BlockCommunityResponse,
6   BlockPersonResponse,
7   CommunityBlockView,
8   DeleteAccountResponse,
9   GetSiteResponse,
10   ListingType,
11   LoginResponse,
12   PersonBlockView,
13   SortType,
14 } from "lemmy-js-client";
15 import { i18n, languages } from "../../i18next";
16 import { UserService } from "../../services";
17 import { HttpService, RequestState } from "../../services/HttpService";
18 import {
19   Choice,
20   capitalizeFirstLetter,
21   communityToChoice,
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="mb-3 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="person-settings 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="mb-3 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="mb-3 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="mb-3 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="input-group mb-3">
362             <button
363               type="submit"
364               className="btn d-block btn-secondary me-4 w-100"
365             >
366               {this.state.changePasswordRes.state === "loading" ? (
367                 <Spinner />
368               ) : (
369                 capitalizeFirstLetter(i18n.t("save"))
370               )}
371             </button>
372           </div>
373         </form>
374       </>
375     );
376   }
377
378   blockUserCard() {
379     const { searchPersonLoading, searchPersonOptions } = this.state;
380
381     return (
382       <div>
383         <Filter
384           filterType="user"
385           loading={searchPersonLoading}
386           onChange={this.handleBlockPerson}
387           onSearch={this.handlePersonSearch}
388           options={searchPersonOptions}
389         />
390         {this.blockedUsersList()}
391       </div>
392     );
393   }
394
395   blockedUsersList() {
396     return (
397       <>
398         <h5>{i18n.t("blocked_users")}</h5>
399         <ul className="list-unstyled mb-0">
400           {this.state.personBlocks.map(pb => (
401             <li key={pb.target.id}>
402               <span>
403                 <PersonListing person={pb.target} />
404                 <button
405                   className="btn btn-sm"
406                   onClick={linkEvent(
407                     { ctx: this, recipientId: pb.target.id },
408                     this.handleUnblockPerson
409                   )}
410                   data-tippy-content={i18n.t("unblock_user")}
411                 >
412                   <Icon icon="x" classes="icon-inline" />
413                 </button>
414               </span>
415             </li>
416           ))}
417         </ul>
418       </>
419     );
420   }
421
422   blockCommunityCard() {
423     const { searchCommunityLoading, searchCommunityOptions } = this.state;
424
425     return (
426       <div>
427         <Filter
428           filterType="community"
429           loading={searchCommunityLoading}
430           onChange={this.handleBlockCommunity}
431           onSearch={this.handleCommunitySearch}
432           options={searchCommunityOptions}
433         />
434         {this.blockedCommunitiesList()}
435       </div>
436     );
437   }
438
439   blockedCommunitiesList() {
440     return (
441       <>
442         <h5>{i18n.t("blocked_communities")}</h5>
443         <ul className="list-unstyled mb-0">
444           {this.state.communityBlocks.map(cb => (
445             <li key={cb.community.id}>
446               <span>
447                 <CommunityLink community={cb.community} />
448                 <button
449                   className="btn btn-sm"
450                   onClick={linkEvent(
451                     { ctx: this, communityId: cb.community.id },
452                     this.handleUnblockCommunity
453                   )}
454                   data-tippy-content={i18n.t("unblock_community")}
455                 >
456                   <Icon icon="x" classes="icon-inline" />
457                 </button>
458               </span>
459             </li>
460           ))}
461         </ul>
462       </>
463     );
464   }
465
466   saveUserSettingsHtmlForm() {
467     const selectedLangs = this.state.saveUserSettingsForm.discussion_languages;
468
469     return (
470       <>
471         <h5>{i18n.t("settings")}</h5>
472         <form onSubmit={linkEvent(this, this.handleSaveSettingsSubmit)}>
473           <div className="mb-3 row">
474             <label className="col-sm-3 col-form-label" htmlFor="display-name">
475               {i18n.t("display_name")}
476             </label>
477             <div className="col-sm-9">
478               <input
479                 id="display-name"
480                 type="text"
481                 className="form-control"
482                 placeholder={i18n.t("optional")}
483                 value={this.state.saveUserSettingsForm.display_name}
484                 onInput={linkEvent(this, this.handleDisplayNameChange)}
485                 pattern="^(?!@)(.+)$"
486                 minLength={3}
487               />
488             </div>
489           </div>
490           <div className="mb-3 row">
491             <label className="col-sm-3 col-form-label" htmlFor="user-bio">
492               {i18n.t("bio")}
493             </label>
494             <div className="col-sm-9">
495               <MarkdownTextArea
496                 initialContent={this.state.saveUserSettingsForm.bio}
497                 onContentChange={this.handleBioChange}
498                 maxLength={300}
499                 hideNavigationWarnings
500                 allLanguages={this.state.siteRes.all_languages}
501                 siteLanguages={this.state.siteRes.discussion_languages}
502               />
503             </div>
504           </div>
505           <div className="mb-3 row">
506             <label className="col-sm-3 col-form-label" htmlFor="user-email">
507               {i18n.t("email")}
508             </label>
509             <div className="col-sm-9">
510               <input
511                 type="email"
512                 id="user-email"
513                 className="form-control"
514                 placeholder={i18n.t("optional")}
515                 value={this.state.saveUserSettingsForm.email}
516                 onInput={linkEvent(this, this.handleEmailChange)}
517                 minLength={3}
518               />
519             </div>
520           </div>
521           <div className="mb-3 row">
522             <label className="col-sm-3 col-form-label" htmlFor="matrix-user-id">
523               <a href={elementUrl} rel={relTags}>
524                 {i18n.t("matrix_user_id")}
525               </a>
526             </label>
527             <div className="col-sm-9">
528               <input
529                 id="matrix-user-id"
530                 type="text"
531                 className="form-control"
532                 placeholder="@user:example.com"
533                 value={this.state.saveUserSettingsForm.matrix_user_id}
534                 onInput={linkEvent(this, this.handleMatrixUserIdChange)}
535                 pattern="^@[A-Za-z0-9._=-]+:[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"
536               />
537             </div>
538           </div>
539           <div className="mb-3 row">
540             <label className="col-sm-3 col-form-label">
541               {i18n.t("avatar")}
542             </label>
543             <div className="col-sm-9">
544               <ImageUploadForm
545                 uploadTitle={i18n.t("upload_avatar")}
546                 imageSrc={this.state.saveUserSettingsForm.avatar}
547                 onUpload={this.handleAvatarUpload}
548                 onRemove={this.handleAvatarRemove}
549                 rounded
550               />
551             </div>
552           </div>
553           <div className="mb-3 row">
554             <label className="col-sm-3 col-form-label">
555               {i18n.t("banner")}
556             </label>
557             <div className="col-sm-9">
558               <ImageUploadForm
559                 uploadTitle={i18n.t("upload_banner")}
560                 imageSrc={this.state.saveUserSettingsForm.banner}
561                 onUpload={this.handleBannerUpload}
562                 onRemove={this.handleBannerRemove}
563               />
564             </div>
565           </div>
566           <div className="mb-3 row">
567             <label className="col-sm-3 form-label" htmlFor="user-language">
568               {i18n.t("interface_language")}
569             </label>
570             <div className="col-sm-9">
571               <select
572                 id="user-language"
573                 value={this.state.saveUserSettingsForm.interface_language}
574                 onChange={linkEvent(this, this.handleInterfaceLangChange)}
575                 className="form-select d-inline-block w-auto"
576               >
577                 <option disabled aria-hidden="true">
578                   {i18n.t("interface_language")}
579                 </option>
580                 <option value="browser">{i18n.t("browser_default")}</option>
581                 <option disabled aria-hidden="true">
582                   â”€â”€
583                 </option>
584                 {languages
585                   .sort((a, b) => a.code.localeCompare(b.code))
586                   .map(lang => (
587                     <option key={lang.code} value={lang.code}>
588                       {lang.name}
589                     </option>
590                   ))}
591               </select>
592             </div>
593           </div>
594           <LanguageSelect
595             allLanguages={this.state.siteRes.all_languages}
596             siteLanguages={this.state.siteRes.discussion_languages}
597             selectedLanguageIds={selectedLangs}
598             multiple={true}
599             showLanguageWarning={true}
600             showSite
601             onChange={this.handleDiscussionLanguageChange}
602           />
603           <div className="mb-3 row">
604             <label className="col-sm-3 col-form-label" htmlFor="user-theme">
605               {i18n.t("theme")}
606             </label>
607             <div className="col-sm-9">
608               <select
609                 id="user-theme"
610                 value={this.state.saveUserSettingsForm.theme}
611                 onChange={linkEvent(this, this.handleThemeChange)}
612                 className="form-select d-inline-block w-auto"
613               >
614                 <option disabled aria-hidden="true">
615                   {i18n.t("theme")}
616                 </option>
617                 <option value="browser">{i18n.t("browser_default")}</option>
618                 {this.state.themeList.map(theme => (
619                   <option key={theme} value={theme}>
620                     {theme}
621                   </option>
622                 ))}
623               </select>
624             </div>
625           </div>
626           <form className="mb-3 row">
627             <label className="col-sm-3 col-form-label">{i18n.t("type")}</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               {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                 {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                 {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                 {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                 {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                 {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                 {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                 {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                 {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(i18n.t("save"))
790               )}
791             </button>
792           </div>
793           <hr />
794           <div className="input-group mb-3">
795             <button
796               className="btn d-block btn-danger"
797               onClick={linkEvent(
798                 this,
799                 this.handleDeleteAccountShowConfirmToggle
800               )}
801             >
802               {i18n.t("delete_account")}
803             </button>
804             {this.state.deleteAccountShowConfirm && (
805               <>
806                 <div className="my-2 alert alert-danger" role="alert">
807                   {i18n.t("delete_account_confirm")}
808                 </div>
809                 <input
810                   type="password"
811                   value={this.state.deleteAccountForm.password}
812                   autoComplete="new-password"
813                   maxLength={60}
814                   onInput={linkEvent(
815                     this,
816                     this.handleDeleteAccountPasswordChange
817                   )}
818                   className="form-control my-2"
819                 />
820                 <button
821                   className="btn btn-danger me-4"
822                   disabled={!this.state.deleteAccountForm.password}
823                   onClick={linkEvent(this, this.handleDeleteAccount)}
824                 >
825                   {this.state.deleteAccountRes.state === "loading" ? (
826                     <Spinner />
827                   ) : (
828                     capitalizeFirstLetter(i18n.t("delete"))
829                   )}
830                 </button>
831                 <button
832                   className="btn btn-secondary"
833                   onClick={linkEvent(
834                     this,
835                     this.handleDeleteAccountShowConfirmToggle
836                   )}
837                 >
838                   {i18n.t("cancel")}
839                 </button>
840               </>
841             )}
842           </div>
843         </form>
844       </>
845     );
846   }
847
848   totpSection() {
849     const totpUrl =
850       UserService.Instance.myUserInfo?.local_user_view.local_user.totp_2fa_url;
851
852     return (
853       <>
854         {!totpUrl && (
855           <div className="input-group mb-3">
856             <div className="form-check">
857               <input
858                 className="form-check-input"
859                 id="user-generate-totp"
860                 type="checkbox"
861                 checked={this.state.saveUserSettingsForm.generate_totp_2fa}
862                 onChange={linkEvent(this, this.handleGenerateTotp)}
863               />
864               <label className="form-check-label" htmlFor="user-generate-totp">
865                 {i18n.t("set_up_two_factor")}
866               </label>
867             </div>
868           </div>
869         )}
870
871         {totpUrl && (
872           <>
873             <div>
874               <a className="btn btn-secondary mb-2" href={totpUrl}>
875                 {i18n.t("two_factor_link")}
876               </a>
877             </div>
878             <div className="input-group mb-3">
879               <div className="form-check">
880                 <input
881                   className="form-check-input"
882                   id="user-remove-totp"
883                   type="checkbox"
884                   checked={
885                     this.state.saveUserSettingsForm.generate_totp_2fa == false
886                   }
887                   onChange={linkEvent(this, this.handleRemoveTotp)}
888                 />
889                 <label className="form-check-label" htmlFor="user-remove-totp">
890                   {i18n.t("remove_two_factor")}
891                 </label>
892               </div>
893             </div>
894           </>
895         )}
896       </>
897     );
898   }
899
900   handlePersonSearch = debounce(async (text: string) => {
901     this.setState({ searchPersonLoading: true });
902
903     const searchPersonOptions: Choice[] = [];
904
905     if (text.length > 0) {
906       searchPersonOptions.push(...(await fetchUsers(text)).map(personToChoice));
907     }
908
909     this.setState({
910       searchPersonLoading: false,
911       searchPersonOptions,
912     });
913   });
914
915   handleCommunitySearch = debounce(async (text: string) => {
916     this.setState({ searchCommunityLoading: true });
917
918     const searchCommunityOptions: Choice[] = [];
919
920     if (text.length > 0) {
921       searchCommunityOptions.push(
922         ...(await fetchCommunities(text)).map(communityToChoice)
923       );
924     }
925
926     this.setState({
927       searchCommunityLoading: false,
928       searchCommunityOptions,
929     });
930   });
931
932   async handleBlockPerson({ value }: Choice) {
933     if (value !== "0") {
934       const res = await HttpService.client.blockPerson({
935         person_id: Number(value),
936         block: true,
937         auth: myAuthRequired(),
938       });
939       this.personBlock(res);
940     }
941   }
942
943   async handleUnblockPerson({
944     ctx,
945     recipientId,
946   }: {
947     ctx: Settings;
948     recipientId: number;
949   }) {
950     const res = await HttpService.client.blockPerson({
951       person_id: recipientId,
952       block: false,
953       auth: myAuthRequired(),
954     });
955     ctx.personBlock(res);
956   }
957
958   async handleBlockCommunity({ value }: Choice) {
959     if (value !== "0") {
960       const res = await HttpService.client.blockCommunity({
961         community_id: Number(value),
962         block: true,
963         auth: myAuthRequired(),
964       });
965       this.communityBlock(res);
966     }
967   }
968
969   async handleUnblockCommunity(i: { ctx: Settings; communityId: number }) {
970     const auth = myAuth();
971     if (auth) {
972       const res = await HttpService.client.blockCommunity({
973         community_id: i.communityId,
974         block: false,
975         auth: myAuthRequired(),
976       });
977       i.ctx.communityBlock(res);
978     }
979   }
980
981   handleShowNsfwChange(i: Settings, event: any) {
982     i.setState(
983       s => ((s.saveUserSettingsForm.show_nsfw = event.target.checked), s)
984     );
985   }
986
987   handleShowAvatarsChange(i: Settings, event: any) {
988     const mui = UserService.Instance.myUserInfo;
989     if (mui) {
990       mui.local_user_view.local_user.show_avatars = event.target.checked;
991     }
992     i.setState(
993       s => ((s.saveUserSettingsForm.show_avatars = event.target.checked), s)
994     );
995   }
996
997   handleBotAccount(i: Settings, event: any) {
998     i.setState(
999       s => ((s.saveUserSettingsForm.bot_account = event.target.checked), s)
1000     );
1001   }
1002
1003   handleShowBotAccounts(i: Settings, event: any) {
1004     i.setState(
1005       s => (
1006         (s.saveUserSettingsForm.show_bot_accounts = event.target.checked), s
1007       )
1008     );
1009   }
1010
1011   handleReadPosts(i: Settings, event: any) {
1012     i.setState(
1013       s => ((s.saveUserSettingsForm.show_read_posts = event.target.checked), s)
1014     );
1015   }
1016
1017   handleShowNewPostNotifs(i: Settings, event: any) {
1018     i.setState(
1019       s => (
1020         (s.saveUserSettingsForm.show_new_post_notifs = event.target.checked), s
1021       )
1022     );
1023   }
1024
1025   handleShowScoresChange(i: Settings, event: any) {
1026     const mui = UserService.Instance.myUserInfo;
1027     if (mui) {
1028       mui.local_user_view.local_user.show_scores = event.target.checked;
1029     }
1030     i.setState(
1031       s => ((s.saveUserSettingsForm.show_scores = event.target.checked), s)
1032     );
1033   }
1034
1035   handleGenerateTotp(i: Settings, event: any) {
1036     // Coerce false to undefined here, so it won't generate it.
1037     const checked: boolean | undefined = event.target.checked || undefined;
1038     if (checked) {
1039       toast(i18n.t("two_factor_setup_instructions"));
1040     }
1041     i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
1042   }
1043
1044   handleRemoveTotp(i: Settings, event: any) {
1045     // Coerce true to undefined here, so it won't generate it.
1046     const checked: boolean | undefined = !event.target.checked && undefined;
1047     i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
1048   }
1049
1050   handleSendNotificationsToEmailChange(i: Settings, event: any) {
1051     i.setState(
1052       s => (
1053         (s.saveUserSettingsForm.send_notifications_to_email =
1054           event.target.checked),
1055         s
1056       )
1057     );
1058   }
1059
1060   handleThemeChange(i: Settings, event: any) {
1061     i.setState(s => ((s.saveUserSettingsForm.theme = event.target.value), s));
1062     setTheme(event.target.value, true);
1063   }
1064
1065   handleInterfaceLangChange(i: Settings, event: any) {
1066     const newLang = event.target.value ?? "browser";
1067     i18n.changeLanguage(newLang === "browser" ? navigator.languages : newLang);
1068
1069     i.setState(
1070       s => ((s.saveUserSettingsForm.interface_language = event.target.value), s)
1071     );
1072   }
1073
1074   handleDiscussionLanguageChange(val: number[]) {
1075     this.setState(
1076       s => ((s.saveUserSettingsForm.discussion_languages = val), s)
1077     );
1078   }
1079
1080   handleSortTypeChange(val: SortType) {
1081     this.setState(s => ((s.saveUserSettingsForm.default_sort_type = val), s));
1082   }
1083
1084   handleListingTypeChange(val: ListingType) {
1085     this.setState(
1086       s => ((s.saveUserSettingsForm.default_listing_type = val), s)
1087     );
1088   }
1089
1090   handleEmailChange(i: Settings, event: any) {
1091     i.setState(s => ((s.saveUserSettingsForm.email = event.target.value), s));
1092   }
1093
1094   handleBioChange(val: string) {
1095     this.setState(s => ((s.saveUserSettingsForm.bio = val), s));
1096   }
1097
1098   handleAvatarUpload(url: string) {
1099     this.setState(s => ((s.saveUserSettingsForm.avatar = url), s));
1100   }
1101
1102   handleAvatarRemove() {
1103     this.setState(s => ((s.saveUserSettingsForm.avatar = ""), s));
1104   }
1105
1106   handleBannerUpload(url: string) {
1107     this.setState(s => ((s.saveUserSettingsForm.banner = url), s));
1108   }
1109
1110   handleBannerRemove() {
1111     this.setState(s => ((s.saveUserSettingsForm.banner = ""), s));
1112   }
1113
1114   handleDisplayNameChange(i: Settings, event: any) {
1115     i.setState(
1116       s => ((s.saveUserSettingsForm.display_name = event.target.value), s)
1117     );
1118   }
1119
1120   handleMatrixUserIdChange(i: Settings, event: any) {
1121     i.setState(
1122       s => ((s.saveUserSettingsForm.matrix_user_id = event.target.value), s)
1123     );
1124   }
1125
1126   handleNewPasswordChange(i: Settings, event: any) {
1127     const newPass: string | undefined =
1128       event.target.value == "" ? undefined : event.target.value;
1129     i.setState(s => ((s.changePasswordForm.new_password = newPass), s));
1130   }
1131
1132   handleNewPasswordVerifyChange(i: Settings, event: any) {
1133     const newPassVerify: string | undefined =
1134       event.target.value == "" ? undefined : event.target.value;
1135     i.setState(
1136       s => ((s.changePasswordForm.new_password_verify = newPassVerify), s)
1137     );
1138   }
1139
1140   handleOldPasswordChange(i: Settings, event: any) {
1141     const oldPass: string | undefined =
1142       event.target.value == "" ? undefined : event.target.value;
1143     i.setState(s => ((s.changePasswordForm.old_password = oldPass), s));
1144   }
1145
1146   async handleSaveSettingsSubmit(i: Settings, event: any) {
1147     event.preventDefault();
1148     i.setState({ saveRes: { state: "loading" } });
1149
1150     const saveRes = await HttpService.client.saveUserSettings({
1151       ...i.state.saveUserSettingsForm,
1152       auth: myAuthRequired(),
1153     });
1154     if (saveRes.state === "success") {
1155       UserService.Instance.login(saveRes.data);
1156       location.reload();
1157       toast(i18n.t("saved"));
1158       window.scrollTo(0, 0);
1159     }
1160
1161     i.setState({ saveRes });
1162   }
1163
1164   async handleChangePasswordSubmit(i: Settings, event: any) {
1165     event.preventDefault();
1166     const { new_password, new_password_verify, old_password } =
1167       i.state.changePasswordForm;
1168
1169     if (new_password && old_password && new_password_verify) {
1170       i.setState({ changePasswordRes: { state: "loading" } });
1171       const changePasswordRes = await HttpService.client.changePassword({
1172         new_password,
1173         new_password_verify,
1174         old_password,
1175         auth: myAuthRequired(),
1176       });
1177       if (changePasswordRes.state === "success") {
1178         UserService.Instance.login(changePasswordRes.data);
1179         window.scrollTo(0, 0);
1180         toast(i18n.t("password_changed"));
1181       }
1182
1183       i.setState({ changePasswordRes });
1184     }
1185   }
1186
1187   handleDeleteAccountShowConfirmToggle(i: Settings) {
1188     i.setState({ deleteAccountShowConfirm: !i.state.deleteAccountShowConfirm });
1189   }
1190
1191   handleDeleteAccountPasswordChange(i: Settings, event: any) {
1192     i.setState(s => ((s.deleteAccountForm.password = event.target.value), s));
1193   }
1194
1195   async handleDeleteAccount(i: Settings) {
1196     const password = i.state.deleteAccountForm.password;
1197     if (password) {
1198       i.setState({ deleteAccountRes: { state: "loading" } });
1199       const deleteAccountRes = await HttpService.client.deleteAccount({
1200         password,
1201         auth: myAuthRequired(),
1202       });
1203       if (deleteAccountRes.state === "success") {
1204         UserService.Instance.logout();
1205         this.context.router.history.replace("/");
1206       }
1207
1208       i.setState({ deleteAccountRes });
1209     }
1210   }
1211
1212   handleSwitchTab(i: { ctx: Settings; tab: string }) {
1213     i.ctx.setState({ currentTab: i.tab });
1214   }
1215
1216   personBlock(res: RequestState<BlockPersonResponse>) {
1217     if (res.state === "success") {
1218       updatePersonBlock(res.data);
1219       const mui = UserService.Instance.myUserInfo;
1220       if (mui) {
1221         this.setState({ personBlocks: mui.person_blocks });
1222       }
1223     }
1224   }
1225
1226   communityBlock(res: RequestState<BlockCommunityResponse>) {
1227     if (res.state === "success") {
1228       updateCommunityBlock(res.data);
1229       const mui = UserService.Instance.myUserInfo;
1230       if (mui) {
1231         this.setState({ communityBlocks: mui.community_blocks });
1232       }
1233     }
1234   }
1235 }