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