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