1 import autosize from "autosize";
2 import { Component, linkEvent } from "inferno";
3 import { Prompt } from "inferno-router";
16 } from "lemmy-js-client";
17 import { Subscription } from "rxjs";
18 import { pictrsUri } from "../../env";
19 import { i18n } from "../../i18next";
20 import { PostFormParams } from "../../interfaces";
21 import { UserService, WebSocketService } from "../../services";
25 capitalizeFirstLetter,
44 import { Icon, Spinner } from "../common/icon";
45 import { MarkdownTextArea } from "../common/markdown-textarea";
46 import { PostListings } from "./post-listings";
50 Choices = require("choices.js");
53 const MAX_POST_TITLE_LENGTH = 200;
55 interface PostFormProps {
56 post_view?: PostView; // If a post is given, that means this is an edit
57 communities?: CommunityView[];
58 params?: PostFormParams;
60 onCreate?(post: PostView): any;
61 onEdit?(post: PostView): any;
63 enableDownvotes: boolean;
66 interface PostFormState {
69 imageLoading: boolean;
71 suggestedTitle: string;
72 suggestedPosts: PostView[];
73 crossPosts: PostView[];
76 export class PostForm extends Component<PostFormProps, PostFormState> {
77 private subscription: Subscription;
79 private emptyState: PostFormState = {
84 auth: authField(false),
89 suggestedTitle: undefined,
94 constructor(props: any, context: any) {
95 super(props, context);
96 this.fetchSimilarPosts = debounce(this.fetchSimilarPosts.bind(this));
97 this.fetchPageTitle = debounce(this.fetchPageTitle.bind(this));
98 this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
100 this.state = this.emptyState;
103 if (this.props.post_view) {
104 this.state.postForm = {
105 body: this.props.post_view.post.body,
106 name: this.props.post_view.post.name,
107 community_id: this.props.post_view.community.id,
108 url: this.props.post_view.post.url,
109 nsfw: this.props.post_view.post.nsfw,
114 if (this.props.params) {
115 this.state.postForm.name = this.props.params.name;
116 if (this.props.params.url) {
117 this.state.postForm.url = this.props.params.url;
119 if (this.props.params.body) {
120 this.state.postForm.body = this.props.params.body;
124 this.parseMessage = this.parseMessage.bind(this);
125 this.subscription = wsSubscribe(this.parseMessage);
128 componentDidMount() {
130 this.setupCommunities();
131 let textarea: any = document.getElementById("post-title");
137 componentDidUpdate() {
139 !this.state.loading &&
140 (this.state.postForm.name ||
141 this.state.postForm.url ||
142 this.state.postForm.body)
144 window.onbeforeunload = () => true;
146 window.onbeforeunload = undefined;
150 componentWillUnmount() {
151 this.subscription.unsubscribe();
152 /* this.choices && this.choices.destroy(); */
153 window.onbeforeunload = null;
161 !this.state.loading &&
162 (this.state.postForm.name ||
163 this.state.postForm.url ||
164 this.state.postForm.body)
166 message={i18n.t("block_leaving")}
168 <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
169 <div class="form-group row">
170 <label class="col-sm-2 col-form-label" htmlFor="post-url">
173 <div class="col-sm-10">
178 value={this.state.postForm.url}
179 onInput={linkEvent(this, this.handlePostUrlChange)}
180 onPaste={linkEvent(this, this.handleImageUploadPaste)}
182 {this.state.suggestedTitle && (
184 class="mt-1 text-muted small font-weight-bold pointer"
186 onClick={linkEvent(this, this.copySuggestedTitle)}
188 {i18n.t("copy_suggested_title", {
189 title: this.state.suggestedTitle,
195 htmlFor="file-upload"
197 UserService.Instance.myUserInfo && "pointer"
198 } d-inline-block float-right text-muted font-weight-bold`}
199 data-tippy-content={i18n.t("upload_image")}
201 <Icon icon="image" classes="icon-inline" />
206 accept="image/*,video/*"
209 disabled={!UserService.Instance.myUserInfo}
210 onChange={linkEvent(this, this.handleImageUpload)}
213 {this.state.postForm.url && validURL(this.state.postForm.url) && (
215 href={`${archiveUrl}/?run=1&url=${encodeURIComponent(
216 this.state.postForm.url
218 class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
221 {i18n.t("archive_link")}
224 {this.state.imageLoading && <Spinner />}
225 {isImage(this.state.postForm.url) && (
226 <img src={this.state.postForm.url} class="img-fluid" alt="" />
228 {this.state.crossPosts.length > 0 && (
230 <div class="my-1 text-muted small font-weight-bold">
231 {i18n.t("cross_posts")}
235 posts={this.state.crossPosts}
236 enableDownvotes={this.props.enableDownvotes}
237 enableNsfw={this.props.enableNsfw}
243 <div class="form-group row">
244 <label class="col-sm-2 col-form-label" htmlFor="post-title">
247 <div class="col-sm-10">
249 value={this.state.postForm.name}
251 onInput={linkEvent(this, this.handlePostNameChange)}
252 class={`form-control ${
253 !validTitle(this.state.postForm.name) && "is-invalid"
258 maxLength={MAX_POST_TITLE_LENGTH}
260 {!validTitle(this.state.postForm.name) && (
261 <div class="invalid-feedback">
262 {i18n.t("invalid_post_title")}
265 {this.state.suggestedPosts.length > 0 && (
267 <div class="my-1 text-muted small font-weight-bold">
268 {i18n.t("related_posts")}
271 posts={this.state.suggestedPosts}
272 enableDownvotes={this.props.enableDownvotes}
273 enableNsfw={this.props.enableNsfw}
280 <div class="form-group row">
281 <label class="col-sm-2 col-form-label">{i18n.t("body")}</label>
282 <div class="col-sm-10">
284 initialContent={this.state.postForm.body}
285 onContentChange={this.handlePostBodyChange}
289 {!this.props.post_view && (
290 <div class="form-group row">
291 <label class="col-sm-2 col-form-label" htmlFor="post-community">
292 {i18n.t("community")}
294 <div class="col-sm-10">
298 value={this.state.postForm.community_id}
299 onInput={linkEvent(this, this.handlePostCommunityChange)}
301 <option>{i18n.t("select_a_community")}</option>
302 {this.props.communities.map(cv => (
303 <option value={cv.community.id}>
304 {communitySelectName(cv)}
311 {this.props.enableNsfw && (
312 <div class="form-group row">
313 <legend class="col-form-label col-sm-2 pt-0">
316 <div class="col-sm-10">
317 <div class="form-check">
319 class="form-check-input position-static"
322 checked={this.state.postForm.nsfw}
323 onChange={linkEvent(this, this.handlePostNsfwChange)}
334 class="form-control honeypot"
336 value={this.state.postForm.honeypot}
337 onInput={linkEvent(this, this.handleHoneyPotChange)}
339 <div class="form-group row">
340 <div class="col-sm-10">
343 !this.state.postForm.community_id || this.state.loading
346 class="btn btn-secondary mr-2"
348 {this.state.loading ? (
350 ) : this.props.post_view ? (
351 capitalizeFirstLetter(i18n.t("save"))
353 capitalizeFirstLetter(i18n.t("create"))
356 {this.props.post_view && (
359 class="btn btn-secondary"
360 onClick={linkEvent(this, this.handleCancel)}
372 handlePostSubmit(i: PostForm, event: any) {
373 event.preventDefault();
375 // Coerce empty url string to undefined
376 if (i.state.postForm.url !== undefined && i.state.postForm.url === "") {
377 i.state.postForm.url = undefined;
380 if (i.props.post_view) {
381 let form: EditPost = {
383 post_id: i.props.post_view.post.id,
385 WebSocketService.Instance.send(wsClient.editPost(form));
387 WebSocketService.Instance.send(wsClient.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;
400 let textarea: any = document.getElementById("post-title");
401 autosize.update(textarea);
406 handlePostUrlChange(i: PostForm, event: any) {
407 i.state.postForm.url = event.target.value;
413 if (validURL(this.state.postForm.url)) {
415 q: this.state.postForm.url,
416 type_: SearchType.Url,
417 sort: SortType.TopAll,
418 listing_type: ListingType.All,
421 auth: authField(false),
424 WebSocketService.Instance.send(wsClient.search(form));
426 // Fetch the page title
427 getSiteMetadata(this.state.postForm.url).then(d => {
428 this.state.suggestedTitle = d.metadata.title;
429 this.setState(this.state);
432 this.state.suggestedTitle = undefined;
433 this.state.crossPosts = [];
437 handlePostNameChange(i: PostForm, event: any) {
438 i.state.postForm.name = event.target.value;
440 i.fetchSimilarPosts();
443 fetchSimilarPosts() {
445 q: this.state.postForm.name,
446 type_: SearchType.Posts,
447 sort: SortType.TopAll,
448 listing_type: ListingType.All,
449 community_id: this.state.postForm.community_id,
452 auth: authField(false),
455 if (this.state.postForm.name !== "") {
456 WebSocketService.Instance.send(wsClient.search(form));
458 this.state.suggestedPosts = [];
461 this.setState(this.state);
464 handlePostBodyChange(val: string) {
465 this.state.postForm.body = val;
466 this.setState(this.state);
469 handlePostCommunityChange(i: PostForm, event: any) {
470 i.state.postForm.community_id = Number(event.target.value);
474 handlePostNsfwChange(i: PostForm, event: any) {
475 i.state.postForm.nsfw = event.target.checked;
479 handleHoneyPotChange(i: PostForm, event: any) {
480 i.state.postForm.honeypot = event.target.value;
484 handleCancel(i: PostForm) {
488 handlePreviewToggle(i: PostForm, event: any) {
489 event.preventDefault();
490 i.state.previewMode = !i.state.previewMode;
494 handleImageUploadPaste(i: PostForm, event: any) {
495 let image = event.clipboardData.files[0];
497 i.handleImageUpload(i, image);
501 handleImageUpload(i: PostForm, event: any) {
504 event.preventDefault();
505 file = event.target.files[0];
510 const formData = new FormData();
511 formData.append("images[]", file);
513 i.state.imageLoading = true;
520 .then(res => res.json())
522 console.log("pictrs upload:");
524 if (res.msg == "ok") {
525 let hash = res.files[0].file;
526 let url = `${pictrsUri}/${hash}`;
527 let deleteToken = res.files[0].delete_token;
528 let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
529 i.state.postForm.url = url;
530 i.state.imageLoading = false;
533 i18n.t("click_to_delete_picture"),
534 i18n.t("picture_deleted"),
538 i.state.imageLoading = false;
540 toast(JSON.stringify(res), "danger");
544 i.state.imageLoading = false;
546 toast(error, "danger");
551 // Set up select searching
553 let selectId: any = document.getElementById("post-community");
555 this.choices = new Choices(selectId, choicesConfig);
556 this.choices.passedElement.element.addEventListener(
559 this.state.postForm.community_id = Number(e.detail.choice.value);
560 this.setState(this.state);
564 this.choices.passedElement.element.addEventListener(
566 debounce(async (e: any) => {
567 let communities = (await fetchCommunities(e.detail.value))
569 this.choices.setChoices(
570 communities.map(cv => communityToChoice(cv)),
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.myUserInfo.local_user_view.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.myUserInfo.local_user_view.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);