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