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