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