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