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;
112 this.props.post_view.match({
114 (this.state.postForm = new CreatePost({
117 community_id: pv.community.id,
119 nsfw: Some(pv.post.nsfw),
121 auth: auth().unwrap(),
126 this.props.params.match({
128 this.state.postForm.name = toUndefined(params.name);
129 this.state.postForm.url = params.url;
130 this.state.postForm.body = params.body;
135 this.parseMessage = this.parseMessage.bind(this);
136 this.subscription = wsSubscribe(this.parseMessage);
139 componentDidMount() {
141 this.setupCommunities();
142 let textarea: any = document.getElementById("post-title");
148 componentDidUpdate() {
150 !this.state.loading &&
151 (this.state.postForm.name ||
152 this.state.postForm.url.isSome() ||
153 this.state.postForm.body.isSome())
155 window.onbeforeunload = () => true;
157 window.onbeforeunload = undefined;
161 componentWillUnmount() {
162 this.subscription.unsubscribe();
163 /* this.choices && this.choices.destroy(); */
164 window.onbeforeunload = null;
172 !this.state.loading &&
173 (this.state.postForm.name ||
174 this.state.postForm.url.isSome() ||
175 this.state.postForm.body.isSome())
177 message={i18n.t("block_leaving")}
179 <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
180 <div class="form-group row">
181 <label class="col-sm-2 col-form-label" htmlFor="post-url">
184 <div class="col-sm-10">
189 value={toUndefined(this.state.postForm.url)}
190 onInput={linkEvent(this, this.handlePostUrlChange)}
191 onPaste={linkEvent(this, this.handleImageUploadPaste)}
193 {this.state.suggestedTitle.match({
196 class="mt-1 text-muted small font-weight-bold pointer"
198 onClick={linkEvent(this, this.copySuggestedTitle)}
200 {i18n.t("copy_suggested_title", { title: "" })} {title}
207 htmlFor="file-upload"
209 UserService.Instance.myUserInfo.isSome() && "pointer"
210 } d-inline-block float-right text-muted font-weight-bold`}
211 data-tippy-content={i18n.t("upload_image")}
213 <Icon icon="image" classes="icon-inline" />
218 accept="image/*,video/*"
221 disabled={UserService.Instance.myUserInfo.isNone()}
222 onChange={linkEvent(this, this.handleImageUpload)}
225 {this.state.postForm.url.match({
230 href={`${webArchiveUrl}/save/${encodeURIComponent(
233 class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
236 archive.org {i18n.t("archive_link")}
239 href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
242 class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
245 ghostarchive.org {i18n.t("archive_link")}
248 href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
251 class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
254 archive.today {i18n.t("archive_link")}
260 {this.state.imageLoading && <Spinner />}
261 {this.state.postForm.url.match({
263 isImage(url) && <img src={url} class="img-fluid" alt="" />,
266 {this.state.crossPosts.match({
268 xPosts.length > 0 && (
270 <div class="my-1 text-muted small font-weight-bold">
271 {i18n.t("cross_posts")}
276 enableDownvotes={this.props.enableDownvotes}
277 enableNsfw={this.props.enableNsfw}
285 <div class="form-group row">
286 <label class="col-sm-2 col-form-label" htmlFor="post-title">
289 <div class="col-sm-10">
291 value={this.state.postForm.name}
293 onInput={linkEvent(this, this.handlePostNameChange)}
294 class={`form-control ${
295 !validTitle(this.state.postForm.name) && "is-invalid"
300 maxLength={MAX_POST_TITLE_LENGTH}
302 {!validTitle(this.state.postForm.name) && (
303 <div class="invalid-feedback">
304 {i18n.t("invalid_post_title")}
307 {this.state.suggestedPosts.match({
309 sPosts.length > 0 && (
311 <div class="my-1 text-muted small font-weight-bold">
312 {i18n.t("related_posts")}
317 enableDownvotes={this.props.enableDownvotes}
318 enableNsfw={this.props.enableNsfw}
327 <div class="form-group row">
328 <label class="col-sm-2 col-form-label">{i18n.t("body")}</label>
329 <div class="col-sm-10">
331 initialContent={this.state.postForm.body}
332 onContentChange={this.handlePostBodyChange}
339 {this.props.post_view.isNone() && (
340 <div class="form-group row">
341 <label class="col-sm-2 col-form-label" htmlFor="post-community">
342 {i18n.t("community")}
344 <div class="col-sm-10">
348 value={this.state.postForm.community_id}
349 onInput={linkEvent(this, this.handlePostCommunityChange)}
351 <option>{i18n.t("select_a_community")}</option>
352 {this.props.communities.unwrapOr([]).map(cv => (
353 <option value={cv.community.id}>
354 {communitySelectName(cv)}
361 {this.props.enableNsfw && (
362 <div class="form-group row">
363 <legend class="col-form-label col-sm-2 pt-0">
366 <div class="col-sm-10">
367 <div class="form-check">
369 class="form-check-input position-static"
372 checked={toUndefined(this.state.postForm.nsfw)}
373 onChange={linkEvent(this, this.handlePostNsfwChange)}
384 class="form-control honeypot"
386 value={toUndefined(this.state.postForm.honeypot)}
387 onInput={linkEvent(this, this.handleHoneyPotChange)}
389 <div class="form-group row">
390 <div class="col-sm-10">
393 !this.state.postForm.community_id || this.state.loading
396 class="btn btn-secondary mr-2"
398 {this.state.loading ? (
400 ) : this.props.post_view.isSome() ? (
401 capitalizeFirstLetter(i18n.t("save"))
403 capitalizeFirstLetter(i18n.t("create"))
406 {this.props.post_view.isSome() && (
409 class="btn btn-secondary"
410 onClick={linkEvent(this, this.handleCancel)}
422 handlePostSubmit(i: PostForm, event: any) {
423 event.preventDefault();
425 // Coerce empty url string to undefined
427 i.state.postForm.url.isSome() &&
428 i.state.postForm.url.unwrapOr("blank") === ""
430 i.state.postForm.url = None;
433 let pForm = i.state.postForm;
434 i.props.post_view.match({
436 let form = new EditPost({
437 name: Some(pForm.name),
442 auth: auth().unwrap(),
444 WebSocketService.Instance.send(wsClient.editPost(form));
447 i.state.postForm.auth = auth().unwrap();
448 WebSocketService.Instance.send(wsClient.createPost(i.state.postForm));
451 i.state.loading = true;
455 copySuggestedTitle(i: PostForm) {
456 i.state.suggestedTitle.match({
458 i.state.postForm.name = sTitle.substring(0, MAX_POST_TITLE_LENGTH);
459 i.state.suggestedTitle = None;
461 let textarea: any = document.getElementById("post-title");
462 autosize.update(textarea);
470 handlePostUrlChange(i: PostForm, event: any) {
471 i.state.postForm.url = Some(event.target.value);
477 this.state.postForm.url.match({
480 let form = new Search({
483 community_name: None,
485 type_: Some(SearchType.Url),
486 sort: Some(SortType.TopAll),
487 listing_type: Some(ListingType.All),
489 limit: Some(trendingFetchLimit),
490 auth: auth(false).ok(),
493 WebSocketService.Instance.send(wsClient.search(form));
495 // Fetch the page title
496 getSiteMetadata(url).then(d => {
497 this.state.suggestedTitle = d.metadata.title;
498 this.setState(this.state);
501 this.state.suggestedTitle = None;
502 this.state.crossPosts = None;
509 handlePostNameChange(i: PostForm, event: any) {
510 i.state.postForm.name = event.target.value;
512 i.fetchSimilarPosts();
515 fetchSimilarPosts() {
516 let form = new Search({
517 q: this.state.postForm.name,
518 type_: Some(SearchType.Posts),
519 sort: Some(SortType.TopAll),
520 listing_type: Some(ListingType.All),
521 community_id: Some(this.state.postForm.community_id),
522 community_name: None,
525 limit: Some(trendingFetchLimit),
526 auth: auth(false).ok(),
529 if (this.state.postForm.name !== "") {
530 WebSocketService.Instance.send(wsClient.search(form));
532 this.state.suggestedPosts = None;
535 this.setState(this.state);
538 handlePostBodyChange(val: string) {
539 this.state.postForm.body = Some(val);
540 this.setState(this.state);
543 handlePostCommunityChange(i: PostForm, event: any) {
544 i.state.postForm.community_id = Number(event.target.value);
548 handlePostNsfwChange(i: PostForm, event: any) {
549 i.state.postForm.nsfw = Some(event.target.checked);
553 handleHoneyPotChange(i: PostForm, event: any) {
554 i.state.postForm.honeypot = Some(event.target.value);
558 handleCancel(i: PostForm) {
562 handlePreviewToggle(i: PostForm, event: any) {
563 event.preventDefault();
564 i.state.previewMode = !i.state.previewMode;
568 handleImageUploadPaste(i: PostForm, event: any) {
569 let image = event.clipboardData.files[0];
571 i.handleImageUpload(i, image);
575 handleImageUpload(i: PostForm, event: any) {
578 event.preventDefault();
579 file = event.target.files[0];
584 const formData = new FormData();
585 formData.append("images[]", file);
587 i.state.imageLoading = true;
594 .then(res => res.json())
596 console.log("pictrs upload:");
598 if (res.msg == "ok") {
599 let hash = res.files[0].file;
600 let url = `${pictrsUri}/${hash}`;
601 let deleteToken = res.files[0].delete_token;
602 let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
603 i.state.postForm.url = Some(url);
604 i.state.imageLoading = false;
607 i18n.t("click_to_delete_picture"),
608 i18n.t("picture_deleted"),
612 i.state.imageLoading = false;
614 toast(JSON.stringify(res), "danger");
618 i.state.imageLoading = false;
620 console.error(error);
621 toast(error, "danger");
626 // Set up select searching
628 let selectId: any = document.getElementById("post-community");
630 this.choices = new Choices(selectId, choicesConfig);
631 this.choices.passedElement.element.addEventListener(
634 this.state.postForm.community_id = Number(e.detail.choice.value);
635 this.setState(this.state);
639 this.choices.passedElement.element.addEventListener(
641 debounce(async (e: any) => {
643 let communities = (await fetchCommunities(e.detail.value))
645 this.choices.setChoices(
646 communities.map(cv => communityToChoice(cv)),
660 this.props.post_view.match({
661 some: pv => (this.state.postForm.community_id = pv.community.id),
664 this.props.params.match({
666 params.nameOrId.match({
670 let foundCommunityId = this.props.communities
672 .find(r => r.community.name == name).community.id;
673 this.state.postForm.community_id = foundCommunityId;
675 right: id => (this.state.postForm.community_id = id),
682 if (isBrowser() && this.state.postForm.community_id) {
683 this.choices.setChoiceByValue(
684 this.state.postForm.community_id.toString()
687 this.setState(this.state);
690 parseMessage(msg: any) {
691 let op = wsUserOp(msg);
694 // Errors handled by top level pages
695 // toast(i18n.t(msg.error), "danger");
696 this.state.loading = false;
697 this.setState(this.state);
699 } else if (op == UserOperation.CreatePost) {
700 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
701 UserService.Instance.myUserInfo.match({
703 if (data.post_view.creator.id == mui.local_user_view.person.id) {
704 this.state.loading = false;
705 this.props.onCreate(data.post_view);
710 } else if (op == UserOperation.EditPost) {
711 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
712 UserService.Instance.myUserInfo.match({
714 if (data.post_view.creator.id == mui.local_user_view.person.id) {
715 this.state.loading = false;
716 this.props.onEdit(data.post_view);
721 } else if (op == UserOperation.Search) {
722 let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
724 if (data.type_ == SearchType[SearchType.Posts]) {
725 this.state.suggestedPosts = Some(data.posts);
726 } else if (data.type_ == SearchType[SearchType.Url]) {
727 this.state.crossPosts = Some(data.posts);
729 this.setState(this.state);