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