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