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