]> Untitled Git - lemmy.git/blob - ui/src/components/post-form.tsx
Merge branch 'dev' into federation
[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 } from '../utils';
39 import autosize from 'autosize';
40 import Tribute from 'tributejs/src/Tribute.js';
41 import emojiShortName from 'emoji-short-name';
42 import Selectr from 'mobius1-selectr';
43 import { i18n } from '../i18next';
44
45 const MAX_POST_TITLE_LENGTH = 200;
46
47 interface PostFormProps {
48   post?: Post; // If a post is given, that means this is an edit
49   params?: PostFormParams;
50   onCancel?(): any;
51   onCreate?(id: number): any;
52   onEdit?(post: Post): any;
53 }
54
55 interface PostFormState {
56   postForm: PostFormI;
57   communities: Array<Community>;
58   loading: boolean;
59   imageLoading: boolean;
60   previewMode: boolean;
61   suggestedTitle: string;
62   suggestedPosts: Array<Post>;
63   crossPosts: Array<Post>;
64   enable_nsfw: boolean;
65 }
66
67 export class PostForm extends Component<PostFormProps, PostFormState> {
68   private id = `post-form-${randomStr()}`;
69   private tribute: Tribute;
70   private subscription: Subscription;
71   private emptyState: PostFormState = {
72     postForm: {
73       name: null,
74       nsfw: false,
75       auth: null,
76       community_id: null,
77       creator_id: UserService.Instance.user
78         ? UserService.Instance.user.id
79         : null,
80     },
81     communities: [],
82     loading: false,
83     imageLoading: false,
84     previewMode: false,
85     suggestedTitle: undefined,
86     suggestedPosts: [],
87     crossPosts: [],
88     enable_nsfw: undefined,
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
96     this.tribute = setupTribute();
97     this.setupEmojiPicker();
98
99     this.state = this.emptyState;
100
101     if (this.props.post) {
102       this.state.postForm = {
103         body: this.props.post.body,
104         // NOTE: debouncing breaks both these for some reason, unless you use defaultValue
105         name: this.props.post.name,
106         community_id: this.props.post.community_id,
107         edit_id: this.props.post.id,
108         creator_id: this.props.post.creator_id,
109         url: this.props.post.url,
110         nsfw: this.props.post.nsfw,
111         auth: null,
112       };
113     }
114
115     if (this.props.params) {
116       this.state.postForm.name = this.props.params.name;
117       if (this.props.params.url) {
118         this.state.postForm.url = this.props.params.url;
119       }
120       if (this.props.params.body) {
121         this.state.postForm.body = this.props.params.body;
122       }
123     }
124
125     this.subscription = WebSocketService.Instance.subject
126       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
127       .subscribe(
128         msg => this.parseMessage(msg),
129         err => console.error(err),
130         () => console.log('complete')
131       );
132
133     let listCommunitiesForm: ListCommunitiesForm = {
134       sort: SortType[SortType.TopAll],
135       limit: 9999,
136     };
137
138     WebSocketService.Instance.listCommunities(listCommunitiesForm);
139     WebSocketService.Instance.getSite();
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                 >
224                   {i18n.t('archive_link')}
225                 </a>
226               )}
227               {this.state.imageLoading && (
228                 <svg class="icon icon-spinner spin">
229                   <use xlinkHref="#icon-spinner"></use>
230                 </svg>
231               )}
232               {isImage(this.state.postForm.url) && (
233                 <img src={this.state.postForm.url} class="img-fluid" />
234               )}
235               {this.state.crossPosts.length > 0 && (
236                 <>
237                   <div class="my-1 text-muted small font-weight-bold">
238                     {i18n.t('cross_posts')}
239                   </div>
240                   <PostListings showCommunity posts={this.state.crossPosts} />
241                 </>
242               )}
243             </div>
244           </div>
245           <div class="form-group row">
246             <label class="col-sm-2 col-form-label" htmlFor="post-title">
247               {i18n.t('title')}
248             </label>
249             <div class="col-sm-10">
250               <textarea
251                 value={this.state.postForm.name}
252                 id="post-title"
253                 onInput={linkEvent(this, this.handlePostNameChange)}
254                 class="form-control"
255                 required
256                 rows={2}
257                 minLength={3}
258                 maxLength={MAX_POST_TITLE_LENGTH}
259               />
260               {this.state.suggestedPosts.length > 0 && (
261                 <>
262                   <div class="my-1 text-muted small font-weight-bold">
263                     {i18n.t('related_posts')}
264                   </div>
265                   <PostListings posts={this.state.suggestedPosts} />
266                 </>
267               )}
268             </div>
269           </div>
270
271           <div class="form-group row">
272             <label class="col-sm-2 col-form-label" htmlFor={this.id}>
273               {i18n.t('body')}
274             </label>
275             <div class="col-sm-10">
276               <textarea
277                 id={this.id}
278                 value={this.state.postForm.body}
279                 onInput={linkEvent(this, this.handlePostBodyChange)}
280                 className={`form-control ${this.state.previewMode && 'd-none'}`}
281                 rows={4}
282                 maxLength={10000}
283               />
284               {this.state.previewMode && (
285                 <div
286                   className="md-div"
287                   dangerouslySetInnerHTML={mdToHtml(this.state.postForm.body)}
288                 />
289               )}
290               {this.state.postForm.body && (
291                 <button
292                   className={`mt-1 mr-2 btn btn-sm btn-secondary ${
293                     this.state.previewMode && 'active'
294                   }`}
295                   onClick={linkEvent(this, this.handlePreviewToggle)}
296                 >
297                   {i18n.t('preview')}
298                 </button>
299               )}
300               <a
301                 href={markdownHelpUrl}
302                 target="_blank"
303                 class="d-inline-block float-right text-muted font-weight-bold"
304                 title={i18n.t('formatting_help')}
305               >
306                 <svg class="icon icon-inline">
307                   <use xlinkHref="#icon-help-circle"></use>
308                 </svg>
309               </a>
310               <span
311                 onClick={linkEvent(this, this.handleEmojiPickerClick)}
312                 class="pointer unselectable d-inline-block mr-3 float-right text-muted font-weight-bold"
313                 data-tippy-content={i18n.t('emoji_picker')}
314               >
315                 <svg class="icon icon-inline">
316                   <use xlinkHref="#icon-smile"></use>
317                 </svg>
318               </span>
319             </div>
320           </div>
321           {!this.props.post && (
322             <div class="form-group row">
323               <label class="col-sm-2 col-form-label" htmlFor="post-community">
324                 {i18n.t('community')}
325               </label>
326               <div class="col-sm-10">
327                 <select
328                   class="form-control"
329                   id="post-community"
330                   value={this.state.postForm.community_id}
331                   onInput={linkEvent(this, this.handlePostCommunityChange)}
332                 >
333                   {this.state.communities.map(community => (
334                     <option value={community.id}>{community.name}</option>
335                   ))}
336                 </select>
337               </div>
338             </div>
339           )}
340           {this.state.enable_nsfw && (
341             <div class="form-group row">
342               <div class="col-sm-10">
343                 <div class="form-check">
344                   <input
345                     class="form-check-input"
346                     id="post-nsfw"
347                     type="checkbox"
348                     checked={this.state.postForm.nsfw}
349                     onChange={linkEvent(this, this.handlePostNsfwChange)}
350                   />
351                   <label class="form-check-label" htmlFor="post-nsfw">
352                     {i18n.t('nsfw')}
353                   </label>
354                 </div>
355               </div>
356             </div>
357           )}
358           <div class="form-group row">
359             <div class="col-sm-10">
360               <button type="submit" class="btn btn-secondary mr-2">
361                 {this.state.loading ? (
362                   <svg class="icon icon-spinner spin">
363                     <use xlinkHref="#icon-spinner"></use>
364                   </svg>
365                 ) : this.props.post ? (
366                   capitalizeFirstLetter(i18n.t('save'))
367                 ) : (
368                   capitalizeFirstLetter(i18n.t('create'))
369                 )}
370               </button>
371               {this.props.post && (
372                 <button
373                   type="button"
374                   class="btn btn-secondary"
375                   onClick={linkEvent(this, this.handleCancel)}
376                 >
377                   {i18n.t('cancel')}
378                 </button>
379               )}
380             </div>
381           </div>
382         </form>
383       </div>
384     );
385   }
386
387   setupEmojiPicker() {
388     emojiPicker.on('emoji', twemojiHtmlStr => {
389       if (this.state.postForm.body == null) {
390         this.state.postForm.body = '';
391       }
392       var el = document.createElement('div');
393       el.innerHTML = twemojiHtmlStr;
394       let nativeUnicode = (el.childNodes[0] as HTMLElement).getAttribute('alt');
395       let shortName = `:${emojiShortName[nativeUnicode]}:`;
396       this.state.postForm.body += shortName;
397       this.setState(this.state);
398     });
399   }
400
401   handlePostSubmit(i: PostForm, event: any) {
402     event.preventDefault();
403     if (i.props.post) {
404       WebSocketService.Instance.editPost(i.state.postForm);
405     } else {
406       WebSocketService.Instance.createPost(i.state.postForm);
407     }
408     i.state.loading = true;
409     i.setState(i.state);
410   }
411
412   copySuggestedTitle(i: PostForm) {
413     i.state.postForm.name = i.state.suggestedTitle.substring(
414       0,
415       MAX_POST_TITLE_LENGTH
416     );
417     i.state.suggestedTitle = undefined;
418     i.setState(i.state);
419   }
420
421   handlePostUrlChange(i: PostForm, event: any) {
422     i.state.postForm.url = event.target.value;
423     i.setState(i.state);
424     i.fetchPageTitle();
425   }
426
427   fetchPageTitle() {
428     if (validURL(this.state.postForm.url)) {
429       let form: SearchForm = {
430         q: this.state.postForm.url,
431         type_: SearchType[SearchType.Url],
432         sort: SortType[SortType.TopAll],
433         page: 1,
434         limit: 6,
435       };
436
437       WebSocketService.Instance.search(form);
438
439       // Fetch the page title
440       getPageTitle(this.state.postForm.url).then(d => {
441         this.state.suggestedTitle = d;
442         this.setState(this.state);
443       });
444     } else {
445       this.state.suggestedTitle = undefined;
446       this.state.crossPosts = [];
447     }
448   }
449
450   handlePostNameChange(i: PostForm, event: any) {
451     i.state.postForm.name = event.target.value;
452     i.setState(i.state);
453     i.fetchSimilarPosts();
454   }
455
456   fetchSimilarPosts() {
457     let form: SearchForm = {
458       q: this.state.postForm.name,
459       type_: SearchType[SearchType.Posts],
460       sort: SortType[SortType.TopAll],
461       community_id: this.state.postForm.community_id,
462       page: 1,
463       limit: 6,
464     };
465
466     if (this.state.postForm.name !== '') {
467       WebSocketService.Instance.search(form);
468     } else {
469       this.state.suggestedPosts = [];
470     }
471
472     this.setState(this.state);
473   }
474
475   handlePostBodyChange(i: PostForm, event: any) {
476     i.state.postForm.body = event.target.value;
477     i.setState(i.state);
478   }
479
480   handlePostCommunityChange(i: PostForm, event: any) {
481     i.state.postForm.community_id = Number(event.target.value);
482     i.setState(i.state);
483   }
484
485   handlePostNsfwChange(i: PostForm, event: any) {
486     i.state.postForm.nsfw = event.target.checked;
487     i.setState(i.state);
488   }
489
490   handleCancel(i: PostForm) {
491     i.props.onCancel();
492   }
493
494   handlePreviewToggle(i: PostForm, event: any) {
495     event.preventDefault();
496     i.state.previewMode = !i.state.previewMode;
497     i.setState(i.state);
498   }
499
500   handleImageUploadPaste(i: PostForm, event: any) {
501     let image = event.clipboardData.files[0];
502     if (image) {
503       i.handleImageUpload(i, image);
504     }
505   }
506
507   handleImageUpload(i: PostForm, event: any) {
508     let file: any;
509     if (event.target) {
510       event.preventDefault();
511       file = event.target.files[0];
512     } else {
513       file = event;
514     }
515
516     const imageUploadUrl = `/pictshare/api/upload.php`;
517     const formData = new FormData();
518     formData.append('file', file);
519
520     i.state.imageLoading = true;
521     i.setState(i.state);
522
523     fetch(imageUploadUrl, {
524       method: 'POST',
525       body: formData,
526     })
527       .then(res => res.json())
528       .then(res => {
529         let url = `${window.location.origin}/pictshare/${encodeURI(res.url)}`;
530         if (res.filetype == 'mp4') {
531           url += '/raw';
532         }
533         i.state.postForm.url = url;
534         i.state.imageLoading = false;
535         i.setState(i.state);
536       })
537       .catch(error => {
538         i.state.imageLoading = false;
539         i.setState(i.state);
540         toast(error, 'danger');
541       });
542   }
543
544   handleEmojiPickerClick(_i: PostForm, event: any) {
545     emojiPicker.togglePicker(event.target);
546   }
547
548   parseMessage(msg: WebSocketJsonResponse) {
549     let res = wsJsonToRes(msg);
550     if (msg.error) {
551       toast(i18n.t(msg.error), 'danger');
552       this.state.loading = false;
553       this.setState(this.state);
554       return;
555     } else if (res.op == UserOperation.ListCommunities) {
556       let data = res.data as ListCommunitiesResponse;
557       this.state.communities = data.communities;
558       if (this.props.post) {
559         this.state.postForm.community_id = this.props.post.community_id;
560       } else if (this.props.params && this.props.params.community) {
561         let foundCommunityId = data.communities.find(
562           r => r.name == this.props.params.community
563         ).id;
564         this.state.postForm.community_id = foundCommunityId;
565       } else {
566         this.state.postForm.community_id = data.communities[0].id;
567       }
568       this.setState(this.state);
569
570       // Set up select searching
571       let selectId: any = document.getElementById('post-community');
572       if (selectId) {
573         let selector = new Selectr(selectId, { nativeDropdown: false });
574         selector.on('selectr.select', option => {
575           this.state.postForm.community_id = Number(option.value);
576         });
577       }
578     } else if (res.op == UserOperation.CreatePost) {
579       let data = res.data as PostResponse;
580       if (data.post.creator_id == UserService.Instance.user.id) {
581         this.state.loading = false;
582         this.props.onCreate(data.post.id);
583       }
584     } else if (res.op == UserOperation.EditPost) {
585       let data = res.data as PostResponse;
586       if (data.post.creator_id == UserService.Instance.user.id) {
587         this.state.loading = false;
588         this.props.onEdit(data.post);
589       }
590     } else if (res.op == UserOperation.Search) {
591       let data = res.data as SearchResponse;
592
593       if (data.type_ == SearchType[SearchType.Posts]) {
594         this.state.suggestedPosts = data.posts;
595       } else if (data.type_ == SearchType[SearchType.Url]) {
596         this.state.crossPosts = data.posts;
597       }
598       this.setState(this.state);
599     } else if (res.op == UserOperation.GetSite) {
600       let data = res.data as GetSiteResponse;
601       this.state.enable_nsfw = data.site.enable_nsfw;
602       this.setState(this.state);
603     }
604   }
605 }