]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post-form.tsx
reset, merge issues
[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   fetchCommunities,
22   getIdFromString,
23   ghostArchiveUrl,
24   isImage,
25   myAuth,
26   myAuthRequired,
27   relTags,
28   setupTippy,
29   toast,
30   trendingFetchLimit,
31   validTitle,
32   validURL,
33   webArchiveUrl,
34 } from "../../utils";
35 import { debounce } from "../../utils/helpers/debounce";
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 onSubmit={linkEvent(this, this.handlePostSubmit)}>
197         <NavigationPrompt
198           when={
199             !!(
200               this.state.form.name ||
201               this.state.form.url ||
202               this.state.form.body
203             ) && !this.state.submitted
204           }
205         />
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.renderSuggestedTitleCopy()}
220             <form>
221               <label
222                 htmlFor="file-upload"
223                 className={`${
224                   UserService.Instance.myUserInfo && "pointer"
225                 } d-inline-block float-right text-muted font-weight-bold`}
226                 data-tippy-content={i18n.t("upload_image")}
227               >
228                 <Icon icon="image" classes="icon-inline" />
229               </label>
230               <input
231                 id="file-upload"
232                 type="file"
233                 accept="image/*,video/*"
234                 name="file"
235                 className="d-none"
236                 disabled={!UserService.Instance.myUserInfo}
237                 onChange={linkEvent(this, this.handleImageUpload)}
238               />
239             </form>
240             {url && validURL(url) && (
241               <div>
242                 <a
243                   href={`${webArchiveUrl}/save/${encodeURIComponent(url)}`}
244                   className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
245                   rel={relTags}
246                 >
247                   archive.org {i18n.t("archive_link")}
248                 </a>
249                 <a
250                   href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
251                     url
252                   )}`}
253                   className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
254                   rel={relTags}
255                 >
256                   ghostarchive.org {i18n.t("archive_link")}
257                 </a>
258                 <a
259                   href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
260                     url
261                   )}`}
262                   className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
263                   rel={relTags}
264                 >
265                   archive.today {i18n.t("archive_link")}
266                 </a>
267               </div>
268             )}
269             {this.state.imageLoading && <Spinner />}
270             {url && isImage(url) && (
271               <img src={url} className="img-fluid" alt="" />
272             )}
273             {this.state.imageDeleteUrl && (
274               <button
275                 className="btn btn-danger btn-sm mt-2"
276                 onClick={linkEvent(this, this.handleImageDelete)}
277                 aria-label={i18n.t("delete")}
278                 data-tippy-content={i18n.t("delete")}
279               >
280                 <Icon icon="x" classes="icon-inline mr-1" />
281                 {capitalizeFirstLetter(i18n.t("delete"))}
282               </button>
283             )}
284             {this.props.crossPosts && this.props.crossPosts.length > 0 && (
285               <>
286                 <div className="my-1 text-muted small font-weight-bold">
287                   {i18n.t("cross_posts")}
288                 </div>
289                 <PostListings
290                   showCommunity
291                   posts={this.props.crossPosts}
292                   enableDownvotes={this.props.enableDownvotes}
293                   enableNsfw={this.props.enableNsfw}
294                   allLanguages={this.props.allLanguages}
295                   siteLanguages={this.props.siteLanguages}
296                   viewOnly
297                   // All of these are unused, since its view only
298                   onPostEdit={() => {}}
299                   onPostVote={() => {}}
300                   onPostReport={() => {}}
301                   onBlockPerson={() => {}}
302                   onLockPost={() => {}}
303                   onDeletePost={() => {}}
304                   onRemovePost={() => {}}
305                   onSavePost={() => {}}
306                   onFeaturePost={() => {}}
307                   onPurgePerson={() => {}}
308                   onPurgePost={() => {}}
309                   onBanPersonFromCommunity={() => {}}
310                   onBanPerson={() => {}}
311                   onAddModToCommunity={() => {}}
312                   onAddAdmin={() => {}}
313                   onTransferCommunity={() => {}}
314                 />
315               </>
316             )}
317           </div>
318         </div>
319         <div className="form-group row">
320           <label className="col-sm-2 col-form-label" htmlFor="post-title">
321             {i18n.t("title")}
322           </label>
323           <div className="col-sm-10">
324             <textarea
325               value={this.state.form.name}
326               id="post-title"
327               onInput={linkEvent(this, this.handlePostNameChange)}
328               className={`form-control ${
329                 !validTitle(this.state.form.name) && "is-invalid"
330               }`}
331               required
332               rows={1}
333               minLength={3}
334               maxLength={MAX_POST_TITLE_LENGTH}
335             />
336             {!validTitle(this.state.form.name) && (
337               <div className="invalid-feedback">
338                 {i18n.t("invalid_post_title")}
339               </div>
340             )}
341             {this.renderSuggestedPosts()}
342           </div>
343         </div>
344
345         <div className="form-group row">
346           <label className="col-sm-2 col-form-label">{i18n.t("body")}</label>
347           <div className="col-sm-10">
348             <MarkdownTextArea
349               initialContent={this.state.form.body}
350               onContentChange={this.handlePostBodyChange}
351               allLanguages={this.props.allLanguages}
352               siteLanguages={this.props.siteLanguages}
353               hideNavigationWarnings
354             />
355           </div>
356         </div>
357         {!this.props.post_view && (
358           <div className="form-group row">
359             <label className="col-sm-2 col-form-label" htmlFor="post-community">
360               {i18n.t("community")}
361             </label>
362             <div className="col-sm-10">
363               <SearchableSelect
364                 id="post-community"
365                 value={this.state.form.community_id}
366                 options={[
367                   {
368                     label: i18n.t("select_a_community"),
369                     value: "",
370                     disabled: true,
371                   } as Choice,
372                 ].concat(this.state.communitySearchOptions)}
373                 loading={this.state.communitySearchLoading}
374                 onChange={this.handleCommunitySelect}
375                 onSearch={this.handleCommunitySearch}
376               />
377             </div>
378           </div>
379         )}
380         {this.props.enableNsfw && (
381           <div className="form-group row">
382             <legend className="col-form-label col-sm-2 pt-0">
383               {i18n.t("nsfw")}
384             </legend>
385             <div className="col-sm-10">
386               <div className="form-check">
387                 <input
388                   className="form-check-input position-static"
389                   id="post-nsfw"
390                   type="checkbox"
391                   checked={this.state.form.nsfw}
392                   onChange={linkEvent(this, this.handlePostNsfwChange)}
393                 />
394               </div>
395             </div>
396           </div>
397         )}
398         <LanguageSelect
399           allLanguages={this.props.allLanguages}
400           siteLanguages={this.props.siteLanguages}
401           selectedLanguageIds={selectedLangs}
402           multiple={false}
403           onChange={this.handleLanguageChange}
404         />
405         <input
406           tabIndex={-1}
407           autoComplete="false"
408           name="a_password"
409           type="text"
410           className="form-control honeypot"
411           id="register-honey"
412           value={this.state.form.honeypot}
413           onInput={linkEvent(this, this.handleHoneyPotChange)}
414         />
415         <div className="form-group row">
416           <div className="col-sm-10">
417             <button
418               disabled={!this.state.form.community_id || this.state.loading}
419               type="submit"
420               className="btn btn-secondary mr-2"
421             >
422               {this.state.loading ? (
423                 <Spinner />
424               ) : this.props.post_view ? (
425                 capitalizeFirstLetter(i18n.t("save"))
426               ) : (
427                 capitalizeFirstLetter(i18n.t("create"))
428               )}
429             </button>
430             {this.props.post_view && (
431               <button
432                 type="button"
433                 className="btn btn-secondary"
434                 onClick={linkEvent(this, this.handleCancel)}
435               >
436                 {i18n.t("cancel")}
437               </button>
438             )}
439           </div>
440         </div>
441       </form>
442     );
443   }
444
445   renderSuggestedTitleCopy() {
446     switch (this.state.metadataRes.state) {
447       case "loading":
448         return <Spinner />;
449       case "success": {
450         const suggestedTitle = this.state.metadataRes.data.metadata.title;
451
452         return (
453           suggestedTitle && (
454             <div
455               className="mt-1 text-muted small font-weight-bold pointer"
456               role="button"
457               onClick={linkEvent(
458                 { i: this, suggestedTitle },
459                 this.copySuggestedTitle
460               )}
461             >
462               {i18n.t("copy_suggested_title", { title: "" })} {suggestedTitle}
463             </div>
464           )
465         );
466       }
467     }
468   }
469
470   renderSuggestedPosts() {
471     switch (this.state.suggestedPostsRes.state) {
472       case "loading":
473         return <Spinner />;
474       case "success": {
475         const suggestedPosts = this.state.suggestedPostsRes.data.posts;
476
477         return (
478           suggestedPosts &&
479           suggestedPosts.length > 0 && (
480             <>
481               <div className="my-1 text-muted small font-weight-bold">
482                 {i18n.t("related_posts")}
483               </div>
484               <PostListings
485                 showCommunity
486                 posts={suggestedPosts}
487                 enableDownvotes={this.props.enableDownvotes}
488                 enableNsfw={this.props.enableNsfw}
489                 allLanguages={this.props.allLanguages}
490                 siteLanguages={this.props.siteLanguages}
491                 viewOnly
492                 // All of these are unused, since its view only
493                 onPostEdit={() => {}}
494                 onPostVote={() => {}}
495                 onPostReport={() => {}}
496                 onBlockPerson={() => {}}
497                 onLockPost={() => {}}
498                 onDeletePost={() => {}}
499                 onRemovePost={() => {}}
500                 onSavePost={() => {}}
501                 onFeaturePost={() => {}}
502                 onPurgePerson={() => {}}
503                 onPurgePost={() => {}}
504                 onBanPersonFromCommunity={() => {}}
505                 onBanPerson={() => {}}
506                 onAddModToCommunity={() => {}}
507                 onAddAdmin={() => {}}
508                 onTransferCommunity={() => {}}
509               />
510             </>
511           )
512         );
513       }
514     }
515   }
516
517   handlePostSubmit(i: PostForm, event: any) {
518     event.preventDefault();
519     // Coerce empty url string to undefined
520     if ((i.state.form.url ?? "") === "") {
521       i.setState(s => ((s.form.url = undefined), s));
522     }
523     i.setState({ loading: true, submitted: true });
524     const auth = myAuthRequired();
525
526     const pForm = i.state.form;
527     const pv = i.props.post_view;
528
529     if (pv) {
530       i.props.onEdit?.({
531         name: pForm.name,
532         url: pForm.url,
533         body: pForm.body,
534         nsfw: pForm.nsfw,
535         post_id: pv.post.id,
536         language_id: pForm.language_id,
537         auth,
538       });
539     } else if (pForm.name && pForm.community_id) {
540       i.props.onCreate?.({
541         name: pForm.name,
542         community_id: pForm.community_id,
543         url: pForm.url,
544         body: pForm.body,
545         nsfw: pForm.nsfw,
546         language_id: pForm.language_id,
547         honeypot: pForm.honeypot,
548         auth,
549       });
550     }
551   }
552
553   copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) {
554     const sTitle = d.suggestedTitle;
555     if (sTitle) {
556       d.i.setState(
557         s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
558       );
559       d.i.setState({ suggestedPostsRes: { state: "empty" } });
560       setTimeout(() => {
561         const textarea: any = document.getElementById("post-title");
562         autosize.update(textarea);
563       }, 10);
564     }
565   }
566
567   handlePostUrlChange(i: PostForm, event: any) {
568     const url = event.target.value;
569
570     i.setState({
571       form: {
572         url,
573       },
574       imageDeleteUrl: "",
575     });
576
577     i.fetchPageTitle();
578   }
579
580   async fetchPageTitle() {
581     const url = this.state.form.url;
582     if (url && validURL(url)) {
583       this.setState({ metadataRes: { state: "loading" } });
584       this.setState({
585         metadataRes: await HttpService.client.getSiteMetadata({ url }),
586       });
587     }
588   }
589
590   handlePostNameChange(i: PostForm, event: any) {
591     i.setState(s => ((s.form.name = event.target.value), s));
592     i.fetchSimilarPosts();
593   }
594
595   async fetchSimilarPosts() {
596     const q = this.state.form.name;
597     if (q && q !== "") {
598       this.setState({ suggestedPostsRes: { state: "loading" } });
599       this.setState({
600         suggestedPostsRes: await HttpService.client.search({
601           q,
602           type_: "Posts",
603           sort: "TopAll",
604           listing_type: "All",
605           community_id: this.state.form.community_id,
606           page: 1,
607           limit: trendingFetchLimit,
608           auth: myAuth(),
609         }),
610       });
611     }
612   }
613
614   handlePostBodyChange(val: string) {
615     this.setState(s => ((s.form.body = val), s));
616   }
617
618   handlePostCommunityChange(i: PostForm, event: any) {
619     i.setState(s => ((s.form.community_id = Number(event.target.value)), s));
620   }
621
622   handlePostNsfwChange(i: PostForm, event: any) {
623     i.setState(s => ((s.form.nsfw = event.target.checked), s));
624   }
625
626   handleLanguageChange(val: number[]) {
627     this.setState(s => ((s.form.language_id = val.at(0)), s));
628   }
629
630   handleHoneyPotChange(i: PostForm, event: any) {
631     i.setState(s => ((s.form.honeypot = event.target.value), s));
632   }
633
634   handleCancel(i: PostForm) {
635     i.props.onCancel?.();
636   }
637
638   handlePreviewToggle(i: PostForm, event: any) {
639     event.preventDefault();
640     i.setState({ previewMode: !i.state.previewMode });
641   }
642
643   handleImageUploadPaste(i: PostForm, event: any) {
644     const image = event.clipboardData.files[0];
645     if (image) {
646       i.handleImageUpload(i, image);
647     }
648   }
649
650   handleImageUpload(i: PostForm, event: any) {
651     let file: any;
652     if (event.target) {
653       event.preventDefault();
654       file = event.target.files[0];
655     } else {
656       file = event;
657     }
658
659     i.setState({ imageLoading: true });
660
661     HttpService.client.uploadImage({ image: file }).then(res => {
662       console.log("pictrs upload:");
663       console.log(res);
664       if (res.state === "success") {
665         if (res.data.msg === "ok") {
666           i.state.form.url = res.data.url;
667           i.setState({
668             imageLoading: false,
669             imageDeleteUrl: res.data.delete_url as string,
670           });
671         } else {
672           toast(JSON.stringify(res), "danger");
673         }
674       } else if (res.state === "failed") {
675         console.error(res.msg);
676         toast(res.msg, "danger");
677         i.setState({ imageLoading: false });
678       }
679     });
680   }
681
682   handleImageDelete(i: PostForm) {
683     const { imageDeleteUrl } = i.state;
684
685     fetch(imageDeleteUrl);
686
687     i.setState({
688       imageDeleteUrl: "",
689       imageLoading: false,
690       form: {
691         url: "",
692       },
693     });
694   }
695
696   handleCommunitySearch = debounce(async (text: string) => {
697     const { selectedCommunityChoice } = this.props;
698     this.setState({ communitySearchLoading: true });
699
700     const newOptions: Choice[] = [];
701
702     if (selectedCommunityChoice) {
703       newOptions.push(selectedCommunityChoice);
704     }
705
706     if (text.length > 0) {
707       newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
708
709       this.setState({
710         communitySearchOptions: newOptions,
711       });
712     }
713
714     this.setState({
715       communitySearchLoading: false,
716     });
717   });
718
719   handleCommunitySelect(choice: Choice) {
720     if (this.props.onSelectCommunity) {
721       this.props.onSelectCommunity(choice);
722     }
723   }
724 }