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