diff --git a/__tests__/unit/components/repost-reaches-the-timeline.test.tsx b/__tests__/unit/components/repost-reaches-the-timeline.test.tsx new file mode 100644 index 000000000..a0f3cdd34 --- /dev/null +++ b/__tests__/unit/components/repost-reaches-the-timeline.test.tsx @@ -0,0 +1,75 @@ +/** + * A quote repost has to reach the list it was made from. + * + * `usePostRepost` only hands the created post back if it was given somewhere to + * put it: `if (result.event && onAddEvent)`. Nobody was giving it one — + * usePostCardActions called `usePostInteractions({ event, onUpdate })` and + * stopped there — so the repost was written to the database and then existed + * nowhere on screen until a reload. + * + * Verified in production 2026-08-28: quote-reposting created event c09b9f0b + * and the feed did not change. + * + * The bug was a missing ARGUMENT, not broken logic, which is why it survived: a + * unit test of usePostRepost passes with or without it. So this asserts the + * forward itself. + */ + +import { renderHook } from '@testing-library/react'; +import { usePostCardActions } from '@/components/timeline/usePostCardActions'; +import type { TimelineDisplayEvent } from '@/types/timeline'; + +const usePostInteractions = jest.fn(() => ({ + isReposting: false, + repostModalOpen: false, + handleRepostClick: jest.fn(), + handleRepostClose: jest.fn(), + handleSimpleRepost: jest.fn(), + handleQuoteRepost: jest.fn(), +})); + +jest.mock('@/hooks/usePostInteractions', () => ({ + usePostInteractions: (args: unknown) => usePostInteractions(args as never), +})); +jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn() }) })); +jest.mock('@/services/timeline', () => ({ timelineService: {} })); + +const event = { id: 'e1', actor: { id: 'a1' } } as TimelineDisplayEvent; + +describe('a quote repost reaches the timeline', () => { + beforeEach(() => jest.clearAllMocks()); + + it('forwards onAddEvent, without which the new post is dropped', () => { + const onAddEvent = jest.fn(); + + renderHook(() => + usePostCardActions({ + event, + user: { id: 'a1' } as never, + profile: null, + onUpdate: jest.fn(), + onAddEvent, + }) + ); + + expect(usePostInteractions).toHaveBeenCalledWith( + expect.objectContaining({ onAddEvent }) + ); + }); + + it('still works for callers that do not want the new post', () => { + renderHook(() => + usePostCardActions({ + event, + user: { id: 'a1' } as never, + profile: null, + onUpdate: jest.fn(), + }) + ); + + // Optional by design — a thread view has its own way of inserting replies. + expect(usePostInteractions).toHaveBeenCalledWith( + expect.objectContaining({ onAddEvent: undefined }) + ); + }); +}); diff --git a/src/components/timeline/BulkDeleteConfirmDialog.tsx b/src/components/timeline/BulkDeleteConfirmDialog.tsx new file mode 100644 index 000000000..0fde365db --- /dev/null +++ b/src/components/timeline/BulkDeleteConfirmDialog.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import { Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Card, CardContent } from '@/components/ui/Card'; +import { TIMELINE_SURFACE } from '@/config/timeline'; + +interface BulkDeleteConfirmDialogProps { + count: number; + isProcessing: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +/** + * Confirmation for deleting several posts at once. + * + * Lifted out of TimelineComponent, which was doing feed state, selection, + * infinite scroll AND this modal. Deleting is the one irreversible thing the + * timeline offers, so its wording lives in one place rather than inline among + * the scroll sentinel and the empty state. + */ +export const BulkDeleteConfirmDialog: React.FC = ({ + count, + isProcessing, + onCancel, + onConfirm, +}) => ( +
+ + +
+
+ +
+
+

+ Delete {count} {count === 1 ? 'post' : 'posts'}? +

+

This action cannot be undone

+
+
+ +

+ Are you sure you want to delete {count === 1 ? 'this post' : 'these posts'}? + {count > 1 && ' They will be'} permanently removed from your timeline. +

+ +
+ + +
+
+
+
+); + +export default BulkDeleteConfirmDialog; diff --git a/src/components/timeline/PostCard.tsx b/src/components/timeline/PostCard.tsx index 4475913a4..1c0b15b3f 100644 --- a/src/components/timeline/PostCard.tsx +++ b/src/components/timeline/PostCard.tsx @@ -23,6 +23,8 @@ import { TIMELINE_SURFACE } from '@/config/timeline'; interface PostCardProps { event: TimelineDisplayEvent; onUpdate: (updates: Partial) => void; + /** A post created from this card (a quote repost), for the list to show. */ + onAddEvent?: (event: TimelineDisplayEvent) => void; onDelete?: () => void; compact?: boolean; showMetrics?: boolean; @@ -37,6 +39,7 @@ interface PostCardProps { export function PostCard({ event, onUpdate, + onAddEvent, onDelete, compact = false, showMetrics: _showMetrics = true, @@ -82,6 +85,7 @@ export function PostCard({ user, profile, onUpdate, + onAddEvent, onDelete, onReplyCreated, isSelectionMode, diff --git a/src/components/timeline/TimelineComponent.tsx b/src/components/timeline/TimelineComponent.tsx index 8f74712c1..12c872c22 100644 --- a/src/components/timeline/TimelineComponent.tsx +++ b/src/components/timeline/TimelineComponent.tsx @@ -6,16 +6,22 @@ import { logger } from '@/utils/logger'; import { toast } from 'sonner'; import { PostCard } from './PostCard'; import { Button } from '@/components/ui/Button'; -import { Card, CardContent } from '@/components/ui/Card'; -import { Trash2, CheckSquare, Loader2, Newspaper } from 'lucide-react'; +import { CheckSquare, Loader2, Newspaper } from 'lucide-react'; import { usePostSelection } from '@/hooks/usePostSelection'; import EmptyState from '@/components/ui/EmptyState'; import { BulkActionsToolbar } from './BulkActionsToolbar'; -import { TIMELINE_SURFACE } from '@/config/timeline'; +import { BulkDeleteConfirmDialog } from './BulkDeleteConfirmDialog'; interface TimelineComponentProps { feed: TimelineFeedResponse; onEventUpdate?: (eventId: string, updates: Partial) => void; + /** + * A post created FROM a card — currently a quote repost. Without it the new + * post is created and then dropped: usePostRepost only hands the result over + * `if (result.event && onAddEvent)`, and nobody was passing one, so a repost + * existed in the database and nowhere on screen until a reload. + */ + onEventCreated?: (event: TimelineDisplayEvent) => void; onLoadMore?: () => void; isLoadingMore?: boolean; showFilters?: boolean; @@ -26,6 +32,7 @@ interface TimelineComponentProps { export const TimelineComponent: React.FC = ({ feed, onEventUpdate, + onEventCreated, onLoadMore, isLoadingMore = false, showFilters: _showFilters = true, @@ -215,6 +222,7 @@ export const TimelineComponent: React.FC = ({ key={event.id} event={event} onUpdate={updates => handleEventUpdate(event.id, updates)} + onAddEvent={onEventCreated} onDelete={() => handlePostDelete(event.id)} compact={compact} showMetrics={true} @@ -246,44 +254,13 @@ export const TimelineComponent: React.FC = ({ )} - {/* Bulk Delete Confirmation Modal */} {showBulkDeleteConfirm && ( -
- - -
-
- -
-
-

- Delete {selectedCount} {selectedCount === 1 ? 'post' : 'posts'}? -

-

This action cannot be undone

-
-
- -

- Are you sure you want to delete {selectedCount === 1 ? 'this post' : 'these posts'}? - {selectedCount > 1 && ' They will be'} permanently removed from your timeline. -

- -
- - -
-
-
-
+ setShowBulkDeleteConfirm(false)} + onConfirm={handleBulkDeleteConfirm} + /> )} ); diff --git a/src/components/timeline/TimelineView.tsx b/src/components/timeline/TimelineView.tsx index 6dbfd7037..dd02c8970 100644 --- a/src/components/timeline/TimelineView.tsx +++ b/src/components/timeline/TimelineView.tsx @@ -45,6 +45,7 @@ export default function TimelineView({ handleEventUpdate, handleLoadMore, handlePostCreated, + handleOptimisticEvent, } = useTimelineView({ feedType, ownerId, onPostCreated, onOptimisticEvent }); if (hydrated && !authLoading && !user && (feedType === 'journey' || feedType === 'community')) { @@ -166,6 +167,10 @@ export default function TimelineView({ ) => void; + /** Where a post created from this card goes — see usePostRepost. */ + onAddEvent?: (event: TimelineDisplayEvent) => void; onDelete?: () => void; onReplyCreated?: (reply: TimelineDisplayEvent) => void; isSelectionMode?: boolean; @@ -26,6 +28,7 @@ export function usePostCardActions({ user, profile, onUpdate, + onAddEvent, onDelete, onReplyCreated, isSelectionMode = false, @@ -49,7 +52,10 @@ export function usePostCardActions({ handleRepostClose, handleSimpleRepost, handleQuoteRepost, - } = usePostInteractions({ event, onUpdate }); + // Without onAddEvent, usePostRepost's success path skips handing the new + // post back (`if (result.event && onAddEvent)`), so a quote repost was + // created and then existed nowhere on screen until a reload. + } = usePostInteractions({ event, onUpdate, onAddEvent }); const canEdit = user?.id === event.actor.id;