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