1 import { Component, linkEvent } from 'inferno';
2 import { Prompt } from 'inferno-router';
3 import { PostListings } from './post-listings';
4 import { MarkdownTextArea } from './markdown-textarea';
5 import { Subscription } from 'rxjs';
6 import { retryWhen, delay, take } from 'rxjs/operators';
14 ListCommunitiesResponse,
20 WebSocketJsonResponse,
21 } from '../interfaces';
22 import { WebSocketService, UserService } from '../services';
27 capitalizeFirstLetter,
38 import Choices from 'choices.js';
39 import { i18n } from '../i18next';
41 const MAX_POST_TITLE_LENGTH = 200;
43 interface PostFormProps {
44 post?: Post; // If a post is given, that means this is an edit
45 params?: PostFormParams;
47 onCreate?(id: number): any;
48 onEdit?(post: Post): any;
50 enableDownvotes: boolean;
53 interface PostFormState {
55 communities: Array<Community>;
57 imageLoading: boolean;
59 suggestedTitle: string;
60 suggestedPosts: Array<Post>;
61 crossPosts: Array<Post>;
64 export class PostForm extends Component<PostFormProps, PostFormState> {
65 private id = `post-form-${randomStr()}`;
66 private subscription: Subscription;
67 private choices: Choices;
68 private emptyState: PostFormState = {
74 creator_id: UserService.Instance.user
75 ? UserService.Instance.user.id
82 suggestedTitle: undefined,
87 constructor(props: any, context: any) {
88 super(props, context);
89 this.fetchSimilarPosts = debounce(this.fetchSimilarPosts).bind(this);
90 this.fetchPageTitle = debounce(this.fetchPageTitle).bind(this);
91 this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
93 this.state = this.emptyState;
95 if (this.props.post) {
96 this.state.postForm = {
97 body: this.props.post.body,
98 // NOTE: debouncing breaks both these for some reason, unless you use defaultValue
99 name: this.props.post.name,
100 community_id: this.props.post.community_id,
101 edit_id: this.props.post.id,
102 creator_id: this.props.post.creator_id,
103 url: this.props.post.url,
104 nsfw: this.props.post.nsfw,
109 if (this.props.params) {
110 this.state.postForm.name = this.props.params.name;
111 if (this.props.params.url) {
112 this.state.postForm.url = this.props.params.url;
114 if (this.props.params.body) {
115 this.state.postForm.body = this.props.params.body;
119 this.subscription = WebSocketService.Instance.subject
120 .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
122 msg => this.parseMessage(msg),
123 err => console.error(err),
124 () => console.log('complete')
127 let listCommunitiesForm: ListCommunitiesForm = {
128 sort: SortType[SortType.TopAll],
132 WebSocketService.Instance.listCommunities(listCommunitiesForm);
135 componentDidMount() {
139 componentDidUpdate() {
141 !this.state.loading &&
142 (this.state.postForm.name ||
143 this.state.postForm.url ||
144 this.state.postForm.body)
146 window.onbeforeunload = () => true;
148 window.onbeforeunload = undefined;
152 componentWillUnmount() {
153 this.subscription.unsubscribe();
154 /* this.choices && this.choices.destroy(); */
155 window.onbeforeunload = null;
163 !this.state.loading &&
164 (this.state.postForm.name ||
165 this.state.postForm.url ||
166 this.state.postForm.body)
168 message={i18n.t('block_leaving')}
170 <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
171 <div class="form-group row">
172 <label class="col-sm-2 col-form-label" htmlFor="post-url">
175 <div class="col-sm-10">
180 value={this.state.postForm.url}
181 onInput={linkEvent(this, this.handlePostUrlChange)}
182 onPaste={linkEvent(this, this.handleImageUploadPaste)}
184 {this.state.suggestedTitle && (
186 class="mt-1 text-muted small font-weight-bold pointer"
187 onClick={linkEvent(this, this.copySuggestedTitle)}
189 {i18n.t('copy_suggested_title', {
190 title: this.state.suggestedTitle,
196 htmlFor="file-upload"
198 UserService.Instance.user && 'pointer'
199 } d-inline-block float-right text-muted font-weight-bold`}
200 data-tippy-content={i18n.t('upload_image')}
202 <svg class="icon icon-inline">
203 <use xlinkHref="#icon-image"></use>
209 accept="image/*,video/*"
212 disabled={!UserService.Instance.user}
213 onChange={linkEvent(this, this.handleImageUpload)}
216 {validURL(this.state.postForm.url) && (
218 href={`${archiveUrl}/?run=1&url=${encodeURIComponent(
219 this.state.postForm.url
222 class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
225 {i18n.t('archive_link')}
228 {this.state.imageLoading && (
229 <svg class="icon icon-spinner spin">
230 <use xlinkHref="#icon-spinner"></use>
233 {isImage(this.state.postForm.url) && (
234 <img src={this.state.postForm.url} class="img-fluid" />
236 {this.state.crossPosts.length > 0 && (
238 <div class="my-1 text-muted small font-weight-bold">
239 {i18n.t('cross_posts')}
243 posts={this.state.crossPosts}
244 enableDownvotes={this.props.enableDownvotes}
245 enableNsfw={this.props.enableNsfw}
251 <div class="form-group row">
252 <label class="col-sm-2 col-form-label" htmlFor="post-title">
255 <div class="col-sm-10">
257 value={this.state.postForm.name}
259 onInput={linkEvent(this, this.handlePostNameChange)}
260 class={`form-control ${
261 !validTitle(this.state.postForm.name) && 'is-invalid'
266 maxLength={MAX_POST_TITLE_LENGTH}
268 {!validTitle(this.state.postForm.name) && (
269 <div class="invalid-feedback">
270 {i18n.t('invalid_post_title')}
273 {this.state.suggestedPosts.length > 0 && (
275 <div class="my-1 text-muted small font-weight-bold">
276 {i18n.t('related_posts')}
279 posts={this.state.suggestedPosts}
280 enableDownvotes={this.props.enableDownvotes}
281 enableNsfw={this.props.enableNsfw}
288 <div class="form-group row">
289 <label class="col-sm-2 col-form-label" htmlFor={this.id}>
292 <div class="col-sm-10">
294 initialContent={this.state.postForm.body}
295 onContentChange={this.handlePostBodyChange}
299 {!this.props.post && (
300 <div class="form-group row">
301 <label class="col-sm-2 col-form-label" htmlFor="post-community">
302 {i18n.t('community')}
304 <div class="col-sm-10">
308 value={this.state.postForm.community_id}
309 onInput={linkEvent(this, this.handlePostCommunityChange)}
311 <option>{i18n.t('select_a_community')}</option>
312 {this.state.communities.map(community => (
313 <option value={community.id}>
316 : `${hostname(community.actor_id)}/${community.name}`}
323 {this.props.enableNsfw && (
324 <div class="form-group row">
325 <div class="col-sm-10">
326 <div class="form-check">
328 class="form-check-input"
331 checked={this.state.postForm.nsfw}
332 onChange={linkEvent(this, this.handlePostNsfwChange)}
334 <label class="form-check-label" htmlFor="post-nsfw">
341 <div class="form-group row">
342 <div class="col-sm-10">
345 !this.state.postForm.community_id || this.state.loading
348 class="btn btn-secondary mr-2"
350 {this.state.loading ? (
351 <svg class="icon icon-spinner spin">
352 <use xlinkHref="#icon-spinner"></use>
354 ) : this.props.post ? (
355 capitalizeFirstLetter(i18n.t('save'))
357 capitalizeFirstLetter(i18n.t('create'))
360 {this.props.post && (
363 class="btn btn-secondary"
364 onClick={linkEvent(this, this.handleCancel)}
376 handlePostSubmit(i: PostForm, event: any) {
377 event.preventDefault();
379 // Coerce empty url string to undefined
380 if (i.state.postForm.url && i.state.postForm.url === '') {
381 i.state.postForm.url = undefined;
385 WebSocketService.Instance.editPost(i.state.postForm);
387 WebSocketService.Instance.createPost(i.state.postForm);
389 i.state.loading = true;
393 copySuggestedTitle(i: PostForm) {
394 i.state.postForm.name = i.state.suggestedTitle.substring(
396 MAX_POST_TITLE_LENGTH
398 i.state.suggestedTitle = undefined;
402 handlePostUrlChange(i: PostForm, event: any) {
403 i.state.postForm.url = event.target.value;
409 if (validURL(this.state.postForm.url)) {
410 let form: SearchForm = {
411 q: this.state.postForm.url,
412 type_: SearchType[SearchType.Url],
413 sort: SortType[SortType.TopAll],
418 WebSocketService.Instance.search(form);
420 // Fetch the page title
421 getPageTitle(this.state.postForm.url).then(d => {
422 this.state.suggestedTitle = d;
423 this.setState(this.state);
426 this.state.suggestedTitle = undefined;
427 this.state.crossPosts = [];
431 handlePostNameChange(i: PostForm, event: any) {
432 i.state.postForm.name = event.target.value;
434 i.fetchSimilarPosts();
437 fetchSimilarPosts() {
438 let form: SearchForm = {
439 q: this.state.postForm.name,
440 type_: SearchType[SearchType.Posts],
441 sort: SortType[SortType.TopAll],
442 community_id: this.state.postForm.community_id,
447 if (this.state.postForm.name !== '') {
448 WebSocketService.Instance.search(form);
450 this.state.suggestedPosts = [];
453 this.setState(this.state);
456 handlePostBodyChange(val: string) {
457 this.state.postForm.body = val;
458 this.setState(this.state);
461 handlePostCommunityChange(i: PostForm, event: any) {
462 i.state.postForm.community_id = Number(event.target.value);
466 handlePostNsfwChange(i: PostForm, event: any) {
467 i.state.postForm.nsfw = event.target.checked;
471 handleCancel(i: PostForm) {
475 handlePreviewToggle(i: PostForm, event: any) {
476 event.preventDefault();
477 i.state.previewMode = !i.state.previewMode;
481 handleImageUploadPaste(i: PostForm, event: any) {
482 let image = event.clipboardData.files[0];
484 i.handleImageUpload(i, image);
488 handleImageUpload(i: PostForm, event: any) {
491 event.preventDefault();
492 file = event.target.files[0];
497 const imageUploadUrl = `/pictrs/image`;
498 const formData = new FormData();
499 formData.append('images[]', file);
501 i.state.imageLoading = true;
504 fetch(imageUploadUrl, {
508 .then(res => res.json())
510 console.log('pictrs upload:');
512 if (res.msg == 'ok') {
513 let hash = res.files[0].file;
514 let url = `${window.location.origin}/pictrs/image/${hash}`;
515 let deleteToken = res.files[0].delete_token;
516 let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
517 i.state.postForm.url = url;
518 i.state.imageLoading = false;
521 i18n.t('click_to_delete_picture'),
522 i18n.t('picture_deleted'),
526 i.state.imageLoading = false;
528 toast(JSON.stringify(res), 'danger');
532 i.state.imageLoading = false;
534 toast(error, 'danger');
538 parseMessage(msg: WebSocketJsonResponse) {
539 let res = wsJsonToRes(msg);
541 toast(i18n.t(msg.error), 'danger');
542 this.state.loading = false;
543 this.setState(this.state);
545 } else if (res.op == UserOperation.ListCommunities) {
546 let data = res.data as ListCommunitiesResponse;
547 this.state.communities = data.communities;
548 if (this.props.post) {
549 this.state.postForm.community_id = this.props.post.community_id;
550 } else if (this.props.params && this.props.params.community) {
551 let foundCommunityId = data.communities.find(
552 r => r.name == this.props.params.community
554 this.state.postForm.community_id = foundCommunityId;
556 // By default, the null valued 'Select a Community'
558 this.setState(this.state);
560 // Set up select searching
561 let selectId: any = document.getElementById('post-community');
563 this.choices = new Choices(selectId, {
566 containerOuter: 'choices',
567 containerInner: 'choices__inner bg-secondary border-0',
568 input: 'form-control',
569 inputCloned: 'choices__input--cloned',
570 list: 'choices__list',
571 listItems: 'choices__list--multiple',
572 listSingle: 'choices__list--single',
573 listDropdown: 'choices__list--dropdown',
574 item: 'choices__item bg-secondary',
575 itemSelectable: 'choices__item--selectable',
576 itemDisabled: 'choices__item--disabled',
577 itemChoice: 'choices__item--choice',
578 placeholder: 'choices__placeholder',
579 group: 'choices__group',
580 groupHeading: 'choices__heading',
581 button: 'choices__button',
582 activeState: 'is-active',
583 focusState: 'is-focused',
584 openState: 'is-open',
585 disabledState: 'is-disabled',
586 highlightedState: 'text-info',
587 selectedState: 'text-info',
588 flippedState: 'is-flipped',
589 loadingState: 'is-loading',
590 noResults: 'has-no-results',
591 noChoices: 'has-no-choices',
594 this.choices.passedElement.element.addEventListener(
597 this.state.postForm.community_id = Number(e.detail.choice.value);
598 this.setState(this.state);
603 } else if (res.op == UserOperation.CreatePost) {
604 let data = res.data as PostResponse;
605 if (data.post.creator_id == UserService.Instance.user.id) {
606 this.state.loading = false;
607 this.props.onCreate(data.post.id);
609 } else if (res.op == UserOperation.EditPost) {
610 let data = res.data as PostResponse;
611 if (data.post.creator_id == UserService.Instance.user.id) {
612 this.state.loading = false;
613 this.props.onEdit(data.post);
615 } else if (res.op == UserOperation.Search) {
616 let data = res.data as SearchResponse;
618 if (data.type_ == SearchType[SearchType.Posts]) {
619 this.state.suggestedPosts = data.posts;
620 } else if (data.type_ == SearchType[SearchType.Url]) {
621 this.state.crossPosts = data.posts;
623 this.setState(this.state);