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