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