1 import { Component, linkEvent } from 'inferno';
2 import { Prompt } from 'inferno-router';
3 import { PostListings } from './post-listings';
4 import { Subscription } from 'rxjs';
5 import { retryWhen, delay, take } from 'rxjs/operators';
13 ListCommunitiesResponse,
19 WebSocketJsonResponse,
20 } from '../interfaces';
21 import { WebSocketService, UserService } from '../services';
26 capitalizeFirstLetter,
40 import autosize from 'autosize';
41 import Tribute from 'tributejs/src/Tribute.js';
42 import emojiShortName from 'emoji-short-name';
43 import Choices from 'choices.js';
44 import { i18n } from '../i18next';
46 const MAX_POST_TITLE_LENGTH = 200;
48 interface PostFormProps {
49 post?: Post; // If a post is given, that means this is an edit
50 params?: PostFormParams;
52 onCreate?(id: number): any;
53 onEdit?(post: Post): any;
55 enableDownvotes: boolean;
58 interface PostFormState {
60 communities: Array<Community>;
62 imageLoading: boolean;
64 suggestedTitle: string;
65 suggestedPosts: Array<Post>;
66 crossPosts: Array<Post>;
69 export class PostForm extends Component<PostFormProps, PostFormState> {
70 private id = `post-form-${randomStr()}`;
71 private tribute: Tribute;
72 private subscription: Subscription;
73 private choices: Choices;
74 private emptyState: PostFormState = {
80 creator_id: UserService.Instance.user
81 ? UserService.Instance.user.id
88 suggestedTitle: undefined,
93 constructor(props: any, context: any) {
94 super(props, context);
95 this.fetchSimilarPosts = debounce(this.fetchSimilarPosts).bind(this);
96 this.fetchPageTitle = debounce(this.fetchPageTitle).bind(this);
98 this.tribute = setupTribute();
100 this.state = this.emptyState;
102 if (this.props.post) {
103 this.state.postForm = {
104 body: this.props.post.body,
105 // NOTE: debouncing breaks both these for some reason, unless you use defaultValue
106 name: this.props.post.name,
107 community_id: this.props.post.community_id,
108 edit_id: this.props.post.id,
109 creator_id: this.props.post.creator_id,
110 url: this.props.post.url,
111 nsfw: this.props.post.nsfw,
116 if (this.props.params) {
117 this.state.postForm.name = this.props.params.name;
118 if (this.props.params.url) {
119 this.state.postForm.url = this.props.params.url;
121 if (this.props.params.body) {
122 this.state.postForm.body = this.props.params.body;
126 this.subscription = WebSocketService.Instance.subject
127 .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
129 msg => this.parseMessage(msg),
130 err => console.error(err),
131 () => console.log('complete')
134 let listCommunitiesForm: ListCommunitiesForm = {
135 sort: SortType[SortType.TopAll],
139 WebSocketService.Instance.listCommunities(listCommunitiesForm);
142 componentDidMount() {
143 var textarea: any = document.getElementById(this.id);
145 this.tribute.attach(textarea);
146 textarea.addEventListener('tribute-replaced', () => {
147 this.state.postForm.body = textarea.value;
148 this.setState(this.state);
149 autosize.update(textarea);
154 componentDidUpdate() {
156 !this.state.loading &&
157 (this.state.postForm.name ||
158 this.state.postForm.url ||
159 this.state.postForm.body)
161 window.onbeforeunload = () => true;
163 window.onbeforeunload = undefined;
167 componentWillUnmount() {
168 this.subscription.unsubscribe();
169 this.choices.destroy();
170 window.onbeforeunload = null;
178 !this.state.loading &&
179 (this.state.postForm.name ||
180 this.state.postForm.url ||
181 this.state.postForm.body)
183 message={i18n.t('block_leaving')}
185 <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
186 <div class="form-group row">
187 <label class="col-sm-2 col-form-label" htmlFor="post-url">
190 <div class="col-sm-10">
195 value={this.state.postForm.url}
196 onInput={linkEvent(this, this.handlePostUrlChange)}
197 onPaste={linkEvent(this, this.handleImageUploadPaste)}
199 {this.state.suggestedTitle && (
201 class="mt-1 text-muted small font-weight-bold pointer"
202 onClick={linkEvent(this, this.copySuggestedTitle)}
204 {i18n.t('copy_suggested_title', {
205 title: this.state.suggestedTitle,
211 htmlFor="file-upload"
213 UserService.Instance.user && 'pointer'
214 } d-inline-block float-right text-muted font-weight-bold`}
215 data-tippy-content={i18n.t('upload_image')}
217 <svg class="icon icon-inline">
218 <use xlinkHref="#icon-image"></use>
224 accept="image/*,video/*"
227 disabled={!UserService.Instance.user}
228 onChange={linkEvent(this, this.handleImageUpload)}
231 {validURL(this.state.postForm.url) && (
233 href={`${archiveUrl}/?run=1&url=${encodeURIComponent(
234 this.state.postForm.url
237 class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
240 {i18n.t('archive_link')}
243 {this.state.imageLoading && (
244 <svg class="icon icon-spinner spin">
245 <use xlinkHref="#icon-spinner"></use>
248 {isImage(this.state.postForm.url) && (
249 <img src={this.state.postForm.url} class="img-fluid" />
251 {this.state.crossPosts.length > 0 && (
253 <div class="my-1 text-muted small font-weight-bold">
254 {i18n.t('cross_posts')}
258 posts={this.state.crossPosts}
259 enableDownvotes={this.props.enableDownvotes}
260 enableNsfw={this.props.enableNsfw}
266 <div class="form-group row">
267 <label class="col-sm-2 col-form-label" htmlFor="post-title">
270 <div class="col-sm-10">
272 value={this.state.postForm.name}
274 onInput={linkEvent(this, this.handlePostNameChange)}
275 class={`form-control ${
276 !validTitle(this.state.postForm.name) && 'is-invalid'
281 maxLength={MAX_POST_TITLE_LENGTH}
283 {!validTitle(this.state.postForm.name) && (
284 <div class="invalid-feedback">
285 {i18n.t('invalid_post_title')}
288 {this.state.suggestedPosts.length > 0 && (
290 <div class="my-1 text-muted small font-weight-bold">
291 {i18n.t('related_posts')}
294 posts={this.state.suggestedPosts}
295 enableDownvotes={this.props.enableDownvotes}
296 enableNsfw={this.props.enableNsfw}
303 <div class="form-group row">
304 <label class="col-sm-2 col-form-label" htmlFor={this.id}>
307 <div class="col-sm-10">
310 value={this.state.postForm.body}
311 onInput={linkEvent(this, this.handlePostBodyChange)}
312 className={`form-control ${this.state.previewMode && 'd-none'}`}
316 {this.state.previewMode && (
318 className="card card-body md-div"
319 dangerouslySetInnerHTML={mdToHtml(this.state.postForm.body)}
322 {this.state.postForm.body && (
324 className={`mt-1 mr-2 btn btn-sm btn-secondary ${
325 this.state.previewMode && 'active'
327 onClick={linkEvent(this, this.handlePreviewToggle)}
333 href={markdownHelpUrl}
336 class="d-inline-block float-right text-muted font-weight-bold"
337 title={i18n.t('formatting_help')}
339 <svg class="icon icon-inline">
340 <use xlinkHref="#icon-help-circle"></use>
345 {!this.props.post && (
346 <div class="form-group row">
347 <label class="col-sm-2 col-form-label" htmlFor="post-community">
348 {i18n.t('community')}
350 <div class="col-sm-10">
354 value={this.state.postForm.community_id}
355 onInput={linkEvent(this, this.handlePostCommunityChange)}
357 <option>{i18n.t('select_a_community')}</option>
358 {this.state.communities.map(community => (
359 <option value={community.id}>
362 : `${hostname(community.actor_id)}/${community.name}`}
369 {this.props.enableNsfw && (
370 <div class="form-group row">
371 <div class="col-sm-10">
372 <div class="form-check">
374 class="form-check-input"
377 checked={this.state.postForm.nsfw}
378 onChange={linkEvent(this, this.handlePostNsfwChange)}
380 <label class="form-check-label" htmlFor="post-nsfw">
387 <div class="form-group row">
388 <div class="col-sm-10">
391 !this.state.postForm.community_id || this.state.loading
394 class="btn btn-secondary mr-2"
396 {this.state.loading ? (
397 <svg class="icon icon-spinner spin">
398 <use xlinkHref="#icon-spinner"></use>
400 ) : this.props.post ? (
401 capitalizeFirstLetter(i18n.t('save'))
403 capitalizeFirstLetter(i18n.t('create'))
406 {this.props.post && (
409 class="btn btn-secondary"
410 onClick={linkEvent(this, this.handleCancel)}
422 handlePostSubmit(i: PostForm, event: any) {
423 event.preventDefault();
425 // Coerce empty url string to undefined
426 if (i.state.postForm.url && i.state.postForm.url === '') {
427 i.state.postForm.url = undefined;
431 WebSocketService.Instance.editPost(i.state.postForm);
433 WebSocketService.Instance.createPost(i.state.postForm);
435 i.state.loading = true;
439 copySuggestedTitle(i: PostForm) {
440 i.state.postForm.name = i.state.suggestedTitle.substring(
442 MAX_POST_TITLE_LENGTH
444 i.state.suggestedTitle = undefined;
448 handlePostUrlChange(i: PostForm, event: any) {
449 i.state.postForm.url = event.target.value;
455 if (validURL(this.state.postForm.url)) {
456 let form: SearchForm = {
457 q: this.state.postForm.url,
458 type_: SearchType[SearchType.Url],
459 sort: SortType[SortType.TopAll],
464 WebSocketService.Instance.search(form);
466 // Fetch the page title
467 getPageTitle(this.state.postForm.url).then(d => {
468 this.state.suggestedTitle = d;
469 this.setState(this.state);
472 this.state.suggestedTitle = undefined;
473 this.state.crossPosts = [];
477 handlePostNameChange(i: PostForm, event: any) {
478 i.state.postForm.name = event.target.value;
480 i.fetchSimilarPosts();
483 fetchSimilarPosts() {
484 let form: SearchForm = {
485 q: this.state.postForm.name,
486 type_: SearchType[SearchType.Posts],
487 sort: SortType[SortType.TopAll],
488 community_id: this.state.postForm.community_id,
493 if (this.state.postForm.name !== '') {
494 WebSocketService.Instance.search(form);
496 this.state.suggestedPosts = [];
499 this.setState(this.state);
502 handlePostBodyChange(i: PostForm, event: any) {
503 i.state.postForm.body = event.target.value;
507 handlePostCommunityChange(i: PostForm, event: any) {
508 i.state.postForm.community_id = Number(event.target.value);
512 handlePostNsfwChange(i: PostForm, event: any) {
513 i.state.postForm.nsfw = event.target.checked;
517 handleCancel(i: PostForm) {
521 handlePreviewToggle(i: PostForm, event: any) {
522 event.preventDefault();
523 i.state.previewMode = !i.state.previewMode;
527 handleImageUploadPaste(i: PostForm, event: any) {
528 let image = event.clipboardData.files[0];
530 i.handleImageUpload(i, image);
534 handleImageUpload(i: PostForm, event: any) {
537 event.preventDefault();
538 file = event.target.files[0];
543 const imageUploadUrl = `/pictrs/image`;
544 const formData = new FormData();
545 formData.append('images[]', file);
547 i.state.imageLoading = true;
550 fetch(imageUploadUrl, {
554 .then(res => res.json())
556 console.log('pictrs upload:');
558 if (res.msg == 'ok') {
559 let hash = res.files[0].file;
560 let url = `${window.location.origin}/pictrs/image/${hash}`;
561 let deleteToken = res.files[0].delete_token;
562 let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
563 i.state.postForm.url = url;
564 i.state.imageLoading = false;
567 i18n.t('click_to_delete_picture'),
568 i18n.t('picture_deleted'),
572 i.state.imageLoading = false;
574 toast(JSON.stringify(res), 'danger');
578 i.state.imageLoading = false;
580 toast(error, 'danger');
584 parseMessage(msg: WebSocketJsonResponse) {
585 let res = wsJsonToRes(msg);
587 toast(i18n.t(msg.error), 'danger');
588 this.state.loading = false;
589 this.setState(this.state);
591 } else if (res.op == UserOperation.ListCommunities) {
592 let data = res.data as ListCommunitiesResponse;
593 this.state.communities = data.communities;
594 if (this.props.post) {
595 this.state.postForm.community_id = this.props.post.community_id;
596 } else if (this.props.params && this.props.params.community) {
597 let foundCommunityId = data.communities.find(
598 r => r.name == this.props.params.community
600 this.state.postForm.community_id = foundCommunityId;
602 // By default, the null valued 'Select a Community'
604 this.setState(this.state);
606 // Set up select searching
607 let selectId: any = document.getElementById('post-community');
609 this.choices = new Choices(selectId, {
612 containerOuter: 'choices',
613 containerInner: 'choices__inner bg-secondary border-0',
614 input: 'form-control',
615 inputCloned: 'choices__input--cloned',
616 list: 'choices__list',
617 listItems: 'choices__list--multiple',
618 listSingle: 'choices__list--single',
619 listDropdown: 'choices__list--dropdown',
620 item: 'choices__item bg-secondary',
621 itemSelectable: 'choices__item--selectable',
622 itemDisabled: 'choices__item--disabled',
623 itemChoice: 'choices__item--choice',
624 placeholder: 'choices__placeholder',
625 group: 'choices__group',
626 groupHeading: 'choices__heading',
627 button: 'choices__button',
628 activeState: 'is-active',
629 focusState: 'is-focused',
630 openState: 'is-open',
631 disabledState: 'is-disabled',
632 highlightedState: 'text-info',
633 selectedState: 'text-info',
634 flippedState: 'is-flipped',
635 loadingState: 'is-loading',
636 noResults: 'has-no-results',
637 noChoices: 'has-no-choices',
640 this.choices.passedElement.element.addEventListener(
643 this.state.postForm.community_id = Number(e.detail.choice.value);
644 this.setState(this.state);
649 } else if (res.op == UserOperation.CreatePost) {
650 let data = res.data as PostResponse;
651 if (data.post.creator_id == UserService.Instance.user.id) {
652 this.state.loading = false;
653 this.props.onCreate(data.post.id);
655 } else if (res.op == UserOperation.EditPost) {
656 let data = res.data as PostResponse;
657 if (data.post.creator_id == UserService.Instance.user.id) {
658 this.state.loading = false;
659 this.props.onEdit(data.post);
661 } else if (res.op == UserOperation.Search) {
662 let data = res.data as SearchResponse;
664 if (data.type_ == SearchType[SearchType.Posts]) {
665 this.state.suggestedPosts = data.posts;
666 } else if (data.type_ == SearchType[SearchType.Url]) {
667 this.state.crossPosts = data.posts;
669 this.setState(this.state);