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", {
209 htmlFor="file-upload"
211 UserService.Instance.myUserInfo.isSome() && "pointer"
212 } d-inline-block float-right text-muted font-weight-bold`}
213 data-tippy-content={i18n.t("upload_image")}
215 <Icon icon="image" classes="icon-inline" />
220 accept="image/*,video/*"
223 disabled={UserService.Instance.myUserInfo.isNone()}
224 onChange={linkEvent(this, this.handleImageUpload)}
227 {this.state.postForm.url.match({
232 href={`${webArchiveUrl}/save/${encodeURIComponent(
235 class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
238 archive.org {i18n.t("archive_link")}
241 href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
244 class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
247 ghostarchive.org {i18n.t("archive_link")}
250 href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
253 class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
256 archive.today {i18n.t("archive_link")}
262 {this.state.imageLoading && <Spinner />}
263 {this.state.postForm.url.match({
265 isImage(url) && <img src={url} class="img-fluid" alt="" />,
268 {this.state.crossPosts.match({
270 xPosts.length > 0 && (
272 <div class="my-1 text-muted small font-weight-bold">
273 {i18n.t("cross_posts")}
278 enableDownvotes={this.props.enableDownvotes}
279 enableNsfw={this.props.enableNsfw}
287 <div class="form-group row">
288 <label class="col-sm-2 col-form-label" htmlFor="post-title">
291 <div class="col-sm-10">
293 value={this.state.postForm.name}
295 onInput={linkEvent(this, this.handlePostNameChange)}
296 class={`form-control ${
297 !validTitle(this.state.postForm.name) && "is-invalid"
302 maxLength={MAX_POST_TITLE_LENGTH}
304 {!validTitle(this.state.postForm.name) && (
305 <div class="invalid-feedback">
306 {i18n.t("invalid_post_title")}
309 {this.state.suggestedPosts.match({
311 sPosts.length > 0 && (
313 <div class="my-1 text-muted small font-weight-bold">
314 {i18n.t("related_posts")}
318 enableDownvotes={this.props.enableDownvotes}
319 enableNsfw={this.props.enableNsfw}
328 <div class="form-group row">
329 <label class="col-sm-2 col-form-label">{i18n.t("body")}</label>
330 <div class="col-sm-10">
332 initialContent={this.state.postForm.body}
333 onContentChange={this.handlePostBodyChange}
340 {this.props.post_view.isNone() && (
341 <div class="form-group row">
342 <label class="col-sm-2 col-form-label" htmlFor="post-community">
343 {i18n.t("community")}
345 <div class="col-sm-10">
349 value={this.state.postForm.community_id}
350 onInput={linkEvent(this, this.handlePostCommunityChange)}
352 <option>{i18n.t("select_a_community")}</option>
353 {this.props.communities.unwrapOr([]).map(cv => (
354 <option value={cv.community.id}>
355 {communitySelectName(cv)}
362 {this.props.enableNsfw && (
363 <div class="form-group row">
364 <legend class="col-form-label col-sm-2 pt-0">
367 <div class="col-sm-10">
368 <div class="form-check">
370 class="form-check-input position-static"
373 checked={toUndefined(this.state.postForm.nsfw)}
374 onChange={linkEvent(this, this.handlePostNsfwChange)}
385 class="form-control honeypot"
387 value={toUndefined(this.state.postForm.honeypot)}
388 onInput={linkEvent(this, this.handleHoneyPotChange)}
390 <div class="form-group row">
391 <div class="col-sm-10">
394 !this.state.postForm.community_id || this.state.loading
397 class="btn btn-secondary mr-2"
399 {this.state.loading ? (
401 ) : this.props.post_view.isSome() ? (
402 capitalizeFirstLetter(i18n.t("save"))
404 capitalizeFirstLetter(i18n.t("create"))
407 {this.props.post_view.isSome() && (
410 class="btn btn-secondary"
411 onClick={linkEvent(this, this.handleCancel)}
423 handlePostSubmit(i: PostForm, event: any) {
424 event.preventDefault();
426 // Coerce empty url string to undefined
428 i.state.postForm.url.isSome() &&
429 i.state.postForm.url.unwrapOr("blank") === ""
431 i.state.postForm.url = None;
434 let pForm = i.state.postForm;
435 i.props.post_view.match({
437 let form = new EditPost({
438 name: Some(pForm.name),
443 auth: auth().unwrap(),
445 WebSocketService.Instance.send(wsClient.editPost(form));
448 i.state.postForm.auth = auth().unwrap();
449 WebSocketService.Instance.send(wsClient.createPost(i.state.postForm));
452 i.state.loading = true;
456 copySuggestedTitle(i: PostForm) {
457 i.state.suggestedTitle.match({
459 i.state.postForm.name = sTitle.substring(0, MAX_POST_TITLE_LENGTH);
460 i.state.suggestedTitle = None;
462 let textarea: any = document.getElementById("post-title");
463 autosize.update(textarea);
471 handlePostUrlChange(i: PostForm, event: any) {
472 i.state.postForm.url = Some(event.target.value);
478 this.state.postForm.url.match({
481 let form = new Search({
484 community_name: None,
486 type_: Some(SearchType.Url),
487 sort: Some(SortType.TopAll),
488 listing_type: Some(ListingType.All),
490 limit: Some(trendingFetchLimit),
491 auth: auth(false).ok(),
494 WebSocketService.Instance.send(wsClient.search(form));
496 // Fetch the page title
497 getSiteMetadata(url).then(d => {
498 this.state.suggestedTitle = d.metadata.title;
499 this.setState(this.state);
502 this.state.suggestedTitle = None;
503 this.state.crossPosts = None;
510 handlePostNameChange(i: PostForm, event: any) {
511 i.state.postForm.name = event.target.value;
513 i.fetchSimilarPosts();
516 fetchSimilarPosts() {
517 let form = new Search({
518 q: this.state.postForm.name,
519 type_: Some(SearchType.Posts),
520 sort: Some(SortType.TopAll),
521 listing_type: Some(ListingType.All),
522 community_id: Some(this.state.postForm.community_id),
523 community_name: None,
526 limit: Some(trendingFetchLimit),
527 auth: auth(false).ok(),
530 if (this.state.postForm.name !== "") {
531 WebSocketService.Instance.send(wsClient.search(form));
533 this.state.suggestedPosts = None;
536 this.setState(this.state);
539 handlePostBodyChange(val: string) {
540 this.state.postForm.body = Some(val);
541 this.setState(this.state);
544 handlePostCommunityChange(i: PostForm, event: any) {
545 i.state.postForm.community_id = Number(event.target.value);
549 handlePostNsfwChange(i: PostForm, event: any) {
550 i.state.postForm.nsfw = Some(event.target.checked);
554 handleHoneyPotChange(i: PostForm, event: any) {
555 i.state.postForm.honeypot = Some(event.target.value);
559 handleCancel(i: PostForm) {
563 handlePreviewToggle(i: PostForm, event: any) {
564 event.preventDefault();
565 i.state.previewMode = !i.state.previewMode;
569 handleImageUploadPaste(i: PostForm, event: any) {
570 let image = event.clipboardData.files[0];
572 i.handleImageUpload(i, image);
576 handleImageUpload(i: PostForm, event: any) {
579 event.preventDefault();
580 file = event.target.files[0];
585 const formData = new FormData();
586 formData.append("images[]", file);
588 i.state.imageLoading = true;
595 .then(res => res.json())
597 console.log("pictrs upload:");
599 if (res.msg == "ok") {
600 let hash = res.files[0].file;
601 let url = `${pictrsUri}/${hash}`;
602 let deleteToken = res.files[0].delete_token;
603 let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`;
604 i.state.postForm.url = Some(url);
605 i.state.imageLoading = false;
608 i18n.t("click_to_delete_picture"),
609 i18n.t("picture_deleted"),
613 i.state.imageLoading = false;
615 toast(JSON.stringify(res), "danger");
619 i.state.imageLoading = false;
621 console.error(error);
622 toast(error, "danger");
627 // Set up select searching
629 let selectId: any = document.getElementById("post-community");
631 this.choices = new Choices(selectId, choicesConfig);
632 this.choices.passedElement.element.addEventListener(
635 this.state.postForm.community_id = Number(e.detail.choice.value);
636 this.setState(this.state);
640 this.choices.passedElement.element.addEventListener(
642 debounce(async (e: any) => {
644 let communities = (await fetchCommunities(e.detail.value))
646 this.choices.setChoices(
647 communities.map(cv => communityToChoice(cv)),
661 this.props.post_view.match({
662 some: pv => (this.state.postForm.community_id = pv.community.id),
665 this.props.params.match({
667 params.nameOrId.match({
671 let foundCommunityId = this.props.communities
673 .find(r => r.community.name == name).community.id;
674 this.state.postForm.community_id = foundCommunityId;
676 right: id => (this.state.postForm.community_id = id),
683 if (isBrowser() && this.state.postForm.community_id) {
684 this.choices.setChoiceByValue(
685 this.state.postForm.community_id.toString()
688 this.setState(this.state);
691 parseMessage(msg: any) {
692 let op = wsUserOp(msg);
695 // Errors handled by top level pages
696 // toast(i18n.t(msg.error), "danger");
697 this.state.loading = false;
698 this.setState(this.state);
700 } else if (op == UserOperation.CreatePost) {
701 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
702 UserService.Instance.myUserInfo.match({
704 if (data.post_view.creator.id == mui.local_user_view.person.id) {
705 this.state.loading = false;
706 this.props.onCreate(data.post_view);
711 } else if (op == UserOperation.EditPost) {
712 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
713 UserService.Instance.myUserInfo.match({
715 if (data.post_view.creator.id == mui.local_user_view.person.id) {
716 this.state.loading = false;
717 this.props.onEdit(data.post_view);
722 } else if (op == UserOperation.Search) {
723 let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
725 if (data.type_ == SearchType[SearchType.Posts]) {
726 this.state.suggestedPosts = Some(data.posts);
727 } else if (data.type_ == SearchType[SearchType.Url]) {
728 this.state.crossPosts = Some(data.posts);
730 this.setState(this.state);