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