From 22225fde786d7f14ebd91c52dbe032af2f1256b0 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:32:37 +0200 Subject: [PATCH 1/2] fix(timeline): a quote repost was created and then shown nowhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 a quote repost was written to the database and then existed nowhere on screen until a reload. Verified in production 2026-08-28: quote-reposting through the UI created event c09b9f0b, the modal closed correctly, and the feed did not change. The list already knew how to do this. useTimelineView keeps an optimisticEvents array and prepends to it, which is how a new post from the composer appears instantly. The repost path simply was not connected to it. Connected now, through the prop chain that was missing: TimelineView → TimelineComponent → PostCard → usePostCardActions. Optional throughout, because the thread view has its own way of inserting replies and should not be forced to adopt this one. The bug was a missing ARGUMENT rather than broken logic, which is why it survived: a unit test of usePostRepost passes with or without it. The test asserts the forward itself. Proven by mutation — removing it again fails both cases. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- .../repost-reaches-the-timeline.test.tsx | 75 +++++++++++++++++++ src/components/timeline/PostCard.tsx | 4 + src/components/timeline/TimelineComponent.tsx | 9 +++ src/components/timeline/TimelineView.tsx | 5 ++ src/components/timeline/usePostCardActions.ts | 8 +- 5 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 __tests__/unit/components/repost-reaches-the-timeline.test.tsx 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/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..cc0979c3d 100644 --- a/src/components/timeline/TimelineComponent.tsx +++ b/src/components/timeline/TimelineComponent.tsx @@ -16,6 +16,13 @@ import { TIMELINE_SURFACE } from '@/config/timeline'; 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 +33,7 @@ interface TimelineComponentProps { export const TimelineComponent: React.FC = ({ feed, onEventUpdate, + onEventCreated, onLoadMore, isLoadingMore = false, showFilters: _showFilters = true, @@ -215,6 +223,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} 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; From cd749e9d73c0b7ba84e26b6eb3ca2045f267f100 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:06:00 +0200 Subject: [PATCH 2/2] refactor(timeline): lift the bulk-delete dialog out of TimelineComponent The repost fix pushed TimelineComponent to 301 lines against a 300 limit, so CI was red on this PR. The gate is right and the exception list only shrinks, so this splits rather than raises the ceiling. The bulk-delete confirmation is a genuine seam, not the shortest 39 lines: TimelineComponent was doing feed state, selection, infinite scroll AND this modal. Deleting is the one irreversible thing the timeline offers, and its wording now lives in one place instead of inline among the scroll sentinel and the empty state. 269 lines, and check:sizes passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- .../timeline/BulkDeleteConfirmDialog.tsx | 68 +++++++++++++++++++ src/components/timeline/TimelineComponent.tsx | 48 +++---------- 2 files changed, 76 insertions(+), 40 deletions(-) create mode 100644 src/components/timeline/BulkDeleteConfirmDialog.tsx 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/TimelineComponent.tsx b/src/components/timeline/TimelineComponent.tsx index cc0979c3d..12c872c22 100644 --- a/src/components/timeline/TimelineComponent.tsx +++ b/src/components/timeline/TimelineComponent.tsx @@ -6,12 +6,11 @@ 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; @@ -255,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} + /> )} );