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