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