]> Untitled Git - lemmy.git/blob - ui/src/components/comment-form.tsx
Merge remote-tracking branch 'LemmyNet/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   componentDidUpdate() {
112     if (this.state.commentForm.content) {
113       window.onbeforeunload = () => true;
114     } else {
115       window.onbeforeunload = undefined;
116     }
117   }
118
119   componentWillUnmount() {
120     this.subscription.unsubscribe();
121     window.onbeforeunload = null;
122   }
123
124   render() {
125     return (
126       <div class="mb-3">
127         <Prompt
128           when={this.state.commentForm.content}
129           message={i18n.t('block_leaving')}
130         />
131         <form
132           id={this.formId}
133           onSubmit={linkEvent(this, this.handleCommentSubmit)}
134         >
135           <div class="form-group row">
136             <div className={`col-sm-12`}>
137               <textarea
138                 id={this.id}
139                 className={`form-control ${this.state.previewMode && 'd-none'}`}
140                 value={this.state.commentForm.content}
141                 onInput={linkEvent(this, this.handleCommentContentChange)}
142                 onPaste={linkEvent(this, this.handleImageUploadPaste)}
143                 required
144                 disabled={this.props.disabled}
145                 rows={2}
146                 maxLength={10000}
147               />
148               {this.state.previewMode && (
149                 <div
150                   className="card card-body md-div"
151                   dangerouslySetInnerHTML={mdToHtml(
152                     this.state.commentForm.content
153                   )}
154                 />
155               )}
156             </div>
157           </div>
158           <div class="row">
159             <div class="col-sm-12">
160               <button
161                 type="submit"
162                 class="btn btn-sm btn-secondary mr-2"
163                 disabled={this.props.disabled || this.state.loading}
164               >
165                 {this.state.loading ? (
166                   <svg class="icon icon-spinner spin">
167                     <use xlinkHref="#icon-spinner"></use>
168                   </svg>
169                 ) : (
170                   <span>{this.state.buttonTitle}</span>
171                 )}
172               </button>
173               {this.state.commentForm.content && (
174                 <button
175                   className={`btn btn-sm mr-2 btn-secondary ${
176                     this.state.previewMode && 'active'
177                   }`}
178                   onClick={linkEvent(this, this.handlePreviewToggle)}
179                 >
180                   {i18n.t('preview')}
181                 </button>
182               )}
183               {this.props.node && (
184                 <button
185                   type="button"
186                   class="btn btn-sm btn-secondary mr-2"
187                   onClick={linkEvent(this, this.handleReplyCancel)}
188                 >
189                   {i18n.t('cancel')}
190                 </button>
191               )}
192               <a
193                 href={markdownHelpUrl}
194                 target="_blank"
195                 class="d-inline-block float-right text-muted font-weight-bold"
196                 title={i18n.t('formatting_help')}
197                 rel="noopener"
198               >
199                 <svg class="icon icon-inline">
200                   <use xlinkHref="#icon-help-circle"></use>
201                 </svg>
202               </a>
203               <form class="d-inline-block mr-3 float-right text-muted font-weight-bold">
204                 <label
205                   htmlFor={`file-upload-${this.id}`}
206                   className={`${UserService.Instance.user && 'pointer'}`}
207                   data-tippy-content={i18n.t('upload_image')}
208                 >
209                   <svg class="icon icon-inline">
210                     <use xlinkHref="#icon-image"></use>
211                   </svg>
212                 </label>
213                 <input
214                   id={`file-upload-${this.id}`}
215                   type="file"
216                   accept="image/*,video/*"
217                   name="file"
218                   class="d-none"
219                   disabled={!UserService.Instance.user}
220                   onChange={linkEvent(this, this.handleImageUpload)}
221                 />
222               </form>
223               {this.state.imageLoading && (
224                 <svg class="icon icon-spinner spin">
225                   <use xlinkHref="#icon-spinner"></use>
226                 </svg>
227               )}
228               <span
229                 onClick={linkEvent(this, this.handleEmojiPickerClick)}
230                 class="pointer unselectable d-inline-block mr-3 float-right text-muted font-weight-bold"
231                 data-tippy-content={i18n.t('emoji_picker')}
232               >
233                 <svg class="icon icon-inline">
234                   <use xlinkHref="#icon-smile"></use>
235                 </svg>
236               </span>
237             </div>
238           </div>
239         </form>
240       </div>
241     );
242   }
243
244   setupEmojiPicker() {
245     emojiPicker.on('emoji', twemojiHtmlStr => {
246       if (this.state.commentForm.content == null) {
247         this.state.commentForm.content = '';
248       }
249       var el = document.createElement('div');
250       el.innerHTML = twemojiHtmlStr;
251       let nativeUnicode = (el.childNodes[0] as HTMLElement).getAttribute('alt');
252       let shortName = `:${emojiShortName[nativeUnicode]}:`;
253       this.state.commentForm.content += shortName;
254       this.setState(this.state);
255     });
256   }
257
258   handleFinished(op: UserOperation, data: CommentResponse) {
259     let isReply =
260       this.props.node !== undefined && data.comment.parent_id !== null;
261     let xor =
262       +!(data.comment.parent_id !== null) ^ +(this.props.node !== undefined);
263
264     if (
265       (data.comment.creator_id == UserService.Instance.user.id &&
266         ((op == UserOperation.CreateComment &&
267           // If its a reply, make sure parent child match
268           isReply &&
269           data.comment.parent_id == this.props.node.comment.id) ||
270           // Otherwise, check the XOR of the two
271           (!isReply && xor))) ||
272       // If its a comment edit, only check that its from your user, and that its a
273       // text edit only
274
275       (data.comment.creator_id == UserService.Instance.user.id &&
276         op == UserOperation.EditComment &&
277         data.comment.content)
278     ) {
279       this.state.previewMode = false;
280       this.state.loading = false;
281       this.state.commentForm.content = '';
282       this.setState(this.state);
283       let form: any = document.getElementById(this.formId);
284       form.reset();
285       if (this.props.node) {
286         this.props.onReplyCancel();
287       }
288       autosize.update(form);
289       this.setState(this.state);
290     }
291   }
292
293   handleCommentSubmit(i: CommentForm, event: any) {
294     event.preventDefault();
295     if (i.props.edit) {
296       WebSocketService.Instance.editComment(i.state.commentForm);
297     } else {
298       WebSocketService.Instance.createComment(i.state.commentForm);
299     }
300
301     i.state.loading = true;
302     i.setState(i.state);
303   }
304
305   handleEmojiPickerClick(_i: CommentForm, event: any) {
306     emojiPicker.togglePicker(event.target);
307   }
308
309   handleCommentContentChange(i: CommentForm, event: any) {
310     i.state.commentForm.content = event.target.value;
311     i.setState(i.state);
312   }
313
314   handlePreviewToggle(i: CommentForm, event: any) {
315     event.preventDefault();
316     i.state.previewMode = !i.state.previewMode;
317     i.setState(i.state);
318   }
319
320   handleReplyCancel(i: CommentForm) {
321     i.props.onReplyCancel();
322   }
323
324   handleImageUploadPaste(i: CommentForm, event: any) {
325     let image = event.clipboardData.files[0];
326     if (image) {
327       i.handleImageUpload(i, image);
328     }
329   }
330
331   handleImageUpload(i: CommentForm, event: any) {
332     let file: any;
333     if (event.target) {
334       event.preventDefault();
335       file = event.target.files[0];
336     } else {
337       file = event;
338     }
339
340     const imageUploadUrl = `/pictrs/image`;
341     const formData = new FormData();
342     formData.append('images[]', file);
343
344     i.state.imageLoading = true;
345     i.setState(i.state);
346
347     fetch(imageUploadUrl, {
348       method: 'POST',
349       body: formData,
350     })
351       .then(res => res.json())
352       .then(res => {
353         console.log('pictrs upload:');
354         console.log(res);
355         if (res.msg == 'ok') {
356           let hash = res.files[0].file;
357           let url = `${window.location.origin}/pictrs/image/${hash}`;
358           let deleteToken = res.files[0].delete_token;
359           let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
360           let imageMarkdown = `![](${url})`;
361           let content = i.state.commentForm.content;
362           content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
363           i.state.commentForm.content = content;
364           i.state.imageLoading = false;
365           i.setState(i.state);
366           let textarea: any = document.getElementById(i.id);
367           autosize.update(textarea);
368           pictrsDeleteToast(
369             i18n.t('click_to_delete_picture'),
370             i18n.t('picture_deleted'),
371             deleteUrl
372           );
373         } else {
374           i.state.imageLoading = false;
375           i.setState(i.state);
376           toast(JSON.stringify(res), 'danger');
377         }
378       })
379       .catch(error => {
380         i.state.imageLoading = false;
381         i.setState(i.state);
382         toast(error, 'danger');
383       });
384   }
385
386   parseMessage(msg: WebSocketJsonResponse) {
387     let res = wsJsonToRes(msg);
388
389     // Only do the showing and hiding if logged in
390     if (UserService.Instance.user) {
391       if (res.op == UserOperation.CreateComment) {
392         let data = res.data as CommentResponse;
393         this.handleFinished(res.op, data);
394       } else if (res.op == UserOperation.EditComment) {
395         let data = res.data as CommentResponse;
396         this.handleFinished(res.op, data);
397       }
398     }
399   }
400 }