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