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