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