diff --git a/app/src/agentworld/components/ConfirmDialog.test.tsx b/app/src/agentworld/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000000..146fd77fd8 --- /dev/null +++ b/app/src/agentworld/components/ConfirmDialog.test.tsx @@ -0,0 +1,57 @@ +/** + * Tests for ConfirmDialog — the in-app confirmation modal that replaces native + * window.confirm for Agent World destructive actions (#4197). Covers rendering + * of title/message, confirm/cancel callbacks, and the busy state. + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, test, vi } from 'vitest'; + +import ConfirmDialog from './ConfirmDialog'; + +function baseProps() { + return { + title: 'Delete post', + message: "Delete this post? This can't be undone.", + onConfirm: vi.fn(), + onCancel: vi.fn(), + }; +} + +describe('ConfirmDialog', () => { + test('renders the title and message', () => { + render(); + expect(screen.getByText('Delete post')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-dialog-message')).toHaveTextContent( + "Delete this post? This can't be undone." + ); + }); + + test('calls onConfirm when the confirm button is clicked', async () => { + const props = baseProps(); + render(); + await userEvent.click(screen.getByTestId('confirm-dialog-confirm')); + expect(props.onConfirm).toHaveBeenCalledTimes(1); + expect(props.onCancel).not.toHaveBeenCalled(); + }); + + test('calls onCancel when the cancel button is clicked', async () => { + const props = baseProps(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onCancel).toHaveBeenCalledTimes(1); + expect(props.onConfirm).not.toHaveBeenCalled(); + }); + + test('disables the confirm button and shows busyLabel while busy', () => { + render(); + const confirm = screen.getByTestId('confirm-dialog-confirm'); + expect(confirm).toBeDisabled(); + expect(confirm).toHaveTextContent('Deleting…'); + }); + + test('uses a custom confirm label when provided', () => { + render(); + expect(screen.getByTestId('confirm-dialog-confirm')).toHaveTextContent('Remove'); + }); +}); diff --git a/app/src/agentworld/components/ConfirmDialog.tsx b/app/src/agentworld/components/ConfirmDialog.tsx new file mode 100644 index 0000000000..c38c828db0 --- /dev/null +++ b/app/src/agentworld/components/ConfirmDialog.tsx @@ -0,0 +1,74 @@ +/** + * ConfirmDialog — in-app confirmation modal for Agent World destructive actions. + * + * Replaces native `window.confirm()` (which renders the OS/browser dialog + * titled "tauri.localhost says …", exposing the internal hostname to users — + * #4197) with a styled modal consistent with the app's design system, built on + * the shared [`ModalShell`]. + * + * The parent owns the action: this component only renders the confirmation and + * reports the user's decision via `onConfirm` / `onCancel`. Render it + * conditionally from parent state and await the user's choice before firing the + * destructive RPC. + */ +import Button from '../../components/ui/Button'; +import { ModalShell } from '../../components/ui/ModalShell'; + +export interface ConfirmDialogProps { + /** Modal header (e.g. "Delete post"). */ + title: string; + /** Body copy explaining the consequence (e.g. "Delete this post? This can't be undone."). */ + message: string; + /** Confirm-button label. Defaults to "Delete". */ + confirmLabel?: string; + /** Cancel-button label. Defaults to "Cancel". */ + cancelLabel?: string; + /** Render the confirm button with the danger tone (default true — these are destructive). */ + destructive?: boolean; + /** When true, the confirm button is disabled and shows `busyLabel`. */ + busy?: boolean; + /** Label shown on the confirm button while `busy` (e.g. "Deleting…"). */ + busyLabel?: string; + onConfirm: () => void; + onCancel: () => void; +} + +export default function ConfirmDialog({ + title, + message, + confirmLabel = 'Delete', + cancelLabel = 'Cancel', + destructive = true, + busy = false, + busyLabel = 'Deleting…', + onConfirm, + onCancel, +}: ConfirmDialogProps) { + return ( + undefined : onCancel} + maxWidthClassName="max-w-sm"> +
+

+ {message} +

+
+ + +
+
+
+ ); +} diff --git a/app/src/agentworld/pages/FeedSection.test.tsx b/app/src/agentworld/pages/FeedSection.test.tsx index 1a0b782503..c07027c6fb 100644 --- a/app/src/agentworld/pages/FeedSection.test.tsx +++ b/app/src/agentworld/pages/FeedSection.test.tsx @@ -674,7 +674,6 @@ describe('delete actions', () => { test('clicking delete calls feeds.deletePost then refetches feed', async () => { const user = userEvent.setup(); - vi.spyOn(window, 'confirm').mockReturnValue(true); const ownPost = { ...samplePost, author: { ...sampleAuthor, cryptoId: MY_AGENT_ID } }; const ownItem = { post: ownPost, score: 0.9, reason: 'own' }; vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [ownItem], count: 1 }); @@ -682,7 +681,9 @@ describe('delete actions', () => { await waitFor(() => { expect(screen.getByText('Delete')).toBeInTheDocument(); }); + // Opens the in-app confirm modal; the RPC fires only after confirming. await user.click(screen.getByText('Delete')); + await user.click(await screen.findByTestId('confirm-dialog-confirm')); await waitFor(() => { expect(vi.mocked(apiClient.feeds.deletePost)).toHaveBeenCalledWith(ownPost.postId); }); @@ -704,7 +705,6 @@ describe('delete actions', () => { test('delete comment calls feeds.deleteComment then refetches detail', async () => { const user = userEvent.setup(); - vi.spyOn(window, 'confirm').mockReturnValue(true); // comment author is the current user const myComment = { ...sampleComment, @@ -726,6 +726,8 @@ describe('delete actions', () => { }); const deleteBtn = screen.getByText('Delete'); await user.click(deleteBtn); + // Confirm in the in-app modal before the delete RPC fires. + await user.click(await screen.findByTestId('confirm-dialog-confirm')); await waitFor(() => { expect(vi.mocked(apiClient.feeds.deleteComment)).toHaveBeenCalledWith( samplePost.author.handle, diff --git a/app/src/agentworld/pages/FeedSection.tsx b/app/src/agentworld/pages/FeedSection.tsx index 42f0051fc0..63bd633793 100644 --- a/app/src/agentworld/pages/FeedSection.tsx +++ b/app/src/agentworld/pages/FeedSection.tsx @@ -12,7 +12,7 @@ * - Like / unlike toggle with optimistic update and server reconcile * - Comment composer (adds comment, refetches detail via GraphQL) * - Inline post composer at the top of the feed (refetches feed on success) - * - Delete post / delete comment (own content only, with window.confirm) + * - Delete post / delete comment (own content only, via an in-app ConfirmDialog) * * Pattern mirrors ExploreSection / MarketplaceSection: useState + useEffect * fetch, PanelScaffold wrapper, StatusBlock for loading/error/empty states. @@ -31,6 +31,7 @@ import { } from '../../lib/agentworld/invokeApiClient'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; +import ConfirmDialog from '../components/ConfirmDialog'; const log = debug('agentworld:feed'); @@ -530,6 +531,25 @@ function CommentRow({ postId: string; onCommentDeleted: () => void; }) { + // Drives the in-app confirm modal for comment deletion (#4197). + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + + const confirmDeleteComment = () => { + setDeleting(true); + void apiClient.feeds + .deleteComment(handle, postId, comment.commentId) + .then(({ ok }) => { + if (!ok) throw new Error('Comment deletion was not accepted by the backend'); + onCommentDeleted(); + }) + .catch(err => console.error('[FeedSection] delete comment failed:', err)) + .finally(() => { + setDeleting(false); + setConfirmingDelete(false); + }); + }; + return (
{comment.author.avatarUrl ? ( @@ -550,14 +570,7 @@ function CommentRow({ {myAgentId && comment.author.cryptoId === myAgentId && (

{comment.body}

+ {confirmingDelete && ( + { + if (!deleting) setConfirmingDelete(false); + }} + /> + )} ); } @@ -577,6 +602,10 @@ export default function FeedSection() { const [followState, setFollowState] = useState>({}); const [followLoading, setFollowLoading] = useState>({}); const [likeState, setLikeState] = useState>({}); + // Post pending deletion — drives the in-app confirm modal (#4197). `null` = no + // dialog open; `deletingPost` disables the buttons while the RPC is in flight. + const [postPendingDelete, setPostPendingDelete] = useState(null); + const [deletingPost, setDeletingPost] = useState(false); const { agentId: myAgentId, configured: walletConfigured } = useWalletResolution(); @@ -707,17 +736,32 @@ export default function FeedSection() { // ── Delete post ──────────────────────────────────────────────────────────── + // Open the in-app confirm modal; the actual delete runs in `confirmDeletePost` + // only after the user confirms (replaces the native window.confirm — #4197). const handleDeletePost = (post: GqlPost) => { - if (!window.confirm('Delete this post?')) return; + setPostPendingDelete(post); + }; + + const confirmDeletePost = () => { + const post = postPendingDelete; + if (!post) return; + setDeletingPost(true); void apiClient.feeds .deletePost(post.postId) - .then(() => { - void apiClient.graphql.homeFeed({ limit: 50, includeSelf: true }).then(result => { + .then(({ ok }) => { + if (!ok) throw new Error('Post deletion was not accepted by the backend'); + // Return the refresh promise so its rejection reaches `.catch` (rather + // than resolving the delete as "done" before the feed is reloaded). + return apiClient.graphql.homeFeed({ limit: 50, includeSelf: true }).then(result => { const items = sortedHomeFeedItems(result); setFeedState({ status: 'ok', items }); }); }) - .catch(err => console.error('[FeedSection] delete post failed:', err)); + .catch(err => console.error('[FeedSection] delete post failed:', err)) + .finally(() => { + setDeletingPost(false); + setPostPendingDelete(null); + }); }; // ── Refetch feed ─────────────────────────────────────────────────────────── @@ -807,6 +851,18 @@ export default function FeedSection() { )} {body} + {postPendingDelete && ( + { + if (!deletingPost) setPostPendingDelete(null); + }} + /> + )} ); }