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