Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions app/src/agentworld/components/ConfirmDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ConfirmDialog {...baseProps()} />);
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(<ConfirmDialog {...props} />);
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(<ConfirmDialog {...props} cancelLabel="Cancel" />);
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(<ConfirmDialog {...baseProps()} busy busyLabel="Deleting…" />);
const confirm = screen.getByTestId('confirm-dialog-confirm');
expect(confirm).toBeDisabled();
expect(confirm).toHaveTextContent('Deleting…');
});

test('uses a custom confirm label when provided', () => {
render(<ConfirmDialog {...baseProps()} confirmLabel="Remove" />);
expect(screen.getByTestId('confirm-dialog-confirm')).toHaveTextContent('Remove');
});
});
74 changes: 74 additions & 0 deletions app/src/agentworld/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ModalShell
title={title}
titleId="agentworld-confirm-title"
onClose={busy ? () => undefined : onCancel}
maxWidthClassName="max-w-sm">
<div className="space-y-4">
<p className="text-sm text-content-secondary" data-testid="confirm-dialog-message">
{message}
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onCancel} disabled={busy}>
{cancelLabel}
</Button>
<Button
variant="primary"
size="sm"
tone={destructive ? 'danger' : 'default'}
onClick={onConfirm}
disabled={busy}
data-testid="confirm-dialog-confirm">
{busy ? busyLabel : confirmLabel}
</Button>
</div>
</div>
</ModalShell>
);
}
6 changes: 4 additions & 2 deletions app/src/agentworld/pages/FeedSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -674,15 +674,16 @@ 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 });
render(<FeedSection />);
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);
});
Expand All @@ -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,
Expand All @@ -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,
Expand Down
82 changes: 69 additions & 13 deletions app/src/agentworld/pages/FeedSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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');

Expand Down Expand Up @@ -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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

return (
<div className="flex gap-3 py-3">
{comment.author.avatarUrl ? (
Expand All @@ -550,14 +570,7 @@ function CommentRow({
{myAgentId && comment.author.cryptoId === myAgentId && (
<button
type="button"
onClick={() => {
if (window.confirm('Delete this comment?')) {
void apiClient.feeds
.deleteComment(handle, postId, comment.commentId)
.then(() => onCommentDeleted())
.catch(err => console.error('[FeedSection] delete comment failed:', err));
}
}}
onClick={() => setConfirmingDelete(true)}
className="text-xs text-content-faint hover:text-red-500
dark:hover:text-red-400">
Delete
Expand All @@ -566,6 +579,18 @@ function CommentRow({
</div>
<p className="mt-0.5 text-sm text-content-secondary">{comment.body}</p>
</div>
{confirmingDelete && (
<ConfirmDialog
title="Delete comment"
message="Delete this comment? This can't be undone."
confirmLabel="Delete"
Comment on lines +584 to +586

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Localize new confirmation copy

AGENTS.md requires all UI text to go through useT() with locale entries, but this new delete dialog copy is hard-coded in English here (and the post dialog below repeats the same pattern). In any non-English locale, users will still see English text for the destructive confirmation modal, so these labels/messages should be moved into the i18n catalog and passed through translations.

Useful? React with 👍 / 👎.

busy={deleting}
onConfirm={confirmDeleteComment}
onCancel={() => {
if (!deleting) setConfirmingDelete(false);
}}
/>
)}
</div>
);
}
Expand All @@ -577,6 +602,10 @@ export default function FeedSection() {
const [followState, setFollowState] = useState<Record<string, boolean>>({});
const [followLoading, setFollowLoading] = useState<Record<string, boolean>>({});
const [likeState, setLikeState] = useState<Record<string, { liked: boolean; count: number }>>({});
// 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<GqlPost | null>(null);
const [deletingPost, setDeletingPost] = useState(false);

const { agentId: myAgentId, configured: walletConfigured } = useWalletResolution();

Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -807,6 +851,18 @@ export default function FeedSection() {
<FeedComposer myAgentId={myAgentId} onPostCreated={refetchFeed} />
)}
{body}
{postPendingDelete && (
<ConfirmDialog
title="Delete post"
message="Delete this post? This can't be undone."
confirmLabel="Delete"
busy={deletingPost}
onConfirm={confirmDeletePost}
onCancel={() => {
if (!deletingPost) setPostPendingDelete(null);
}}
/>
)}
</PanelScaffold>
);
}
Loading