]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post-form.tsx
Add honeypot for user and form creation. Fixes #433 (#435)
[lemmy-ui.git] / src / shared / components / post / post-form.tsx
1 import autosize from "autosize";
2 import { Component, linkEvent } from "inferno";
3 import { Prompt } from "inferno-router";
4 import {
5   CommunityView,
6   CreatePost,
7   EditPost,
8   ListingType,
9   PostResponse,
10   PostView,
11   Search,
12   SearchResponse,
13   SearchType,
14   SortType,
15   UserOperation,
16 } from "lemmy-js-client";
17 import { Subscription } from "rxjs";
18 import { pictrsUri } from "../../env";
19 import { i18n } from "../../i18next";
20 import { PostFormParams } from "../../interfaces";
21 import { UserService, WebSocketService } from "../../services";
22 import {
23   archiveUrl,
24   authField,
25   capitalizeFirstLetter,
26   choicesConfig,
27   communitySelectName,
28   communityToChoice,
29   debounce,
30   fetchCommunities,
31   getSiteMetadata,
32   isBrowser,
33   isImage,
34   pictrsDeleteToast,
35   setupTippy,
36   toast,
37   validTitle,
38   validURL,
39   wsClient,
40   wsJsonToRes,
41   wsSubscribe,
42   wsUserOp,
43 } from "../../utils";
44 import { Icon, Spinner } from "../common/icon";
45 import { MarkdownTextArea } from "../common/markdown-textarea";
46 import { PostListings } from "./post-listings";
47
48 var Choices: any;
49 if (isBrowser()) {
50   Choices = require("choices.js");
51 }
52
53 const MAX_POST_TITLE_LENGTH = 200;
54
55 interface PostFormProps {
56   post_view?: PostView; // If a post is given, that means this is an edit
57   communities?: CommunityView[];
58   params?: PostFormParams;
59   onCancel?(): any;
60   onCreate?(post: PostView): any;
61   onEdit?(post: PostView): any;
62   enableNsfw: boolean;
63   enableDownvotes: boolean;
64 }
65
66 interface PostFormState {
67   postForm: CreatePost;
68   loading: boolean;
69   imageLoading: boolean;
70   previewMode: boolean;
71   suggestedTitle: string;
72   suggestedPosts: PostView[];
73   crossPosts: PostView[];
74 }
75
76 export class PostForm extends Component<PostFormProps, PostFormState> {
77   private subscription: Subscription;
78   private choices: any;
79   private emptyState: PostFormState = {
80     postForm: {
81       community_id: null,
82       name: null,
83       nsfw: false,
84       auth: authField(false),
85     },
86     loading: false,
87     imageLoading: false,
88     previewMode: false,
89     suggestedTitle: undefined,
90     suggestedPosts: [],
91     crossPosts: [],
92   };
93
94   constructor(props: any, context: any) {
95     super(props, context);
96     this.fetchSimilarPosts = debounce(this.fetchSimilarPosts.bind(this));
97     this.fetchPageTitle = debounce(this.fetchPageTitle.bind(this));
98     this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
99
100     this.state = this.emptyState;
101
102     // Means its an edit
103     if (this.props.post_view) {
104       this.state.postForm = {
105         body: this.props.post_view.post.body,
106         name: this.props.post_view.post.name,
107         community_id: this.props.post_view.community.id,
108         url: this.props.post_view.post.url,
109         nsfw: this.props.post_view.post.nsfw,
110         auth: authField(),
111       };
112     }
113
114     if (this.props.params) {
115       this.state.postForm.name = this.props.params.name;
116       if (this.props.params.url) {
117         this.state.postForm.url = this.props.params.url;
118       }
119       if (this.props.params.body) {
120         this.state.postForm.body = this.props.params.body;
121       }
122     }
123
124     this.parseMessage = this.parseMessage.bind(this);
125     this.subscription = wsSubscribe(this.parseMessage);
126   }
127
128   componentDidMount() {
129     setupTippy();
130     this.setupCommunities();
131     let textarea: any = document.getElementById("post-title");
132     if (textarea) {
133       autosize(textarea);
134     }
135   }
136
137   componentDidUpdate() {
138     if (
139       !this.state.loading &&
140       (this.state.postForm.name ||
141         this.state.postForm.url ||
142         this.state.postForm.body)
143     ) {
144       window.onbeforeunload = () => true;
145     } else {
146       window.onbeforeunload = undefined;
147     }
148   }
149
150   componentWillUnmount() {
151     this.subscription.unsubscribe();
152     /* this.choices && this.choices.destroy(); */
153     window.onbeforeunload = null;
154   }
155
156   render() {
157     return (
158       <div>
159         <Prompt
160           when={
161             !this.state.loading &&
162             (this.state.postForm.name ||
163               this.state.postForm.url ||
164               this.state.postForm.body)
165           }
166           message={i18n.t("block_leaving")}
167         />
168         <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
169           <div class="form-group row">
170             <label class="col-sm-2 col-form-label" htmlFor="post-url">
171               {i18n.t("url")}
172             </label>
173             <div class="col-sm-10">
174               <input
175                 type="url"
176                 id="post-url"
177                 class="form-control"
178                 value={this.state.postForm.url}
179                 onInput={linkEvent(this, this.handlePostUrlChange)}
180                 onPaste={linkEvent(this, this.handleImageUploadPaste)}
181               />
182               {this.state.suggestedTitle && (
183                 <div
184                   class="mt-1 text-muted small font-weight-bold pointer"
185                   role="button"
186                   onClick={linkEvent(this, this.copySuggestedTitle)}
187                 >
188                   {i18n.t("copy_suggested_title", {
189                     title: this.state.suggestedTitle,
190                   })}
191                 </div>
192               )}
193               <form>
194                 <label
195                   htmlFor="file-upload"
196                   className={`${
197                     UserService.Instance.myUserInfo && "pointer"
198                   } d-inline-block float-right text-muted font-weight-bold`}
199                   data-tippy-content={i18n.t("upload_image")}
200                 >
201                   <Icon icon="image" classes="icon-inline" />
202                 </label>
203                 <input
204                   id="file-upload"
205                   type="file"
206                   accept="image/*,video/*"
207                   name="file"
208                   class="d-none"
209                   disabled={!UserService.Instance.myUserInfo}
210                   onChange={linkEvent(this, this.handleImageUpload)}
211                 />
212               </form>
213               {this.state.postForm.url && validURL(this.state.postForm.url) && (
214                 <a
215                   href={`${archiveUrl}/?run=1&url=${encodeURIComponent(
216                     this.state.postForm.url
217                   )}`}
218                   class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
219                   rel="noopener"
220                 >
221                   {i18n.t("archive_link")}
222                 </a>
223               )}
224               {this.state.imageLoading && <Spinner />}
225               {isImage(this.state.postForm.url) && (
226                 <img src={this.state.postForm.url} class="img-fluid" alt="" />
227               )}
228               {this.state.crossPosts.length > 0 && (
229                 <>
230                   <div class="my-1 text-muted small font-weight-bold">
231                     {i18n.t("cross_posts")}
232                   </div>
233                   <PostListings
234                     showCommunity
235                     posts={this.state.crossPosts}
236                     enableDownvotes={this.props.enableDownvotes}
237                     enableNsfw={this.props.enableNsfw}
238                   />
239                 </>
240               )}
241             </div>
242           </div>
243           <div class="form-group row">
244             <label class="col-sm-2 col-form-label" htmlFor="post-title">
245               {i18n.t("title")}
246             </label>
247             <div class="col-sm-10">
248               <textarea
249                 value={this.state.postForm.name}
250                 id="post-title"
251                 onInput={linkEvent(this, this.handlePostNameChange)}
252                 class={`form-control ${
253                   !validTitle(this.state.postForm.name) && "is-invalid"
254                 }`}
255                 required
256                 rows={1}
257                 minLength={3}
258                 maxLength={MAX_POST_TITLE_LENGTH}
259               />
260               {!validTitle(this.state.postForm.name) && (
261                 <div class="invalid-feedback">
262                   {i18n.t("invalid_post_title")}
263                 </div>
264               )}
265               {this.state.suggestedPosts.length > 0 && (
266                 <>
267                   <div class="my-1 text-muted small font-weight-bold">
268                     {i18n.t("related_posts")}
269                   </div>
270                   <PostListings
271                     posts={this.state.suggestedPosts}
272                     enableDownvotes={this.props.enableDownvotes}
273                     enableNsfw={this.props.enableNsfw}
274                   />
275                 </>
276               )}
277             </div>
278           </div>
279
280           <div class="form-group row">
281             <label class="col-sm-2 col-form-label">{i18n.t("body")}</label>
282             <div class="col-sm-10">
283               <MarkdownTextArea
284                 initialContent={this.state.postForm.body}
285                 onContentChange={this.handlePostBodyChange}
286               />
287             </div>
288           </div>
289           {!this.props.post_view && (
290             <div class="form-group row">
291               <label class="col-sm-2 col-form-label" htmlFor="post-community">
292                 {i18n.t("community")}
293               </label>
294               <div class="col-sm-10">
295                 <select
296                   class="form-control"
297                   id="post-community"
298                   value={this.state.postForm.community_id}
299                   onInput={linkEvent(this, this.handlePostCommunityChange)}
300                 >
301                   <option>{i18n.t("select_a_community")}</option>
302                   {this.props.communities.map(cv => (
303                     <option value={cv.community.id}>
304                       {communitySelectName(cv)}
305                     </option>
306                   ))}
307                 </select>
308               </div>
309             </div>
310           )}
311           {this.props.enableNsfw && (
312             <div class="form-group row">
313               <legend class="col-form-label col-sm-2 pt-0">
314                 {i18n.t("nsfw")}
315               </legend>
316               <div class="col-sm-10">
317                 <div class="form-check">
318                   <input
319                     class="form-check-input position-static"
320                     id="post-nsfw"
321                     type="checkbox"
322                     checked={this.state.postForm.nsfw}
323                     onChange={linkEvent(this, this.handlePostNsfwChange)}
324                   />
325                 </div>
326               </div>
327             </div>
328           )}
329           <input
330             tabIndex={-1}
331             autoComplete="false"
332             name="a_password"
333             type="text"
334             class="form-control honeypot"
335             id="register-honey"
336             value={this.state.postForm.honeypot}
337             onInput={linkEvent(this, this.handleHoneyPotChange)}
338           />
339           <div class="form-group row">
340             <div class="col-sm-10">
341               <button
342                 disabled={
343                   !this.state.postForm.community_id || this.state.loading
344                 }
345                 type="submit"
346                 class="btn btn-secondary mr-2"
347               >
348                 {this.state.loading ? (
349                   <Spinner />
350                 ) : this.props.post_view ? (
351                   capitalizeFirstLetter(i18n.t("save"))
352                 ) : (
353                   capitalizeFirstLetter(i18n.t("create"))
354                 )}
355               </button>
356               {this.props.post_view && (
357                 <button
358                   type="button"
359                   class="btn btn-secondary"
360                   onClick={linkEvent(this, this.handleCancel)}
361                 >
362                   {i18n.t("cancel")}
363                 </button>
364               )}
365             </div>
366           </div>
367         </form>
368       </div>
369     );
370   }
371
372   handlePostSubmit(i: PostForm, event: any) {
373     event.preventDefault();
374
375     // Coerce empty url string to undefined
376     if (i.state.postForm.url !== undefined && i.state.postForm.url === "") {
377       i.state.postForm.url = undefined;
378     }
379
380     if (i.props.post_view) {
381       let form: EditPost = {
382         ...i.state.postForm,
383         post_id: i.props.post_view.post.id,
384       };
385       WebSocketService.Instance.send(wsClient.editPost(form));
386     } else {
387       WebSocketService.Instance.send(wsClient.createPost(i.state.postForm));
388     }
389     i.state.loading = true;
390     i.setState(i.state);
391   }
392
393   copySuggestedTitle(i: PostForm) {
394     i.state.postForm.name = i.state.suggestedTitle.substring(
395       0,
396       MAX_POST_TITLE_LENGTH
397     );
398     i.state.suggestedTitle = undefined;
399     setTimeout(() => {
400       let textarea: any = document.getElementById("post-title");
401       autosize.update(textarea);
402     }, 10);
403     i.setState(i.state);
404   }
405
406   handlePostUrlChange(i: PostForm, event: any) {
407     i.state.postForm.url = event.target.value;
408     i.setState(i.state);
409     i.fetchPageTitle();
410   }
411
412   fetchPageTitle() {
413     if (validURL(this.state.postForm.url)) {
414       let form: Search = {
415         q: this.state.postForm.url,
416         type_: SearchType.Url,
417         sort: SortType.TopAll,
418         listing_type: ListingType.All,
419         page: 1,
420         limit: 6,
421         auth: authField(false),
422       };
423
424       WebSocketService.Instance.send(wsClient.search(form));
425
426       // Fetch the page title
427       getSiteMetadata(this.state.postForm.url).then(d => {
428         this.state.suggestedTitle = d.metadata.title;
429         this.setState(this.state);
430       });
431     } else {
432       this.state.suggestedTitle = undefined;
433       this.state.crossPosts = [];
434     }
435   }
436
437   handlePostNameChange(i: PostForm, event: any) {
438     i.state.postForm.name = event.target.value;
439     i.setState(i.state);
440     i.fetchSimilarPosts();
441   }
442
443   fetchSimilarPosts() {
444     let form: Search = {
445       q: this.state.postForm.name,
446       type_: SearchType.Posts,
447       sort: SortType.TopAll,
448       listing_type: ListingType.All,
449       community_id: this.state.postForm.community_id,
450       page: 1,
451       limit: 6,
452       auth: authField(false),
453     };
454
455     if (this.state.postForm.name !== "") {
456       WebSocketService.Instance.send(wsClient.search(form));
457     } else {
458       this.state.suggestedPosts = [];
459     }
460
461     this.setState(this.state);
462   }
463
464   handlePostBodyChange(val: string) {
465     this.state.postForm.body = val;
466     this.setState(this.state);
467   }
468
469   handlePostCommunityChange(i: PostForm, event: any) {
470     i.state.postForm.community_id = Number(event.target.value);
471     i.setState(i.state);
472   }
473
474   handlePostNsfwChange(i: PostForm, event: any) {
475     i.state.postForm.nsfw = event.target.checked;
476     i.setState(i.state);
477   }
478
479   handleHoneyPotChange(i: PostForm, event: any) {
480     i.state.postForm.honeypot = event.target.value;
481     i.setState(i.state);
482   }
483
484   handleCancel(i: PostForm) {
485     i.props.onCancel();
486   }
487
488   handlePreviewToggle(i: PostForm, event: any) {
489     event.preventDefault();
490     i.state.previewMode = !i.state.previewMode;
491     i.setState(i.state);
492   }
493
494   handleImageUploadPaste(i: PostForm, event: any) {
495     let image = event.clipboardData.files[0];
496     if (image) {
497       i.handleImageUpload(i, image);
498     }
499   }
500
501   handleImageUpload(i: PostForm, event: any) {
502     let file: any;
503     if (event.target) {
504       event.preventDefault();
505       file = event.target.files[0];
506     } else {
507       file = event;
508     }
509
510     const formData = new FormData();
511     formData.append("images[]", file);
512
513     i.state.imageLoading = true;
514     i.setState(i.state);
515
516     fetch(pictrsUri, {
517       method: "POST",
518       body: formData,
519     })
520       .then(res => res.json())
521       .then(res => {
522         console.log("pictrs upload:");
523         console.log(res);
524         if (res.msg == "ok") {
525           let hash = res.files[0].file;
526           let url = `${pictrsUri}/${hash}`;
527           let deleteToken = res.files[0].delete_token;
528           let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
529           i.state.postForm.url = url;
530           i.state.imageLoading = false;
531           i.setState(i.state);
532           pictrsDeleteToast(
533             i18n.t("click_to_delete_picture"),
534             i18n.t("picture_deleted"),
535             deleteUrl
536           );
537         } else {
538           i.state.imageLoading = false;
539           i.setState(i.state);
540           toast(JSON.stringify(res), "danger");
541         }
542       })
543       .catch(error => {
544         i.state.imageLoading = false;
545         i.setState(i.state);
546         toast(error, "danger");
547       });
548   }
549
550   setupCommunities() {
551     // Set up select searching
552     if (isBrowser()) {
553       let selectId: any = document.getElementById("post-community");
554       if (selectId) {
555         this.choices = new Choices(selectId, choicesConfig);
556         this.choices.passedElement.element.addEventListener(
557           "choice",
558           (e: any) => {
559             this.state.postForm.community_id = Number(e.detail.choice.value);
560             this.setState(this.state);
561           },
562           false
563         );
564         this.choices.passedElement.element.addEventListener(
565           "search",
566           debounce(async (e: any) => {
567             let communities = (await fetchCommunities(e.detail.value))
568               .communities;
569             this.choices.setChoices(
570               communities.map(cv => communityToChoice(cv)),
571               "value",
572               "label",
573               true
574             );
575           }, 400),
576           false
577         );
578       }
579     }
580
581     if (this.props.post_view) {
582       this.state.postForm.community_id = this.props.post_view.community.id;
583     } else if (
584       this.props.params &&
585       (this.props.params.community_id || this.props.params.community_name)
586     ) {
587       if (this.props.params.community_name) {
588         let foundCommunityId = this.props.communities.find(
589           r => r.community.name == this.props.params.community_name
590         ).community.id;
591         this.state.postForm.community_id = foundCommunityId;
592       } else if (this.props.params.community_id) {
593         this.state.postForm.community_id = this.props.params.community_id;
594       }
595
596       if (isBrowser()) {
597         this.choices.setChoiceByValue(
598           this.state.postForm.community_id.toString()
599         );
600       }
601       this.setState(this.state);
602     } else {
603       // By default, the null valued 'Select a Community'
604     }
605   }
606
607   parseMessage(msg: any) {
608     let op = wsUserOp(msg);
609     console.log(msg);
610     if (msg.error) {
611       toast(i18n.t(msg.error), "danger");
612       this.state.loading = false;
613       this.setState(this.state);
614       return;
615     } else if (op == UserOperation.CreatePost) {
616       let data = wsJsonToRes<PostResponse>(msg).data;
617       if (
618         data.post_view.creator.id ==
619         UserService.Instance.myUserInfo.local_user_view.person.id
620       ) {
621         this.state.loading = false;
622         this.props.onCreate(data.post_view);
623       }
624     } else if (op == UserOperation.EditPost) {
625       let data = wsJsonToRes<PostResponse>(msg).data;
626       if (
627         data.post_view.creator.id ==
628         UserService.Instance.myUserInfo.local_user_view.person.id
629       ) {
630         this.state.loading = false;
631         this.props.onEdit(data.post_view);
632       }
633     } else if (op == UserOperation.Search) {
634       let data = wsJsonToRes<SearchResponse>(msg).data;
635
636       if (data.type_ == SearchType[SearchType.Posts]) {
637         this.state.suggestedPosts = data.posts;
638       } else if (data.type_ == SearchType[SearchType.Url]) {
639         this.state.crossPosts = data.posts;
640       }
641       this.setState(this.state);
642     }
643   }
644 }