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