]> Untitled Git - lemmy-ui.git/blob - src/shared/components/home/signup.tsx
Adding option types 2 (#689)
[lemmy-ui.git] / src / shared / components / home / signup.tsx
1 import { None, Option, Some } from "@sniptt/monads";
2 import { Options, passwordStrength } from "check-password-strength";
3 import { I18nKeys } from "i18next";
4 import { Component, linkEvent } from "inferno";
5 import { T } from "inferno-i18next-dess";
6 import {
7   CaptchaResponse,
8   GetCaptchaResponse,
9   GetSiteResponse,
10   LoginResponse,
11   Register,
12   SiteView,
13   toUndefined,
14   UserOperation,
15   wsJsonToRes,
16   wsUserOp,
17 } from "lemmy-js-client";
18 import { Subscription } from "rxjs";
19 import { i18n } from "../../i18next";
20 import { UserService, WebSocketService } from "../../services";
21 import {
22   auth,
23   isBrowser,
24   joinLemmyUrl,
25   mdToHtml,
26   setIsoData,
27   toast,
28   validEmail,
29   wsClient,
30   wsSubscribe,
31 } from "../../utils";
32 import { HtmlTags } from "../common/html-tags";
33 import { Icon, Spinner } from "../common/icon";
34 import { MarkdownTextArea } from "../common/markdown-textarea";
35
36 const passwordStrengthOptions: Options<string> = [
37   {
38     id: 0,
39     value: "very_weak",
40     minDiversity: 0,
41     minLength: 0,
42   },
43   {
44     id: 1,
45     value: "weak",
46     minDiversity: 2,
47     minLength: 10,
48   },
49   {
50     id: 2,
51     value: "medium",
52     minDiversity: 3,
53     minLength: 12,
54   },
55   {
56     id: 3,
57     value: "strong",
58     minDiversity: 4,
59     minLength: 14,
60   },
61 ];
62
63 interface State {
64   registerForm: Register;
65   registerLoading: boolean;
66   captcha: Option<GetCaptchaResponse>;
67   captchaPlaying: boolean;
68   siteRes: GetSiteResponse;
69 }
70
71 export class Signup extends Component<any, State> {
72   private isoData = setIsoData(this.context);
73   private subscription: Subscription;
74   private audio: HTMLAudioElement;
75
76   emptyState: State = {
77     registerForm: new Register({
78       username: undefined,
79       password: undefined,
80       password_verify: undefined,
81       show_nsfw: false,
82       captcha_uuid: None,
83       captcha_answer: None,
84       honeypot: None,
85       answer: None,
86       email: None,
87     }),
88     registerLoading: false,
89     captcha: None,
90     captchaPlaying: false,
91     siteRes: this.isoData.site_res,
92   };
93
94   constructor(props: any, context: any) {
95     super(props, context);
96
97     this.state = this.emptyState;
98     this.handleAnswerChange = this.handleAnswerChange.bind(this);
99
100     this.parseMessage = this.parseMessage.bind(this);
101     this.subscription = wsSubscribe(this.parseMessage);
102
103     if (isBrowser()) {
104       WebSocketService.Instance.send(wsClient.getCaptcha());
105     }
106   }
107
108   componentWillUnmount() {
109     if (isBrowser()) {
110       this.subscription.unsubscribe();
111     }
112   }
113
114   get documentTitle(): string {
115     return this.state.siteRes.site_view.match({
116       some: siteView => `${this.titleName(siteView)} - ${siteView.site.name}`,
117       none: "",
118     });
119   }
120
121   titleName(siteView: SiteView): string {
122     return i18n.t(siteView.site.private_instance ? "apply_to_join" : "sign_up");
123   }
124
125   get isLemmyMl(): boolean {
126     return isBrowser() && window.location.hostname == "lemmy.ml";
127   }
128
129   render() {
130     return (
131       <div class="container">
132         <HtmlTags
133           title={this.documentTitle}
134           path={this.context.router.route.match.url}
135           description={None}
136           image={None}
137         />
138         <div class="row">
139           <div class="col-12 col-lg-6 offset-lg-3">{this.registerForm()}</div>
140         </div>
141       </div>
142     );
143   }
144
145   registerForm() {
146     return this.state.siteRes.site_view.match({
147       some: siteView => (
148         <form onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
149           <h5>{this.titleName(siteView)}</h5>
150
151           {this.isLemmyMl && (
152             <div class="form-group row">
153               <div class="mt-2 mb-0 alert alert-warning" role="alert">
154                 <T i18nKey="lemmy_ml_registration_message">
155                   #<a href={joinLemmyUrl}>#</a>
156                 </T>
157               </div>
158             </div>
159           )}
160
161           <div class="form-group row">
162             <label class="col-sm-2 col-form-label" htmlFor="register-username">
163               {i18n.t("username")}
164             </label>
165
166             <div class="col-sm-10">
167               <input
168                 type="text"
169                 id="register-username"
170                 class="form-control"
171                 value={this.state.registerForm.username}
172                 onInput={linkEvent(this, this.handleRegisterUsernameChange)}
173                 required
174                 minLength={3}
175                 pattern="[a-zA-Z0-9_]+"
176                 title={i18n.t("community_reqs")}
177               />
178             </div>
179           </div>
180
181           <div class="form-group row">
182             <label class="col-sm-2 col-form-label" htmlFor="register-email">
183               {i18n.t("email")}
184             </label>
185             <div class="col-sm-10">
186               <input
187                 type="email"
188                 id="register-email"
189                 class="form-control"
190                 placeholder={
191                   siteView.site.require_email_verification
192                     ? i18n.t("required")
193                     : i18n.t("optional")
194                 }
195                 value={toUndefined(this.state.registerForm.email)}
196                 autoComplete="email"
197                 onInput={linkEvent(this, this.handleRegisterEmailChange)}
198                 required={siteView.site.require_email_verification}
199                 minLength={3}
200               />
201               {!siteView.site.require_email_verification &&
202                 !this.state.registerForm.email
203                   .map(validEmail)
204                   .unwrapOr(true) && (
205                   <div class="mt-2 mb-0 alert alert-warning" role="alert">
206                     <Icon icon="alert-triangle" classes="icon-inline mr-2" />
207                     {i18n.t("no_password_reset")}
208                   </div>
209                 )}
210             </div>
211           </div>
212
213           <div class="form-group row">
214             <label class="col-sm-2 col-form-label" htmlFor="register-password">
215               {i18n.t("password")}
216             </label>
217             <div class="col-sm-10">
218               <input
219                 type="password"
220                 id="register-password"
221                 value={this.state.registerForm.password}
222                 autoComplete="new-password"
223                 onInput={linkEvent(this, this.handleRegisterPasswordChange)}
224                 minLength={10}
225                 maxLength={60}
226                 class="form-control"
227                 required
228               />
229               {this.state.registerForm.password && (
230                 <div class={this.passwordColorClass}>
231                   {i18n.t(this.passwordStrength as I18nKeys)}
232                 </div>
233               )}
234             </div>
235           </div>
236
237           <div class="form-group row">
238             <label
239               class="col-sm-2 col-form-label"
240               htmlFor="register-verify-password"
241             >
242               {i18n.t("verify_password")}
243             </label>
244             <div class="col-sm-10">
245               <input
246                 type="password"
247                 id="register-verify-password"
248                 value={this.state.registerForm.password_verify}
249                 autoComplete="new-password"
250                 onInput={linkEvent(
251                   this,
252                   this.handleRegisterPasswordVerifyChange
253                 )}
254                 maxLength={60}
255                 class="form-control"
256                 required
257               />
258             </div>
259           </div>
260
261           {siteView.site.require_application && (
262             <>
263               <div class="form-group row">
264                 <div class="offset-sm-2 col-sm-10">
265                   <div class="mt-2 alert alert-warning" role="alert">
266                     <Icon icon="alert-triangle" classes="icon-inline mr-2" />
267                     {i18n.t("fill_out_application")}
268                   </div>
269                   {siteView.site.application_question.match({
270                     some: question => (
271                       <div
272                         className="md-div"
273                         dangerouslySetInnerHTML={mdToHtml(question)}
274                       />
275                     ),
276                     none: <></>,
277                   })}
278                 </div>
279               </div>
280
281               <div class="form-group row">
282                 <label
283                   class="col-sm-2 col-form-label"
284                   htmlFor="application_answer"
285                 >
286                   {i18n.t("answer")}
287                 </label>
288                 <div class="col-sm-10">
289                   <MarkdownTextArea
290                     initialContent={None}
291                     placeholder={None}
292                     buttonTitle={None}
293                     maxLength={None}
294                     onContentChange={this.handleAnswerChange}
295                     hideNavigationWarnings
296                   />
297                 </div>
298               </div>
299             </>
300           )}
301
302           {this.state.captcha.isSome() && (
303             <div class="form-group row">
304               <label class="col-sm-2" htmlFor="register-captcha">
305                 <span class="mr-2">{i18n.t("enter_code")}</span>
306                 <button
307                   type="button"
308                   class="btn btn-secondary"
309                   onClick={linkEvent(this, this.handleRegenCaptcha)}
310                   aria-label={i18n.t("captcha")}
311                 >
312                   <Icon icon="refresh-cw" classes="icon-refresh-cw" />
313                 </button>
314               </label>
315               {this.showCaptcha()}
316               <div class="col-sm-6">
317                 <input
318                   type="text"
319                   class="form-control"
320                   id="register-captcha"
321                   value={toUndefined(this.state.registerForm.captcha_answer)}
322                   onInput={linkEvent(
323                     this,
324                     this.handleRegisterCaptchaAnswerChange
325                   )}
326                   required
327                 />
328               </div>
329             </div>
330           )}
331           {siteView.site.enable_nsfw && (
332             <div class="form-group row">
333               <div class="col-sm-10">
334                 <div class="form-check">
335                   <input
336                     class="form-check-input"
337                     id="register-show-nsfw"
338                     type="checkbox"
339                     checked={this.state.registerForm.show_nsfw}
340                     onChange={linkEvent(
341                       this,
342                       this.handleRegisterShowNsfwChange
343                     )}
344                   />
345                   <label class="form-check-label" htmlFor="register-show-nsfw">
346                     {i18n.t("show_nsfw")}
347                   </label>
348                 </div>
349               </div>
350             </div>
351           )}
352           <input
353             tabIndex={-1}
354             autoComplete="false"
355             name="a_password"
356             type="text"
357             class="form-control honeypot"
358             id="register-honey"
359             value={toUndefined(this.state.registerForm.honeypot)}
360             onInput={linkEvent(this, this.handleHoneyPotChange)}
361           />
362           <div class="form-group row">
363             <div class="col-sm-10">
364               <button type="submit" class="btn btn-secondary">
365                 {this.state.registerLoading ? (
366                   <Spinner />
367                 ) : (
368                   this.titleName(siteView)
369                 )}
370               </button>
371             </div>
372           </div>
373         </form>
374       ),
375       none: <></>,
376     });
377   }
378
379   showCaptcha() {
380     return this.state.captcha.match({
381       some: captcha => (
382         <div class="col-sm-4">
383           {captcha.ok.match({
384             some: res => (
385               <>
386                 <img
387                   class="rounded-top img-fluid"
388                   src={this.captchaPngSrc(res)}
389                   style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;"
390                   alt={i18n.t("captcha")}
391                 />
392                 {res.wav.isSome() && (
393                   <button
394                     class="rounded-bottom btn btn-sm btn-secondary btn-block"
395                     style="border-top-right-radius: 0; border-top-left-radius: 0;"
396                     title={i18n.t("play_captcha_audio")}
397                     onClick={linkEvent(this, this.handleCaptchaPlay)}
398                     type="button"
399                     disabled={this.state.captchaPlaying}
400                   >
401                     <Icon icon="play" classes="icon-play" />
402                   </button>
403                 )}
404               </>
405             ),
406             none: <></>,
407           })}
408         </div>
409       ),
410       none: <></>,
411     });
412   }
413
414   get passwordStrength() {
415     return passwordStrength(
416       this.state.registerForm.password,
417       passwordStrengthOptions
418     ).value;
419   }
420
421   get passwordColorClass(): string {
422     let strength = this.passwordStrength;
423
424     if (["weak", "medium"].includes(strength)) {
425       return "text-warning";
426     } else if (strength == "strong") {
427       return "text-success";
428     } else {
429       return "text-danger";
430     }
431   }
432
433   handleRegisterSubmit(i: Signup, event: any) {
434     event.preventDefault();
435     i.state.registerLoading = true;
436     i.setState(i.state);
437     WebSocketService.Instance.send(wsClient.register(i.state.registerForm));
438   }
439
440   handleRegisterUsernameChange(i: Signup, event: any) {
441     i.state.registerForm.username = event.target.value;
442     i.setState(i.state);
443   }
444
445   handleRegisterEmailChange(i: Signup, event: any) {
446     i.state.registerForm.email = Some(event.target.value);
447     if (i.state.registerForm.email.unwrap() == "") {
448       i.state.registerForm.email = None;
449     }
450     i.setState(i.state);
451   }
452
453   handleRegisterPasswordChange(i: Signup, event: any) {
454     i.state.registerForm.password = event.target.value;
455     i.setState(i.state);
456   }
457
458   handleRegisterPasswordVerifyChange(i: Signup, event: any) {
459     i.state.registerForm.password_verify = event.target.value;
460     i.setState(i.state);
461   }
462
463   handleRegisterShowNsfwChange(i: Signup, event: any) {
464     i.state.registerForm.show_nsfw = event.target.checked;
465     i.setState(i.state);
466   }
467
468   handleRegisterCaptchaAnswerChange(i: Signup, event: any) {
469     i.state.registerForm.captcha_answer = Some(event.target.value);
470     i.setState(i.state);
471   }
472
473   handleAnswerChange(val: string) {
474     this.state.registerForm.answer = Some(val);
475     this.setState(this.state);
476   }
477
478   handleHoneyPotChange(i: Signup, event: any) {
479     i.state.registerForm.honeypot = Some(event.target.value);
480     i.setState(i.state);
481   }
482
483   handleRegenCaptcha(i: Signup) {
484     i.audio = null;
485     i.state.captchaPlaying = false;
486     i.setState(i.state);
487     WebSocketService.Instance.send(wsClient.getCaptcha());
488   }
489
490   handleCaptchaPlay(i: Signup) {
491     // This was a bad bug, it should only build the new audio on a new file.
492     // Replays would stop prematurely if this was rebuilt every time.
493     i.state.captcha.match({
494       some: captcha =>
495         captcha.ok.match({
496           some: res => {
497             if (i.audio == null) {
498               let base64 = `data:audio/wav;base64,${res.wav}`;
499               i.audio = new Audio(base64);
500             }
501
502             i.audio.play();
503
504             i.state.captchaPlaying = true;
505             i.setState(i.state);
506
507             i.audio.addEventListener("ended", () => {
508               i.audio.currentTime = 0;
509               i.state.captchaPlaying = false;
510               i.setState(i.state);
511             });
512           },
513           none: void 0,
514         }),
515       none: void 0,
516     });
517   }
518
519   captchaPngSrc(captcha: CaptchaResponse) {
520     return `data:image/png;base64,${captcha.png}`;
521   }
522
523   parseMessage(msg: any) {
524     let op = wsUserOp(msg);
525     console.log(msg);
526     if (msg.error) {
527       toast(i18n.t(msg.error), "danger");
528       this.state = this.emptyState;
529       this.state.registerForm.captcha_answer = undefined;
530       // Refetch another captcha
531       // WebSocketService.Instance.send(wsClient.getCaptcha());
532       this.setState(this.state);
533       return;
534     } else {
535       if (op == UserOperation.Register) {
536         let data = wsJsonToRes<LoginResponse>(msg, LoginResponse);
537         this.state = this.emptyState;
538         this.setState(this.state);
539         // Only log them in if a jwt was set
540         if (data.jwt) {
541           UserService.Instance.login(data);
542           WebSocketService.Instance.send(
543             wsClient.userJoin({
544               auth: auth().unwrap(),
545             })
546           );
547           this.props.history.push("/communities");
548         } else {
549           if (data.verify_email_sent) {
550             toast(i18n.t("verify_email_sent"));
551           }
552           if (data.registration_created) {
553             toast(i18n.t("registration_application_sent"));
554           }
555           this.props.history.push("/");
556         }
557       } else if (op == UserOperation.GetCaptcha) {
558         let data = wsJsonToRes<GetCaptchaResponse>(msg, GetCaptchaResponse);
559         data.ok.match({
560           some: res => {
561             this.state.captcha = Some(data);
562             this.state.registerForm.captcha_uuid = Some(res.uuid);
563             this.setState(this.state);
564           },
565           none: void 0,
566         });
567       } else if (op == UserOperation.PasswordReset) {
568         toast(i18n.t("reset_password_mail_sent"));
569       } else if (op == UserOperation.GetSite) {
570         let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse);
571         this.state.siteRes = data;
572         this.setState(this.state);
573       }
574     }
575   }
576 }