]> Untitled Git - lemmy.git/blob - ui/src/components/comment-form.tsx
Merge remote-tracking branch 'weblate/master'
[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       (op == UserOperation.EditComment && data.comment.content)
267     ) {
268       this.state.previewMode = false;
269       this.state.loading = false;
270       this.state.commentForm.content = '';
271       this.setState(this.state);
272       let form: any = document.getElementById(this.formId);
273       form.reset();
274       if (this.props.node) {
275         this.props.onReplyCancel();
276       }
277       autosize.update(form);
278       this.setState(this.state);
279     }
280   }
281
282   handleCommentSubmit(i: CommentForm, event: any) {
283     event.preventDefault();
284     if (i.props.edit) {
285       WebSocketService.Instance.editComment(i.state.commentForm);
286     } else {
287       WebSocketService.Instance.createComment(i.state.commentForm);
288     }
289
290     i.state.loading = true;
291     i.setState(i.state);
292   }
293
294   handleEmojiPickerClick(_i: CommentForm, event: any) {
295     emojiPicker.togglePicker(event.target);
296   }
297
298   handleCommentContentChange(i: CommentForm, event: any) {
299     i.state.commentForm.content = event.target.value;
300     i.setState(i.state);
301   }
302
303   handlePreviewToggle(i: CommentForm, event: any) {
304     event.preventDefault();
305     i.state.previewMode = !i.state.previewMode;
306     i.setState(i.state);
307   }
308
309   handleReplyCancel(i: CommentForm) {
310     i.props.onReplyCancel();
311   }
312
313   handleImageUploadPaste(i: CommentForm, event: any) {
314     let image = event.clipboardData.files[0];
315     if (image) {
316       i.handleImageUpload(i, image);
317     }
318   }
319
320   handleImageUpload(i: CommentForm, event: any) {
321     let file: any;
322     if (event.target) {
323       event.preventDefault();
324       file = event.target.files[0];
325     } else {
326       file = event;
327     }
328
329     const imageUploadUrl = `/pictrs/image`;
330     const formData = new FormData();
331     formData.append('images[]', file);
332
333     i.state.imageLoading = true;
334     i.setState(i.state);
335
336     fetch(imageUploadUrl, {
337       method: 'POST',
338       body: formData,
339     })
340       .then(res => res.json())
341       .then(res => {
342         console.log('pictrs upload:');
343         console.log(res);
344         if (res.msg == 'ok') {
345           let hash = res.files[0].file;
346           let url = `${window.location.origin}/pictrs/image/${hash}`;
347           let deleteToken = res.files[0].delete_token;
348           let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
349           let imageMarkdown = `![](${url})`;
350           let content = i.state.commentForm.content;
351           content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
352           i.state.commentForm.content = content;
353           i.state.imageLoading = false;
354           i.setState(i.state);
355           let textarea: any = document.getElementById(i.id);
356           autosize.update(textarea);
357           pictrsDeleteToast(
358             i18n.t('click_to_delete_picture'),
359             i18n.t('picture_deleted'),
360             deleteUrl
361           );
362         } else {
363           i.state.imageLoading = false;
364           i.setState(i.state);
365           toast(JSON.stringify(res), 'danger');
366         }
367       })
368       .catch(error => {
369         i.state.imageLoading = false;
370         i.setState(i.state);
371         toast(error, 'danger');
372       });
373   }
374
375   parseMessage(msg: WebSocketJsonResponse) {
376     let res = wsJsonToRes(msg);
377
378     // Only do the showing and hiding if logged in
379     if (UserService.Instance.user) {
380       if (res.op == UserOperation.CreateComment) {
381         let data = res.data as CommentResponse;
382         this.handleFinished(res.op, data);
383       } else if (res.op == UserOperation.EditComment) {
384         let data = res.data as CommentResponse;
385         this.handleFinished(res.op, data);
386       }
387     }
388   }
389 }