]> 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               >
189                 <svg class="icon icon-inline">
190                   <use xlinkHref="#icon-help-circle"></use>
191                 </svg>
192               </a>
193               <form class="d-inline-block mr-3 float-right text-muted font-weight-bold">
194                 <label
195                   htmlFor={`file-upload-${this.id}`}
196                   className={`${UserService.Instance.user && 'pointer'}`}
197                   data-tippy-content={i18n.t('upload_image')}
198                 >
199                   <svg class="icon icon-inline">
200                     <use xlinkHref="#icon-image"></use>
201                   </svg>
202                 </label>
203                 <input
204                   id={`file-upload-${this.id}`}
205                   type="file"
206                   accept="image/*,video/*"
207                   name="file"
208                   class="d-none"
209                   disabled={!UserService.Instance.user}
210                   onChange={linkEvent(this, this.handleImageUpload)}
211                 />
212               </form>
213               {this.state.imageLoading && (
214                 <svg class="icon icon-spinner spin">
215                   <use xlinkHref="#icon-spinner"></use>
216                 </svg>
217               )}
218               <span
219                 onClick={linkEvent(this, this.handleEmojiPickerClick)}
220                 class="pointer unselectable d-inline-block mr-3 float-right text-muted font-weight-bold"
221                 data-tippy-content={i18n.t('emoji_picker')}
222               >
223                 <svg class="icon icon-inline">
224                   <use xlinkHref="#icon-smile"></use>
225                 </svg>
226               </span>
227             </div>
228           </div>
229         </form>
230       </div>
231     );
232   }
233
234   setupEmojiPicker() {
235     emojiPicker.on('emoji', twemojiHtmlStr => {
236       if (this.state.commentForm.content == null) {
237         this.state.commentForm.content = '';
238       }
239       var el = document.createElement('div');
240       el.innerHTML = twemojiHtmlStr;
241       let nativeUnicode = (el.childNodes[0] as HTMLElement).getAttribute('alt');
242       let shortName = `:${emojiShortName[nativeUnicode]}:`;
243       this.state.commentForm.content += shortName;
244       this.setState(this.state);
245     });
246   }
247
248   handleFinished(op: UserOperation, data: CommentResponse) {
249     let isReply =
250       this.props.node !== undefined && data.comment.parent_id !== null;
251     let xor =
252       +!(data.comment.parent_id !== null) ^ +(this.props.node !== undefined);
253
254     if (
255       (data.comment.creator_id == UserService.Instance.user.id &&
256         ((op == UserOperation.CreateComment &&
257           // If its a reply, make sure parent child match
258           isReply &&
259           data.comment.parent_id == this.props.node.comment.id) ||
260           // Otherwise, check the XOR of the two
261           (!isReply && xor))) ||
262       // If its a comment edit, only check that its from your user, and that its a
263       // text edit only
264
265       (op == UserOperation.EditComment && data.comment.content)
266     ) {
267       this.state.previewMode = false;
268       this.state.loading = false;
269       this.state.commentForm.content = '';
270       this.setState(this.state);
271       let form: any = document.getElementById(this.formId);
272       form.reset();
273       if (this.props.node) {
274         this.props.onReplyCancel();
275       }
276       autosize.update(form);
277       this.setState(this.state);
278     }
279   }
280
281   handleCommentSubmit(i: CommentForm, event: any) {
282     event.preventDefault();
283     if (i.props.edit) {
284       WebSocketService.Instance.editComment(i.state.commentForm);
285     } else {
286       WebSocketService.Instance.createComment(i.state.commentForm);
287     }
288
289     i.state.loading = true;
290     i.setState(i.state);
291   }
292
293   handleEmojiPickerClick(_i: CommentForm, event: any) {
294     emojiPicker.togglePicker(event.target);
295   }
296
297   handleCommentContentChange(i: CommentForm, event: any) {
298     i.state.commentForm.content = event.target.value;
299     i.setState(i.state);
300   }
301
302   handlePreviewToggle(i: CommentForm, event: any) {
303     event.preventDefault();
304     i.state.previewMode = !i.state.previewMode;
305     i.setState(i.state);
306   }
307
308   handleReplyCancel(i: CommentForm) {
309     i.props.onReplyCancel();
310   }
311
312   handleImageUploadPaste(i: CommentForm, event: any) {
313     let image = event.clipboardData.files[0];
314     if (image) {
315       i.handleImageUpload(i, image);
316     }
317   }
318
319   handleImageUpload(i: CommentForm, event: any) {
320     let file: any;
321     if (event.target) {
322       event.preventDefault();
323       file = event.target.files[0];
324     } else {
325       file = event;
326     }
327
328     const imageUploadUrl = `/pictrs/image`;
329     const formData = new FormData();
330     formData.append('images[]', file);
331
332     i.state.imageLoading = true;
333     i.setState(i.state);
334
335     fetch(imageUploadUrl, {
336       method: 'POST',
337       body: formData,
338     })
339       .then(res => res.json())
340       .then(res => {
341         console.log('pictrs upload:');
342         console.log(res);
343         if (res.msg == 'ok') {
344           let hash = res.files[0].file;
345           let url = `${window.location.origin}/pictrs/image/${hash}`;
346           let deleteToken = res.files[0].delete_token;
347           let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
348           let imageMarkdown = `![](${url})`;
349           let content = i.state.commentForm.content;
350           content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
351           i.state.commentForm.content = content;
352           i.state.imageLoading = false;
353           i.setState(i.state);
354           let textarea: any = document.getElementById(i.id);
355           autosize.update(textarea);
356           pictrsDeleteToast(
357             i18n.t('click_to_delete_picture'),
358             i18n.t('picture_deleted'),
359             deleteUrl
360           );
361         } else {
362           i.state.imageLoading = false;
363           i.setState(i.state);
364           toast(JSON.stringify(res), 'danger');
365         }
366       })
367       .catch(error => {
368         i.state.imageLoading = false;
369         i.setState(i.state);
370         toast(error, 'danger');
371       });
372   }
373
374   parseMessage(msg: WebSocketJsonResponse) {
375     let res = wsJsonToRes(msg);
376
377     // Only do the showing and hiding if logged in
378     if (UserService.Instance.user) {
379       if (res.op == UserOperation.CreateComment) {
380         let data = res.data as CommentResponse;
381         this.handleFinished(res.op, data);
382       } else if (res.op == UserOperation.EditComment) {
383         let data = res.data as CommentResponse;
384         this.handleFinished(res.op, data);
385       }
386     }
387   }
388 }