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