1 import { None, Option, Some } from "@sniptt/monads";
2 import autosize from "autosize";
3 import { Component, linkEvent } from "inferno";
4 import { Prompt } from "inferno-router";
20 } from "lemmy-js-client";
21 import { Subscription } from "rxjs";
22 import { pictrsUri } from "../../env";
23 import { i18n } from "../../i18next";
24 import { PostFormParams } from "../../interfaces";
25 import { UserService, WebSocketService } from "../../services";
29 capitalizeFirstLetter,
50 import { Icon, Spinner } from "../common/icon";
51 import { MarkdownTextArea } from "../common/markdown-textarea";
52 import { PostListings } from "./post-listings";
56 Choices = require("choices.js");
59 const MAX_POST_TITLE_LENGTH = 200;
61 interface PostFormProps {
62 post_view: Option<PostView>; // If a post is given, that means this is an edit
63 communities: Option<CommunityView[]>;
64 params: Option<PostFormParams>;
66 onCreate?(post: PostView): any;
67 onEdit?(post: PostView): any;
69 enableDownvotes?: boolean;
72 interface PostFormState {
74 suggestedTitle: Option<string>;
75 suggestedPosts: Option<PostView[]>;
76 crossPosts: Option<PostView[]>;
78 imageLoading: boolean;
82 export class PostForm extends Component<PostFormProps, PostFormState> {
83 private subscription: Subscription;
85 private emptyState: PostFormState = {
86 postForm: new CreatePost({
87 community_id: undefined,
103 constructor(props: any, context: any) {
104 super(props, context);
105 this.fetchSimilarPosts = debounce(this.fetchSimilarPosts.bind(this));
106 this.fetchPageTitle = debounce(this.fetchPageTitle.bind(this));
107 this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
109 this.state = this.emptyState;
111 this.parseMessage = this.parseMessage.bind(this);
112 this.subscription = wsSubscribe(this.parseMessage);
115 if (this.props.post_view.isSome()) {
116 let pv = this.props.post_view.unwrap();
120 postForm: new CreatePost({
123 community_id: pv.community.id,
125 nsfw: Some(pv.post.nsfw),
127 auth: auth().unwrap(),
132 if (this.props.params.isSome()) {
133 let params = this.props.params.unwrap();
137 ...this.state.postForm,
138 name: toUndefined(params.name),
146 componentDidMount() {
148 this.setupCommunities();
149 let textarea: any = document.getElementById("post-title");
155 componentDidUpdate() {
157 !this.state.loading &&
158 (this.state.postForm.name ||
159 this.state.postForm.url.isSome() ||
160 this.state.postForm.body.isSome())
162 window.onbeforeunload = () => true;
164 window.onbeforeunload = undefined;
168 componentWillUnmount() {
169 this.subscription.unsubscribe();
170 /* this.choices && this.choices.destroy(); */
171 window.onbeforeunload = null;
179 !this.state.loading &&
180 (this.state.postForm.name ||
181 this.state.postForm.url.isSome() ||
182 this.state.postForm.body.isSome())
184 message={i18n.t("block_leaving")}
186 <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
187 <div className="form-group row">
188 <label className="col-sm-2 col-form-label" htmlFor="post-url">
191 <div className="col-sm-10">
195 className="form-control"
196 value={toUndefined(this.state.postForm.url)}
197 onInput={linkEvent(this, this.handlePostUrlChange)}
198 onPaste={linkEvent(this, this.handleImageUploadPaste)}
200 {this.state.suggestedTitle.match({
203 className="mt-1 text-muted small font-weight-bold pointer"
205 onClick={linkEvent(this, this.copySuggestedTitle)}
207 {i18n.t("copy_suggested_title", { title: "" })} {title}
214 htmlFor="file-upload"
216 UserService.Instance.myUserInfo.isSome() && "pointer"
217 } d-inline-block float-right text-muted font-weight-bold`}
218 data-tippy-content={i18n.t("upload_image")}
220 <Icon icon="image" classes="icon-inline" />
225 accept="image/*,video/*"
228 disabled={UserService.Instance.myUserInfo.isNone()}
229 onChange={linkEvent(this, this.handleImageUpload)}
232 {this.state.postForm.url.match({
237 href={`${webArchiveUrl}/save/${encodeURIComponent(
240 className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
243 archive.org {i18n.t("archive_link")}
246 href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
249 className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
252 ghostarchive.org {i18n.t("archive_link")}
255 href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
258 className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
261 archive.today {i18n.t("archive_link")}
267 {this.state.imageLoading && <Spinner />}
268 {this.state.postForm.url.match({
271 <img src={url} className="img-fluid" alt="" />
275 {this.state.crossPosts.match({
277 xPosts.length > 0 && (
279 <div className="my-1 text-muted small font-weight-bold">
280 {i18n.t("cross_posts")}
285 enableDownvotes={this.props.enableDownvotes}
286 enableNsfw={this.props.enableNsfw}
294 <div className="form-group row">
295 <label className="col-sm-2 col-form-label" htmlFor="post-title">
298 <div className="col-sm-10">
300 value={this.state.postForm.name}
302 onInput={linkEvent(this, this.handlePostNameChange)}
303 className={`form-control ${
304 !validTitle(this.state.postForm.name) && "is-invalid"
309 maxLength={MAX_POST_TITLE_LENGTH}
311 {!validTitle(this.state.postForm.name) && (
312 <div className="invalid-feedback">
313 {i18n.t("invalid_post_title")}
316 {this.state.suggestedPosts.match({
318 sPosts.length > 0 && (
320 <div className="my-1 text-muted small font-weight-bold">
321 {i18n.t("related_posts")}
326 enableDownvotes={this.props.enableDownvotes}
327 enableNsfw={this.props.enableNsfw}
336 <div className="form-group row">
337 <label className="col-sm-2 col-form-label">{i18n.t("body")}</label>
338 <div className="col-sm-10">
340 initialContent={this.state.postForm.body}
341 onContentChange={this.handlePostBodyChange}
348 {this.props.post_view.isNone() && (
349 <div className="form-group row">
351 className="col-sm-2 col-form-label"
352 htmlFor="post-community"
354 {i18n.t("community")}
356 <div className="col-sm-10">
358 className="form-control"
360 value={this.state.postForm.community_id}
361 onInput={linkEvent(this, this.handlePostCommunityChange)}
363 <option>{i18n.t("select_a_community")}</option>
364 {this.props.communities.unwrapOr([]).map(cv => (
365 <option key={cv.community.id} value={cv.community.id}>
366 {communitySelectName(cv)}
373 {this.props.enableNsfw && (
374 <div className="form-group row">
375 <legend className="col-form-label col-sm-2 pt-0">
378 <div className="col-sm-10">
379 <div className="form-check">
381 className="form-check-input position-static"
384 checked={toUndefined(this.state.postForm.nsfw)}
385 onChange={linkEvent(this, this.handlePostNsfwChange)}
396 className="form-control honeypot"
398 value={toUndefined(this.state.postForm.honeypot)}
399 onInput={linkEvent(this, this.handleHoneyPotChange)}
401 <div className="form-group row">
402 <div className="col-sm-10">
405 !this.state.postForm.community_id || this.state.loading
408 className="btn btn-secondary mr-2"
410 {this.state.loading ? (
412 ) : this.props.post_view.isSome() ? (
413 capitalizeFirstLetter(i18n.t("save"))
415 capitalizeFirstLetter(i18n.t("create"))
418 {this.props.post_view.isSome() && (
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
441 i.state.postForm.url.isSome() &&
442 i.state.postForm.url.unwrapOr("blank") === ""
444 i.setState(s => ((s.postForm.url = None), s));
447 let pForm = i.state.postForm;
448 i.props.post_view.match({
450 let form = new EditPost({
451 name: Some(pForm.name),
456 auth: auth().unwrap(),
458 WebSocketService.Instance.send(wsClient.editPost(form));
461 i.setState(s => ((s.postForm.auth = auth().unwrap()), s));
462 let form = new CreatePost({ ...i.state.postForm });
463 WebSocketService.Instance.send(wsClient.createPost(form));
468 copySuggestedTitle(i: PostForm) {
469 i.state.suggestedTitle.match({
473 (s.postForm.name = sTitle.substring(0, MAX_POST_TITLE_LENGTH)), s
476 i.setState({ suggestedTitle: None });
478 let textarea: any = document.getElementById("post-title");
479 autosize.update(textarea);
486 handlePostUrlChange(i: PostForm, event: any) {
487 i.setState(s => ((s.postForm.url = Some(event.target.value)), s));
492 this.state.postForm.url.match({
495 let form = new Search({
498 community_name: None,
500 type_: Some(SearchType.Url),
501 sort: Some(SortType.TopAll),
502 listing_type: Some(ListingType.All),
504 limit: Some(trendingFetchLimit),
505 auth: auth(false).ok(),
508 WebSocketService.Instance.send(wsClient.search(form));
510 // Fetch the page title
511 getSiteMetadata(url).then(d => {
512 this.setState({ suggestedTitle: d.metadata.title });
515 this.setState({ suggestedTitle: None, crossPosts: None });
522 handlePostNameChange(i: PostForm, event: any) {
523 i.setState(s => ((s.postForm.name = event.target.value), s));
524 i.fetchSimilarPosts();
527 fetchSimilarPosts() {
528 let form = new Search({
529 q: this.state.postForm.name,
530 type_: Some(SearchType.Posts),
531 sort: Some(SortType.TopAll),
532 listing_type: Some(ListingType.All),
533 community_id: Some(this.state.postForm.community_id),
534 community_name: None,
537 limit: Some(trendingFetchLimit),
538 auth: auth(false).ok(),
541 if (this.state.postForm.name !== "") {
542 WebSocketService.Instance.send(wsClient.search(form));
544 this.setState({ suggestedPosts: None });
548 handlePostBodyChange(val: string) {
549 this.setState(s => ((s.postForm.body = Some(val)), s));
552 handlePostCommunityChange(i: PostForm, event: any) {
554 s => ((s.postForm.community_id = Number(event.target.value)), s)
558 handlePostNsfwChange(i: PostForm, event: any) {
559 i.setState(s => ((s.postForm.nsfw = Some(event.target.checked)), s));
562 handleHoneyPotChange(i: PostForm, event: any) {
563 i.setState(s => ((s.postForm.honeypot = Some(event.target.value)), s));
566 handleCancel(i: PostForm) {
570 handlePreviewToggle(i: PostForm, event: any) {
571 event.preventDefault();
572 i.setState({ previewMode: !i.state.previewMode });
575 handleImageUploadPaste(i: PostForm, event: any) {
576 let image = event.clipboardData.files[0];
578 i.handleImageUpload(i, image);
582 handleImageUpload(i: PostForm, event: any) {
585 event.preventDefault();
586 file = event.target.files[0];
591 const formData = new FormData();
592 formData.append("images[]", file);
594 i.setState({ imageLoading: true });
600 .then(res => res.json())
602 console.log("pictrs upload:");
604 if (res.msg == "ok") {
605 let hash = res.files[0].file;
606 let url = `${pictrsUri}/${hash}`;
607 let deleteToken = res.files[0].delete_token;
608 let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
609 i.state.postForm.url = Some(url);
610 i.setState({ imageLoading: false });
612 i18n.t("click_to_delete_picture"),
613 i18n.t("picture_deleted"),
617 i.setState({ imageLoading: false });
618 toast(JSON.stringify(res), "danger");
622 i.setState({ imageLoading: false });
623 console.error(error);
624 toast(error, "danger");
629 // Set up select searching
631 let selectId: any = document.getElementById("post-community");
633 this.choices = new Choices(selectId, choicesConfig);
634 this.choices.passedElement.element.addEventListener(
639 (s.postForm.community_id = Number(e.detail.choice.value)), s
645 this.choices.passedElement.element.addEventListener(
647 debounce(async (e: any) => {
649 let communities = (await fetchCommunities(e.detail.value))
651 this.choices.setChoices(
652 communities.map(cv => communityToChoice(cv)),
666 this.props.post_view.match({
668 this.setState(s => ((s.postForm.community_id = pv.community.id), s)),
671 this.props.params.match({
673 params.nameOrId.match({
677 let foundCommunityId = this.props.communities
679 .find(r => r.community.name == name).community.id;
681 s => ((s.postForm.community_id = foundCommunityId), s)
685 this.setState(s => ((s.postForm.community_id = id), s)),
692 if (isBrowser() && this.state.postForm.community_id) {
693 this.choices.setChoiceByValue(
694 this.state.postForm.community_id.toString()
697 this.setState(this.state);
700 parseMessage(msg: any) {
701 let op = wsUserOp(msg);
704 // Errors handled by top level pages
705 // toast(i18n.t(msg.error), "danger");
706 this.setState({ loading: false });
708 } else if (op == UserOperation.CreatePost) {
709 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
710 UserService.Instance.myUserInfo.match({
712 if (data.post_view.creator.id == mui.local_user_view.person.id) {
713 this.props.onCreate(data.post_view);
718 } else if (op == UserOperation.EditPost) {
719 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
720 UserService.Instance.myUserInfo.match({
722 if (data.post_view.creator.id == mui.local_user_view.person.id) {
723 this.setState({ loading: false });
724 this.props.onEdit(data.post_view);
729 } else if (op == UserOperation.Search) {
730 let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
732 if (data.type_ == SearchType[SearchType.Posts]) {
733 this.setState({ suggestedPosts: Some(data.posts) });
734 } else if (data.type_ == SearchType[SearchType.Url]) {
735 this.setState({ crossPosts: Some(data.posts) });