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