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