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