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