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