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