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