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