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