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 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")} />
197 className="post-form"
198 onSubmit={linkEvent(this, this.handlePostSubmit)}
203 this.state.form.name ||
204 this.state.form.url ||
206 ) && !this.state.submitted
209 <div className="mb-3 row">
210 <label className="col-sm-2 col-form-label" htmlFor="post-url">
213 <div className="col-sm-10">
217 className="form-control"
218 value={this.state.form.url}
219 onInput={linkEvent(this, this.handlePostUrlChange)}
220 onPaste={linkEvent(this, this.handleImageUploadPaste)}
222 {this.renderSuggestedTitleCopy()}
225 htmlFor="file-upload"
227 UserService.Instance.myUserInfo && "pointer"
228 } d-inline-block float-right text-muted font-weight-bold`}
229 data-tippy-content={i18n.t("upload_image")}
231 <Icon icon="image" classes="icon-inline" />
236 accept="image/*,video/*"
239 disabled={!UserService.Instance.myUserInfo}
240 onChange={linkEvent(this, this.handleImageUpload)}
243 {url && validURL(url) && (
246 href={`${webArchiveUrl}/save/${encodeURIComponent(url)}`}
247 className="me-2 d-inline-block float-right text-muted small font-weight-bold"
250 archive.org {i18n.t("archive_link")}
253 href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
256 className="me-2 d-inline-block float-right text-muted small font-weight-bold"
259 ghostarchive.org {i18n.t("archive_link")}
262 href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
265 className="me-2 d-inline-block float-right text-muted small font-weight-bold"
268 archive.today {i18n.t("archive_link")}
272 {this.state.imageLoading && <Spinner />}
273 {url && isImage(url) && (
274 <img src={url} className="img-fluid" alt="" />
276 {this.state.imageDeleteUrl && (
278 className="btn btn-danger btn-sm mt-2"
279 onClick={linkEvent(this, this.handleImageDelete)}
280 aria-label={i18n.t("delete")}
281 data-tippy-content={i18n.t("delete")}
283 <Icon icon="x" classes="icon-inline me-1" />
284 {capitalizeFirstLetter(i18n.t("delete"))}
287 {this.props.crossPosts && this.props.crossPosts.length > 0 && (
289 <div className="my-1 text-muted small font-weight-bold">
290 {i18n.t("cross_posts")}
294 posts={this.props.crossPosts}
295 enableDownvotes={this.props.enableDownvotes}
296 enableNsfw={this.props.enableNsfw}
297 allLanguages={this.props.allLanguages}
298 siteLanguages={this.props.siteLanguages}
300 // All of these are unused, since its view only
301 onPostEdit={() => {}}
302 onPostVote={() => {}}
303 onPostReport={() => {}}
304 onBlockPerson={() => {}}
305 onLockPost={() => {}}
306 onDeletePost={() => {}}
307 onRemovePost={() => {}}
308 onSavePost={() => {}}
309 onFeaturePost={() => {}}
310 onPurgePerson={() => {}}
311 onPurgePost={() => {}}
312 onBanPersonFromCommunity={() => {}}
313 onBanPerson={() => {}}
314 onAddModToCommunity={() => {}}
315 onAddAdmin={() => {}}
316 onTransferCommunity={() => {}}
322 <div className="mb-3 row">
323 <label className="col-sm-2 col-form-label" htmlFor="post-title">
326 <div className="col-sm-10">
328 value={this.state.form.name}
330 onInput={linkEvent(this, this.handlePostNameChange)}
331 className={`form-control ${
332 !validTitle(this.state.form.name) && "is-invalid"
337 maxLength={MAX_POST_TITLE_LENGTH}
339 {!validTitle(this.state.form.name) && (
340 <div className="invalid-feedback">
341 {i18n.t("invalid_post_title")}
344 {this.renderSuggestedPosts()}
348 <div className="mb-3 row">
349 <label className="col-sm-2 col-form-label">{i18n.t("body")}</label>
350 <div className="col-sm-10">
352 initialContent={this.state.form.body}
353 onContentChange={this.handlePostBodyChange}
354 allLanguages={this.props.allLanguages}
355 siteLanguages={this.props.siteLanguages}
356 hideNavigationWarnings
360 {!this.props.post_view && (
361 <div className="mb-3 row">
362 <label className="col-sm-2 col-form-label" htmlFor="post-community">
363 {i18n.t("community")}
365 <div className="col-sm-10">
368 value={this.state.form.community_id}
371 label: i18n.t("select_a_community"),
375 ].concat(this.state.communitySearchOptions)}
376 loading={this.state.communitySearchLoading}
377 onChange={this.handleCommunitySelect}
378 onSearch={this.handleCommunitySearch}
383 {this.props.enableNsfw && (
384 <div className="mb-3 row">
385 <legend className="col-form-label col-sm-2 pt-0">
388 <div className="col-sm-10">
389 <div className="form-check">
391 className="form-check-input position-static"
394 checked={this.state.form.nsfw}
395 onChange={linkEvent(this, this.handlePostNsfwChange)}
402 allLanguages={this.props.allLanguages}
403 siteLanguages={this.props.siteLanguages}
404 selectedLanguageIds={selectedLangs}
406 onChange={this.handleLanguageChange}
413 className="form-control honeypot"
415 value={this.state.form.honeypot}
416 onInput={linkEvent(this, this.handleHoneyPotChange)}
418 <div className="mb-3 row">
419 <div className="col-sm-10">
421 disabled={!this.state.form.community_id || this.state.loading}
423 className="btn btn-secondary me-2"
425 {this.state.loading ? (
427 ) : this.props.post_view ? (
428 capitalizeFirstLetter(i18n.t("save"))
430 capitalizeFirstLetter(i18n.t("create"))
433 {this.props.post_view && (
436 className="btn btn-secondary"
437 onClick={linkEvent(this, this.handleCancel)}
448 renderSuggestedTitleCopy() {
449 switch (this.state.metadataRes.state) {
453 const suggestedTitle = this.state.metadataRes.data.metadata.title;
458 className="mt-1 text-muted small font-weight-bold pointer"
461 { i: this, suggestedTitle },
462 this.copySuggestedTitle
465 {i18n.t("copy_suggested_title", { title: "" })} {suggestedTitle}
473 renderSuggestedPosts() {
474 switch (this.state.suggestedPostsRes.state) {
478 const suggestedPosts = this.state.suggestedPostsRes.data.posts;
482 suggestedPosts.length > 0 && (
484 <div className="my-1 text-muted small font-weight-bold">
485 {i18n.t("related_posts")}
489 posts={suggestedPosts}
490 enableDownvotes={this.props.enableDownvotes}
491 enableNsfw={this.props.enableNsfw}
492 allLanguages={this.props.allLanguages}
493 siteLanguages={this.props.siteLanguages}
495 // All of these are unused, since its view only
496 onPostEdit={() => {}}
497 onPostVote={() => {}}
498 onPostReport={() => {}}
499 onBlockPerson={() => {}}
500 onLockPost={() => {}}
501 onDeletePost={() => {}}
502 onRemovePost={() => {}}
503 onSavePost={() => {}}
504 onFeaturePost={() => {}}
505 onPurgePerson={() => {}}
506 onPurgePost={() => {}}
507 onBanPersonFromCommunity={() => {}}
508 onBanPerson={() => {}}
509 onAddModToCommunity={() => {}}
510 onAddAdmin={() => {}}
511 onTransferCommunity={() => {}}
520 handlePostSubmit(i: PostForm, event: any) {
521 event.preventDefault();
522 // Coerce empty url string to undefined
523 if ((i.state.form.url ?? "") === "") {
524 i.setState(s => ((s.form.url = undefined), s));
526 i.setState({ loading: true, submitted: true });
527 const auth = myAuthRequired();
529 const pForm = i.state.form;
530 const pv = i.props.post_view;
539 language_id: pForm.language_id,
542 } else if (pForm.name && pForm.community_id) {
545 community_id: pForm.community_id,
549 language_id: pForm.language_id,
550 honeypot: pForm.honeypot,
556 copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) {
557 const sTitle = d.suggestedTitle;
560 s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
562 d.i.setState({ suggestedPostsRes: { state: "empty" } });
564 const textarea: any = document.getElementById("post-title");
565 autosize.update(textarea);
570 handlePostUrlChange(i: PostForm, event: any) {
571 const url = event.target.value;
583 async fetchPageTitle() {
584 const url = this.state.form.url;
585 if (url && validURL(url)) {
586 this.setState({ metadataRes: { state: "loading" } });
588 metadataRes: await HttpService.client.getSiteMetadata({ url }),
593 handlePostNameChange(i: PostForm, event: any) {
594 i.setState(s => ((s.form.name = event.target.value), s));
595 i.fetchSimilarPosts();
598 async fetchSimilarPosts() {
599 const q = this.state.form.name;
601 this.setState({ suggestedPostsRes: { state: "loading" } });
603 suggestedPostsRes: await HttpService.client.search({
608 community_id: this.state.form.community_id,
610 limit: trendingFetchLimit,
617 handlePostBodyChange(val: string) {
618 this.setState(s => ((s.form.body = val), s));
621 handlePostCommunityChange(i: PostForm, event: any) {
622 i.setState(s => ((s.form.community_id = Number(event.target.value)), s));
625 handlePostNsfwChange(i: PostForm, event: any) {
626 i.setState(s => ((s.form.nsfw = event.target.checked), s));
629 handleLanguageChange(val: number[]) {
630 this.setState(s => ((s.form.language_id = val.at(0)), s));
633 handleHoneyPotChange(i: PostForm, event: any) {
634 i.setState(s => ((s.form.honeypot = event.target.value), s));
637 handleCancel(i: PostForm) {
638 i.props.onCancel?.();
641 handlePreviewToggle(i: PostForm, event: any) {
642 event.preventDefault();
643 i.setState({ previewMode: !i.state.previewMode });
646 handleImageUploadPaste(i: PostForm, event: any) {
647 const image = event.clipboardData.files[0];
649 i.handleImageUpload(i, image);
653 handleImageUpload(i: PostForm, event: any) {
656 event.preventDefault();
657 file = event.target.files[0];
662 i.setState({ imageLoading: true });
664 HttpService.client.uploadImage({ image: file }).then(res => {
665 console.log("pictrs upload:");
667 if (res.state === "success") {
668 if (res.data.msg === "ok") {
669 i.state.form.url = res.data.url;
672 imageDeleteUrl: res.data.delete_url as string,
675 toast(JSON.stringify(res), "danger");
677 } else if (res.state === "failed") {
678 console.error(res.msg);
679 toast(res.msg, "danger");
680 i.setState({ imageLoading: false });
685 handleImageDelete(i: PostForm) {
686 const { imageDeleteUrl } = i.state;
688 fetch(imageDeleteUrl);
699 handleCommunitySearch = debounce(async (text: string) => {
700 const { selectedCommunityChoice } = this.props;
701 this.setState({ communitySearchLoading: true });
703 const newOptions: Choice[] = [];
705 if (selectedCommunityChoice) {
706 newOptions.push(selectedCommunityChoice);
709 if (text.length > 0) {
710 newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
713 communitySearchOptions: newOptions,
718 communitySearchLoading: false,
722 handleCommunitySelect(choice: Choice) {
723 if (this.props.onSelectCommunity) {
724 this.props.onSelectCommunity(choice);