]> Untitled Git - lemmy.git/blob - ui/src/components/comment-form.tsx
Merge branch 'dev' into federation
[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 ${
166                     this.state.previewMode && 'active'
167                   }`}
168                   onClick={linkEvent(this, this.handlePreviewToggle)}
169                 >
170                   {i18n.t('preview')}
171                 </button>
172               )}
173               {this.props.node && (
174                 <button
175                   type="button"
176                   class="btn btn-sm btn-secondary mr-2"
177                   onClick={linkEvent(this, this.handleReplyCancel)}
178                 >
179                   {i18n.t('cancel')}
180                 </button>
181               )}
182               <a
183                 href={markdownHelpUrl}
184                 target="_blank"
185                 class="d-inline-block float-right text-muted font-weight-bold"
186                 title={i18n.t('formatting_help')}
187               >
188                 <svg class="icon icon-inline">
189                   <use xlinkHref="#icon-help-circle"></use>
190                 </svg>
191               </a>
192               <form class="d-inline-block mr-3 float-right text-muted font-weight-bold">
193                 <label
194                   htmlFor={`file-upload-${this.id}`}
195                   className={`${UserService.Instance.user && 'pointer'}`}
196                   data-tippy-content={i18n.t('upload_image')}
197                 >
198                   <svg class="icon icon-inline">
199                     <use xlinkHref="#icon-image"></use>
200                   </svg>
201                 </label>
202                 <input
203                   id={`file-upload-${this.id}`}
204                   type="file"
205                   accept="image/*,video/*"
206                   name="file"
207                   class="d-none"
208                   disabled={!UserService.Instance.user}
209                   onChange={linkEvent(this, this.handleImageUpload)}
210                 />
211               </form>
212               {this.state.imageLoading && (
213                 <svg class="icon icon-spinner spin">
214                   <use xlinkHref="#icon-spinner"></use>
215                 </svg>
216               )}
217               <span
218                 onClick={linkEvent(this, this.handleEmojiPickerClick)}
219                 class="pointer unselectable d-inline-block mr-3 float-right text-muted font-weight-bold"
220                 data-tippy-content={i18n.t('emoji_picker')}
221               >
222                 <svg class="icon icon-inline">
223                   <use xlinkHref="#icon-smile"></use>
224                 </svg>
225               </span>
226             </div>
227           </div>
228         </form>
229       </div>
230     );
231   }
232
233   setupEmojiPicker() {
234     emojiPicker.on('emoji', twemojiHtmlStr => {
235       if (this.state.commentForm.content == null) {
236         this.state.commentForm.content = '';
237       }
238       var el = document.createElement('div');
239       el.innerHTML = twemojiHtmlStr;
240       let nativeUnicode = (el.childNodes[0] as HTMLElement).getAttribute('alt');
241       let shortName = `:${emojiShortName[nativeUnicode]}:`;
242       this.state.commentForm.content += shortName;
243       this.setState(this.state);
244     });
245   }
246
247   handleFinished() {
248     this.state.previewMode = false;
249     this.state.loading = false;
250     this.state.commentForm.content = '';
251     this.setState(this.state);
252     let form: any = document.getElementById(this.formId);
253     form.reset();
254     if (this.props.node) {
255       this.props.onReplyCancel();
256     }
257     autosize.update(document.querySelector('textarea'));
258     this.setState(this.state);
259   }
260
261   handleCommentSubmit(i: CommentForm, event: any) {
262     event.preventDefault();
263     if (i.props.edit) {
264       WebSocketService.Instance.editComment(i.state.commentForm);
265     } else {
266       WebSocketService.Instance.createComment(i.state.commentForm);
267     }
268
269     i.state.loading = true;
270     i.setState(i.state);
271   }
272
273   handleEmojiPickerClick(_i: CommentForm, event: any) {
274     emojiPicker.togglePicker(event.target);
275   }
276
277   handleCommentContentChange(i: CommentForm, event: any) {
278     i.state.commentForm.content = event.target.value;
279     i.setState(i.state);
280   }
281
282   handlePreviewToggle(i: CommentForm, event: any) {
283     event.preventDefault();
284     i.state.previewMode = !i.state.previewMode;
285     i.setState(i.state);
286   }
287
288   handleReplyCancel(i: CommentForm) {
289     i.props.onReplyCancel();
290   }
291
292   handleImageUploadPaste(i: CommentForm, event: any) {
293     let image = event.clipboardData.files[0];
294     if (image) {
295       i.handleImageUpload(i, image);
296     }
297   }
298
299   handleImageUpload(i: CommentForm, event: any) {
300     let file: any;
301     if (event.target) {
302       event.preventDefault();
303       file = event.target.files[0];
304     } else {
305       file = event;
306     }
307
308     const imageUploadUrl = `/pictshare/api/upload.php`;
309     const formData = new FormData();
310     formData.append('file', file);
311
312     i.state.imageLoading = true;
313     i.setState(i.state);
314
315     fetch(imageUploadUrl, {
316       method: 'POST',
317       body: formData,
318     })
319       .then(res => res.json())
320       .then(res => {
321         let url = `${window.location.origin}/pictshare/${res.url}`;
322         let imageMarkdown =
323           res.filetype == 'mp4' ? `[vid](${url}/raw)` : `![](${url})`;
324         let content = i.state.commentForm.content;
325         content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
326         i.state.commentForm.content = content;
327         i.state.imageLoading = false;
328         i.setState(i.state);
329         let textarea: any = document.getElementById(i.id);
330         autosize.update(textarea);
331       })
332       .catch(error => {
333         i.state.imageLoading = false;
334         i.setState(i.state);
335         toast(error, 'danger');
336       });
337   }
338
339   parseMessage(msg: WebSocketJsonResponse) {
340     let res = wsJsonToRes(msg);
341
342     // Only do the showing and hiding if logged in
343     if (UserService.Instance.user) {
344       if (res.op == UserOperation.CreateComment) {
345         let data = res.data as CommentResponse;
346         if (data.comment.creator_id == UserService.Instance.user.id) {
347           this.handleFinished();
348         }
349       } else if (res.op == UserOperation.EditComment) {
350         let data = res.data as CommentResponse;
351         if (data.comment.creator_id == UserService.Instance.user.id) {
352           this.handleFinished();
353         }
354       }
355     }
356   }
357 }