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