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