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