]> Untitled Git - lemmy.git/blob - ui/src/components/comment-form.tsx
Change styling on comment-form no login-alert.
[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-light" 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               #
262               <Link class="alert-link" to="/login">
263                 #
264               </Link>
265             </T>
266           </div>
267         )}
268       </div>
269     );
270   }
271
272   handleFinished(op: UserOperation, data: CommentResponse) {
273     let isReply =
274       this.props.node !== undefined && data.comment.parent_id !== null;
275     let xor =
276       +!(data.comment.parent_id !== null) ^ +(this.props.node !== undefined);
277
278     if (
279       (data.comment.creator_id == UserService.Instance.user.id &&
280         ((op == UserOperation.CreateComment &&
281           // If its a reply, make sure parent child match
282           isReply &&
283           data.comment.parent_id == this.props.node.comment.id) ||
284           // Otherwise, check the XOR of the two
285           (!isReply && xor))) ||
286       // If its a comment edit, only check that its from your user, and that its a
287       // text edit only
288
289       (data.comment.creator_id == UserService.Instance.user.id &&
290         op == UserOperation.EditComment &&
291         data.comment.content)
292     ) {
293       this.state.previewMode = false;
294       this.state.loading = false;
295       this.state.commentForm.content = '';
296       this.setState(this.state);
297       let form: any = document.getElementById(this.formId);
298       form.reset();
299       if (this.props.node) {
300         this.props.onReplyCancel();
301       }
302       autosize.update(form);
303       this.setState(this.state);
304     }
305   }
306
307   handleCommentSubmit(i: CommentForm, event: any) {
308     event.preventDefault();
309     if (i.props.edit) {
310       WebSocketService.Instance.editComment(i.state.commentForm);
311     } else {
312       WebSocketService.Instance.createComment(i.state.commentForm);
313     }
314
315     i.state.loading = true;
316     i.setState(i.state);
317   }
318
319   handleCommentContentChange(i: CommentForm, event: any) {
320     i.state.commentForm.content = event.target.value;
321     i.setState(i.state);
322   }
323
324   handlePreviewToggle(i: CommentForm, event: any) {
325     event.preventDefault();
326     i.state.previewMode = !i.state.previewMode;
327     i.setState(i.state);
328   }
329
330   handleReplyCancel(i: CommentForm) {
331     i.props.onReplyCancel();
332   }
333
334   handleImageUploadPaste(i: CommentForm, event: any) {
335     let image = event.clipboardData.files[0];
336     if (image) {
337       i.handleImageUpload(i, image);
338     }
339   }
340
341   handleImageUpload(i: CommentForm, event: any) {
342     let file: any;
343     if (event.target) {
344       event.preventDefault();
345       file = event.target.files[0];
346     } else {
347       file = event;
348     }
349
350     const imageUploadUrl = `/pictrs/image`;
351     const formData = new FormData();
352     formData.append('images[]', file);
353
354     i.state.imageLoading = true;
355     i.setState(i.state);
356
357     fetch(imageUploadUrl, {
358       method: 'POST',
359       body: formData,
360     })
361       .then(res => res.json())
362       .then(res => {
363         console.log('pictrs upload:');
364         console.log(res);
365         if (res.msg == 'ok') {
366           let hash = res.files[0].file;
367           let url = `${window.location.origin}/pictrs/image/${hash}`;
368           let deleteToken = res.files[0].delete_token;
369           let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
370           let imageMarkdown = `![](${url})`;
371           let content = i.state.commentForm.content;
372           content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
373           i.state.commentForm.content = content;
374           i.state.imageLoading = false;
375           i.setState(i.state);
376           let textarea: any = document.getElementById(i.id);
377           autosize.update(textarea);
378           pictrsDeleteToast(
379             i18n.t('click_to_delete_picture'),
380             i18n.t('picture_deleted'),
381             deleteUrl
382           );
383         } else {
384           i.state.imageLoading = false;
385           i.setState(i.state);
386           toast(JSON.stringify(res), 'danger');
387         }
388       })
389       .catch(error => {
390         i.state.imageLoading = false;
391         i.setState(i.state);
392         toast(error, 'danger');
393       });
394   }
395
396   parseMessage(msg: WebSocketJsonResponse) {
397     let res = wsJsonToRes(msg);
398
399     // Only do the showing and hiding if logged in
400     if (UserService.Instance.user) {
401       if (res.op == UserOperation.CreateComment) {
402         let data = res.data as CommentResponse;
403         this.handleFinished(res.op, data);
404       } else if (res.op == UserOperation.EditComment) {
405         let data = res.data as CommentResponse;
406         this.handleFinished(res.op, data);
407       }
408     }
409   }
410 }