1 import { debounce } from "@utils/helpers";
2 import autosize from "autosize";
3 import { Component, InfernoNode, linkEvent } from "inferno";
8 GetSiteMetadataResponse,
12 } from "lemmy-js-client";
13 import { i18n } from "../../i18next";
14 import { PostFormParams } from "../../interfaces";
15 import { UserService } from "../../services";
16 import { HttpService, RequestState } from "../../services/HttpService";
20 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 function handlePostSubmit(i: PostForm, event: any) {
83 event.preventDefault();
84 // Coerce empty url string to undefined
85 if ((i.state.form.url ?? "") === "") {
86 i.setState(s => ((s.form.url = undefined), s));
88 i.setState({ loading: true, submitted: true });
89 const auth = myAuthRequired();
91 const pForm = i.state.form;
92 const pv = i.props.post_view;
101 language_id: pForm.language_id,
104 } else if (pForm.name && pForm.community_id) {
107 community_id: pForm.community_id,
111 language_id: pForm.language_id,
112 honeypot: pForm.honeypot,
118 function copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) {
119 const sTitle = d.suggestedTitle;
122 s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
124 d.i.setState({ suggestedPostsRes: { state: "empty" } });
126 const textarea: any = document.getElementById("post-title");
127 autosize.update(textarea);
132 function handlePostUrlChange(i: PostForm, event: any) {
133 const url = event.target.value;
135 i.setState(prev => ({
147 function handlePostNsfwChange(i: PostForm, event: any) {
148 i.setState(s => ((s.form.nsfw = event.target.checked), s));
151 function handleHoneyPotChange(i: PostForm, event: any) {
152 i.setState(s => ((s.form.honeypot = event.target.value), s));
155 function handleCancel(i: PostForm) {
156 i.props.onCancel?.();
159 function handleImageUploadPaste(i: PostForm, event: any) {
160 const image = event.clipboardData.files[0];
162 handleImageUpload(i, image);
166 function handleImageUpload(i: PostForm, event: any) {
169 event.preventDefault();
170 file = event.target.files[0];
175 i.setState({ imageLoading: true });
177 HttpService.client.uploadImage({ image: file }).then(res => {
178 console.log("pictrs upload:");
180 if (res.state === "success") {
181 if (res.data.msg === "ok") {
182 i.state.form.url = res.data.url;
185 imageDeleteUrl: res.data.delete_url as string,
188 toast(JSON.stringify(res), "danger");
190 } else if (res.state === "failed") {
191 console.error(res.msg);
192 toast(res.msg, "danger");
193 i.setState({ imageLoading: false });
198 function handlePostNameChange(i: PostForm, event: any) {
199 i.setState(s => ((s.form.name = event.target.value), s));
200 i.fetchSimilarPosts();
203 function handleImageDelete(i: PostForm) {
204 const { imageDeleteUrl } = i.state;
206 fetch(imageDeleteUrl);
208 i.setState(prev => ({
219 export class PostForm extends Component<PostFormProps, PostFormState> {
220 state: PostFormState = {
221 suggestedPostsRes: { state: "empty" },
222 metadataRes: { state: "empty" },
227 communitySearchLoading: false,
229 communitySearchOptions: [],
233 constructor(props: PostFormProps, context: any) {
234 super(props, context);
235 this.fetchSimilarPosts = debounce(this.fetchSimilarPosts.bind(this));
236 this.fetchPageTitle = debounce(this.fetchPageTitle.bind(this));
237 this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
238 this.handleLanguageChange = this.handleLanguageChange.bind(this);
239 this.handleCommunitySelect = this.handleCommunitySelect.bind(this);
241 const { post_view, selectedCommunityChoice, params } = this.props;
248 body: post_view.post.body,
249 name: post_view.post.name,
250 community_id: post_view.community.id,
251 url: post_view.post.url,
252 nsfw: post_view.post.nsfw,
253 language_id: post_view.post.language_id,
256 } else if (selectedCommunityChoice) {
261 community_id: getIdFromString(selectedCommunityChoice.value),
263 communitySearchOptions: [selectedCommunityChoice].concat(
265 this.props.initialCommunities?.map(
266 ({ community: { id, title } }) => ({
268 value: id.toString(),
271 ).filter(option => option.value !== selectedCommunityChoice.value)
277 communitySearchOptions:
278 this.props.initialCommunities?.map(
279 ({ community: { id, title } }) => ({
281 value: id.toString(),
298 componentDidMount() {
300 const textarea: any = document.getElementById("post-title");
307 componentWillReceiveProps(
308 nextProps: Readonly<{ children?: InfernoNode } & PostFormProps>
310 if (this.props != nextProps) {
313 (s.form.community_id = getIdFromString(
314 nextProps.selectedCommunityChoice?.value
323 const firstLang = this.state.form.language_id;
324 const selectedLangs = firstLang ? Array.of(firstLang) : undefined;
326 const url = this.state.form.url;
329 <form className="post-form" onSubmit={linkEvent(this, handlePostSubmit)}>
333 this.state.form.name ||
334 this.state.form.url ||
336 ) && !this.state.submitted
339 <div className="mb-3 row">
340 <label className="col-sm-2 col-form-label" htmlFor="post-url">
343 <div className="col-sm-10">
347 className="form-control"
349 onInput={linkEvent(this, handlePostUrlChange)}
350 onPaste={linkEvent(this, handleImageUploadPaste)}
352 {this.renderSuggestedTitleCopy()}
355 htmlFor="file-upload"
357 UserService.Instance.myUserInfo && "pointer"
358 } d-inline-block float-right text-muted font-weight-bold`}
359 data-tippy-content={i18n.t("upload_image")}
361 <Icon icon="image" classes="icon-inline" />
366 accept="image/*,video/*"
369 disabled={!UserService.Instance.myUserInfo}
370 onChange={linkEvent(this, handleImageUpload)}
373 {url && validURL(url) && (
376 href={`${webArchiveUrl}/save/${encodeURIComponent(url)}`}
377 className="me-2 d-inline-block float-right text-muted small font-weight-bold"
380 archive.org {i18n.t("archive_link")}
383 href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
386 className="me-2 d-inline-block float-right text-muted small font-weight-bold"
389 ghostarchive.org {i18n.t("archive_link")}
392 href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
395 className="me-2 d-inline-block float-right text-muted small font-weight-bold"
398 archive.today {i18n.t("archive_link")}
402 {this.state.imageLoading && <Spinner />}
403 {url && isImage(url) && (
404 <img src={url} className="img-fluid" alt="" />
406 {this.state.imageDeleteUrl && (
408 className="btn btn-danger btn-sm mt-2"
409 onClick={linkEvent(this, handleImageDelete)}
410 aria-label={i18n.t("delete")}
411 data-tippy-content={i18n.t("delete")}
413 <Icon icon="x" classes="icon-inline me-1" />
414 {capitalizeFirstLetter(i18n.t("delete"))}
417 {this.props.crossPosts && this.props.crossPosts.length > 0 && (
419 <div className="my-1 text-muted small font-weight-bold">
420 {i18n.t("cross_posts")}
424 posts={this.props.crossPosts}
425 enableDownvotes={this.props.enableDownvotes}
426 enableNsfw={this.props.enableNsfw}
427 allLanguages={this.props.allLanguages}
428 siteLanguages={this.props.siteLanguages}
430 // All of these are unused, since its view only
431 onPostEdit={() => {}}
432 onPostVote={() => {}}
433 onPostReport={() => {}}
434 onBlockPerson={() => {}}
435 onLockPost={() => {}}
436 onDeletePost={() => {}}
437 onRemovePost={() => {}}
438 onSavePost={() => {}}
439 onFeaturePost={() => {}}
440 onPurgePerson={() => {}}
441 onPurgePost={() => {}}
442 onBanPersonFromCommunity={() => {}}
443 onBanPerson={() => {}}
444 onAddModToCommunity={() => {}}
445 onAddAdmin={() => {}}
446 onTransferCommunity={() => {}}
452 <div className="mb-3 row">
453 <label className="col-sm-2 col-form-label" htmlFor="post-title">
456 <div className="col-sm-10">
458 value={this.state.form.name}
460 onInput={linkEvent(this, handlePostNameChange)}
461 className={`form-control ${
462 !validTitle(this.state.form.name) && "is-invalid"
467 maxLength={MAX_POST_TITLE_LENGTH}
469 {!validTitle(this.state.form.name) && (
470 <div className="invalid-feedback">
471 {i18n.t("invalid_post_title")}
474 {this.renderSuggestedPosts()}
478 <div className="mb-3 row">
479 <label className="col-sm-2 col-form-label">{i18n.t("body")}</label>
480 <div className="col-sm-10">
482 initialContent={this.state.form.body}
483 onContentChange={this.handlePostBodyChange}
484 allLanguages={this.props.allLanguages}
485 siteLanguages={this.props.siteLanguages}
486 hideNavigationWarnings
491 allLanguages={this.props.allLanguages}
492 siteLanguages={this.props.siteLanguages}
493 selectedLanguageIds={selectedLangs}
495 onChange={this.handleLanguageChange}
497 {!this.props.post_view && (
498 <div className="mb-3 row">
499 <label className="col-sm-2 col-form-label" htmlFor="post-community">
500 {i18n.t("community")}
502 <div className="col-sm-10">
505 value={this.state.form.community_id}
508 label: i18n.t("select_a_community"),
512 ].concat(this.state.communitySearchOptions)}
513 loading={this.state.communitySearchLoading}
514 onChange={this.handleCommunitySelect}
515 onSearch={this.handleCommunitySearch}
520 {this.props.enableNsfw && (
521 <div className="form-check mb-3">
523 className="form-check-input"
526 checked={this.state.form.nsfw}
527 onChange={linkEvent(this, handlePostNsfwChange)}
529 <label className="form-check-label">{i18n.t("nsfw")}</label>
537 className="form-control honeypot"
539 value={this.state.form.honeypot}
540 onInput={linkEvent(this, handleHoneyPotChange)}
542 <div className="mb-3 row">
543 <div className="col-sm-10">
545 disabled={!this.state.form.community_id || this.state.loading}
547 className="btn btn-secondary me-2"
549 {this.state.loading ? (
551 ) : this.props.post_view ? (
552 capitalizeFirstLetter(i18n.t("save"))
554 capitalizeFirstLetter(i18n.t("create"))
557 {this.props.post_view && (
560 className="btn btn-secondary"
561 onClick={linkEvent(this, handleCancel)}
572 renderSuggestedTitleCopy() {
573 switch (this.state.metadataRes.state) {
577 const suggestedTitle = this.state.metadataRes.data.metadata.title;
582 className="mt-1 text-muted small font-weight-bold pointer"
585 { i: this, suggestedTitle },
589 {i18n.t("copy_suggested_title", { title: "" })} {suggestedTitle}
597 renderSuggestedPosts() {
598 switch (this.state.suggestedPostsRes.state) {
602 const suggestedPosts = this.state.suggestedPostsRes.data.posts;
606 suggestedPosts.length > 0 && (
608 <div className="my-1 text-muted small font-weight-bold">
609 {i18n.t("related_posts")}
613 posts={suggestedPosts}
614 enableDownvotes={this.props.enableDownvotes}
615 enableNsfw={this.props.enableNsfw}
616 allLanguages={this.props.allLanguages}
617 siteLanguages={this.props.siteLanguages}
619 // All of these are unused, since its view only
620 onPostEdit={() => {}}
621 onPostVote={() => {}}
622 onPostReport={() => {}}
623 onBlockPerson={() => {}}
624 onLockPost={() => {}}
625 onDeletePost={() => {}}
626 onRemovePost={() => {}}
627 onSavePost={() => {}}
628 onFeaturePost={() => {}}
629 onPurgePerson={() => {}}
630 onPurgePost={() => {}}
631 onBanPersonFromCommunity={() => {}}
632 onBanPerson={() => {}}
633 onAddModToCommunity={() => {}}
634 onAddAdmin={() => {}}
635 onTransferCommunity={() => {}}
644 async fetchPageTitle() {
645 const url = this.state.form.url;
646 if (url && validURL(url)) {
647 this.setState({ metadataRes: { state: "loading" } });
649 metadataRes: await HttpService.client.getSiteMetadata({ url }),
654 async fetchSimilarPosts() {
655 const q = this.state.form.name;
657 this.setState({ suggestedPostsRes: { state: "loading" } });
659 suggestedPostsRes: await HttpService.client.search({
664 community_id: this.state.form.community_id,
666 limit: trendingFetchLimit,
673 handlePostBodyChange(val: string) {
674 this.setState(s => ((s.form.body = val), s));
677 handleLanguageChange(val: number[]) {
678 this.setState(s => ((s.form.language_id = val.at(0)), s));
681 handleCommunitySearch = debounce(async (text: string) => {
682 const { selectedCommunityChoice } = this.props;
683 this.setState({ communitySearchLoading: true });
685 const newOptions: Choice[] = [];
687 if (selectedCommunityChoice) {
688 newOptions.push(selectedCommunityChoice);
691 if (text.length > 0) {
692 newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
695 communitySearchOptions: newOptions,
700 communitySearchLoading: false,
704 handleCommunitySelect(choice: Choice) {
705 if (this.props.onSelectCommunity) {
706 this.props.onSelectCommunity(choice);