]> Untitled Git - lemmy.git/blob - ui/src/components/post-form.tsx
Merge branch 'dev' into richardj-feature/frontend-a11y
[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 import { T } from 'inferno-i18next';
40
41 interface PostFormProps {
42   post?: Post; // If a post is given, that means this is an edit
43   params?: PostFormParams;
44   onCancel?(): any;
45   onCreate?(id: number): any;
46   onEdit?(post: Post): any;
47 }
48
49 interface PostFormState {
50   postForm: PostFormI;
51   communities: Array<Community>;
52   loading: boolean;
53   imageLoading: boolean;
54   previewMode: boolean;
55   suggestedTitle: string;
56   suggestedPosts: Array<Post>;
57   crossPosts: Array<Post>;
58   enable_nsfw: boolean;
59 }
60
61 export class PostForm extends Component<PostFormProps, PostFormState> {
62   private id = `post-form-${randomStr()}`;
63   private tribute: Tribute;
64   private subscription: Subscription;
65   private emptyState: PostFormState = {
66     postForm: {
67       name: null,
68       nsfw: false,
69       auth: null,
70       community_id: null,
71       creator_id: UserService.Instance.user
72         ? UserService.Instance.user.id
73         : null,
74     },
75     communities: [],
76     loading: false,
77     imageLoading: false,
78     previewMode: false,
79     suggestedTitle: undefined,
80     suggestedPosts: [],
81     crossPosts: [],
82     enable_nsfw: undefined,
83   };
84
85   constructor(props: any, context: any) {
86     super(props, context);
87     this.fetchSimilarPosts = debounce(this.fetchSimilarPosts).bind(this);
88     this.fetchPageTitle = debounce(this.fetchPageTitle).bind(this);
89
90     this.tribute = setupTribute();
91     this.state = this.emptyState;
92
93     if (this.props.post) {
94       this.state.postForm = {
95         body: this.props.post.body,
96         // NOTE: debouncing breaks both these for some reason, unless you use defaultValue
97         name: this.props.post.name,
98         community_id: this.props.post.community_id,
99         edit_id: this.props.post.id,
100         creator_id: this.props.post.creator_id,
101         url: this.props.post.url,
102         nsfw: this.props.post.nsfw,
103         auth: null,
104       };
105     }
106
107     if (this.props.params) {
108       this.state.postForm.name = this.props.params.name;
109       if (this.props.params.url) {
110         this.state.postForm.url = this.props.params.url;
111       }
112       if (this.props.params.body) {
113         this.state.postForm.body = this.props.params.body;
114       }
115     }
116
117     this.subscription = WebSocketService.Instance.subject
118       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
119       .subscribe(
120         msg => this.parseMessage(msg),
121         err => console.error(err),
122         () => console.log('complete')
123       );
124
125     let listCommunitiesForm: ListCommunitiesForm = {
126       sort: SortType[SortType.TopAll],
127       limit: 9999,
128     };
129
130     WebSocketService.Instance.listCommunities(listCommunitiesForm);
131     WebSocketService.Instance.getSite();
132   }
133
134   componentDidMount() {
135     var textarea: any = document.getElementById(this.id);
136     autosize(textarea);
137     this.tribute.attach(textarea);
138     textarea.addEventListener('tribute-replaced', () => {
139       this.state.postForm.body = textarea.value;
140       this.setState(this.state);
141       autosize.update(textarea);
142     });
143   }
144
145   componentWillUnmount() {
146     this.subscription.unsubscribe();
147   }
148
149   render() {
150     return (
151       <div>
152         <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
153           <div class="form-group row">
154             <label class="col-sm-2 col-form-label" for="post-url">
155               { i18n.t('url') }
156             </label>
157             <div class="col-sm-10">
158               <input
159                 type="url"
160                 id="post-url"
161                 class="form-control"
162                 value={this.state.postForm.url}
163                 onInput={linkEvent(this, this.handlePostUrlChange)}
164                 onPaste={linkEvent(this, this.handleImageUploadPaste)}
165               />
166               {this.state.suggestedTitle && (
167                 <div
168                   class="mt-1 text-muted small font-weight-bold pointer"
169                   onClick={linkEvent(this, this.copySuggestedTitle)}
170                 >
171                   <T
172                     i18nKey="copy_suggested_title"
173                     interpolation={{ title: this.state.suggestedTitle }}
174                   >
175                     #
176                   </T>
177                 </div>
178               )}
179               <form>
180                 <label
181                   htmlFor="file-upload"
182                   className={`${UserService.Instance.user &&
183                     'pointer'} d-inline-block mr-2 float-right text-muted small font-weight-bold`}
184                 >
185                   { i18n.t('upload_image') }
186                 </label>
187                 <input
188                   id="file-upload"
189                   type="file"
190                   accept="image/*,video/*"
191                   name="file"
192                   class="d-none"
193                   disabled={!UserService.Instance.user}
194                   onChange={linkEvent(this, this.handleImageUpload)}
195                 />
196               </form>
197               {validURL(this.state.postForm.url) && (
198                 <a
199                   href={`${archiveUrl}/?run=1&url=${encodeURIComponent(
200                     this.state.postForm.url
201                   )}`}
202                   target="_blank"
203                   class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
204                 >
205                   { i18n.t('archive_link') }
206                 </a>
207               )}
208               {this.state.imageLoading && (
209                 <svg class="icon icon-spinner spin">
210                   <use xlinkHref="#icon-spinner"></use>
211                 </svg>
212               )}
213               {isImage(this.state.postForm.url) && (
214                 <img src={this.state.postForm.url} class="img-fluid" />
215               )}
216               {this.state.crossPosts.length > 0 && (
217                 <>
218                   <div class="my-1 text-muted small font-weight-bold">
219                     { i18n.t('cross_posts') }
220                   </div>
221                   <PostListings showCommunity posts={this.state.crossPosts} />
222                 </>
223               )}
224             </div>
225           </div>
226           <div class="form-group row">
227             <label class="col-sm-2 col-form-label" for="post-title">
228               { i18n.t('title') }
229             </label>
230             <div class="col-sm-10">
231               <textarea
232                 value={this.state.postForm.name}
233                 id="post-title"
234                 onInput={linkEvent(this, this.handlePostNameChange)}
235                 class="form-control"
236                 required
237                 rows={2}
238                 minLength={3}
239                 maxLength={100}
240               />
241               {this.state.suggestedPosts.length > 0 && (
242                 <>
243                   <div class="my-1 text-muted small font-weight-bold">
244                     { i18n.t('related_posts') }
245                   </div>
246                   <PostListings posts={this.state.suggestedPosts} />
247                 </>
248               )}
249             </div>
250           </div>
251
252           <div class="form-group row">
253             <label class="col-sm-2 col-form-label" for="post-body">
254               { i18n.t('body') }
255             </label>
256             <div class="col-sm-10">
257               <textarea
258                 id={this.id}
259                 value={this.state.postForm.body}
260                 id="post-body"
261                 onInput={linkEvent(this, this.handlePostBodyChange)}
262                 className={`form-control ${this.state.previewMode && 'd-none'}`}
263                 rows={4}
264                 maxLength={10000}
265               />
266               {this.state.previewMode && (
267                 <div
268                   className="md-div"
269                   dangerouslySetInnerHTML={mdToHtml(this.state.postForm.body)}
270                 />
271               )}
272               {this.state.postForm.body && (
273                 <button
274                   className={`mt-1 mr-2 btn btn-sm btn-secondary ${this.state
275                     .previewMode && 'active'}`}
276                   onClick={linkEvent(this, this.handlePreviewToggle)}
277                 >
278                   { i18n.t('preview') }
279                 </button>
280               )}
281               <a
282                 href={markdownHelpUrl}
283                 target="_blank"
284                 class="d-inline-block float-right text-muted small font-weight-bold"
285               >
286                 { i18n.t('formatting_help') }
287               </a>
288             </div>
289           </div>
290           {!this.props.post && (
291             <div class="form-group row">
292               <label class="col-sm-2 col-form-label" for="post-community">
293                 { i18n.t('community') }
294               </label>
295               <div class="col-sm-10">
296                 <select
297                   class="form-control"
298                   id="post-community"
299                   value={this.state.postForm.community_id}
300                   onInput={linkEvent(this, this.handlePostCommunityChange)}
301                 >
302                   {this.state.communities.map(community => (
303                     <option value={community.id}>{community.name}</option>
304                   ))}
305                 </select>
306               </div>
307             </div>
308           )}
309           {this.state.enable_nsfw && (
310             <div class="form-group row">
311               <div class="col-sm-10">
312                 <div class="form-check">
313                   <input
314                     class="form-check-input"
315                     id="post-nsfw"
316                     type="checkbox"
317                     checked={this.state.postForm.nsfw}
318                     onChange={linkEvent(this, this.handlePostNsfwChange)}
319                   />
320                   <label class="form-check-label" for="post-nsfw">
321                     { i18n.t('nsfw') }
322                   </label>
323                 </div>
324               </div>
325             </div>
326           )}
327           <div class="form-group row">
328             <div class="col-sm-10">
329               <button type="submit" class="btn btn-secondary mr-2">
330                 {this.state.loading ? (
331                   <svg class="icon icon-spinner spin">
332                     <use xlinkHref="#icon-spinner"></use>
333                   </svg>
334                 ) : this.props.post ? (
335                   capitalizeFirstLetter(i18n.t('save'))
336                 ) : (
337                   capitalizeFirstLetter(i18n.t('create'))
338                 )}
339               </button>
340               {this.props.post && (
341                 <button
342                   type="button"
343                   class="btn btn-secondary"
344                   onClick={linkEvent(this, this.handleCancel)}
345                 >
346                   { i18n.t('cancel') }
347                 </button>
348               )}
349             </div>
350           </div>
351         </form>
352       </div>
353     );
354   }
355
356   handlePostSubmit(i: PostForm, event: any) {
357     event.preventDefault();
358     if (i.props.post) {
359       WebSocketService.Instance.editPost(i.state.postForm);
360     } else {
361       WebSocketService.Instance.createPost(i.state.postForm);
362     }
363     i.state.loading = true;
364     i.setState(i.state);
365   }
366
367   copySuggestedTitle(i: PostForm) {
368     i.state.postForm.name = i.state.suggestedTitle;
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       this.state.loading = false;
520       this.props.onCreate(data.post.id);
521     } else if (res.op == UserOperation.EditPost) {
522       let data = res.data as PostResponse;
523       this.state.loading = false;
524       this.props.onEdit(data.post);
525     } else if (res.op == UserOperation.Search) {
526       let data = res.data as SearchResponse;
527
528       if (data.type_ == SearchType[SearchType.Posts]) {
529         this.state.suggestedPosts = data.posts;
530       } else if (data.type_ == SearchType[SearchType.Url]) {
531         this.state.crossPosts = data.posts;
532       }
533       this.setState(this.state);
534     } else if (res.op == UserOperation.GetSite) {
535       let data = res.data as GetSiteResponse;
536       this.state.enable_nsfw = data.site.enable_nsfw;
537       this.setState(this.state);
538     }
539   }
540 }