]> Untitled Git - lemmy.git/blob - ui/src/components/post-form.tsx
Merge pull request #8 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 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">
155               <T i18nKey="url">#</T>
156             </label>
157             <div class="col-sm-10">
158               <input
159                 type="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                   <T
171                     i18nKey="copy_suggested_title"
172                     interpolation={{ title: this.state.suggestedTitle }}
173                   >
174                     #
175                   </T>
176                 </div>
177               )}
178               <form>
179                 <label
180                   htmlFor="file-upload"
181                   className={`${UserService.Instance.user &&
182                     'pointer'} d-inline-block mr-2 float-right text-muted small font-weight-bold`}
183                 >
184                   <T i18nKey="upload_image">#</T>
185                 </label>
186                 <input
187                   id="file-upload"
188                   type="file"
189                   accept="image/*,video/*"
190                   name="file"
191                   class="d-none"
192                   disabled={!UserService.Instance.user}
193                   onChange={linkEvent(this, this.handleImageUpload)}
194                 />
195               </form>
196               {validURL(this.state.postForm.url) && (
197                 <a
198                   href={`${archiveUrl}/?run=1&url=${encodeURIComponent(
199                     this.state.postForm.url
200                   )}`}
201                   target="_blank"
202                   class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
203                 >
204                   <T i18nKey="archive_link">#</T>
205                 </a>
206               )}
207               {this.state.imageLoading && (
208                 <svg class="icon icon-spinner spin">
209                   <use xlinkHref="#icon-spinner"></use>
210                 </svg>
211               )}
212               {isImage(this.state.postForm.url) && (
213                 <img src={this.state.postForm.url} class="img-fluid" />
214               )}
215               {this.state.crossPosts.length > 0 && (
216                 <>
217                   <div class="my-1 text-muted small font-weight-bold">
218                     <T i18nKey="cross_posts">#</T>
219                   </div>
220                   <PostListings showCommunity posts={this.state.crossPosts} />
221                 </>
222               )}
223             </div>
224           </div>
225           <div class="form-group row">
226             <label class="col-sm-2 col-form-label">
227               <T i18nKey="title">#</T>
228             </label>
229             <div class="col-sm-10">
230               <textarea
231                 value={this.state.postForm.name}
232                 onInput={linkEvent(this, this.handlePostNameChange)}
233                 class="form-control"
234                 required
235                 rows={2}
236                 minLength={3}
237                 maxLength={100}
238               />
239               {this.state.suggestedPosts.length > 0 && (
240                 <>
241                   <div class="my-1 text-muted small font-weight-bold">
242                     <T i18nKey="related_posts">#</T>
243                   </div>
244                   <PostListings posts={this.state.suggestedPosts} />
245                 </>
246               )}
247             </div>
248           </div>
249           <div class="form-group row">
250             <label class="col-sm-2 col-form-label">
251               <T i18nKey="body">#</T>
252             </label>
253             <div class="col-sm-10">
254               <textarea
255                 id={this.id}
256                 value={this.state.postForm.body}
257                 onInput={linkEvent(this, this.handlePostBodyChange)}
258                 className={`form-control ${this.state.previewMode && 'd-none'}`}
259                 rows={4}
260                 maxLength={10000}
261               />
262               {this.state.previewMode && (
263                 <div
264                   className="md-div"
265                   dangerouslySetInnerHTML={mdToHtml(this.state.postForm.body)}
266                 />
267               )}
268               {this.state.postForm.body && (
269                 <button
270                   className={`mt-1 mr-2 btn btn-sm btn-secondary ${this.state
271                     .previewMode && 'active'}`}
272                   onClick={linkEvent(this, this.handlePreviewToggle)}
273                 >
274                   <T i18nKey="preview">#</T>
275                 </button>
276               )}
277               <a
278                 href={markdownHelpUrl}
279                 target="_blank"
280                 class="d-inline-block float-right text-muted small font-weight-bold"
281               >
282                 <T i18nKey="formatting_help">#</T>
283               </a>
284             </div>
285           </div>
286           {!this.props.post && (
287             <div class="form-group row">
288               <label class="col-sm-2 col-form-label">
289                 <T i18nKey="community">#</T>
290               </label>
291               <div class="col-sm-10">
292                 <select
293                   class="form-control"
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                     type="checkbox"
311                     checked={this.state.postForm.nsfw}
312                     onChange={linkEvent(this, this.handlePostNsfwChange)}
313                   />
314                   <label class="form-check-label">
315                     <T i18nKey="nsfw">#</T>
316                   </label>
317                 </div>
318               </div>
319             </div>
320           )}
321           <div class="form-group row">
322             <div class="col-sm-10">
323               <button type="submit" class="btn btn-secondary mr-2">
324                 {this.state.loading ? (
325                   <svg class="icon icon-spinner spin">
326                     <use xlinkHref="#icon-spinner"></use>
327                   </svg>
328                 ) : this.props.post ? (
329                   capitalizeFirstLetter(i18n.t('save'))
330                 ) : (
331                   capitalizeFirstLetter(i18n.t('create'))
332                 )}
333               </button>
334               {this.props.post && (
335                 <button
336                   type="button"
337                   class="btn btn-secondary"
338                   onClick={linkEvent(this, this.handleCancel)}
339                 >
340                   <T i18nKey="cancel">#</T>
341                 </button>
342               )}
343             </div>
344           </div>
345         </form>
346       </div>
347     );
348   }
349
350   handlePostSubmit(i: PostForm, event: any) {
351     event.preventDefault();
352     if (i.props.post) {
353       WebSocketService.Instance.editPost(i.state.postForm);
354     } else {
355       WebSocketService.Instance.createPost(i.state.postForm);
356     }
357     i.state.loading = true;
358     i.setState(i.state);
359   }
360
361   copySuggestedTitle(i: PostForm) {
362     i.state.postForm.name = i.state.suggestedTitle;
363     i.state.suggestedTitle = undefined;
364     i.setState(i.state);
365   }
366
367   handlePostUrlChange(i: PostForm, event: any) {
368     i.state.postForm.url = event.target.value;
369     i.setState(i.state);
370     i.fetchPageTitle();
371   }
372
373   fetchPageTitle() {
374     if (validURL(this.state.postForm.url)) {
375       let form: SearchForm = {
376         q: this.state.postForm.url,
377         type_: SearchType[SearchType.Url],
378         sort: SortType[SortType.TopAll],
379         page: 1,
380         limit: 6,
381       };
382
383       WebSocketService.Instance.search(form);
384
385       // Fetch the page title
386       getPageTitle(this.state.postForm.url).then(d => {
387         this.state.suggestedTitle = d;
388         this.setState(this.state);
389       });
390     } else {
391       this.state.suggestedTitle = undefined;
392       this.state.crossPosts = [];
393     }
394   }
395
396   handlePostNameChange(i: PostForm, event: any) {
397     i.state.postForm.name = event.target.value;
398     i.setState(i.state);
399     i.fetchSimilarPosts();
400   }
401
402   fetchSimilarPosts() {
403     let form: SearchForm = {
404       q: this.state.postForm.name,
405       type_: SearchType[SearchType.Posts],
406       sort: SortType[SortType.TopAll],
407       community_id: this.state.postForm.community_id,
408       page: 1,
409       limit: 6,
410     };
411
412     if (this.state.postForm.name !== '') {
413       WebSocketService.Instance.search(form);
414     } else {
415       this.state.suggestedPosts = [];
416     }
417
418     this.setState(this.state);
419   }
420
421   handlePostBodyChange(i: PostForm, event: any) {
422     i.state.postForm.body = event.target.value;
423     i.setState(i.state);
424   }
425
426   handlePostCommunityChange(i: PostForm, event: any) {
427     i.state.postForm.community_id = Number(event.target.value);
428     i.setState(i.state);
429   }
430
431   handlePostNsfwChange(i: PostForm, event: any) {
432     i.state.postForm.nsfw = event.target.checked;
433     i.setState(i.state);
434   }
435
436   handleCancel(i: PostForm) {
437     i.props.onCancel();
438   }
439
440   handlePreviewToggle(i: PostForm, event: any) {
441     event.preventDefault();
442     i.state.previewMode = !i.state.previewMode;
443     i.setState(i.state);
444   }
445
446   handleImageUploadPaste(i: PostForm, event: any) {
447     let image = event.clipboardData.files[0];
448     if (image) {
449       i.handleImageUpload(i, image);
450     }
451   }
452
453   handleImageUpload(i: PostForm, event: any) {
454     let file: any;
455     if (event.target) {
456       event.preventDefault();
457       file = event.target.files[0];
458     } else {
459       file = event;
460     }
461
462     const imageUploadUrl = `/pictshare/api/upload.php`;
463     const formData = new FormData();
464     formData.append('file', file);
465
466     i.state.imageLoading = true;
467     i.setState(i.state);
468
469     fetch(imageUploadUrl, {
470       method: 'POST',
471       body: formData,
472     })
473       .then(res => res.json())
474       .then(res => {
475         let url = `${window.location.origin}/pictshare/${res.url}`;
476         if (res.filetype == 'mp4') {
477           url += '/raw';
478         }
479         i.state.postForm.url = url;
480         i.state.imageLoading = false;
481         i.setState(i.state);
482       })
483       .catch(error => {
484         i.state.imageLoading = false;
485         i.setState(i.state);
486         toast(error, 'danger');
487       });
488   }
489
490   parseMessage(msg: WebSocketJsonResponse) {
491     let res = wsJsonToRes(msg);
492     if (msg.error) {
493       toast(i18n.t(msg.error), 'danger');
494       this.state.loading = false;
495       this.setState(this.state);
496       return;
497     } else if (res.op == UserOperation.ListCommunities) {
498       let data = res.data as ListCommunitiesResponse;
499       this.state.communities = data.communities;
500       if (this.props.post) {
501         this.state.postForm.community_id = this.props.post.community_id;
502       } else if (this.props.params && this.props.params.community) {
503         let foundCommunityId = data.communities.find(
504           r => r.name == this.props.params.community
505         ).id;
506         this.state.postForm.community_id = foundCommunityId;
507       } else {
508         this.state.postForm.community_id = data.communities[0].id;
509       }
510       this.setState(this.state);
511     } else if (res.op == UserOperation.CreatePost) {
512       let data = res.data as PostResponse;
513       this.state.loading = false;
514       this.props.onCreate(data.post.id);
515     } else if (res.op == UserOperation.EditPost) {
516       let data = res.data as PostResponse;
517       this.state.loading = false;
518       this.props.onEdit(data.post);
519     } else if (res.op == UserOperation.Search) {
520       let data = res.data as SearchResponse;
521
522       if (data.type_ == SearchType[SearchType.Posts]) {
523         this.state.suggestedPosts = data.posts;
524       } else if (data.type_ == SearchType[SearchType.Url]) {
525         this.state.crossPosts = data.posts;
526       }
527       this.setState(this.state);
528     } else if (res.op == UserOperation.GetSite) {
529       let data = res.data as GetSiteResponse;
530       this.state.enable_nsfw = data.site.enable_nsfw;
531       this.setState(this.state);
532     }
533   }
534 }