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