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