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