1 import autosize from "autosize";
2 import { Component, linkEvent } from "inferno";
3 import { Prompt } from "inferno-router";
19 } from "lemmy-js-client";
20 import { Subscription } from "rxjs";
21 import { i18n } from "../../i18next";
22 import { PostFormParams } from "../../interfaces";
23 import { UserService, WebSocketService } from "../../services";
26 capitalizeFirstLetter,
37 myFirstDiscussionLanguageId,
50 import { Icon, Spinner } from "../common/icon";
51 import { LanguageSelect } from "../common/language-select";
52 import { MarkdownTextArea } from "../common/markdown-textarea";
53 import { PostListings } from "./post-listings";
57 Choices = require("choices.js");
60 const MAX_POST_TITLE_LENGTH = 200;
62 interface PostFormProps {
63 post_view?: PostView; // If a post is given, that means this is an edit
64 allLanguages: Language[];
65 siteLanguages: number[];
66 communities?: CommunityView[];
67 params?: PostFormParams;
69 onCreate?(post: PostView): any;
70 onEdit?(post: PostView): any;
72 enableDownvotes?: boolean;
75 interface PostFormState {
82 community_id?: number;
85 suggestedTitle?: string;
86 suggestedPosts?: PostView[];
87 crossPosts?: PostView[];
89 imageLoading: boolean;
90 communitySearchLoading: boolean;
94 export class PostForm extends Component<PostFormProps, PostFormState> {
95 private subscription?: Subscription;
97 state: PostFormState = {
101 communitySearchLoading: false,
105 constructor(props: any, context: any) {
106 super(props, context);
107 this.fetchSimilarPosts = debounce(this.fetchSimilarPosts.bind(this));
108 this.fetchPageTitle = debounce(this.fetchPageTitle.bind(this));
109 this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
110 this.handleLanguageChange = this.handleLanguageChange.bind(this);
112 this.parseMessage = this.parseMessage.bind(this);
113 this.subscription = wsSubscribe(this.parseMessage);
116 let pv = this.props.post_view;
123 community_id: pv.community.id,
126 language_id: pv.post.language_id,
131 let params = this.props.params;
145 componentDidMount() {
147 this.setupCommunities();
148 let textarea: any = document.getElementById("post-title");
154 componentDidUpdate() {
156 !this.state.loading &&
157 (this.state.form.name || this.state.form.url || this.state.form.body)
159 window.onbeforeunload = () => true;
161 window.onbeforeunload = null;
165 componentWillUnmount() {
166 this.subscription?.unsubscribe();
167 /* this.choices && this.choices.destroy(); */
168 window.onbeforeunload = null;
173 this.state.form.language_id ??
174 myFirstDiscussionLanguageId(
175 this.props.allLanguages,
176 this.props.siteLanguages,
177 UserService.Instance.myUserInfo
179 let selectedLangs = firstLang ? Array.of(firstLang) : undefined;
181 let url = this.state.form.url;
186 !this.state.loading &&
187 (this.state.form.name ||
188 this.state.form.url ||
189 this.state.form.body)
191 message={i18n.t("block_leaving")}
193 <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
194 <div className="form-group row">
195 <label className="col-sm-2 col-form-label" htmlFor="post-url">
198 <div className="col-sm-10">
202 className="form-control"
203 value={this.state.form.url}
204 onInput={linkEvent(this, this.handlePostUrlChange)}
205 onPaste={linkEvent(this, this.handleImageUploadPaste)}
207 {this.state.suggestedTitle && (
209 className="mt-1 text-muted small font-weight-bold pointer"
211 onClick={linkEvent(this, this.copySuggestedTitle)}
213 {i18n.t("copy_suggested_title", { title: "" })}{" "}
214 {this.state.suggestedTitle}
219 htmlFor="file-upload"
221 UserService.Instance.myUserInfo && "pointer"
222 } d-inline-block float-right text-muted font-weight-bold`}
223 data-tippy-content={i18n.t("upload_image")}
225 <Icon icon="image" classes="icon-inline" />
230 accept="image/*,video/*"
233 disabled={!UserService.Instance.myUserInfo}
234 onChange={linkEvent(this, this.handleImageUpload)}
237 {url && validURL(url) && (
240 href={`${webArchiveUrl}/save/${encodeURIComponent(url)}`}
241 className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
244 archive.org {i18n.t("archive_link")}
247 href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
250 className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
253 ghostarchive.org {i18n.t("archive_link")}
256 href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
259 className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
262 archive.today {i18n.t("archive_link")}
266 {this.state.imageLoading && <Spinner />}
267 {url && isImage(url) && (
268 <img src={url} className="img-fluid" alt="" />
270 {this.state.crossPosts && this.state.crossPosts.length > 0 && (
272 <div className="my-1 text-muted small font-weight-bold">
273 {i18n.t("cross_posts")}
277 posts={this.state.crossPosts}
278 enableDownvotes={this.props.enableDownvotes}
279 enableNsfw={this.props.enableNsfw}
280 allLanguages={this.props.allLanguages}
281 siteLanguages={this.props.siteLanguages}
287 <div className="form-group row">
288 <label className="col-sm-2 col-form-label" htmlFor="post-title">
291 <div className="col-sm-10">
293 value={this.state.form.name}
295 onInput={linkEvent(this, this.handlePostNameChange)}
296 className={`form-control ${
297 !validTitle(this.state.form.name) && "is-invalid"
302 maxLength={MAX_POST_TITLE_LENGTH}
304 {!validTitle(this.state.form.name) && (
305 <div className="invalid-feedback">
306 {i18n.t("invalid_post_title")}
309 {this.state.suggestedPosts &&
310 this.state.suggestedPosts.length > 0 && (
312 <div className="my-1 text-muted small font-weight-bold">
313 {i18n.t("related_posts")}
317 posts={this.state.suggestedPosts}
318 enableDownvotes={this.props.enableDownvotes}
319 enableNsfw={this.props.enableNsfw}
320 allLanguages={this.props.allLanguages}
321 siteLanguages={this.props.siteLanguages}
328 <div className="form-group row">
329 <label className="col-sm-2 col-form-label">{i18n.t("body")}</label>
330 <div className="col-sm-10">
332 initialContent={this.state.form.body}
333 onContentChange={this.handlePostBodyChange}
334 allLanguages={this.props.allLanguages}
335 siteLanguages={this.props.siteLanguages}
339 {!this.props.post_view && (
340 <div className="form-group row">
342 className="col-sm-2 col-form-label"
343 htmlFor="post-community"
345 {this.state.communitySearchLoading ? (
351 <div className="col-sm-10">
353 className="form-control"
355 value={this.state.form.community_id}
356 onInput={linkEvent(this, this.handlePostCommunityChange)}
358 <option>{i18n.t("select_a_community")}</option>
359 {this.props.communities?.map(cv => (
360 <option key={cv.community.id} value={cv.community.id}>
361 {communitySelectName(cv)}
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)}
434 handlePostSubmit(i: PostForm, event: any) {
435 event.preventDefault();
437 i.setState({ loading: true });
439 // Coerce empty url string to undefined
440 if ((i.state.form.url ?? "blank") === "") {
441 i.setState(s => ((s.form.url = undefined), s));
444 let pForm = i.state.form;
445 let pv = i.props.post_view;
449 let form: EditPost = {
455 language_id: pv.post.language_id,
458 WebSocketService.Instance.send(wsClient.editPost(form));
460 if (pForm.name && pForm.community_id) {
461 let form: CreatePost = {
463 community_id: pForm.community_id,
467 language_id: pForm.language_id,
468 honeypot: pForm.honeypot,
471 WebSocketService.Instance.send(wsClient.createPost(form));
477 copySuggestedTitle(i: PostForm) {
478 let sTitle = i.state.suggestedTitle;
481 s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
483 i.setState({ suggestedTitle: undefined });
485 let textarea: any = document.getElementById("post-title");
486 autosize.update(textarea);
491 handlePostUrlChange(i: PostForm, event: any) {
492 i.setState(s => ((s.form.url = event.target.value), s));
497 let url = this.state.form.url;
498 if (url && validURL(url)) {
501 type_: SearchType.Url,
502 sort: SortType.TopAll,
503 listing_type: ListingType.All,
505 limit: trendingFetchLimit,
509 WebSocketService.Instance.send(wsClient.search(form));
511 // Fetch the page title
512 getSiteMetadata(url).then(d => {
513 this.setState({ suggestedTitle: d.metadata.title });
516 this.setState({ suggestedTitle: undefined, crossPosts: undefined });
520 handlePostNameChange(i: PostForm, event: any) {
521 i.setState(s => ((s.form.name = event.target.value), s));
522 i.fetchSimilarPosts();
525 fetchSimilarPosts() {
526 let q = this.state.form.name;
530 type_: SearchType.Posts,
531 sort: SortType.TopAll,
532 listing_type: ListingType.All,
533 community_id: this.state.form.community_id,
535 limit: trendingFetchLimit,
539 WebSocketService.Instance.send(wsClient.search(form));
541 this.setState({ suggestedPosts: undefined });
545 handlePostBodyChange(val: string) {
546 this.setState(s => ((s.form.body = val), s));
549 handlePostCommunityChange(i: PostForm, event: any) {
550 i.setState(s => ((s.form.community_id = Number(event.target.value)), s));
553 handlePostNsfwChange(i: PostForm, event: any) {
554 i.setState(s => ((s.form.nsfw = event.target.checked), s));
557 handleLanguageChange(val: number[]) {
558 this.setState(s => ((s.form.language_id = val.at(0)), s));
561 handleHoneyPotChange(i: PostForm, event: any) {
562 i.setState(s => ((s.form.honeypot = event.target.value), s));
565 handleCancel(i: PostForm) {
566 i.props.onCancel?.();
569 handlePreviewToggle(i: PostForm, event: any) {
570 event.preventDefault();
571 i.setState({ previewMode: !i.state.previewMode });
574 handleImageUploadPaste(i: PostForm, event: any) {
575 let image = event.clipboardData.files[0];
577 i.handleImageUpload(i, image);
581 handleImageUpload(i: PostForm, event: any) {
584 event.preventDefault();
585 file = event.target.files[0];
590 i.setState({ imageLoading: true });
594 console.log("pictrs upload:");
596 if (res.msg === "ok") {
597 i.state.form.url = res.url;
598 i.setState({ imageLoading: false });
600 `${i18n.t("click_to_delete_picture")}: ${file.name}`,
601 `${i18n.t("picture_deleted")}: ${file.name}`,
602 `${i18n.t("failed_to_delete_picture")}: ${file.name}`,
603 res.delete_url as string
606 i.setState({ imageLoading: false });
607 toast(JSON.stringify(res), "danger");
611 i.setState({ imageLoading: false });
612 console.error(error);
613 toast(error, "danger");
618 // Set up select searching
620 let selectId: any = document.getElementById("post-community");
622 this.choices = new Choices(selectId, choicesConfig);
623 this.choices.passedElement.element.addEventListener(
627 s => ((s.form.community_id = Number(e.detail.choice.value)), s)
632 this.choices.passedElement.element.addEventListener("search", () => {
633 this.setState({ communitySearchLoading: true });
635 this.choices.passedElement.element.addEventListener(
637 debounce(async (e: any) => {
639 let communities = (await fetchCommunities(e.detail.value))
641 this.choices.setChoices(
642 communities.map(cv => communityToChoice(cv)),
647 this.setState({ communitySearchLoading: false });
657 let pv = this.props.post_view;
658 this.setState(s => ((s.form.community_id = pv?.community.id), s));
660 let nameOrId = this.props.params?.nameOrId;
662 if (typeof nameOrId === "string") {
663 let name_ = nameOrId;
664 let foundCommunityId = this.props.communities?.find(
665 r => r.community.name == name_
667 this.setState(s => ((s.form.community_id = foundCommunityId), s));
670 this.setState(s => ((s.form.community_id = id), s));
674 if (isBrowser() && this.state.form.community_id) {
675 this.choices.setChoiceByValue(this.state.form.community_id.toString());
677 this.setState(this.state);
680 parseMessage(msg: any) {
681 let mui = UserService.Instance.myUserInfo;
682 let op = wsUserOp(msg);
685 // Errors handled by top level pages
686 // toast(i18n.t(msg.error), "danger");
687 this.setState({ loading: false });
689 } else if (op == UserOperation.CreatePost) {
690 let data = wsJsonToRes<PostResponse>(msg);
691 if (data.post_view.creator.id == mui?.local_user_view.person.id) {
692 this.props.onCreate?.(data.post_view);
694 } else if (op == UserOperation.EditPost) {
695 let data = wsJsonToRes<PostResponse>(msg);
696 if (data.post_view.creator.id == mui?.local_user_view.person.id) {
697 this.setState({ loading: false });
698 this.props.onEdit?.(data.post_view);
700 } else if (op == UserOperation.Search) {
701 let data = wsJsonToRes<SearchResponse>(msg);
703 if (data.type_ == SearchType[SearchType.Posts]) {
704 this.setState({ suggestedPosts: data.posts });
705 } else if (data.type_ == SearchType[SearchType.Url]) {
706 this.setState({ crossPosts: data.posts });