]> Untitled Git - lemmy.git/blob - ui/src/components/comment-form.tsx
Merge branch 'dev' into admin_settings
[lemmy.git] / ui / src / components / comment-form.tsx
1 import { Component, linkEvent } from 'inferno';
2 import { Subscription } from 'rxjs';
3 import { retryWhen, delay, take } from 'rxjs/operators';
4 import { Prompt } from 'inferno-router';
5 import {
6   CommentNode as CommentNodeI,
7   CommentForm as CommentFormI,
8   WebSocketJsonResponse,
9   UserOperation,
10   CommentResponse,
11 } from '../interfaces';
12 import {
13   capitalizeFirstLetter,
14   mdToHtml,
15   randomStr,
16   markdownHelpUrl,
17   toast,
18   setupTribute,
19   wsJsonToRes,
20   emojiPicker,
21 } from '../utils';
22 import { WebSocketService, UserService } from '../services';
23 import autosize from 'autosize';
24 import Tribute from 'tributejs/src/Tribute.js';
25 import emojiShortName from 'emoji-short-name';
26 import { i18n } from '../i18next';
27
28 interface CommentFormProps {
29   postId?: number;
30   node?: CommentNodeI;
31   onReplyCancel?(): any;
32   edit?: boolean;
33   disabled?: boolean;
34 }
35
36 interface CommentFormState {
37   commentForm: CommentFormI;
38   buttonTitle: string;
39   previewMode: boolean;
40   loading: boolean;
41   imageLoading: boolean;
42 }
43
44 export class CommentForm extends Component<CommentFormProps, CommentFormState> {
45   private id = `comment-textarea-${randomStr()}`;
46   private formId = `comment-form-${randomStr()}`;
47   private tribute: Tribute;
48   private subscription: Subscription;
49   private emptyState: CommentFormState = {
50     commentForm: {
51       auth: null,
52       content: null,
53       post_id: this.props.node
54         ? this.props.node.comment.post_id
55         : this.props.postId,
56       creator_id: UserService.Instance.user
57         ? UserService.Instance.user.id
58         : null,
59     },
60     buttonTitle: !this.props.node
61       ? capitalizeFirstLetter(i18n.t('post'))
62       : this.props.edit
63       ? capitalizeFirstLetter(i18n.t('edit'))
64       : capitalizeFirstLetter(i18n.t('reply')),
65     previewMode: false,
66     loading: false,
67     imageLoading: false,
68   };
69
70   constructor(props: any, context: any) {
71     super(props, context);
72
73     this.tribute = setupTribute();
74     this.setupEmojiPicker();
75
76     this.state = this.emptyState;
77
78     if (this.props.node) {
79       if (this.props.edit) {
80         this.state.commentForm.edit_id = this.props.node.comment.id;
81         this.state.commentForm.parent_id = this.props.node.comment.parent_id;
82         this.state.commentForm.content = this.props.node.comment.content;
83         this.state.commentForm.creator_id = this.props.node.comment.creator_id;
84       } else {
85         // A reply gets a new parent id
86         this.state.commentForm.parent_id = this.props.node.comment.id;
87       }
88     }
89
90     this.subscription = WebSocketService.Instance.subject
91       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
92       .subscribe(
93         msg => this.parseMessage(msg),
94         err => console.error(err),
95         () => console.log('complete')
96       );
97   }
98
99   componentDidMount() {
100     var textarea: any = document.getElementById(this.id);
101     autosize(textarea);
102     this.tribute.attach(textarea);
103     textarea.addEventListener('tribute-replaced', () => {
104       this.state.commentForm.content = textarea.value;
105       this.setState(this.state);
106       autosize.update(textarea);
107     });
108   }
109
110   componentWillUnmount() {
111     this.subscription.unsubscribe();
112   }
113
114   render() {
115     return (
116       <div class="mb-3">
117         <Prompt
118           when={this.state.commentForm.content}
119           message={i18n.t('block_leaving')}
120         />
121         <form
122           id={this.formId}
123           onSubmit={linkEvent(this, this.handleCommentSubmit)}
124         >
125           <div class="form-group row">
126             <div className={`col-sm-12`}>
127               <textarea
128                 id={this.id}
129                 className={`form-control ${this.state.previewMode && 'd-none'}`}
130                 value={this.state.commentForm.content}
131                 onInput={linkEvent(this, this.handleCommentContentChange)}
132                 onPaste={linkEvent(this, this.handleImageUploadPaste)}
133                 required
134                 disabled={this.props.disabled}
135                 rows={2}
136                 maxLength={10000}
137               />
138               {this.state.previewMode && (
139                 <div
140                   className="md-div"
141                   dangerouslySetInnerHTML={mdToHtml(
142                     this.state.commentForm.content
143                   )}
144                 />
145               )}
146             </div>
147           </div>
148           <div class="row">
149             <div class="col-sm-12">
150               <button
151                 type="submit"
152                 class="btn btn-sm btn-secondary mr-2"
153                 disabled={this.props.disabled}
154               >
155                 {this.state.loading ? (
156                   <svg class="icon icon-spinner spin">
157                     <use xlinkHref="#icon-spinner"></use>
158                   </svg>
159                 ) : (
160                   <span>{this.state.buttonTitle}</span>
161                 )}
162               </button>
163               {this.state.commentForm.content && (
164                 <button
165                   className={`btn btn-sm mr-2 btn-secondary ${this.state
166                     .previewMode && 'active'}`}
167                   onClick={linkEvent(this, this.handlePreviewToggle)}
168                 >
169                   {i18n.t('preview')}
170                 </button>
171               )}
172               {this.props.node && (
173                 <button
174                   type="button"
175                   class="btn btn-sm btn-secondary mr-2"
176                   onClick={linkEvent(this, this.handleReplyCancel)}
177                 >
178                   {i18n.t('cancel')}
179                 </button>
180               )}
181               <a
182                 href={markdownHelpUrl}
183                 target="_blank"
184                 class="d-inline-block float-right text-muted font-weight-bold"
185                 title={i18n.t('formatting_help')}
186               >
187                 <svg class="icon icon-inline">
188                   <use xlinkHref="#icon-help-circle"></use>
189                 </svg>
190               </a>
191               <form class="d-inline-block mr-3 float-right text-muted font-weight-bold">
192                 <label
193                   htmlFor={`file-upload-${this.id}`}
194                   className={`${UserService.Instance.user && 'pointer'}`}
195                   data-tippy-content={i18n.t('upload_image')}
196                 >
197                   <svg class="icon icon-inline">
198                     <use xlinkHref="#icon-image"></use>
199                   </svg>
200                 </label>
201                 <input
202                   id={`file-upload-${this.id}`}
203                   type="file"
204                   accept="image/*,video/*"
205                   name="file"
206                   class="d-none"
207                   disabled={!UserService.Instance.user}
208                   onChange={linkEvent(this, this.handleImageUpload)}
209                 />
210               </form>
211               {this.state.imageLoading && (
212                 <svg class="icon icon-spinner spin">
213                   <use xlinkHref="#icon-spinner"></use>
214                 </svg>
215               )}
216               <span
217                 onClick={linkEvent(this, this.handleEmojiPickerClick)}
218                 class="pointer unselectable d-inline-block mr-3 float-right text-muted font-weight-bold"
219                 data-tippy-content={i18n.t('emoji_picker')}
220               >
221                 <svg class="icon icon-inline">
222                   <use xlinkHref="#icon-smile"></use>
223                 </svg>
224               </span>
225             </div>
226           </div>
227         </form>
228       </div>
229     );
230   }
231
232   setupEmojiPicker() {
233     emojiPicker.on('emoji', twemojiHtmlStr => {
234       if (this.state.commentForm.content == null) {
235         this.state.commentForm.content = '';
236       }
237       var el = document.createElement('div');
238       el.innerHTML = twemojiHtmlStr;
239       let nativeUnicode = (el.childNodes[0] as HTMLElement).getAttribute('alt');
240       let shortName = `:${emojiShortName[nativeUnicode]}:`;
241       this.state.commentForm.content += shortName;
242       this.setState(this.state);
243     });
244   }
245
246   handleFinished() {
247     this.state.previewMode = false;
248     this.state.loading = false;
249     this.state.commentForm.content = '';
250     this.setState(this.state);
251     let form: any = document.getElementById(this.formId);
252     form.reset();
253     if (this.props.node) {
254       this.props.onReplyCancel();
255     }
256     autosize.update(document.querySelector('textarea'));
257     this.setState(this.state);
258   }
259
260   handleCommentSubmit(i: CommentForm, event: any) {
261     event.preventDefault();
262     if (i.props.edit) {
263       WebSocketService.Instance.editComment(i.state.commentForm);
264     } else {
265       WebSocketService.Instance.createComment(i.state.commentForm);
266     }
267
268     i.state.loading = true;
269     i.setState(i.state);
270   }
271
272   handleEmojiPickerClick(_i: CommentForm, event: any) {
273     emojiPicker.togglePicker(event.target);
274   }
275
276   handleCommentContentChange(i: CommentForm, event: any) {
277     i.state.commentForm.content = event.target.value;
278     i.setState(i.state);
279   }
280
281   handlePreviewToggle(i: CommentForm, event: any) {
282     event.preventDefault();
283     i.state.previewMode = !i.state.previewMode;
284     i.setState(i.state);
285   }
286
287   handleReplyCancel(i: CommentForm) {
288     i.props.onReplyCancel();
289   }
290
291   handleImageUploadPaste(i: CommentForm, event: any) {
292     let image = event.clipboardData.files[0];
293     if (image) {
294       i.handleImageUpload(i, image);
295     }
296   }
297
298   handleImageUpload(i: CommentForm, event: any) {
299     let file: any;
300     if (event.target) {
301       event.preventDefault();
302       file = event.target.files[0];
303     } else {
304       file = event;
305     }
306
307     const imageUploadUrl = `/pictshare/api/upload.php`;
308     const formData = new FormData();
309     formData.append('file', file);
310
311     i.state.imageLoading = true;
312     i.setState(i.state);
313
314     fetch(imageUploadUrl, {
315       method: 'POST',
316       body: formData,
317     })
318       .then(res => res.json())
319       .then(res => {
320         let url = `${window.location.origin}/pictshare/${res.url}`;
321         let imageMarkdown =
322           res.filetype == 'mp4' ? `[vid](${url}/raw)` : `![](${url})`;
323         let content = i.state.commentForm.content;
324         content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
325         i.state.commentForm.content = content;
326         i.state.imageLoading = false;
327         i.setState(i.state);
328         let textarea: any = document.getElementById(i.id);
329         autosize.update(textarea);
330       })
331       .catch(error => {
332         i.state.imageLoading = false;
333         i.setState(i.state);
334         toast(error, 'danger');
335       });
336   }
337
338   parseMessage(msg: WebSocketJsonResponse) {
339     let res = wsJsonToRes(msg);
340
341     // Only do the showing and hiding if logged in
342     if (UserService.Instance.user) {
343       if (res.op == UserOperation.CreateComment) {
344         let data = res.data as CommentResponse;
345         if (data.comment.creator_id == UserService.Instance.user.id) {
346           this.handleFinished();
347         }
348       } else if (res.op == UserOperation.EditComment) {
349         let data = res.data as CommentResponse;
350         if (data.comment.creator_id == UserService.Instance.user.id) {
351           this.handleFinished();
352         }
353       }
354     }
355   }
356 }