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