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