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