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