1 import autosize from "autosize";
2 import { Component, InfernoNode, linkEvent } from "inferno";
7 GetSiteMetadataResponse,
11 } from "lemmy-js-client";
12 import { i18n } from "../../i18next";
13 import { PostFormParams } from "../../interfaces";
14 import { UserService } from "../../services";
15 import { HttpService, RequestState } from "../../services/HttpService";
19 capitalizeFirstLetter,
36 import { Icon, Spinner } from "../common/icon";
37 import { LanguageSelect } from "../common/language-select";
38 import { MarkdownTextArea } from "../common/markdown-textarea";
39 import NavigationPrompt from "../common/navigation-prompt";
40 import { SearchableSelect } from "../common/searchable-select";
41 import { PostListings } from "./post-listings";
43 const MAX_POST_TITLE_LENGTH = 200;
45 interface PostFormProps {
46 post_view?: PostView; // If a post is given, that means this is an edit
47 crossPosts?: PostView[];
48 allLanguages: Language[];
49 siteLanguages: number[];
50 params?: PostFormParams;
52 onCreate?(form: CreatePost): void;
53 onEdit?(form: EditPost): void;
55 enableDownvotes?: boolean;
56 selectedCommunityChoice?: Choice;
57 onSelectCommunity?: (choice: Choice) => void;
58 initialCommunities?: CommunityView[];
61 interface PostFormState {
68 community_id?: number;
72 suggestedPostsRes: RequestState<SearchResponse>;
73 metadataRes: RequestState<GetSiteMetadataResponse>;
74 imageLoading: boolean;
75 imageDeleteUrl: string;
76 communitySearchLoading: boolean;
77 communitySearchOptions: Choice[];
82 export class PostForm extends Component<PostFormProps, PostFormState> {
83 state: PostFormState = {
84 suggestedPostsRes: { state: "empty" },
85 metadataRes: { state: "empty" },
90 communitySearchLoading: false,
92 communitySearchOptions: [],
96 constructor(props: PostFormProps, context: any) {
97 super(props, context);
98 this.fetchSimilarPosts = debounce(this.fetchSimilarPosts.bind(this));
99 this.fetchPageTitle = debounce(this.fetchPageTitle.bind(this));
100 this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
101 this.handleLanguageChange = this.handleLanguageChange.bind(this);
102 this.handleCommunitySelect = this.handleCommunitySelect.bind(this);
104 const { post_view, selectedCommunityChoice, params } = this.props;
111 body: post_view.post.body,
112 name: post_view.post.name,
113 community_id: post_view.community.id,
114 url: post_view.post.url,
115 nsfw: post_view.post.nsfw,
116 language_id: post_view.post.language_id,
119 } else if (selectedCommunityChoice) {
124 community_id: getIdFromString(selectedCommunityChoice.value),
126 communitySearchOptions: [selectedCommunityChoice]
128 this.props.initialCommunities?.map(
129 ({ community: { id, title } }) => ({
131 value: id.toString(),
135 .filter(option => option.value !== selectedCommunityChoice.value),
140 communitySearchOptions:
141 this.props.initialCommunities?.map(
142 ({ community: { id, title } }) => ({
144 value: id.toString(),
161 componentDidMount() {
163 const textarea: any = document.getElementById("post-title");
170 componentWillReceiveProps(
171 nextProps: Readonly<{ children?: InfernoNode } & PostFormProps>
173 if (this.props != nextProps) {
176 (s.form.community_id = getIdFromString(
177 nextProps.selectedCommunityChoice?.value
186 const firstLang = this.state.form.language_id;
187 const selectedLangs = firstLang ? Array.of(firstLang) : undefined;
189 const url = this.state.form.url;
192 // const promptCheck =
193 // !!this.state.form.name || !!this.state.form.url || !!this.state.form.body;
194 // <Prompt when={promptCheck} message={i18n.t("block_leaving")} />
196 <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
200 this.state.form.name ||
201 this.state.form.url ||
203 ) && !this.state.submitted
206 <div className="mb-3 row">
207 <label className="col-sm-2 col-form-label" htmlFor="post-url">
210 <div className="col-sm-10">
214 className="form-control"
215 value={this.state.form.url}
216 onInput={linkEvent(this, this.handlePostUrlChange)}
217 onPaste={linkEvent(this, this.handleImageUploadPaste)}
219 {this.renderSuggestedTitleCopy()}
222 htmlFor="file-upload"
224 UserService.Instance.myUserInfo && "pointer"
225 } d-inline-block float-right text-muted font-weight-bold`}
226 data-tippy-content={i18n.t("upload_image")}
228 <Icon icon="image" classes="icon-inline" />
233 accept="image/*,video/*"
236 disabled={!UserService.Instance.myUserInfo}
237 onChange={linkEvent(this, this.handleImageUpload)}
240 {url && validURL(url) && (
243 href={`${webArchiveUrl}/save/${encodeURIComponent(url)}`}
244 className="me-2 d-inline-block float-right text-muted small font-weight-bold"
247 archive.org {i18n.t("archive_link")}
250 href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
253 className="me-2 d-inline-block float-right text-muted small font-weight-bold"
256 ghostarchive.org {i18n.t("archive_link")}
259 href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
262 className="me-2 d-inline-block float-right text-muted small font-weight-bold"
265 archive.today {i18n.t("archive_link")}
269 {this.state.imageLoading && <Spinner />}
270 {url && isImage(url) && (
271 <img src={url} className="img-fluid" alt="" />
273 {this.state.imageDeleteUrl && (
275 className="btn btn-danger btn-sm mt-2"
276 onClick={linkEvent(this, this.handleImageDelete)}
277 aria-label={i18n.t("delete")}
278 data-tippy-content={i18n.t("delete")}
280 <Icon icon="x" classes="icon-inline me-1" />
281 {capitalizeFirstLetter(i18n.t("delete"))}
284 {this.props.crossPosts && this.props.crossPosts.length > 0 && (
286 <div className="my-1 text-muted small font-weight-bold">
287 {i18n.t("cross_posts")}
291 posts={this.props.crossPosts}
292 enableDownvotes={this.props.enableDownvotes}
293 enableNsfw={this.props.enableNsfw}
294 allLanguages={this.props.allLanguages}
295 siteLanguages={this.props.siteLanguages}
297 // All of these are unused, since its view only
298 onPostEdit={() => {}}
299 onPostVote={() => {}}
300 onPostReport={() => {}}
301 onBlockPerson={() => {}}
302 onLockPost={() => {}}
303 onDeletePost={() => {}}
304 onRemovePost={() => {}}
305 onSavePost={() => {}}
306 onFeaturePost={() => {}}
307 onPurgePerson={() => {}}
308 onPurgePost={() => {}}
309 onBanPersonFromCommunity={() => {}}
310 onBanPerson={() => {}}
311 onAddModToCommunity={() => {}}
312 onAddAdmin={() => {}}
313 onTransferCommunity={() => {}}
319 <div className="mb-3 row">
320 <label className="col-sm-2 col-form-label" htmlFor="post-title">
323 <div className="col-sm-10">
325 value={this.state.form.name}
327 onInput={linkEvent(this, this.handlePostNameChange)}
328 className={`form-control ${
329 !validTitle(this.state.form.name) && "is-invalid"
334 maxLength={MAX_POST_TITLE_LENGTH}
336 {!validTitle(this.state.form.name) && (
337 <div className="invalid-feedback">
338 {i18n.t("invalid_post_title")}
341 {this.renderSuggestedPosts()}
345 <div className="mb-3 row">
346 <label className="col-sm-2 col-form-label">{i18n.t("body")}</label>
347 <div className="col-sm-10">
349 initialContent={this.state.form.body}
350 onContentChange={this.handlePostBodyChange}
351 allLanguages={this.props.allLanguages}
352 siteLanguages={this.props.siteLanguages}
353 hideNavigationWarnings
357 {!this.props.post_view && (
358 <div className="mb-3 row">
359 <label className="col-sm-2 col-form-label" htmlFor="post-community">
360 {i18n.t("community")}
362 <div className="col-sm-10">
365 value={this.state.form.community_id}
368 label: i18n.t("select_a_community"),
372 ].concat(this.state.communitySearchOptions)}
373 loading={this.state.communitySearchLoading}
374 onChange={this.handleCommunitySelect}
375 onSearch={this.handleCommunitySearch}
380 {this.props.enableNsfw && (
381 <div className="mb-3 row">
382 <legend className="col-form-label col-sm-2 pt-0">
385 <div className="col-sm-10">
386 <div className="form-check">
388 className="form-check-input position-static"
391 checked={this.state.form.nsfw}
392 onChange={linkEvent(this, this.handlePostNsfwChange)}
399 allLanguages={this.props.allLanguages}
400 siteLanguages={this.props.siteLanguages}
401 selectedLanguageIds={selectedLangs}
403 onChange={this.handleLanguageChange}
410 className="form-control honeypot"
412 value={this.state.form.honeypot}
413 onInput={linkEvent(this, this.handleHoneyPotChange)}
415 <div className="mb-3 row">
416 <div className="col-sm-10">
418 disabled={!this.state.form.community_id || this.state.loading}
420 className="btn btn-secondary me-2"
422 {this.state.loading ? (
424 ) : this.props.post_view ? (
425 capitalizeFirstLetter(i18n.t("save"))
427 capitalizeFirstLetter(i18n.t("create"))
430 {this.props.post_view && (
433 className="btn btn-secondary"
434 onClick={linkEvent(this, this.handleCancel)}
445 renderSuggestedTitleCopy() {
446 switch (this.state.metadataRes.state) {
450 const suggestedTitle = this.state.metadataRes.data.metadata.title;
455 className="mt-1 text-muted small font-weight-bold pointer"
458 { i: this, suggestedTitle },
459 this.copySuggestedTitle
462 {i18n.t("copy_suggested_title", { title: "" })} {suggestedTitle}
470 renderSuggestedPosts() {
471 switch (this.state.suggestedPostsRes.state) {
475 const suggestedPosts = this.state.suggestedPostsRes.data.posts;
479 suggestedPosts.length > 0 && (
481 <div className="my-1 text-muted small font-weight-bold">
482 {i18n.t("related_posts")}
486 posts={suggestedPosts}
487 enableDownvotes={this.props.enableDownvotes}
488 enableNsfw={this.props.enableNsfw}
489 allLanguages={this.props.allLanguages}
490 siteLanguages={this.props.siteLanguages}
492 // All of these are unused, since its view only
493 onPostEdit={() => {}}
494 onPostVote={() => {}}
495 onPostReport={() => {}}
496 onBlockPerson={() => {}}
497 onLockPost={() => {}}
498 onDeletePost={() => {}}
499 onRemovePost={() => {}}
500 onSavePost={() => {}}
501 onFeaturePost={() => {}}
502 onPurgePerson={() => {}}
503 onPurgePost={() => {}}
504 onBanPersonFromCommunity={() => {}}
505 onBanPerson={() => {}}
506 onAddModToCommunity={() => {}}
507 onAddAdmin={() => {}}
508 onTransferCommunity={() => {}}
517 handlePostSubmit(i: PostForm, event: any) {
518 event.preventDefault();
519 // Coerce empty url string to undefined
520 if ((i.state.form.url ?? "") === "") {
521 i.setState(s => ((s.form.url = undefined), s));
523 i.setState({ loading: true, submitted: true });
524 const auth = myAuthRequired();
526 const pForm = i.state.form;
527 const pv = i.props.post_view;
536 language_id: pForm.language_id,
539 } else if (pForm.name && pForm.community_id) {
542 community_id: pForm.community_id,
546 language_id: pForm.language_id,
547 honeypot: pForm.honeypot,
553 copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) {
554 const sTitle = d.suggestedTitle;
557 s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
559 d.i.setState({ suggestedPostsRes: { state: "empty" } });
561 const textarea: any = document.getElementById("post-title");
562 autosize.update(textarea);
567 handlePostUrlChange(i: PostForm, event: any) {
568 const url = event.target.value;
580 async fetchPageTitle() {
581 const url = this.state.form.url;
582 if (url && validURL(url)) {
583 this.setState({ metadataRes: { state: "loading" } });
585 metadataRes: await HttpService.client.getSiteMetadata({ url }),
590 handlePostNameChange(i: PostForm, event: any) {
591 i.setState(s => ((s.form.name = event.target.value), s));
592 i.fetchSimilarPosts();
595 async fetchSimilarPosts() {
596 const q = this.state.form.name;
598 this.setState({ suggestedPostsRes: { state: "loading" } });
600 suggestedPostsRes: await HttpService.client.search({
605 community_id: this.state.form.community_id,
607 limit: trendingFetchLimit,
614 handlePostBodyChange(val: string) {
615 this.setState(s => ((s.form.body = val), s));
618 handlePostCommunityChange(i: PostForm, event: any) {
619 i.setState(s => ((s.form.community_id = Number(event.target.value)), s));
622 handlePostNsfwChange(i: PostForm, event: any) {
623 i.setState(s => ((s.form.nsfw = event.target.checked), s));
626 handleLanguageChange(val: number[]) {
627 this.setState(s => ((s.form.language_id = val.at(0)), s));
630 handleHoneyPotChange(i: PostForm, event: any) {
631 i.setState(s => ((s.form.honeypot = event.target.value), s));
634 handleCancel(i: PostForm) {
635 i.props.onCancel?.();
638 handlePreviewToggle(i: PostForm, event: any) {
639 event.preventDefault();
640 i.setState({ previewMode: !i.state.previewMode });
643 handleImageUploadPaste(i: PostForm, event: any) {
644 const image = event.clipboardData.files[0];
646 i.handleImageUpload(i, image);
650 handleImageUpload(i: PostForm, event: any) {
653 event.preventDefault();
654 file = event.target.files[0];
659 i.setState({ imageLoading: true });
661 HttpService.client.uploadImage({ image: file }).then(res => {
662 console.log("pictrs upload:");
664 if (res.state === "success") {
665 if (res.data.msg === "ok") {
666 i.state.form.url = res.data.url;
669 imageDeleteUrl: res.data.delete_url as string,
672 toast(JSON.stringify(res), "danger");
674 } else if (res.state === "failed") {
675 console.error(res.msg);
676 toast(res.msg, "danger");
677 i.setState({ imageLoading: false });
682 handleImageDelete(i: PostForm) {
683 const { imageDeleteUrl } = i.state;
685 fetch(imageDeleteUrl);
696 handleCommunitySearch = debounce(async (text: string) => {
697 const { selectedCommunityChoice } = this.props;
698 this.setState({ communitySearchLoading: true });
700 const newOptions: Choice[] = [];
702 if (selectedCommunityChoice) {
703 newOptions.push(selectedCommunityChoice);
706 if (text.length > 0) {
707 newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
710 communitySearchOptions: newOptions,
715 communitySearchLoading: false,
719 handleCommunitySelect(choice: Choice) {
720 if (this.props.onSelectCommunity) {
721 this.props.onSelectCommunity(choice);