-
Notifications
You must be signed in to change notification settings - Fork 3.6k
fix(tinyplace): replace native window.confirm with in-app modal on feed delete #4214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2c8f797
af11eb0
2b735d8
aa45866
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'); | ||
| }); | ||
| }); |
| 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> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ( | ||
| <div className="flex gap-3 py-3"> | ||
| {comment.author.avatarUrl ? ( | ||
|
|
@@ -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 | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
AGENTS.md requires all UI text to go through Useful? React with 👍 / 👎. |
||
| busy={deleting} | ||
| onConfirm={confirmDeleteComment} | ||
| onCancel={() => { | ||
| if (!deleting) setConfirmingDelete(false); | ||
| }} | ||
| /> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
@@ -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(); | ||
|
|
||
|
|
@@ -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() { | |
| <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> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.