]> Untitled Git - lemmy-ui.git/blob - src/shared/components/person/settings.tsx
2858efce1ce3cf4ba10cb345594324e76621b0c0
[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                 <option value="browser-compact">
639                   {I18NextService.i18n.t("browser_default_compact")}
640                 </option>
641                 {this.state.themeList.map(theme => (
642                   <option key={theme} value={theme}>
643                     {theme}
644                   </option>
645                 ))}
646               </select>
647             </div>
648           </div>
649           <form className="mb-3 row">
650             <label className="col-sm-3 col-form-label">
651               {I18NextService.i18n.t("type")}
652             </label>
653             <div className="col-sm-9">
654               <ListingTypeSelect
655                 type_={
656                   this.state.saveUserSettingsForm.default_listing_type ??
657                   "Local"
658                 }
659                 showLocal={showLocal(this.isoData)}
660                 showSubscribed
661                 onChange={this.handleListingTypeChange}
662               />
663             </div>
664           </form>
665           <form className="mb-3 row">
666             <label className="col-sm-3 col-form-label">
667               {I18NextService.i18n.t("sort_type")}
668             </label>
669             <div className="col-sm-9">
670               <SortSelect
671                 sort={
672                   this.state.saveUserSettingsForm.default_sort_type ?? "Active"
673                 }
674                 onChange={this.handleSortTypeChange}
675               />
676             </div>
677           </form>
678           <div className="input-group mb-3">
679             <div className="form-check">
680               <input
681                 className="form-check-input"
682                 id="user-show-nsfw"
683                 type="checkbox"
684                 checked={this.state.saveUserSettingsForm.show_nsfw}
685                 onChange={linkEvent(this, this.handleShowNsfwChange)}
686               />
687               <label className="form-check-label" htmlFor="user-show-nsfw">
688                 {I18NextService.i18n.t("show_nsfw")}
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-show-scores"
697                 type="checkbox"
698                 checked={this.state.saveUserSettingsForm.show_scores}
699                 onChange={linkEvent(this, this.handleShowScoresChange)}
700               />
701               <label className="form-check-label" htmlFor="user-show-scores">
702                 {I18NextService.i18n.t("show_scores")}
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-avatars"
711                 type="checkbox"
712                 checked={this.state.saveUserSettingsForm.show_avatars}
713                 onChange={linkEvent(this, this.handleShowAvatarsChange)}
714               />
715               <label className="form-check-label" htmlFor="user-show-avatars">
716                 {I18NextService.i18n.t("show_avatars")}
717               </label>
718             </div>
719           </div>
720           <div className="input-group mb-3">
721             <div className="form-check">
722               <input
723                 className="form-check-input"
724                 id="user-bot-account"
725                 type="checkbox"
726                 checked={this.state.saveUserSettingsForm.bot_account}
727                 onChange={linkEvent(this, this.handleBotAccount)}
728               />
729               <label className="form-check-label" htmlFor="user-bot-account">
730                 {I18NextService.i18n.t("bot_account")}
731               </label>
732             </div>
733           </div>
734           <div className="input-group mb-3">
735             <div className="form-check">
736               <input
737                 className="form-check-input"
738                 id="user-show-bot-accounts"
739                 type="checkbox"
740                 checked={this.state.saveUserSettingsForm.show_bot_accounts}
741                 onChange={linkEvent(this, this.handleShowBotAccounts)}
742               />
743               <label
744                 className="form-check-label"
745                 htmlFor="user-show-bot-accounts"
746               >
747                 {I18NextService.i18n.t("show_bot_accounts")}
748               </label>
749             </div>
750           </div>
751           <div className="input-group mb-3">
752             <div className="form-check">
753               <input
754                 className="form-check-input"
755                 id="user-show-read-posts"
756                 type="checkbox"
757                 checked={this.state.saveUserSettingsForm.show_read_posts}
758                 onChange={linkEvent(this, this.handleReadPosts)}
759               />
760               <label
761                 className="form-check-label"
762                 htmlFor="user-show-read-posts"
763               >
764                 {I18NextService.i18n.t("show_read_posts")}
765               </label>
766             </div>
767           </div>
768           <div className="input-group mb-3">
769             <div className="form-check">
770               <input
771                 className="form-check-input"
772                 id="user-show-new-post-notifs"
773                 type="checkbox"
774                 checked={this.state.saveUserSettingsForm.show_new_post_notifs}
775                 onChange={linkEvent(this, this.handleShowNewPostNotifs)}
776               />
777               <label
778                 className="form-check-label"
779                 htmlFor="user-show-new-post-notifs"
780               >
781                 {I18NextService.i18n.t("show_new_post_notifs")}
782               </label>
783             </div>
784           </div>
785           <div className="input-group mb-3">
786             <div className="form-check">
787               <input
788                 className="form-check-input"
789                 id="user-send-notifications-to-email"
790                 type="checkbox"
791                 disabled={!this.state.saveUserSettingsForm.email}
792                 checked={
793                   this.state.saveUserSettingsForm.send_notifications_to_email
794                 }
795                 onChange={linkEvent(
796                   this,
797                   this.handleSendNotificationsToEmailChange
798                 )}
799               />
800               <label
801                 className="form-check-label"
802                 htmlFor="user-send-notifications-to-email"
803               >
804                 {I18NextService.i18n.t("send_notifications_to_email")}
805               </label>
806             </div>
807           </div>
808           {this.totpSection()}
809           <div className="input-group mb-3">
810             <button type="submit" className="btn d-block btn-secondary me-4">
811               {this.state.saveRes.state === "loading" ? (
812                 <Spinner />
813               ) : (
814                 capitalizeFirstLetter(I18NextService.i18n.t("save"))
815               )}
816             </button>
817           </div>
818           <hr />
819           <div className="input-group mb-3">
820             <button
821               className="btn d-block btn-danger"
822               onClick={linkEvent(
823                 this,
824                 this.handleDeleteAccountShowConfirmToggle
825               )}
826             >
827               {I18NextService.i18n.t("delete_account")}
828             </button>
829             {this.state.deleteAccountShowConfirm && (
830               <>
831                 <div className="my-2 alert alert-danger" role="alert">
832                   {I18NextService.i18n.t("delete_account_confirm")}
833                 </div>
834                 <input
835                   type="password"
836                   value={this.state.deleteAccountForm.password}
837                   autoComplete="new-password"
838                   maxLength={60}
839                   onInput={linkEvent(
840                     this,
841                     this.handleDeleteAccountPasswordChange
842                   )}
843                   className="form-control my-2"
844                 />
845                 <button
846                   className="btn btn-danger me-4"
847                   disabled={!this.state.deleteAccountForm.password}
848                   onClick={linkEvent(this, this.handleDeleteAccount)}
849                 >
850                   {this.state.deleteAccountRes.state === "loading" ? (
851                     <Spinner />
852                   ) : (
853                     capitalizeFirstLetter(I18NextService.i18n.t("delete"))
854                   )}
855                 </button>
856                 <button
857                   className="btn btn-secondary"
858                   onClick={linkEvent(
859                     this,
860                     this.handleDeleteAccountShowConfirmToggle
861                   )}
862                 >
863                   {I18NextService.i18n.t("cancel")}
864                 </button>
865               </>
866             )}
867           </div>
868         </form>
869       </>
870     );
871   }
872
873   totpSection() {
874     const totpUrl =
875       UserService.Instance.myUserInfo?.local_user_view.local_user.totp_2fa_url;
876
877     return (
878       <>
879         {!totpUrl && (
880           <div className="input-group mb-3">
881             <div className="form-check">
882               <input
883                 className="form-check-input"
884                 id="user-generate-totp"
885                 type="checkbox"
886                 checked={this.state.saveUserSettingsForm.generate_totp_2fa}
887                 onChange={linkEvent(this, this.handleGenerateTotp)}
888               />
889               <label className="form-check-label" htmlFor="user-generate-totp">
890                 {I18NextService.i18n.t("set_up_two_factor")}
891               </label>
892             </div>
893           </div>
894         )}
895
896         {totpUrl && (
897           <>
898             <div>
899               <a className="btn btn-secondary mb-2" href={totpUrl}>
900                 {I18NextService.i18n.t("two_factor_link")}
901               </a>
902             </div>
903             <div className="input-group mb-3">
904               <div className="form-check">
905                 <input
906                   className="form-check-input"
907                   id="user-remove-totp"
908                   type="checkbox"
909                   checked={
910                     this.state.saveUserSettingsForm.generate_totp_2fa == false
911                   }
912                   onChange={linkEvent(this, this.handleRemoveTotp)}
913                 />
914                 <label className="form-check-label" htmlFor="user-remove-totp">
915                   {I18NextService.i18n.t("remove_two_factor")}
916                 </label>
917               </div>
918             </div>
919           </>
920         )}
921       </>
922     );
923   }
924
925   handlePersonSearch = debounce(async (text: string) => {
926     this.setState({ searchPersonLoading: true });
927
928     const searchPersonOptions: Choice[] = [];
929
930     if (text.length > 0) {
931       searchPersonOptions.push(...(await fetchUsers(text)).map(personToChoice));
932     }
933
934     this.setState({
935       searchPersonLoading: false,
936       searchPersonOptions,
937     });
938   });
939
940   handleCommunitySearch = debounce(async (text: string) => {
941     this.setState({ searchCommunityLoading: true });
942
943     const searchCommunityOptions: Choice[] = [];
944
945     if (text.length > 0) {
946       searchCommunityOptions.push(
947         ...(await fetchCommunities(text)).map(communityToChoice)
948       );
949     }
950
951     this.setState({
952       searchCommunityLoading: false,
953       searchCommunityOptions,
954     });
955   });
956
957   async handleBlockPerson({ value }: Choice) {
958     if (value !== "0") {
959       const res = await HttpService.client.blockPerson({
960         person_id: Number(value),
961         block: true,
962         auth: myAuthRequired(),
963       });
964       this.personBlock(res);
965     }
966   }
967
968   async handleUnblockPerson({
969     ctx,
970     recipientId,
971   }: {
972     ctx: Settings;
973     recipientId: number;
974   }) {
975     const res = await HttpService.client.blockPerson({
976       person_id: recipientId,
977       block: false,
978       auth: myAuthRequired(),
979     });
980     ctx.personBlock(res);
981   }
982
983   async handleBlockCommunity({ value }: Choice) {
984     if (value !== "0") {
985       const res = await HttpService.client.blockCommunity({
986         community_id: Number(value),
987         block: true,
988         auth: myAuthRequired(),
989       });
990       this.communityBlock(res);
991     }
992   }
993
994   async handleUnblockCommunity(i: { ctx: Settings; communityId: number }) {
995     const auth = myAuth();
996     if (auth) {
997       const res = await HttpService.client.blockCommunity({
998         community_id: i.communityId,
999         block: false,
1000         auth: myAuthRequired(),
1001       });
1002       i.ctx.communityBlock(res);
1003     }
1004   }
1005
1006   handleShowNsfwChange(i: Settings, event: any) {
1007     i.setState(
1008       s => ((s.saveUserSettingsForm.show_nsfw = event.target.checked), s)
1009     );
1010   }
1011
1012   handleShowAvatarsChange(i: Settings, event: any) {
1013     const mui = UserService.Instance.myUserInfo;
1014     if (mui) {
1015       mui.local_user_view.local_user.show_avatars = event.target.checked;
1016     }
1017     i.setState(
1018       s => ((s.saveUserSettingsForm.show_avatars = event.target.checked), s)
1019     );
1020   }
1021
1022   handleBotAccount(i: Settings, event: any) {
1023     i.setState(
1024       s => ((s.saveUserSettingsForm.bot_account = event.target.checked), s)
1025     );
1026   }
1027
1028   handleShowBotAccounts(i: Settings, event: any) {
1029     i.setState(
1030       s => (
1031         (s.saveUserSettingsForm.show_bot_accounts = event.target.checked), s
1032       )
1033     );
1034   }
1035
1036   handleReadPosts(i: Settings, event: any) {
1037     i.setState(
1038       s => ((s.saveUserSettingsForm.show_read_posts = event.target.checked), s)
1039     );
1040   }
1041
1042   handleShowNewPostNotifs(i: Settings, event: any) {
1043     i.setState(
1044       s => (
1045         (s.saveUserSettingsForm.show_new_post_notifs = event.target.checked), s
1046       )
1047     );
1048   }
1049
1050   handleShowScoresChange(i: Settings, event: any) {
1051     const mui = UserService.Instance.myUserInfo;
1052     if (mui) {
1053       mui.local_user_view.local_user.show_scores = event.target.checked;
1054     }
1055     i.setState(
1056       s => ((s.saveUserSettingsForm.show_scores = event.target.checked), s)
1057     );
1058   }
1059
1060   handleGenerateTotp(i: Settings, event: any) {
1061     // Coerce false to undefined here, so it won't generate it.
1062     const checked: boolean | undefined = event.target.checked || undefined;
1063     if (checked) {
1064       toast(I18NextService.i18n.t("two_factor_setup_instructions"));
1065     }
1066     i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
1067   }
1068
1069   handleRemoveTotp(i: Settings, event: any) {
1070     // Coerce true to undefined here, so it won't generate it.
1071     const checked: boolean | undefined = !event.target.checked && undefined;
1072     i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
1073   }
1074
1075   handleSendNotificationsToEmailChange(i: Settings, event: any) {
1076     i.setState(
1077       s => (
1078         (s.saveUserSettingsForm.send_notifications_to_email =
1079           event.target.checked),
1080         s
1081       )
1082     );
1083   }
1084
1085   handleThemeChange(i: Settings, event: any) {
1086     i.setState(s => ((s.saveUserSettingsForm.theme = event.target.value), s));
1087     setTheme(event.target.value, true);
1088   }
1089
1090   handleInterfaceLangChange(i: Settings, event: any) {
1091     const newLang = event.target.value ?? "browser";
1092     I18NextService.i18n.changeLanguage(
1093       newLang === "browser" ? navigator.languages : newLang
1094     );
1095
1096     i.setState(
1097       s => ((s.saveUserSettingsForm.interface_language = event.target.value), s)
1098     );
1099   }
1100
1101   handleDiscussionLanguageChange(val: number[]) {
1102     this.setState(
1103       s => ((s.saveUserSettingsForm.discussion_languages = val), s)
1104     );
1105   }
1106
1107   handleSortTypeChange(val: SortType) {
1108     this.setState(s => ((s.saveUserSettingsForm.default_sort_type = val), s));
1109   }
1110
1111   handleListingTypeChange(val: ListingType) {
1112     this.setState(
1113       s => ((s.saveUserSettingsForm.default_listing_type = val), s)
1114     );
1115   }
1116
1117   handleEmailChange(i: Settings, event: any) {
1118     i.setState(s => ((s.saveUserSettingsForm.email = event.target.value), s));
1119   }
1120
1121   handleBioChange(val: string) {
1122     this.setState(s => ((s.saveUserSettingsForm.bio = val), s));
1123   }
1124
1125   handleAvatarUpload(url: string) {
1126     this.setState(s => ((s.saveUserSettingsForm.avatar = url), s));
1127   }
1128
1129   handleAvatarRemove() {
1130     this.setState(s => ((s.saveUserSettingsForm.avatar = ""), s));
1131   }
1132
1133   handleBannerUpload(url: string) {
1134     this.setState(s => ((s.saveUserSettingsForm.banner = url), s));
1135   }
1136
1137   handleBannerRemove() {
1138     this.setState(s => ((s.saveUserSettingsForm.banner = ""), s));
1139   }
1140
1141   handleDisplayNameChange(i: Settings, event: any) {
1142     i.setState(
1143       s => ((s.saveUserSettingsForm.display_name = event.target.value), s)
1144     );
1145   }
1146
1147   handleMatrixUserIdChange(i: Settings, event: any) {
1148     i.setState(
1149       s => ((s.saveUserSettingsForm.matrix_user_id = event.target.value), s)
1150     );
1151   }
1152
1153   handleNewPasswordChange(i: Settings, event: any) {
1154     const newPass: string | undefined =
1155       event.target.value == "" ? undefined : event.target.value;
1156     i.setState(s => ((s.changePasswordForm.new_password = newPass), s));
1157   }
1158
1159   handleNewPasswordVerifyChange(i: Settings, event: any) {
1160     const newPassVerify: string | undefined =
1161       event.target.value == "" ? undefined : event.target.value;
1162     i.setState(
1163       s => ((s.changePasswordForm.new_password_verify = newPassVerify), s)
1164     );
1165   }
1166
1167   handleOldPasswordChange(i: Settings, event: any) {
1168     const oldPass: string | undefined =
1169       event.target.value == "" ? undefined : event.target.value;
1170     i.setState(s => ((s.changePasswordForm.old_password = oldPass), s));
1171   }
1172
1173   async handleSaveSettingsSubmit(i: Settings, event: any) {
1174     event.preventDefault();
1175     i.setState({ saveRes: { state: "loading" } });
1176
1177     const saveRes = await HttpService.client.saveUserSettings({
1178       ...i.state.saveUserSettingsForm,
1179       auth: myAuthRequired(),
1180     });
1181
1182     if (saveRes.state === "success") {
1183       UserService.Instance.login({
1184         res: saveRes.data,
1185         showToast: false,
1186       });
1187       toast(I18NextService.i18n.t("saved"));
1188       window.scrollTo(0, 0);
1189     }
1190
1191     i.setState({ saveRes });
1192   }
1193
1194   async handleChangePasswordSubmit(i: Settings, event: any) {
1195     event.preventDefault();
1196     const { new_password, new_password_verify, old_password } =
1197       i.state.changePasswordForm;
1198
1199     if (new_password && old_password && new_password_verify) {
1200       i.setState({ changePasswordRes: { state: "loading" } });
1201       const changePasswordRes = await HttpService.client.changePassword({
1202         new_password,
1203         new_password_verify,
1204         old_password,
1205         auth: myAuthRequired(),
1206       });
1207       if (changePasswordRes.state === "success") {
1208         UserService.Instance.login({
1209           res: changePasswordRes.data,
1210           showToast: false,
1211         });
1212         window.scrollTo(0, 0);
1213         toast(I18NextService.i18n.t("password_changed"));
1214       }
1215
1216       i.setState({ changePasswordRes });
1217     }
1218   }
1219
1220   handleDeleteAccountShowConfirmToggle(i: Settings) {
1221     i.setState({ deleteAccountShowConfirm: !i.state.deleteAccountShowConfirm });
1222   }
1223
1224   handleDeleteAccountPasswordChange(i: Settings, event: any) {
1225     i.setState(s => ((s.deleteAccountForm.password = event.target.value), s));
1226   }
1227
1228   async handleDeleteAccount(i: Settings) {
1229     const password = i.state.deleteAccountForm.password;
1230     if (password) {
1231       i.setState({ deleteAccountRes: { state: "loading" } });
1232       const deleteAccountRes = await HttpService.client.deleteAccount({
1233         password,
1234         auth: myAuthRequired(),
1235       });
1236       if (deleteAccountRes.state === "success") {
1237         UserService.Instance.logout();
1238         this.context.router.history.replace("/");
1239       }
1240
1241       i.setState({ deleteAccountRes });
1242     }
1243   }
1244
1245   handleSwitchTab(i: { ctx: Settings; tab: string }) {
1246     i.ctx.setState({ currentTab: i.tab });
1247   }
1248
1249   personBlock(res: RequestState<BlockPersonResponse>) {
1250     if (res.state === "success") {
1251       updatePersonBlock(res.data);
1252       const mui = UserService.Instance.myUserInfo;
1253       if (mui) {
1254         this.setState({ personBlocks: mui.person_blocks });
1255       }
1256     }
1257   }
1258
1259   communityBlock(res: RequestState<BlockCommunityResponse>) {
1260     if (res.state === "success") {
1261       updateCommunityBlock(res.data);
1262       const mui = UserService.Instance.myUserInfo;
1263       if (mui) {
1264         this.setState({ communityBlocks: mui.community_blocks });
1265       }
1266     }
1267   }
1268 }