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,
37 import { Icon, Spinner } from "../common/icon";
38 import { LanguageSelect } from "../common/language-select";
39 import { MarkdownTextArea } from "../common/markdown-textarea";
40 import NavigationPrompt from "../common/navigation-prompt";
41 import { SearchableSelect } from "../common/searchable-select";
42 import { PostListings } from "./post-listings";
44 const MAX_POST_TITLE_LENGTH = 200;
46 interface PostFormProps {
47 post_view?: PostView; // If a post is given, that means this is an edit
48 crossPosts?: PostView[];
49 allLanguages: Language[];
50 siteLanguages: number[];
51 params?: PostFormParams;
53 onCreate?(form: CreatePost): void;
54 onEdit?(form: EditPost): void;
56 enableDownvotes?: boolean;
57 selectedCommunityChoice?: Choice;
58 onSelectCommunity?: (choice: Choice) => void;
59 initialCommunities?: CommunityView[];
62 interface PostFormState {
69 community_id?: number;
73 suggestedPostsRes: RequestState<SearchResponse>;
74 metadataRes: RequestState<GetSiteMetadataResponse>;
75 imageLoading: boolean;
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" },
89 communitySearchLoading: false,
91 communitySearchOptions: [],
95 constructor(props: PostFormProps, context: any) {
96 super(props, context);
97 this.fetchSimilarPosts = debounce(this.fetchSimilarPosts.bind(this));
98 this.fetchPageTitle = debounce(this.fetchPageTitle.bind(this));
99 this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
100 this.handleLanguageChange = this.handleLanguageChange.bind(this);
101 this.handleCommunitySelect = this.handleCommunitySelect.bind(this);
103 const { post_view, selectedCommunityChoice, params } = this.props;
110 body: post_view.post.body,
111 name: post_view.post.name,
112 community_id: post_view.community.id,
113 url: post_view.post.url,
114 nsfw: post_view.post.nsfw,
115 language_id: post_view.post.language_id,
118 } else if (selectedCommunityChoice) {
123 community_id: getIdFromString(selectedCommunityChoice.value),
125 communitySearchOptions: [selectedCommunityChoice]
127 this.props.initialCommunities?.map(
128 ({ community: { id, title } }) => ({
130 value: id.toString(),
134 .filter(option => option.value !== selectedCommunityChoice.value),
139 communitySearchOptions:
140 this.props.initialCommunities?.map(
141 ({ community: { id, title } }) => ({
143 value: id.toString(),
160 componentDidMount() {
162 const textarea: any = document.getElementById("post-title");
169 componentWillReceiveProps(
170 nextProps: Readonly<{ children?: InfernoNode } & PostFormProps>
172 if (this.props != nextProps) {
175 (s.form.community_id = getIdFromString(
176 nextProps.selectedCommunityChoice?.value
185 const firstLang = this.state.form.language_id;
186 const selectedLangs = firstLang ? Array.of(firstLang) : undefined;
188 const url = this.state.form.url;
191 // const promptCheck =
192 // !!this.state.form.name || !!this.state.form.url || !!this.state.form.body;
193 // <Prompt when={promptCheck} message={i18n.t("block_leaving")} />
195 <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
199 this.state.form.name ||
200 this.state.form.url ||
202 ) && !this.state.submitted
205 <div className="form-group row">
206 <label className="col-sm-2 col-form-label" htmlFor="post-url">
209 <div className="col-sm-10">
213 className="form-control"
214 value={this.state.form.url}
215 onInput={linkEvent(this, this.handlePostUrlChange)}
216 onPaste={linkEvent(this, this.handleImageUploadPaste)}
218 {this.renderSuggestedTitleCopy()}
221 htmlFor="file-upload"
223 UserService.Instance.myUserInfo && "pointer"
224 } d-inline-block float-right text-muted font-weight-bold`}
225 data-tippy-content={i18n.t("upload_image")}
227 <Icon icon="image" classes="icon-inline" />
232 accept="image/*,video/*"
235 disabled={!UserService.Instance.myUserInfo}
236 onChange={linkEvent(this, this.handleImageUpload)}
239 {url && validURL(url) && (
242 href={`${webArchiveUrl}/save/${encodeURIComponent(url)}`}
243 className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
246 archive.org {i18n.t("archive_link")}
249 href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
252 className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
255 ghostarchive.org {i18n.t("archive_link")}
258 href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
261 className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
264 archive.today {i18n.t("archive_link")}
268 {this.state.imageLoading && <Spinner />}
269 {url && isImage(url) && (
270 <img src={url} className="img-fluid" alt="" />
272 {this.props.crossPosts && this.props.crossPosts.length > 0 && (
274 <div className="my-1 text-muted small font-weight-bold">
275 {i18n.t("cross_posts")}
279 posts={this.props.crossPosts}
280 enableDownvotes={this.props.enableDownvotes}
281 enableNsfw={this.props.enableNsfw}
282 allLanguages={this.props.allLanguages}
283 siteLanguages={this.props.siteLanguages}
285 // All of these are unused, since its view only
286 onPostEdit={() => {}}
287 onPostVote={() => {}}
288 onPostReport={() => {}}
289 onBlockPerson={() => {}}
290 onLockPost={() => {}}
291 onDeletePost={() => {}}
292 onRemovePost={() => {}}
293 onSavePost={() => {}}
294 onFeaturePost={() => {}}
295 onPurgePerson={() => {}}
296 onPurgePost={() => {}}
297 onBanPersonFromCommunity={() => {}}
298 onBanPerson={() => {}}
299 onAddModToCommunity={() => {}}
300 onAddAdmin={() => {}}
301 onTransferCommunity={() => {}}
307 <div className="form-group row">
308 <label className="col-sm-2 col-form-label" htmlFor="post-title">
311 <div className="col-sm-10">
313 value={this.state.form.name}
315 onInput={linkEvent(this, this.handlePostNameChange)}
316 className={`form-control ${
317 !validTitle(this.state.form.name) && "is-invalid"
322 maxLength={MAX_POST_TITLE_LENGTH}
324 {!validTitle(this.state.form.name) && (
325 <div className="invalid-feedback">
326 {i18n.t("invalid_post_title")}
329 {this.renderSuggestedPosts()}
333 <div className="form-group row">
334 <label className="col-sm-2 col-form-label">{i18n.t("body")}</label>
335 <div className="col-sm-10">
337 initialContent={this.state.form.body}
338 onContentChange={this.handlePostBodyChange}
339 allLanguages={this.props.allLanguages}
340 siteLanguages={this.props.siteLanguages}
341 hideNavigationWarnings
345 {!this.props.post_view && (
346 <div className="form-group row">
347 <label className="col-sm-2 col-form-label" htmlFor="post-community">
348 {i18n.t("community")}
350 <div className="col-sm-10">
353 value={this.state.form.community_id}
356 label: i18n.t("select_a_community"),
360 ].concat(this.state.communitySearchOptions)}
361 loading={this.state.communitySearchLoading}
362 onChange={this.handleCommunitySelect}
363 onSearch={this.handleCommunitySearch}
368 {this.props.enableNsfw && (
369 <div className="form-group row">
370 <legend className="col-form-label col-sm-2 pt-0">
373 <div className="col-sm-10">
374 <div className="form-check">
376 className="form-check-input position-static"
379 checked={this.state.form.nsfw}
380 onChange={linkEvent(this, this.handlePostNsfwChange)}
387 allLanguages={this.props.allLanguages}
388 siteLanguages={this.props.siteLanguages}
389 selectedLanguageIds={selectedLangs}
391 onChange={this.handleLanguageChange}
398 className="form-control honeypot"
400 value={this.state.form.honeypot}
401 onInput={linkEvent(this, this.handleHoneyPotChange)}
403 <div className="form-group row">
404 <div className="col-sm-10">
406 disabled={!this.state.form.community_id || this.state.loading}
408 className="btn btn-secondary mr-2"
410 {this.state.loading ? (
412 ) : this.props.post_view ? (
413 capitalizeFirstLetter(i18n.t("save"))
415 capitalizeFirstLetter(i18n.t("create"))
418 {this.props.post_view && (
421 className="btn btn-secondary"
422 onClick={linkEvent(this, this.handleCancel)}
433 renderSuggestedTitleCopy() {
434 switch (this.state.metadataRes.state) {
438 const suggestedTitle = this.state.metadataRes.data.metadata.title;
443 className="mt-1 text-muted small font-weight-bold pointer"
446 { i: this, suggestedTitle },
447 this.copySuggestedTitle
450 {i18n.t("copy_suggested_title", { title: "" })} {suggestedTitle}
458 renderSuggestedPosts() {
459 switch (this.state.suggestedPostsRes.state) {
463 const suggestedPosts = this.state.suggestedPostsRes.data.posts;
467 suggestedPosts.length > 0 && (
469 <div className="my-1 text-muted small font-weight-bold">
470 {i18n.t("related_posts")}
474 posts={suggestedPosts}
475 enableDownvotes={this.props.enableDownvotes}
476 enableNsfw={this.props.enableNsfw}
477 allLanguages={this.props.allLanguages}
478 siteLanguages={this.props.siteLanguages}
480 // All of these are unused, since its view only
481 onPostEdit={() => {}}
482 onPostVote={() => {}}
483 onPostReport={() => {}}
484 onBlockPerson={() => {}}
485 onLockPost={() => {}}
486 onDeletePost={() => {}}
487 onRemovePost={() => {}}
488 onSavePost={() => {}}
489 onFeaturePost={() => {}}
490 onPurgePerson={() => {}}
491 onPurgePost={() => {}}
492 onBanPersonFromCommunity={() => {}}
493 onBanPerson={() => {}}
494 onAddModToCommunity={() => {}}
495 onAddAdmin={() => {}}
496 onTransferCommunity={() => {}}
505 handlePostSubmit(i: PostForm, event: any) {
506 event.preventDefault();
507 // Coerce empty url string to undefined
508 if ((i.state.form.url ?? "") === "") {
509 i.setState(s => ((s.form.url = undefined), s));
511 i.setState({ loading: true, submitted: true });
512 const auth = myAuthRequired();
514 const pForm = i.state.form;
515 const pv = i.props.post_view;
524 language_id: pForm.language_id,
527 } else if (pForm.name && pForm.community_id) {
530 community_id: pForm.community_id,
534 language_id: pForm.language_id,
535 honeypot: pForm.honeypot,
541 copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) {
542 const sTitle = d.suggestedTitle;
545 s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
547 d.i.setState({ suggestedPostsRes: { state: "empty" } });
549 const textarea: any = document.getElementById("post-title");
550 autosize.update(textarea);
555 handlePostUrlChange(i: PostForm, event: any) {
556 i.setState(s => ((s.form.url = event.target.value), s));
560 async fetchPageTitle() {
561 const url = this.state.form.url;
562 if (url && validURL(url)) {
563 this.setState({ metadataRes: { state: "loading" } });
565 metadataRes: await HttpService.client.getSiteMetadata({ url }),
570 handlePostNameChange(i: PostForm, event: any) {
571 i.setState(s => ((s.form.name = event.target.value), s));
572 i.fetchSimilarPosts();
575 async fetchSimilarPosts() {
576 const q = this.state.form.name;
578 this.setState({ suggestedPostsRes: { state: "loading" } });
580 suggestedPostsRes: await HttpService.client.search({
585 community_id: this.state.form.community_id,
587 limit: trendingFetchLimit,
594 handlePostBodyChange(val: string) {
595 this.setState(s => ((s.form.body = val), s));
598 handlePostCommunityChange(i: PostForm, event: any) {
599 i.setState(s => ((s.form.community_id = Number(event.target.value)), s));
602 handlePostNsfwChange(i: PostForm, event: any) {
603 i.setState(s => ((s.form.nsfw = event.target.checked), s));
606 handleLanguageChange(val: number[]) {
607 this.setState(s => ((s.form.language_id = val.at(0)), s));
610 handleHoneyPotChange(i: PostForm, event: any) {
611 i.setState(s => ((s.form.honeypot = event.target.value), s));
614 handleCancel(i: PostForm) {
615 i.props.onCancel?.();
618 handlePreviewToggle(i: PostForm, event: any) {
619 event.preventDefault();
620 i.setState({ previewMode: !i.state.previewMode });
623 handleImageUploadPaste(i: PostForm, event: any) {
624 const image = event.clipboardData.files[0];
626 i.handleImageUpload(i, image);
630 handleImageUpload(i: PostForm, event: any) {
633 event.preventDefault();
634 file = event.target.files[0];
639 i.setState({ imageLoading: true });
641 HttpService.client.uploadImage({ image: file }).then(res => {
642 console.log("pictrs upload:");
644 if (res.state === "success") {
645 if (res.data.msg === "ok") {
646 i.state.form.url = res.data.url;
647 pictrsDeleteToast(file.name, res.data.delete_url as string);
648 i.setState({ imageLoading: false });
650 toast(JSON.stringify(res), "danger");
652 } else if (res.state === "failed") {
653 console.error(res.msg);
654 toast(res.msg, "danger");
659 handleCommunitySearch = debounce(async (text: string) => {
660 const { selectedCommunityChoice } = this.props;
661 this.setState({ communitySearchLoading: true });
663 const newOptions: Choice[] = [];
665 if (selectedCommunityChoice) {
666 newOptions.push(selectedCommunityChoice);
669 if (text.length > 0) {
670 newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
673 communitySearchOptions: newOptions,
678 communitySearchLoading: false,
682 handleCommunitySelect(choice: Choice) {
683 if (this.props.onSelectCommunity) {
684 this.props.onSelectCommunity(choice);