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