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