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 Selectr from 'mobius1-selectr';
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 emptyState: PostFormState = {
79 creator_id: UserService.Instance.user
80 ? UserService.Instance.user.id
87 suggestedTitle: undefined,
92 constructor(props: any, context: any) {
93 super(props, context);
94 this.fetchSimilarPosts = debounce(this.fetchSimilarPosts).bind(this);
95 this.fetchPageTitle = debounce(this.fetchPageTitle).bind(this);
97 this.tribute = setupTribute();
98 this.setupEmojiPicker();
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 componentWillUnmount() {
155 this.subscription.unsubscribe();
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)}
264 maxLength={MAX_POST_TITLE_LENGTH}
266 {this.state.suggestedPosts.length > 0 && (
268 <div class="my-1 text-muted small font-weight-bold">
269 {i18n.t('related_posts')}
272 posts={this.state.suggestedPosts}
273 enableDownvotes={this.props.enableDownvotes}
274 enableNsfw={this.props.enableNsfw}
281 <div class="form-group row">
282 <label class="col-sm-2 col-form-label" htmlFor={this.id}>
285 <div class="col-sm-10">
288 value={this.state.postForm.body}
289 onInput={linkEvent(this, this.handlePostBodyChange)}
290 className={`form-control ${this.state.previewMode && 'd-none'}`}
294 {this.state.previewMode && (
296 className="card card-body md-div"
297 dangerouslySetInnerHTML={mdToHtml(this.state.postForm.body)}
300 {this.state.postForm.body && (
302 className={`mt-1 mr-2 btn btn-sm btn-secondary ${
303 this.state.previewMode && 'active'
305 onClick={linkEvent(this, this.handlePreviewToggle)}
311 href={markdownHelpUrl}
314 class="d-inline-block float-right text-muted font-weight-bold"
315 title={i18n.t('formatting_help')}
317 <svg class="icon icon-inline">
318 <use xlinkHref="#icon-help-circle"></use>
322 onClick={linkEvent(this, this.handleEmojiPickerClick)}
323 class="pointer unselectable d-inline-block mr-3 float-right text-muted font-weight-bold"
324 data-tippy-content={i18n.t('emoji_picker')}
326 <svg class="icon icon-inline">
327 <use xlinkHref="#icon-smile"></use>
332 {!this.props.post && (
333 <div class="form-group row">
334 <label class="col-sm-2 col-form-label" htmlFor="post-community">
335 {i18n.t('community')}
337 <div class="col-sm-10">
341 value={this.state.postForm.community_id}
342 onInput={linkEvent(this, this.handlePostCommunityChange)}
344 <option>{i18n.t('select_a_community')}</option>
345 {this.state.communities.map(community => (
346 <option value={community.id}>
349 : `${hostname(community.actor_id)}/${community.name}`}
356 {this.props.enableNsfw && (
357 <div class="form-group row">
358 <div class="col-sm-10">
359 <div class="form-check">
361 class="form-check-input"
364 checked={this.state.postForm.nsfw}
365 onChange={linkEvent(this, this.handlePostNsfwChange)}
367 <label class="form-check-label" htmlFor="post-nsfw">
374 <div class="form-group row">
375 <div class="col-sm-10">
378 !this.state.postForm.community_id || this.state.loading
381 class="btn btn-secondary mr-2"
383 {this.state.loading ? (
384 <svg class="icon icon-spinner spin">
385 <use xlinkHref="#icon-spinner"></use>
387 ) : this.props.post ? (
388 capitalizeFirstLetter(i18n.t('save'))
390 capitalizeFirstLetter(i18n.t('create'))
393 {this.props.post && (
396 class="btn btn-secondary"
397 onClick={linkEvent(this, this.handleCancel)}
410 emojiPicker.on('emoji', twemojiHtmlStr => {
411 if (this.state.postForm.body == null) {
412 this.state.postForm.body = '';
414 var el = document.createElement('div');
415 el.innerHTML = twemojiHtmlStr;
416 let nativeUnicode = (el.childNodes[0] as HTMLElement).getAttribute('alt');
417 let shortName = `:${emojiShortName[nativeUnicode]}:`;
418 this.state.postForm.body += shortName;
419 this.setState(this.state);
423 handlePostSubmit(i: PostForm, event: any) {
424 event.preventDefault();
426 // Coerce empty url string to undefined
427 if (i.state.postForm.url && i.state.postForm.url === '') {
428 i.state.postForm.url = undefined;
432 WebSocketService.Instance.editPost(i.state.postForm);
434 WebSocketService.Instance.createPost(i.state.postForm);
436 i.state.loading = true;
440 copySuggestedTitle(i: PostForm) {
441 i.state.postForm.name = i.state.suggestedTitle.substring(
443 MAX_POST_TITLE_LENGTH
445 i.state.suggestedTitle = undefined;
449 handlePostUrlChange(i: PostForm, event: any) {
450 i.state.postForm.url = event.target.value;
456 if (validURL(this.state.postForm.url)) {
457 let form: SearchForm = {
458 q: this.state.postForm.url,
459 type_: SearchType[SearchType.Url],
460 sort: SortType[SortType.TopAll],
465 WebSocketService.Instance.search(form);
467 // Fetch the page title
468 getPageTitle(this.state.postForm.url).then(d => {
469 this.state.suggestedTitle = d;
470 this.setState(this.state);
473 this.state.suggestedTitle = undefined;
474 this.state.crossPosts = [];
478 handlePostNameChange(i: PostForm, event: any) {
479 i.state.postForm.name = event.target.value;
481 i.fetchSimilarPosts();
484 fetchSimilarPosts() {
485 let form: SearchForm = {
486 q: this.state.postForm.name,
487 type_: SearchType[SearchType.Posts],
488 sort: SortType[SortType.TopAll],
489 community_id: this.state.postForm.community_id,
494 if (this.state.postForm.name !== '') {
495 WebSocketService.Instance.search(form);
497 this.state.suggestedPosts = [];
500 this.setState(this.state);
503 handlePostBodyChange(i: PostForm, event: any) {
504 i.state.postForm.body = event.target.value;
508 handlePostCommunityChange(i: PostForm, event: any) {
509 i.state.postForm.community_id = Number(event.target.value);
513 handlePostNsfwChange(i: PostForm, event: any) {
514 i.state.postForm.nsfw = event.target.checked;
518 handleCancel(i: PostForm) {
522 handlePreviewToggle(i: PostForm, event: any) {
523 event.preventDefault();
524 i.state.previewMode = !i.state.previewMode;
528 handleImageUploadPaste(i: PostForm, event: any) {
529 let image = event.clipboardData.files[0];
531 i.handleImageUpload(i, image);
535 handleImageUpload(i: PostForm, event: any) {
538 event.preventDefault();
539 file = event.target.files[0];
544 const imageUploadUrl = `/pictrs/image`;
545 const formData = new FormData();
546 formData.append('images[]', file);
548 i.state.imageLoading = true;
551 fetch(imageUploadUrl, {
555 .then(res => res.json())
557 console.log('pictrs upload:');
559 if (res.msg == 'ok') {
560 let hash = res.files[0].file;
561 let url = `${window.location.origin}/pictrs/image/${hash}`;
562 let deleteToken = res.files[0].delete_token;
563 let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
564 i.state.postForm.url = url;
565 i.state.imageLoading = false;
568 i18n.t('click_to_delete_picture'),
569 i18n.t('picture_deleted'),
573 i.state.imageLoading = false;
575 toast(JSON.stringify(res), 'danger');
579 i.state.imageLoading = false;
581 toast(error, 'danger');
585 handleEmojiPickerClick(_i: PostForm, event: any) {
586 emojiPicker.togglePicker(event.target);
589 parseMessage(msg: WebSocketJsonResponse) {
590 let res = wsJsonToRes(msg);
592 toast(i18n.t(msg.error), 'danger');
593 this.state.loading = false;
594 this.setState(this.state);
596 } else if (res.op == UserOperation.ListCommunities) {
597 let data = res.data as ListCommunitiesResponse;
598 this.state.communities = data.communities;
599 if (this.props.post) {
600 this.state.postForm.community_id = this.props.post.community_id;
601 } else if (this.props.params && this.props.params.community) {
602 let foundCommunityId = data.communities.find(
603 r => r.name == this.props.params.community
605 this.state.postForm.community_id = foundCommunityId;
607 // By default, the null valued 'Select a Community'
609 this.setState(this.state);
611 // Set up select searching
612 let selectId: any = document.getElementById('post-community');
614 let selector = new Selectr(selectId, { nativeDropdown: false });
615 selector.on('selectr.select', option => {
616 this.state.postForm.community_id = Number(option.value);
617 this.setState(this.state);
620 } else if (res.op == UserOperation.CreatePost) {
621 let data = res.data as PostResponse;
622 if (data.post.creator_id == UserService.Instance.user.id) {
623 this.state.loading = false;
624 this.props.onCreate(data.post.id);
626 } else if (res.op == UserOperation.EditPost) {
627 let data = res.data as PostResponse;
628 if (data.post.creator_id == UserService.Instance.user.id) {
629 this.state.loading = false;
630 this.props.onEdit(data.post);
632 } else if (res.op == UserOperation.Search) {
633 let data = res.data as SearchResponse;
635 if (data.type_ == SearchType[SearchType.Posts]) {
636 this.state.suggestedPosts = data.posts;
637 } else if (data.type_ == SearchType[SearchType.Url]) {
638 this.state.crossPosts = data.posts;
640 this.setState(this.state);