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