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