Fix some submit button issues (#2487)

* Prevent PostForm submit button spam

* Keep CreatePost PostForm visible during submission

* Keep PostListing PostForm visible during submission

* Keep PostForm navigation warning enabled during submission

* Remove `finished` from MarkdownTextAreaProps

* Handle CommentForm submission failures

* Keep CommentForm navigation warning enabled during submission

* Handle PrivateMessageForm submission failures

* Bypass navigation warning for successful CreatePrivateMessage

* Fix absolute import, add eslint rule

* Cleaner handleCommentSubmit

---------

Co-authored-by: SleeplessOne1917 <28871516+SleeplessOne1917@users.noreply.github.com>
This commit is contained in:
matc-pub 2024-06-02 17:46:32 +02:00 committed by GitHub
parent 14ae45fe95
commit 02fcfa26ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 270 additions and 251 deletions

View file

@ -79,6 +79,17 @@ export default [
"unicorn/filename-case": 0,
"jsx-a11y/media-has-caption": 0,
"jsx-a11y/label-has-associated-control": 0,
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["assets/*", "client/*", "server/*", "shared/*"],
message: "Use relative import instead.",
},
],
},
],
},
},
];

View file

@ -2,18 +2,23 @@ import { capitalizeFirstLetter } from "@utils/helpers";
import { Component } from "inferno";
import { T } from "inferno-i18next-dess";
import { Link } from "inferno-router";
import { CreateComment, EditComment, Language } from "lemmy-js-client";
import {
CommentResponse,
CreateComment,
EditComment,
Language,
} from "lemmy-js-client";
import { CommentNodeI } from "../../interfaces";
import { I18NextService, UserService } from "../../services";
import { Icon } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea";
import { RequestState } from "../../services/HttpService";
interface CommentFormProps {
/**
* Can either be the parent, or the editable comment. The right side is a postId.
*/
node: CommentNodeI | number;
finished?: boolean;
edit?: boolean;
disabled?: boolean;
focus?: boolean;
@ -21,7 +26,9 @@ interface CommentFormProps {
allLanguages: Language[];
siteLanguages: number[];
containerClass?: string;
onUpsertComment(form: EditComment | CreateComment): void;
onUpsertComment(
form: EditComment | CreateComment,
): Promise<RequestState<CommentResponse>>;
}
export class CommentForm extends Component<CommentFormProps, any> {
@ -50,7 +57,6 @@ export class CommentForm extends Component<CommentFormProps, any> {
initialContent={initialContent}
showLanguage
buttonTitle={this.buttonTitle}
finished={this.props.finished}
replyType={typeof this.props.node !== "number"}
focus={this.props.focus}
disabled={this.props.disabled}
@ -83,33 +89,38 @@ export class CommentForm extends Component<CommentFormProps, any> {
: capitalizeFirstLetter(I18NextService.i18n.t("reply"));
}
handleCommentSubmit(content: string, language_id?: number) {
async handleCommentSubmit(
content: string,
language_id?: number,
): Promise<boolean> {
const { node, onUpsertComment, edit } = this.props;
let response: RequestState<CommentResponse>;
if (typeof node === "number") {
const post_id = node;
onUpsertComment({
response = await onUpsertComment({
content,
post_id,
language_id,
});
} else if (edit) {
const comment_id = node.comment_view.comment.id;
response = await onUpsertComment({
content,
comment_id,
language_id,
});
} else {
if (edit) {
const comment_id = node.comment_view.comment.id;
onUpsertComment({
content,
comment_id,
language_id,
});
} else {
const post_id = node.comment_view.post.id;
const parent_id = node.comment_view.comment.id;
this.props.onUpsertComment({
content,
parent_id,
post_id,
language_id,
});
}
const post_id = node.comment_view.post.id;
const parent_id = node.comment_view.comment.id;
response = await onUpsertComment({
content,
parent_id,
post_id,
language_id,
});
}
return response.state !== "failed";
}
}

View file

@ -2,7 +2,7 @@ import { colorList, getCommentParentId } from "@utils/app";
import { futureDaysToUnixTime, numToSI } from "@utils/helpers";
import classNames from "classnames";
import { isBefore, parseISO, subMinutes } from "date-fns";
import { Component, InfernoNode, linkEvent } from "inferno";
import { Component, linkEvent } from "inferno";
import { Link } from "inferno-router";
import {
AddAdmin,
@ -33,7 +33,6 @@ import {
SaveComment,
TransferCommunity,
} from "lemmy-js-client";
import deepEqual from "lodash.isequal";
import { commentTreeMaxDepth } from "../../config";
import {
CommentNodeI,
@ -87,7 +86,6 @@ interface CommentNodeProps {
allLanguages: Language[];
siteLanguages: number[];
hideImages?: boolean;
finished: Map<CommentId, boolean | undefined>;
onSaveComment(form: SaveComment): Promise<void>;
onCommentReplyRead(form: MarkCommentReplyAsRead): void;
onPersonMentionRead(form: MarkPersonMentionAsRead): void;
@ -139,6 +137,8 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
super(props, context);
this.handleReplyCancel = this.handleReplyCancel.bind(this);
this.handleCreateComment = this.handleCreateComment.bind(this);
this.handleEditComment = this.handleEditComment.bind(this);
this.handleReportComment = this.handleReportComment.bind(this);
this.handleRemoveComment = this.handleRemoveComment.bind(this);
this.handleReplyClick = this.handleReplyClick.bind(this);
@ -164,22 +164,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
return this.commentView.comment.id;
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & CommentNodeProps>,
): void {
if (!deepEqual(this.props, nextProps)) {
this.setState({
showEdit: false,
showAdvanced: false,
createOrEditCommentLoading: false,
upvoteLoading: false,
downvoteLoading: false,
readLoading: false,
fetchChildrenLoading: false,
});
}
}
render() {
const node = this.props.node;
const cv = this.commentView;
@ -283,12 +267,11 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
edit
onReplyCancel={this.handleReplyCancel}
disabled={this.props.locked}
finished={this.props.finished.get(id)}
focus
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
containerClass="comment-comment-container"
onUpsertComment={this.props.onEditComment}
onUpsertComment={this.handleEditComment}
/>
)}
{!this.state.showEdit && !this.state.collapsed && (
@ -425,12 +408,11 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
node={node}
onReplyCancel={this.handleReplyCancel}
disabled={this.props.locked}
finished={this.props.finished.get(id)}
focus
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
containerClass="comment-comment-container"
onUpsertComment={this.props.onCreateComment}
onUpsertComment={this.handleCreateComment}
/>
)}
{!this.state.collapsed && node.children.length > 0 && (
@ -447,7 +429,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
hideImages={this.props.hideImages}
isChild={!this.props.isTopLevel}
depth={this.props.node.depth + 1}
finished={this.props.finished}
onCommentReplyRead={this.props.onCommentReplyRead}
onPersonMentionRead={this.props.onPersonMentionRead}
onCreateComment={this.props.onCreateComment}
@ -559,6 +540,26 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
this.setState({ showReply: false, showEdit: false });
}
async handleCreateComment(
form: CreateComment,
): Promise<RequestState<CommentResponse>> {
const res = await this.props.onCreateComment(form);
if (res.state !== "failed") {
this.setState({ showReply: false, showEdit: false });
}
return res;
}
async handleEditComment(
form: EditComment,
): Promise<RequestState<CommentResponse>> {
const res = await this.props.onEditComment(form);
if (res.state !== "failed") {
this.setState({ showReply: false, showEdit: false });
}
return res;
}
isPersonMentionType(item: CommentNodeView): item is PersonMentionView {
return item.person_mention?.id !== undefined;
}

View file

@ -7,7 +7,6 @@ import {
BanFromCommunity,
BanPerson,
BlockPerson,
CommentId,
CommentResponse,
CommunityModeratorView,
CreateComment,
@ -52,7 +51,6 @@ interface CommentNodesProps {
hideImages?: boolean;
isChild?: boolean;
depth?: number;
finished: Map<CommentId, boolean | undefined>;
onSaveComment(form: SaveComment): Promise<void>;
onCommentReplyRead(form: MarkCommentReplyAsRead): void;
onPersonMentionRead(form: MarkPersonMentionAsRead): void;
@ -124,7 +122,6 @@ export class CommentNodes extends Component<CommentNodesProps, any> {
hideImages={this.props.hideImages}
onCommentReplyRead={this.props.onCommentReplyRead}
onPersonMentionRead={this.props.onPersonMentionRead}
finished={this.props.finished}
onCreateComment={this.props.onCreateComment}
onEditComment={this.props.onEditComment}
onCommentVote={this.props.onCommentVote}

View file

@ -90,7 +90,6 @@ export class CommentReport extends Component<
siteLanguages={[]}
hideImages
// All of these are unused, since its viewonly
finished={new Map()}
onSaveComment={async () => {}}
onBlockPerson={async () => {}}
onDeleteComment={async () => {}}

View file

@ -3,7 +3,7 @@ import { numToSI, randomStr } from "@utils/helpers";
import autosize from "autosize";
import classNames from "classnames";
import { NoOptionI18nKeys } from "i18next";
import { Component, InfernoNode, linkEvent } from "inferno";
import { Component, linkEvent } from "inferno";
import { Prompt } from "inferno-router";
import { Language } from "lemmy-js-client";
import {
@ -41,7 +41,6 @@ interface MarkdownTextAreaProps {
replyType?: boolean;
focus?: boolean;
disabled?: boolean;
finished?: boolean;
/**
* Whether to show the language selector
*/
@ -49,7 +48,7 @@ interface MarkdownTextAreaProps {
hideNavigationWarnings?: boolean;
onContentChange?(val: string): void;
onReplyCancel?(): void;
onSubmit?(content: string, languageId?: number): void;
onSubmit?(content: string, languageId?: number): Promise<boolean>;
allLanguages: Language[]; // TODO should probably be nullable
siteLanguages: number[]; // TODO same
}
@ -115,27 +114,6 @@ export class MarkdownTextArea extends Component<
}
}
componentWillReceiveProps(
nextProps: MarkdownTextAreaProps & { children?: InfernoNode },
) {
if (nextProps.finished) {
this.setState({
previewMode: false,
imageUploadStatus: undefined,
loading: false,
content: undefined,
});
if (this.props.replyType) {
this.props.onReplyCancel?.();
}
const textarea: any = document.getElementById(this.id);
const form: any = document.getElementById(this.formId);
form.reset();
setTimeout(() => autosize.update(textarea), 10);
}
}
render() {
const languageId = this.state.languageId;
@ -149,8 +127,8 @@ export class MarkdownTextArea extends Component<
message={I18NextService.i18n.t("block_leaving")}
when={
!this.props.hideNavigationWarnings &&
!!this.state.content &&
!this.state.submitted
((!!this.state.content && !this.state.submitted) ||
this.state.loading)
}
/>
<div className="mb-3 row">
@ -575,11 +553,15 @@ export class MarkdownTextArea extends Component<
this.setState({ languageId: val[0] });
}
handleSubmit(i: MarkdownTextArea, event: any) {
async handleSubmit(i: MarkdownTextArea, event: any) {
event.preventDefault();
if (i.state.content) {
i.setState({ loading: true, submitted: true });
i.props.onSubmit?.(i.state.content, i.state.languageId);
const success = await i.props.onSubmit?.(
i.state.content,
i.state.languageId,
);
i.setState({ loading: false, submitted: success ?? true });
}
}

View file

@ -6,7 +6,6 @@ import {
editWith,
enableDownvotes,
enableNsfw,
getCommentParentId,
getDataTypeString,
postToCommentSortType,
setIsoData,
@ -42,7 +41,6 @@ import {
BanPersonResponse,
BlockCommunity,
BlockPerson,
CommentId,
CommentReplyResponse,
CommentResponse,
CommunityResponse,
@ -136,7 +134,6 @@ interface State {
commentsRes: RequestState<GetCommentsResponse>;
siteRes: GetSiteResponse;
showSidebarMobile: boolean;
finished: Map<CommentId, boolean | undefined>;
isIsomorphic: boolean;
}
@ -201,7 +198,6 @@ export class Community extends Component<CommunityRouteProps, State> {
commentsRes: EMPTY_REQUEST,
siteRes: this.isoData.site_res,
showSidebarMobile: false,
finished: new Map(),
isIsomorphic: false,
};
private readonly mainContentRef: RefObject<HTMLElement>;
@ -528,7 +524,6 @@ export class Community extends Component<CommunityRouteProps, State> {
<CommentNodes
nodes={commentsToFlatNodes(this.state.commentsRes.data.comments)}
viewType={CommentViewType.Flat}
finished={this.state.finished}
isTopLevel
showContext
enableDownvotes={enableDownvotes(siteRes)}
@ -820,6 +815,9 @@ export class Community extends Component<CommunityRouteProps, State> {
const createCommentRes = await HttpService.client.createComment(form);
this.createAndUpdateComments(createCommentRes);
if (createCommentRes.state === "failed") {
toast(I18NextService.i18n.t(createCommentRes.err.message), "danger");
}
return createCommentRes;
}
@ -827,6 +825,9 @@ export class Community extends Component<CommunityRouteProps, State> {
const editCommentRes = await HttpService.client.editComment(form);
this.findAndUpdateCommentEdit(editCommentRes);
if (editCommentRes.state === "failed") {
toast(I18NextService.i18n.t(editCommentRes.err.message), "danger");
}
return editCommentRes;
}
@ -1038,7 +1039,6 @@ export class Community extends Component<CommunityRouteProps, State> {
res.data.comment_view,
s.commentsRes.data.comments,
);
s.finished.set(res.data.comment_view.comment.id, true);
}
return s;
});
@ -1060,12 +1060,6 @@ export class Community extends Component<CommunityRouteProps, State> {
this.setState(s => {
if (s.commentsRes.state === "success" && res.state === "success") {
s.commentsRes.data.comments.unshift(res.data.comment_view);
// Set finished for the parent
s.finished.set(
getCommentParentId(res.data.comment_view.comment) ?? 0,
true,
);
}
return s;
});

View file

@ -5,7 +5,6 @@ import {
editWith,
enableDownvotes,
enableNsfw,
getCommentParentId,
getDataTypeString,
myAuth,
postToCommentSortType,
@ -37,7 +36,6 @@ import {
BanPerson,
BanPersonResponse,
BlockPerson,
CommentId,
CommentReplyResponse,
CommentResponse,
CreateComment,
@ -125,7 +123,6 @@ interface HomeState {
subscribedCollapsed: boolean;
tagline?: string;
siteRes: GetSiteResponse;
finished: Map<CommentId, boolean | undefined>;
isIsomorphic: boolean;
}
@ -274,7 +271,6 @@ export class Home extends Component<HomeRouteProps, HomeState> {
showTrendingMobile: false,
showSidebarMobile: false,
subscribedCollapsed: false,
finished: new Map(),
isIsomorphic: false,
};
@ -770,7 +766,6 @@ export class Home extends Component<HomeRouteProps, HomeState> {
<CommentNodes
nodes={commentsToFlatNodes(comments)}
viewType={CommentViewType.Flat}
finished={this.state.finished}
isTopLevel
showCommunity
showContext
@ -973,6 +968,9 @@ export class Home extends Component<HomeRouteProps, HomeState> {
const createCommentRes = await HttpService.client.createComment(form);
this.createAndUpdateComments(createCommentRes);
if (createCommentRes.state === "failed") {
toast(I18NextService.i18n.t(createCommentRes.err.message), "danger");
}
return createCommentRes;
}
@ -980,6 +978,9 @@ export class Home extends Component<HomeRouteProps, HomeState> {
const editCommentRes = await HttpService.client.editComment(form);
this.findAndUpdateCommentEdit(editCommentRes);
if (editCommentRes.state === "failed") {
toast(I18NextService.i18n.t(editCommentRes.err.message), "danger");
}
return editCommentRes;
}
@ -1168,7 +1169,6 @@ export class Home extends Component<HomeRouteProps, HomeState> {
res.data.comment_view,
s.commentsRes.data.comments,
);
s.finished.set(res.data.comment_view.comment.id, true);
}
return s;
});
@ -1190,12 +1190,6 @@ export class Home extends Component<HomeRouteProps, HomeState> {
this.setState(s => {
if (s.commentsRes.state === "success" && res.state === "success") {
s.commentsRes.data.comments.unshift(res.data.comment_view);
// Set finished for the parent
s.finished.set(
getCommentParentId(res.data.comment_view.comment) ?? 0,
true,
);
}
return s;
});

View file

@ -5,7 +5,6 @@ import {
editPrivateMessage,
editWith,
enableDownvotes,
getCommentParentId,
myAuth,
setIsoData,
updatePersonBlock,
@ -28,7 +27,6 @@ import {
BanPerson,
BanPersonResponse,
BlockPerson,
CommentId,
CommentReplyResponse,
CommentReplyView,
CommentReportResponse,
@ -132,7 +130,6 @@ interface InboxState {
sort: CommentSortType;
page: number;
siteRes: GetSiteResponse;
finished: Map<CommentId, boolean | undefined>;
isIsomorphic: boolean;
}
@ -157,7 +154,6 @@ export class Inbox extends Component<InboxRouteProps, InboxState> {
mentionsRes: EMPTY_REQUEST,
messagesRes: EMPTY_REQUEST,
markAllAsReadRes: EMPTY_REQUEST,
finished: new Map(),
isIsomorphic: false,
};
@ -512,7 +508,6 @@ export class Inbox extends Component<InboxRouteProps, InboxState> {
{ comment_view: i.view as CommentView, children: [], depth: 0 },
]}
viewType={CommentViewType.Flat}
finished={this.state.finished}
markable
showCommunity
showContext
@ -551,7 +546,6 @@ export class Inbox extends Component<InboxRouteProps, InboxState> {
depth: 0,
},
]}
finished={this.state.finished}
viewType={CommentViewType.Flat}
markable
showCommunity
@ -623,7 +617,6 @@ export class Inbox extends Component<InboxRouteProps, InboxState> {
<CommentNodes
nodes={commentsToFlatNodes(replies)}
viewType={CommentViewType.Flat}
finished={this.state.finished}
markable
showCommunity
showContext
@ -670,7 +663,6 @@ export class Inbox extends Component<InboxRouteProps, InboxState> {
key={umv.person_mention.id}
nodes={[{ comment_view: umv, children: [], depth: 0 }]}
viewType={CommentViewType.Flat}
finished={this.state.finished}
markable
showCommunity
showContext
@ -996,9 +988,13 @@ export class Inbox extends Component<InboxRouteProps, InboxState> {
this.findAndUpdateMessage(res);
}
async handleEditMessage(form: EditPrivateMessage) {
async handleEditMessage(form: EditPrivateMessage): Promise<boolean> {
const res = await HttpService.client.editPrivateMessage(form);
this.findAndUpdateMessage(res);
if (res.state === "failed") {
toast(I18NextService.i18n.t(res.err.message), "danger");
}
return res.state !== "failed";
}
async handleMarkMessageAsRead(form: MarkPrivateMessageAsRead) {
@ -1015,7 +1011,7 @@ export class Inbox extends Component<InboxRouteProps, InboxState> {
this.reportToast(res);
}
async handleCreateMessage(form: CreatePrivateMessage) {
async handleCreateMessage(form: CreatePrivateMessage): Promise<boolean> {
const res = await HttpService.client.createPrivateMessage(form);
this.setState(s => {
if (s.messagesRes.state === "success" && res.state === "success") {
@ -1026,6 +1022,10 @@ export class Inbox extends Component<InboxRouteProps, InboxState> {
return s;
});
if (res.state === "failed") {
toast(I18NextService.i18n.t(res.err.message), "danger");
}
return res.state !== "failed";
}
findAndUpdateMessage(res: RequestState<PrivateMessageResponse>) {
@ -1094,6 +1094,8 @@ export class Inbox extends Component<InboxRouteProps, InboxState> {
) {
if (res.state === "success") {
toast(I18NextService.i18n.t("report_created"));
} else if (res.state === "failed") {
toast(I18NextService.i18n.t(res.err.message), "danger");
}
}
@ -1113,11 +1115,6 @@ export class Inbox extends Component<InboxRouteProps, InboxState> {
s.mentionsRes.data.mentions,
);
}
// Set finished for the parent
s.finished.set(
getCommentParentId(res.data.comment_view.comment) ?? 0,
true,
);
return s;
});
}

View file

@ -6,7 +6,6 @@ import {
BanFromCommunity,
BanPerson,
BlockPerson,
CommentId,
CommentResponse,
CommentView,
CreateComment,
@ -49,7 +48,6 @@ import { RequestState } from "../../services/HttpService";
interface PersonDetailsProps {
personRes: GetPersonDetailsResponse;
finished: Map<CommentId, boolean | undefined>;
admins: PersonView[];
allLanguages: Language[];
siteLanguages: number[];
@ -153,7 +151,6 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
key={i.id}
nodes={[{ comment_view: c, children: [], depth: 0 }]}
viewType={CommentViewType.Flat}
finished={this.props.finished}
admins={this.props.admins}
noBorder
showCommunity
@ -266,7 +263,6 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
nodes={commentsToFlatNodes(this.props.personRes.comments)}
viewType={CommentViewType.Flat}
admins={this.props.admins}
finished={this.props.finished}
isTopLevel
showCommunity
showContext

View file

@ -4,7 +4,6 @@ import {
editWith,
enableDownvotes,
enableNsfw,
getCommentParentId,
setIsoData,
updatePersonBlock,
voteDisplayMode,
@ -38,7 +37,6 @@ import {
BanPerson,
BanPersonResponse,
BlockPerson,
CommentId,
CommentReplyResponse,
CommentResponse,
Community,
@ -120,7 +118,6 @@ interface ProfileState {
showBanDialog: boolean;
removeData: boolean;
siteRes: GetSiteResponse;
finished: Map<CommentId, boolean | undefined>;
isIsomorphic: boolean;
}
@ -206,7 +203,6 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
siteRes: this.isoData.site_res,
showBanDialog: false,
removeData: false,
finished: new Map(),
isIsomorphic: false,
};
@ -491,7 +487,6 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
sort={sort}
page={page}
limit={fetchLimit}
finished={this.state.finished}
enableDownvotes={enableDownvotes(siteRes)}
voteDisplayMode={voteDisplayMode(siteRes)}
enableNsfw={enableNsfw(siteRes)}
@ -1178,7 +1173,6 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
res.data.comment_view,
s.personRes.data.comments,
);
s.finished.set(res.data.comment_view.comment.id, true);
}
return s;
});
@ -1200,11 +1194,6 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
this.setState(s => {
if (s.personRes.state === "success" && res.state === "success") {
s.personRes.data.comments.unshift(res.data.comment_view);
// Set finished for the parent
s.finished.set(
getCommentParentId(res.data.comment_view.comment) ?? 0,
true,
);
}
return s;
});

View file

@ -27,7 +27,6 @@ import {
wrapClient,
} from "../../services/HttpService";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import { PostForm } from "./post-form";
import { getHttpBaseInternal } from "../../utils/env";
import { IRoutePropsWithFetch } from "../../routes";
@ -178,39 +177,28 @@ export class CreatePost extends Component<
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
{this.state.loading ? (
<h5>
<Spinner large />
</h5>
) : (
<div className="row">
<div
id="createPostForm"
className="col-12 col-lg-6 offset-lg-3 mb-4"
>
<h1 className="h4 mb-4">
{I18NextService.i18n.t("create_post")}
</h1>
<PostForm
onCreate={this.handlePostCreate}
params={locationState}
enableDownvotes={enableDownvotes(siteRes)}
voteDisplayMode={voteDisplayMode(siteRes)}
enableNsfw={enableNsfw(siteRes)}
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
selectedCommunityChoice={selectedCommunityChoice}
onSelectCommunity={this.handleSelectedCommunityChange}
initialCommunities={
this.state.initialCommunitiesRes.state === "success"
? this.state.initialCommunitiesRes.data.communities
: []
}
loading={loading}
/>
</div>
<div className="row">
<div id="createPostForm" className="col-12 col-lg-6 offset-lg-3 mb-4">
<h1 className="h4 mb-4">{I18NextService.i18n.t("create_post")}</h1>
<PostForm
onCreate={this.handlePostCreate}
params={locationState}
enableDownvotes={enableDownvotes(siteRes)}
voteDisplayMode={voteDisplayMode(siteRes)}
enableNsfw={enableNsfw(siteRes)}
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
selectedCommunityChoice={selectedCommunityChoice}
onSelectCommunity={this.handleSelectedCommunityChange}
initialCommunities={
this.state.initialCommunitiesRes.state === "success"
? this.state.initialCommunitiesRes.data.communities
: []
}
loading={loading}
/>
</div>
)}
</div>
</div>
);
}
@ -242,11 +230,13 @@ export class CreatePost extends Component<
});
}
async handlePostCreate(form: CreatePostI) {
async handlePostCreate(form: CreatePostI, bypassNavWarning: () => void) {
this.setState({ loading: true });
const res = await HttpService.client.createPost(form);
if (res.state === "success") {
const postId = res.data.post_view.post.id;
bypassNavWarning();
this.props.history.replace(`/post/${postId}`);
} else if (res.state === "failed") {
this.setState({

View file

@ -54,8 +54,8 @@ interface PostFormProps {
siteLanguages: number[];
params?: PostFormParams;
onCancel?(): void;
onCreate?(form: CreatePost): void;
onEdit?(form: EditPost): void;
onCreate?(form: CreatePost, bypassNavWarning: () => void): void;
onEdit?(form: EditPost, bypassNavWarning: () => void): void;
enableNsfw?: boolean;
enableDownvotes?: boolean;
voteDisplayMode: LocalUserVoteDisplayMode;
@ -85,6 +85,7 @@ interface PostFormState {
communitySearchOptions: Choice[];
previewMode: boolean;
submitted: boolean;
bypassNavWarning: boolean;
}
function handlePostSubmit(i: PostForm, event: any) {
@ -93,34 +94,46 @@ function handlePostSubmit(i: PostForm, event: any) {
if ((i.state.form.url ?? "") === "") {
i.setState(s => ((s.form.url = undefined), s));
}
// This forces `props.loading` to become true, then false, to enable the
// submit button again.
i.setState({ submitted: true });
const pForm = i.state.form;
const pv = i.props.post_view;
if (pv) {
i.props.onEdit?.({
post_id: pv.post.id,
name: pForm.name,
url: pForm.url,
body: pForm.body,
nsfw: pForm.nsfw,
language_id: pForm.language_id,
custom_thumbnail: pForm.custom_thumbnail,
alt_text: pForm.alt_text,
});
i.props.onEdit?.(
{
post_id: pv.post.id,
name: pForm.name,
url: pForm.url,
body: pForm.body,
nsfw: pForm.nsfw,
language_id: pForm.language_id,
custom_thumbnail: pForm.custom_thumbnail,
alt_text: pForm.alt_text,
},
() => {
i.setState({ bypassNavWarning: true });
},
);
} else if (pForm.name && pForm.community_id) {
i.props.onCreate?.({
name: pForm.name,
community_id: pForm.community_id,
url: pForm.url,
body: pForm.body,
nsfw: pForm.nsfw,
language_id: pForm.language_id,
honeypot: pForm.honeypot,
custom_thumbnail: pForm.custom_thumbnail,
alt_text: pForm.alt_text,
});
i.props.onCreate?.(
{
name: pForm.name,
community_id: pForm.community_id,
url: pForm.url,
body: pForm.body,
nsfw: pForm.nsfw,
language_id: pForm.language_id,
honeypot: pForm.honeypot,
custom_thumbnail: pForm.custom_thumbnail,
alt_text: pForm.alt_text,
},
() => {
i.setState({ bypassNavWarning: true });
},
);
}
}
@ -247,6 +260,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
previewMode: false,
communitySearchOptions: [],
submitted: false,
bypassNavWarning: false,
};
postTitleRef = createRef<HTMLTextAreaElement>();
@ -347,6 +361,9 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
nextProps.initialCommunities?.map(communityToChoice) ?? [],
});
}
if (this.props.loading && !nextProps.loading) {
this.setState({ submitted: false, bypassNavWarning: false });
}
}
render() {
@ -364,7 +381,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
this.state.form.name ||
this.state.form.url ||
this.state.form.body
) && !this.state.submitted
) && !this.state.bypassNavWarning
}
/>
<div className="mb-3 row">
@ -618,7 +635,11 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
<div className="mb-3 row">
<div className="col-sm-10">
<button
disabled={!this.state.form.community_id || this.props.loading}
disabled={
!this.state.form.community_id ||
this.props.loading ||
this.state.submitted
}
type="submit"
className="btn btn-secondary me-2"
>

View file

@ -842,13 +842,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
// The actual editing is done in the receive for post
async handleEditPost(form: EditPost) {
this.setState({ showEdit: false, loading: true });
this.setState({ loading: true });
const res = await this.props.onPostEdit(form);
if (res.state === "success") {
toast(I18NextService.i18n.t("edited_post"));
this.setState({ loading: false, showEdit: false });
} else if (res.state === "failed") {
toast(I18NextService.i18n.t(res.err.message), "danger");
this.setState({ loading: false });
}
}

View file

@ -120,8 +120,8 @@ interface PostState {
siteRes: GetSiteResponse;
showSidebarMobile: boolean;
maxCommentsShown: number;
finished: Map<CommentId, boolean | undefined>;
isIsomorphic: boolean;
lastCreatedCommentId?: CommentId;
}
const defaultCommentSort: CommentSortType = "Hot";
@ -219,7 +219,6 @@ export class Post extends Component<PostRouteProps, PostState> {
siteRes: this.isoData.site_res,
showSidebarMobile: false,
maxCommentsShown: commentsShownInterval,
finished: new Map(),
isIsomorphic: false,
};
@ -236,6 +235,8 @@ export class Post extends Component<PostRouteProps, PostState> {
this.handleFollow = this.handleFollow.bind(this);
this.handleModRemoveCommunity = this.handleModRemoveCommunity.bind(this);
this.handleCreateComment = this.handleCreateComment.bind(this);
this.handleCreateToplevelComment =
this.handleCreateToplevelComment.bind(this);
this.handleEditComment = this.handleEditComment.bind(this);
this.handleSaveComment = this.handleSaveComment.bind(this);
this.handleBlockCommunity = this.handleBlockCommunity.bind(this);
@ -585,7 +586,8 @@ export class Post extends Component<PostRouteProps, PostState> {
) && (
<CommentForm
key={
this.context.router.history.location.key
this.context.router.history.location.key +
this.state.lastCreatedCommentId
// reset on new location, otherwise <Prompt /> stops working
}
node={res.post_view.post.id}
@ -593,8 +595,7 @@ export class Post extends Component<PostRouteProps, PostState> {
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
containerClass="post-comment-container"
onUpsertComment={this.handleCreateComment}
finished={this.state.finished.get(0)}
onUpsertComment={this.handleCreateToplevelComment}
/>
)}
<div className="d-block d-md-none">
@ -774,7 +775,6 @@ export class Post extends Component<PostRouteProps, PostState> {
enableDownvotes={enableDownvotes(siteRes)}
voteDisplayMode={voteDisplayMode(siteRes)}
showContext
finished={this.state.finished}
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
onSaveComment={this.handleSaveComment}
@ -885,7 +885,6 @@ export class Post extends Component<PostRouteProps, PostState> {
admins={siteRes.admins}
enableDownvotes={enableDownvotes(siteRes)}
voteDisplayMode={voteDisplayMode(siteRes)}
finished={this.state.finished}
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
onSaveComment={this.handleSaveComment}
@ -1063,6 +1062,14 @@ export class Post extends Component<PostRouteProps, PostState> {
return res;
}
async handleCreateToplevelComment(form: CreateComment) {
const res = await this.handleCreateComment(form);
if (res.state === "success") {
this.setState({ lastCreatedCommentId: res.data.comment_view.comment.id });
}
return res;
}
async handleCreateComment(form: CreateComment) {
const createCommentRes = await HttpService.client.createComment(form);
this.createAndUpdateComments(createCommentRes);
@ -1380,12 +1387,12 @@ export class Post extends Component<PostRouteProps, PostState> {
);
comments.splice(foundCommentParentIndex + 1, 0, newComment);
// Set finished for the parent
s.finished.set(newCommentParentId ?? 0, true);
}
return s;
});
if (res.state === "failed") {
toast(I18NextService.i18n.t(res.err.message), "danger");
}
}
findAndUpdateCommentEdit(res: RequestState<CommentResponse>) {
@ -1395,13 +1402,14 @@ export class Post extends Component<PostRouteProps, PostState> {
res.data.comment_view,
s.commentsRes.data.comments,
);
s.finished.set(res.data.comment_view.comment.id, true);
}
return s;
});
if (res.state === "failed") {
toast(I18NextService.i18n.t(res.err.message), "danger");
}
}
// No need to set finished on a comment vote, save, etc
findAndUpdateComment(res: RequestState<CommentResponse>) {
this.setState(s => {
if (s.commentsRes.state === "success" && res.state === "success") {
@ -1412,6 +1420,9 @@ export class Post extends Component<PostRouteProps, PostState> {
}
return s;
});
if (res.state === "failed") {
toast(I18NextService.i18n.t(res.err.message), "danger");
}
}
findAndUpdateCommentReply(res: RequestState<CommentReplyResponse>) {
@ -1424,6 +1435,9 @@ export class Post extends Component<PostRouteProps, PostState> {
}
return s;
});
if (res.state === "failed") {
toast(I18NextService.i18n.t(res.err.message), "danger");
}
}
updateModerators(res: RequestState<AddModToCommunityResponse>) {

View file

@ -168,14 +168,22 @@ export class CreatePrivateMessage extends Component<
);
}
async handlePrivateMessageCreate(form: CreatePrivateMessageI) {
async handlePrivateMessageCreate(
form: CreatePrivateMessageI,
bypassNavWarning: () => void,
): Promise<boolean> {
const res = await HttpService.client.createPrivateMessage(form);
if (res.state === "success") {
toast(I18NextService.i18n.t("message_sent"));
bypassNavWarning();
// Navigate to the front
this.context.router.history.push("/");
} else if (res.state === "failed") {
toast(I18NextService.i18n.t(res.err.message), "danger");
}
return res.state !== "failed";
}
}

View file

@ -1,5 +1,5 @@
import { capitalizeFirstLetter } from "@utils/helpers";
import { Component, InfernoNode } from "inferno";
import { Component } from "inferno";
import { T } from "inferno-i18next-dess";
import { Prompt } from "inferno-router";
import {
@ -19,8 +19,14 @@ interface PrivateMessageFormProps {
privateMessageView?: PrivateMessageView; // If a pm is given, that means this is an edit
replyType?: boolean;
onCancel?(): any;
onCreate?(form: CreatePrivateMessage): void;
onEdit?(form: EditPrivateMessage): void;
onCreate?(
form: CreatePrivateMessage,
bypassNavWarning: () => void,
): Promise<boolean>;
onEdit?(
form: EditPrivateMessage,
bypassNavWarning: () => void,
): Promise<boolean>;
}
interface PrivateMessageFormState {
@ -28,6 +34,7 @@ interface PrivateMessageFormState {
loading: boolean;
previewMode: boolean;
submitted: boolean;
bypassNavWarning?: boolean;
}
export class PrivateMessageForm extends Component<
@ -51,21 +58,15 @@ export class PrivateMessageForm extends Component<
this.handlePrivateMessageSubmit.bind(this);
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & PrivateMessageFormProps>,
): void {
if (this.props !== nextProps) {
this.setState({ loading: false, content: undefined, previewMode: false });
}
}
render() {
return (
<form className="private-message-form">
<Prompt
message={I18NextService.i18n.t("block_leaving")}
when={
!this.state.loading && !!this.state.content && !this.state.submitted
!this.state.bypassNavWarning &&
((!!this.state.content && !this.state.submitted) ||
this.state.loading)
}
/>
{!this.props.privateMessageView && (
@ -140,21 +141,34 @@ export class PrivateMessageForm extends Component<
);
}
handlePrivateMessageSubmit() {
async handlePrivateMessageSubmit(): Promise<boolean> {
this.setState({ loading: true, submitted: true });
const pm = this.props.privateMessageView;
const content = this.state.content ?? "";
let success: boolean | undefined;
if (pm) {
this.props.onEdit?.({
private_message_id: pm.private_message.id,
content,
});
success = await this.props.onEdit?.(
{
private_message_id: pm.private_message.id,
content,
},
() => {
this.setState({ bypassNavWarning: true });
},
);
} else {
this.props.onCreate?.({
content,
recipient_id: this.props.recipient.id,
});
success = await this.props.onCreate?.(
{
content,
recipient_id: this.props.recipient.id,
},
() => {
this.setState({ bypassNavWarning: true });
},
);
}
this.setState({ loading: false, submitted: success ?? true });
return success ?? true;
}
handleContentChange(val: string) {

View file

@ -1,4 +1,4 @@
import { Component, InfernoNode, linkEvent } from "inferno";
import { Component, linkEvent } from "inferno";
import {
CreatePrivateMessage,
CreatePrivateMessageReport,
@ -32,8 +32,8 @@ interface PrivateMessageProps {
onDelete(form: DeletePrivateMessage): void;
onMarkRead(form: MarkPrivateMessageAsRead): void;
onReport(form: CreatePrivateMessageReport): void;
onCreate(form: CreatePrivateMessage): void;
onEdit(form: EditPrivateMessage): void;
onCreate(form: CreatePrivateMessage): Promise<boolean>;
onEdit(form: EditPrivateMessage): Promise<boolean>;
}
@tippyMixin
@ -56,6 +56,8 @@ export class PrivateMessage extends Component<
this.handleReplyCancel = this.handleReplyCancel.bind(this);
this.handleReportSubmit = this.handleReportSubmit.bind(this);
this.hideReportDialog = this.hideReportDialog.bind(this);
this.handleCreate = this.handleCreate.bind(this);
this.handleEdit = this.handleEdit.bind(this);
}
get mine(): boolean {
@ -65,22 +67,6 @@ export class PrivateMessage extends Component<
);
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & PrivateMessageProps>,
): void {
if (this.props !== nextProps) {
this.setState({
showReply: false,
showEdit: false,
collapsed: false,
viewSource: false,
showReportDialog: false,
deleteLoading: false,
readLoading: false,
});
}
}
render() {
const message_view = this.props.private_message_view;
const otherPerson: Person = this.mine
@ -126,7 +112,7 @@ export class PrivateMessage extends Component<
<PrivateMessageForm
recipient={otherPerson}
privateMessageView={message_view}
onEdit={this.props.onEdit}
onEdit={this.handleEdit}
onCancel={this.handleReplyCancel}
/>
)}
@ -265,7 +251,7 @@ export class PrivateMessage extends Component<
<PrivateMessageForm
replyType={true}
recipient={otherPerson}
onCreate={this.props.onCreate}
onCreate={this.handleCreate}
onCancel={this.handleReplyCancel}
/>
</div>
@ -304,7 +290,6 @@ export class PrivateMessage extends Component<
handleEditClick(i: PrivateMessage) {
i.setState({ showEdit: true });
i.setState(i.state);
}
handleDeleteClick(i: PrivateMessage) {
@ -319,6 +304,22 @@ export class PrivateMessage extends Component<
this.setState({ showReply: false, showEdit: false });
}
async handleCreate(form: CreatePrivateMessage): Promise<boolean> {
const success = await this.props.onCreate(form);
if (success) {
this.setState({ showReply: false });
}
return success;
}
async handleEdit(form: EditPrivateMessage): Promise<boolean> {
const success = await this.props.onEdit(form);
if (success) {
this.setState({ showEdit: false });
}
return success;
}
handleMarkRead(i: PrivateMessage) {
i.setState({ readLoading: true });
i.props.onMarkRead({

View file

@ -824,7 +824,6 @@ export class Search extends Component<SearchRouteProps, SearchState> {
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
// All of these are unused, since its viewonly
finished={new Map()}
onSaveComment={async () => {}}
onBlockPerson={async () => {}}
onDeleteComment={async () => {}}
@ -886,7 +885,6 @@ export class Search extends Component<SearchRouteProps, SearchState> {
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
// All of these are unused, since its viewonly
finished={new Map()}
onSaveComment={async () => {}}
onBlockPerson={async () => {}}
onDeleteComment={async () => {}}