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';
17 WebSocketJsonResponse,
18 } from 'lemmy-js-client';
19 import { WebSocketService, UserService } from '../services';
24 capitalizeFirstLetter,
40 Choices = require('choices.js');
43 import { i18n } from '../i18next';
44 import { pictrsUri } from '../env';
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 communities?: Community[];
51 params?: PostFormParams;
53 onCreate?(id: number): any;
54 onEdit?(post: Post): any;
56 enableDownvotes: boolean;
59 interface PostFormState {
62 imageLoading: boolean;
64 suggestedTitle: string;
65 suggestedPosts: Post[];
69 export class PostForm extends Component<PostFormProps, PostFormState> {
70 private id = `post-form-${randomStr()}`;
71 private subscription: Subscription;
73 private emptyState: PostFormState = {
83 suggestedTitle: undefined,
88 constructor(props: any, context: any) {
89 super(props, context);
90 this.fetchSimilarPosts = debounce(this.fetchSimilarPosts).bind(this);
91 this.fetchPageTitle = debounce(this.fetchPageTitle).bind(this);
92 this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
94 this.state = this.emptyState;
96 if (this.props.post) {
97 this.state.postForm = {
98 body: this.props.post.body,
99 // NOTE: debouncing breaks both these for some reason, unless you use defaultValue
100 name: this.props.post.name,
101 community_id: this.props.post.community_id,
102 edit_id: this.props.post.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.parseMessage = this.parseMessage.bind(this);
120 this.subscription = wsSubscribe(this.parseMessage);
123 componentDidMount() {
125 this.setupCommunities();
128 componentDidUpdate() {
130 !this.state.loading &&
131 (this.state.postForm.name ||
132 this.state.postForm.url ||
133 this.state.postForm.body)
135 window.onbeforeunload = () => true;
137 window.onbeforeunload = undefined;
141 componentWillUnmount() {
142 this.subscription.unsubscribe();
143 /* this.choices && this.choices.destroy(); */
144 window.onbeforeunload = null;
152 !this.state.loading &&
153 (this.state.postForm.name ||
154 this.state.postForm.url ||
155 this.state.postForm.body)
157 message={i18n.t('block_leaving')}
159 <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
160 <div class="form-group row">
161 <label class="col-sm-2 col-form-label" htmlFor="post-url">
164 <div class="col-sm-10">
169 value={this.state.postForm.url}
170 onInput={linkEvent(this, this.handlePostUrlChange)}
171 onPaste={linkEvent(this, this.handleImageUploadPaste)}
173 {this.state.suggestedTitle && (
175 class="mt-1 text-muted small font-weight-bold pointer"
176 onClick={linkEvent(this, this.copySuggestedTitle)}
178 {i18n.t('copy_suggested_title', {
179 title: this.state.suggestedTitle,
185 htmlFor="file-upload"
187 UserService.Instance.user && 'pointer'
188 } d-inline-block float-right text-muted font-weight-bold`}
189 data-tippy-content={i18n.t('upload_image')}
191 <svg class="icon icon-inline">
192 <use xlinkHref="#icon-image"></use>
198 accept="image/*,video/*"
201 disabled={!UserService.Instance.user}
202 onChange={linkEvent(this, this.handleImageUpload)}
205 {this.state.postForm.url && validURL(this.state.postForm.url) && (
207 href={`${archiveUrl}/?run=1&url=${encodeURIComponent(
208 this.state.postForm.url
211 class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
214 {i18n.t('archive_link')}
217 {this.state.imageLoading && (
218 <svg class="icon icon-spinner spin">
219 <use xlinkHref="#icon-spinner"></use>
222 {isImage(this.state.postForm.url) && (
223 <img src={this.state.postForm.url} class="img-fluid" />
225 {this.state.crossPosts.length > 0 && (
227 <div class="my-1 text-muted small font-weight-bold">
228 {i18n.t('cross_posts')}
232 posts={this.state.crossPosts}
233 enableDownvotes={this.props.enableDownvotes}
234 enableNsfw={this.props.enableNsfw}
240 <div class="form-group row">
241 <label class="col-sm-2 col-form-label" htmlFor="post-title">
244 <div class="col-sm-10">
246 value={this.state.postForm.name}
248 onInput={linkEvent(this, this.handlePostNameChange)}
249 class={`form-control ${
250 !validTitle(this.state.postForm.name) && 'is-invalid'
255 maxLength={MAX_POST_TITLE_LENGTH}
257 {!validTitle(this.state.postForm.name) && (
258 <div class="invalid-feedback">
259 {i18n.t('invalid_post_title')}
262 {this.state.suggestedPosts.length > 0 && (
264 <div class="my-1 text-muted small font-weight-bold">
265 {i18n.t('related_posts')}
268 posts={this.state.suggestedPosts}
269 enableDownvotes={this.props.enableDownvotes}
270 enableNsfw={this.props.enableNsfw}
277 <div class="form-group row">
278 <label class="col-sm-2 col-form-label" htmlFor={this.id}>
281 <div class="col-sm-10">
283 initialContent={this.state.postForm.body}
284 onContentChange={this.handlePostBodyChange}
288 {!this.props.post && (
289 <div class="form-group row">
290 <label class="col-sm-2 col-form-label" htmlFor="post-community">
291 {i18n.t('community')}
293 <div class="col-sm-10">
297 value={this.state.postForm.community_id}
298 onInput={linkEvent(this, this.handlePostCommunityChange)}
300 <option>{i18n.t('select_a_community')}</option>
301 {this.props.communities.map(community => (
302 <option value={community.id}>
305 : `${hostname(community.actor_id)}/${community.name}`}
312 {this.props.enableNsfw && (
313 <div class="form-group row">
314 <div class="col-sm-10">
315 <div class="form-check">
317 class="form-check-input"
320 checked={this.state.postForm.nsfw}
321 onChange={linkEvent(this, this.handlePostNsfwChange)}
323 <label class="form-check-label" htmlFor="post-nsfw">
330 <div class="form-group row">
331 <div class="col-sm-10">
334 !this.state.postForm.community_id || this.state.loading
337 class="btn btn-secondary mr-2"
339 {this.state.loading ? (
340 <svg class="icon icon-spinner spin">
341 <use xlinkHref="#icon-spinner"></use>
343 ) : this.props.post ? (
344 capitalizeFirstLetter(i18n.t('save'))
346 capitalizeFirstLetter(i18n.t('create'))
349 {this.props.post && (
352 class="btn btn-secondary"
353 onClick={linkEvent(this, this.handleCancel)}
365 handlePostSubmit(i: PostForm, event: any) {
366 event.preventDefault();
368 // Coerce empty url string to undefined
369 if (i.state.postForm.url !== undefined && i.state.postForm.url === '') {
370 i.state.postForm.url = undefined;
374 WebSocketService.Instance.editPost(i.state.postForm);
376 WebSocketService.Instance.createPost(i.state.postForm);
378 i.state.loading = true;
382 copySuggestedTitle(i: PostForm) {
383 i.state.postForm.name = i.state.suggestedTitle.substring(
385 MAX_POST_TITLE_LENGTH
387 i.state.suggestedTitle = undefined;
391 handlePostUrlChange(i: PostForm, event: any) {
392 i.state.postForm.url = event.target.value;
398 if (validURL(this.state.postForm.url)) {
399 let form: SearchForm = {
400 q: this.state.postForm.url,
401 type_: SearchType.Url,
402 sort: SortType.TopAll,
407 WebSocketService.Instance.search(form);
409 // Fetch the page title
410 getPageTitle(this.state.postForm.url).then(d => {
411 this.state.suggestedTitle = d;
412 this.setState(this.state);
415 this.state.suggestedTitle = undefined;
416 this.state.crossPosts = [];
420 handlePostNameChange(i: PostForm, event: any) {
421 i.state.postForm.name = event.target.value;
423 i.fetchSimilarPosts();
426 fetchSimilarPosts() {
427 let form: SearchForm = {
428 q: this.state.postForm.name,
429 type_: SearchType.Posts,
430 sort: SortType.TopAll,
431 community_id: this.state.postForm.community_id,
436 if (this.state.postForm.name !== '') {
437 WebSocketService.Instance.search(form);
439 this.state.suggestedPosts = [];
442 this.setState(this.state);
445 handlePostBodyChange(val: string) {
446 this.state.postForm.body = val;
447 this.setState(this.state);
450 handlePostCommunityChange(i: PostForm, event: any) {
451 i.state.postForm.community_id = Number(event.target.value);
455 handlePostNsfwChange(i: PostForm, event: any) {
456 i.state.postForm.nsfw = event.target.checked;
460 handleCancel(i: PostForm) {
464 handlePreviewToggle(i: PostForm, event: any) {
465 event.preventDefault();
466 i.state.previewMode = !i.state.previewMode;
470 handleImageUploadPaste(i: PostForm, event: any) {
471 let image = event.clipboardData.files[0];
473 i.handleImageUpload(i, image);
477 handleImageUpload(i: PostForm, event: any) {
480 event.preventDefault();
481 file = event.target.files[0];
486 const formData = new FormData();
487 formData.append('images[]', file);
489 i.state.imageLoading = true;
496 .then(res => res.json())
498 console.log('pictrs upload:');
500 if (res.msg == 'ok') {
501 let hash = res.files[0].file;
502 let url = `${pictrsUri}/${hash}`;
503 let deleteToken = res.files[0].delete_token;
504 let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
505 i.state.postForm.url = url;
506 i.state.imageLoading = false;
509 i18n.t('click_to_delete_picture'),
510 i18n.t('picture_deleted'),
514 i.state.imageLoading = false;
516 toast(JSON.stringify(res), 'danger');
520 i.state.imageLoading = false;
522 toast(error, 'danger');
527 // Set up select searching
529 let selectId: any = document.getElementById('post-community');
531 this.choices = new Choices(selectId, {
534 containerOuter: 'choices',
535 containerInner: 'choices__inner bg-light border-0',
536 input: 'form-control',
537 inputCloned: 'choices__input--cloned',
538 list: 'choices__list',
539 listItems: 'choices__list--multiple',
540 listSingle: 'choices__list--single',
541 listDropdown: 'choices__list--dropdown',
542 item: 'choices__item bg-light',
543 itemSelectable: 'choices__item--selectable',
544 itemDisabled: 'choices__item--disabled',
545 itemChoice: 'choices__item--choice',
546 placeholder: 'choices__placeholder',
547 group: 'choices__group',
548 groupHeading: 'choices__heading',
549 button: 'choices__button',
550 activeState: 'is-active',
551 focusState: 'is-focused',
552 openState: 'is-open',
553 disabledState: 'is-disabled',
554 highlightedState: 'text-info',
555 selectedState: 'text-info',
556 flippedState: 'is-flipped',
557 loadingState: 'is-loading',
558 noResults: 'has-no-results',
559 noChoices: 'has-no-choices',
562 this.choices.passedElement.element.addEventListener(
565 this.state.postForm.community_id = Number(e.detail.choice.value);
566 this.setState(this.state);
573 if (this.props.post) {
574 this.state.postForm.community_id = this.props.post.community_id;
577 (this.props.params.community_id || this.props.params.community_name)
579 if (this.props.params.community_name) {
580 let foundCommunityId = this.props.communities.find(
581 r => r.name == this.props.params.community_name
583 this.state.postForm.community_id = foundCommunityId;
584 } else if (this.props.params.community_id) {
585 this.state.postForm.community_id = this.props.params.community_id;
589 this.choices.setChoiceByValue(
590 this.state.postForm.community_id.toString()
593 this.setState(this.state);
595 // By default, the null valued 'Select a Community'
599 parseMessage(msg: WebSocketJsonResponse) {
600 let res = wsJsonToRes(msg);
602 toast(i18n.t(msg.error), 'danger');
603 this.state.loading = false;
604 this.setState(this.state);
606 } else if (res.op == UserOperation.CreatePost) {
607 let data = res.data as PostResponse;
608 if (data.post.creator_id == UserService.Instance.user.id) {
609 this.state.loading = false;
610 this.props.onCreate(data.post.id);
612 } else if (res.op == UserOperation.EditPost) {
613 let data = res.data as PostResponse;
614 if (data.post.creator_id == UserService.Instance.user.id) {
615 this.state.loading = false;
616 this.props.onEdit(data.post);
618 } else if (res.op == UserOperation.Search) {
619 let data = res.data as SearchResponse;
621 if (data.type_ == SearchType[SearchType.Posts]) {
622 this.state.suggestedPosts = data.posts;
623 } else if (data.type_ == SearchType[SearchType.Url]) {
624 this.state.crossPosts = data.posts;
626 this.setState(this.state);