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