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