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