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 { Icon, Spinner } from "./icon";
6 import { Subscription } from "rxjs";
18 } from "lemmy-js-client";
19 import { WebSocketService, UserService } from "../services";
20 import { PostFormParams } from "../interfaces";
25 capitalizeFirstLetter,
40 import autosize from "autosize";
44 Choices = require("choices.js");
47 import { i18n } from "../i18next";
48 import { pictrsUri } from "../env";
50 const MAX_POST_TITLE_LENGTH = 200;
52 interface PostFormProps {
53 post_view?: PostView; // If a post is given, that means this is an edit
54 communities?: CommunityView[];
55 params?: PostFormParams;
57 onCreate?(post: PostView): any;
58 onEdit?(post: PostView): any;
60 enableDownvotes: boolean;
63 interface PostFormState {
66 imageLoading: boolean;
68 suggestedTitle: string;
69 suggestedPosts: PostView[];
70 crossPosts: PostView[];
73 export class PostForm extends Component<PostFormProps, PostFormState> {
74 private subscription: Subscription;
76 private emptyState: PostFormState = {
81 auth: authField(false),
86 suggestedTitle: undefined,
91 constructor(props: any, context: any) {
92 super(props, context);
93 this.fetchSimilarPosts = debounce(this.fetchSimilarPosts.bind(this));
94 this.fetchPageTitle = debounce(this.fetchPageTitle.bind(this));
95 this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
97 this.state = this.emptyState;
100 if (this.props.post_view) {
101 this.state.postForm = {
102 body: this.props.post_view.post.body,
103 name: this.props.post_view.post.name,
104 community_id: this.props.post_view.community.id,
105 url: this.props.post_view.post.url,
106 nsfw: this.props.post_view.post.nsfw,
111 if (this.props.params) {
112 this.state.postForm.name = this.props.params.name;
113 if (this.props.params.url) {
114 this.state.postForm.url = this.props.params.url;
116 if (this.props.params.body) {
117 this.state.postForm.body = this.props.params.body;
121 this.parseMessage = this.parseMessage.bind(this);
122 this.subscription = wsSubscribe(this.parseMessage);
125 componentDidMount() {
127 this.setupCommunities();
128 let textarea: any = document.getElementById("post-title");
134 componentDidUpdate() {
136 !this.state.loading &&
137 (this.state.postForm.name ||
138 this.state.postForm.url ||
139 this.state.postForm.body)
141 window.onbeforeunload = () => true;
143 window.onbeforeunload = undefined;
147 componentWillUnmount() {
148 this.subscription.unsubscribe();
149 /* this.choices && this.choices.destroy(); */
150 window.onbeforeunload = null;
158 !this.state.loading &&
159 (this.state.postForm.name ||
160 this.state.postForm.url ||
161 this.state.postForm.body)
163 message={i18n.t("block_leaving")}
165 <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
166 <div class="form-group row">
167 <label class="col-sm-2 col-form-label" htmlFor="post-url">
170 <div class="col-sm-10">
175 value={this.state.postForm.url}
176 onInput={linkEvent(this, this.handlePostUrlChange)}
177 onPaste={linkEvent(this, this.handleImageUploadPaste)}
179 {this.state.suggestedTitle && (
181 class="mt-1 text-muted small font-weight-bold pointer"
183 onClick={linkEvent(this, this.copySuggestedTitle)}
185 {i18n.t("copy_suggested_title", {
186 title: this.state.suggestedTitle,
192 htmlFor="file-upload"
194 UserService.Instance.localUserView && "pointer"
195 } d-inline-block float-right text-muted font-weight-bold`}
196 data-tippy-content={i18n.t("upload_image")}
198 <Icon icon="image" classes="icon-inline" />
203 accept="image/*,video/*"
206 disabled={!UserService.Instance.localUserView}
207 onChange={linkEvent(this, this.handleImageUpload)}
210 {this.state.postForm.url && validURL(this.state.postForm.url) && (
212 href={`${archiveUrl}/?run=1&url=${encodeURIComponent(
213 this.state.postForm.url
215 class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
218 {i18n.t("archive_link")}
221 {this.state.imageLoading && <Spinner />}
222 {isImage(this.state.postForm.url) && (
223 <img src={this.state.postForm.url} class="img-fluid" alt="" />
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">{i18n.t("body")}</label>
279 <div class="col-sm-10">
281 initialContent={this.state.postForm.body}
282 onContentChange={this.handlePostBodyChange}
286 {!this.props.post_view && (
287 <div class="form-group row">
288 <label class="col-sm-2 col-form-label" htmlFor="post-community">
289 {i18n.t("community")}
291 <div class="col-sm-10">
295 value={this.state.postForm.community_id}
296 onInput={linkEvent(this, this.handlePostCommunityChange)}
298 <option>{i18n.t("select_a_community")}</option>
299 {this.props.communities.map(cv => (
300 <option value={cv.community.id}>
303 : `${hostname(cv.community.actor_id)}/${
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 ? (
341 ) : this.props.post_view ? (
342 capitalizeFirstLetter(i18n.t("save"))
344 capitalizeFirstLetter(i18n.t("create"))
347 {this.props.post_view && (
350 class="btn btn-secondary"
351 onClick={linkEvent(this, this.handleCancel)}
363 handlePostSubmit(i: PostForm, event: any) {
364 event.preventDefault();
366 // Coerce empty url string to undefined
367 if (i.state.postForm.url !== undefined && i.state.postForm.url === "") {
368 i.state.postForm.url = undefined;
371 if (i.props.post_view) {
372 let form: EditPost = {
374 post_id: i.props.post_view.post.id,
376 WebSocketService.Instance.send(wsClient.editPost(form));
378 WebSocketService.Instance.send(wsClient.createPost(i.state.postForm));
380 i.state.loading = true;
384 copySuggestedTitle(i: PostForm) {
385 i.state.postForm.name = i.state.suggestedTitle.substring(
387 MAX_POST_TITLE_LENGTH
389 i.state.suggestedTitle = undefined;
391 let textarea: any = document.getElementById("post-title");
392 autosize.update(textarea);
397 handlePostUrlChange(i: PostForm, event: any) {
398 i.state.postForm.url = event.target.value;
404 if (validURL(this.state.postForm.url)) {
406 q: this.state.postForm.url,
407 type_: SearchType.Url,
408 sort: SortType.TopAll,
411 auth: authField(false),
414 WebSocketService.Instance.send(wsClient.search(form));
416 // Fetch the page title
417 getPageTitle(this.state.postForm.url).then(d => {
418 this.state.suggestedTitle = d;
419 this.setState(this.state);
422 this.state.suggestedTitle = undefined;
423 this.state.crossPosts = [];
427 handlePostNameChange(i: PostForm, event: any) {
428 i.state.postForm.name = event.target.value;
430 i.fetchSimilarPosts();
433 fetchSimilarPosts() {
435 q: this.state.postForm.name,
436 type_: SearchType.Posts,
437 sort: SortType.TopAll,
438 community_id: this.state.postForm.community_id,
441 auth: authField(false),
444 if (this.state.postForm.name !== "") {
445 WebSocketService.Instance.send(wsClient.search(form));
447 this.state.suggestedPosts = [];
450 this.setState(this.state);
453 handlePostBodyChange(val: string) {
454 this.state.postForm.body = val;
455 this.setState(this.state);
458 handlePostCommunityChange(i: PostForm, event: any) {
459 i.state.postForm.community_id = Number(event.target.value);
463 handlePostNsfwChange(i: PostForm, event: any) {
464 i.state.postForm.nsfw = event.target.checked;
468 handleCancel(i: PostForm) {
472 handlePreviewToggle(i: PostForm, event: any) {
473 event.preventDefault();
474 i.state.previewMode = !i.state.previewMode;
478 handleImageUploadPaste(i: PostForm, event: any) {
479 let image = event.clipboardData.files[0];
481 i.handleImageUpload(i, image);
485 handleImageUpload(i: PostForm, event: any) {
488 event.preventDefault();
489 file = event.target.files[0];
494 const formData = new FormData();
495 formData.append("images[]", file);
497 i.state.imageLoading = true;
504 .then(res => res.json())
506 console.log("pictrs upload:");
508 if (res.msg == "ok") {
509 let hash = res.files[0].file;
510 let url = `${pictrsUri}/${hash}`;
511 let deleteToken = res.files[0].delete_token;
512 let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
513 i.state.postForm.url = url;
514 i.state.imageLoading = false;
517 i18n.t("click_to_delete_picture"),
518 i18n.t("picture_deleted"),
522 i.state.imageLoading = false;
524 toast(JSON.stringify(res), "danger");
528 i.state.imageLoading = false;
530 toast(error, "danger");
535 // Set up select searching
537 let selectId: any = document.getElementById("post-community");
539 this.choices = new Choices(selectId, {
542 containerOuter: "choices",
543 containerInner: "choices__inner bg-light border-0",
544 input: "form-control",
545 inputCloned: "choices__input--cloned",
546 list: "choices__list",
547 listItems: "choices__list--multiple",
548 listSingle: "choices__list--single",
549 listDropdown: "choices__list--dropdown",
550 item: "choices__item bg-light",
551 itemSelectable: "choices__item--selectable",
552 itemDisabled: "choices__item--disabled",
553 itemChoice: "choices__item--choice",
554 placeholder: "choices__placeholder",
555 group: "choices__group",
556 groupHeading: "choices__heading",
557 button: "choices__button",
558 activeState: "is-active",
559 focusState: "is-focused",
560 openState: "is-open",
561 disabledState: "is-disabled",
562 highlightedState: "text-info",
563 selectedState: "text-info",
564 flippedState: "is-flipped",
565 loadingState: "is-loading",
566 noResults: "has-no-results",
567 noChoices: "has-no-choices",
570 this.choices.passedElement.element.addEventListener(
573 this.state.postForm.community_id = Number(e.detail.choice.value);
574 this.setState(this.state);
581 if (this.props.post_view) {
582 this.state.postForm.community_id = this.props.post_view.community.id;
585 (this.props.params.community_id || this.props.params.community_name)
587 if (this.props.params.community_name) {
588 let foundCommunityId = this.props.communities.find(
589 r => r.community.name == this.props.params.community_name
591 this.state.postForm.community_id = foundCommunityId;
592 } else if (this.props.params.community_id) {
593 this.state.postForm.community_id = this.props.params.community_id;
597 this.choices.setChoiceByValue(
598 this.state.postForm.community_id.toString()
601 this.setState(this.state);
603 // By default, the null valued 'Select a Community'
607 parseMessage(msg: any) {
608 let op = wsUserOp(msg);
611 toast(i18n.t(msg.error), "danger");
612 this.state.loading = false;
613 this.setState(this.state);
615 } else if (op == UserOperation.CreatePost) {
616 let data = wsJsonToRes<PostResponse>(msg).data;
618 data.post_view.creator.id ==
619 UserService.Instance.localUserView.person.id
621 this.state.loading = false;
622 this.props.onCreate(data.post_view);
624 } else if (op == UserOperation.EditPost) {
625 let data = wsJsonToRes<PostResponse>(msg).data;
627 data.post_view.creator.id ==
628 UserService.Instance.localUserView.person.id
630 this.state.loading = false;
631 this.props.onEdit(data.post_view);
633 } else if (op == UserOperation.Search) {
634 let data = wsJsonToRes<SearchResponse>(msg).data;
636 if (data.type_ == SearchType[SearchType.Posts]) {
637 this.state.suggestedPosts = data.posts;
638 } else if (data.type_ == SearchType[SearchType.Url]) {
639 this.state.crossPosts = data.posts;
641 this.setState(this.state);