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