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
75 changes: 75 additions & 0 deletions __tests__/unit/components/repost-reaches-the-timeline.test.tsx
Original file line number Diff line number Diff line change
@@ -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 })
);
});
});
68 changes: 68 additions & 0 deletions src/components/timeline/BulkDeleteConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<BulkDeleteConfirmDialogProps> = ({
count,
isProcessing,
onCancel,
onConfirm,
}) => (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface-page/80 backdrop-blur-sm">
<Card className="mx-4 w-full max-w-md rounded-md border-subtle bg-surface-page">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-md border border-status-negative/20 bg-status-negative/10">
<Trash2 className="w-6 h-6 text-status-negative" />
</div>
<div>
<h2 className="text-xl font-semibold">
Delete {count} {count === 1 ? 'post' : 'posts'}?
</h2>
<p className="text-sm text-fg-secondary">This action cannot be undone</p>
</div>
</div>

<p className="text-fg-primary mb-6">
Are you sure you want to delete {count === 1 ? 'this post' : 'these posts'}?
{count > 1 && ' They will be'} permanently removed from your timeline.
</p>

<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={onCancel}
disabled={isProcessing}
className={TIMELINE_SURFACE.chip}
>
Cancel
</Button>
<Button variant="danger" onClick={onConfirm} disabled={isProcessing}>
{isProcessing ? 'Deleting...' : 'Delete'}
</Button>
</div>
</CardContent>
</Card>
</div>
);

export default BulkDeleteConfirmDialog;
4 changes: 4 additions & 0 deletions src/components/timeline/PostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { TIMELINE_SURFACE } from '@/config/timeline';
interface PostCardProps {
event: TimelineDisplayEvent;
onUpdate: (updates: Partial<TimelineDisplayEvent>) => 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;
Expand All @@ -37,6 +39,7 @@ interface PostCardProps {
export function PostCard({
event,
onUpdate,
onAddEvent,
onDelete,
compact = false,
showMetrics: _showMetrics = true,
Expand Down Expand Up @@ -82,6 +85,7 @@ export function PostCard({
user,
profile,
onUpdate,
onAddEvent,
onDelete,
onReplyCreated,
isSelectionMode,
Expand Down
57 changes: 17 additions & 40 deletions src/components/timeline/TimelineComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TimelineDisplayEvent>) => 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;
Expand All @@ -26,6 +32,7 @@ interface TimelineComponentProps {
export const TimelineComponent: React.FC<TimelineComponentProps> = ({
feed,
onEventUpdate,
onEventCreated,
onLoadMore,
isLoadingMore = false,
showFilters: _showFilters = true,
Expand Down Expand Up @@ -215,6 +222,7 @@ export const TimelineComponent: React.FC<TimelineComponentProps> = ({
key={event.id}
event={event}
onUpdate={updates => handleEventUpdate(event.id, updates)}
onAddEvent={onEventCreated}
onDelete={() => handlePostDelete(event.id)}
compact={compact}
showMetrics={true}
Expand Down Expand Up @@ -246,44 +254,13 @@ export const TimelineComponent: React.FC<TimelineComponentProps> = ({
</div>
)}

{/* Bulk Delete Confirmation Modal */}
{showBulkDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface-page/80 backdrop-blur-sm">
<Card className="mx-4 w-full max-w-md rounded-md border-subtle bg-surface-page">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-md border border-status-negative/20 bg-status-negative/10">
<Trash2 className="w-6 h-6 text-status-negative" />
</div>
<div>
<h2 className="text-xl font-semibold">
Delete {selectedCount} {selectedCount === 1 ? 'post' : 'posts'}?
</h2>
<p className="text-sm text-fg-secondary">This action cannot be undone</p>
</div>
</div>

<p className="text-fg-primary mb-6">
Are you sure you want to delete {selectedCount === 1 ? 'this post' : 'these posts'}?
{selectedCount > 1 && ' They will be'} permanently removed from your timeline.
</p>

<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => setShowBulkDeleteConfirm(false)}
disabled={isProcessing}
className={TIMELINE_SURFACE.chip}
>
Cancel
</Button>
<Button variant="danger" onClick={handleBulkDeleteConfirm} disabled={isProcessing}>
{isProcessing ? 'Deleting...' : 'Delete'}
</Button>
</div>
</CardContent>
</Card>
</div>
<BulkDeleteConfirmDialog
count={selectedCount}
isProcessing={isProcessing}
onCancel={() => setShowBulkDeleteConfirm(false)}
onConfirm={handleBulkDeleteConfirm}
/>
)}
</div>
);
Expand Down
5 changes: 5 additions & 0 deletions src/components/timeline/TimelineView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
handleEventUpdate,
handleLoadMore,
handlePostCreated,
handleOptimisticEvent,
} = useTimelineView({ feedType, ownerId, onPostCreated, onOptimisticEvent });

if (hydrated && !authLoading && !user && (feedType === 'journey' || feedType === 'community')) {
Expand All @@ -52,7 +53,7 @@
<div className="text-center py-16">
<h2 className="text-2xl font-semibold text-fg-primary mb-4">Please sign in</h2>
<p className="text-fg-secondary mb-6">You need to be signed in to view this timeline.</p>
<Button onClick={() => (window.location.href = '/auth')}>Sign In</Button>

Check warning on line 56 in src/components/timeline/TimelineView.tsx

View workflow job for this annotation

GitHub Actions / build-and-smoke

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
</div>
);
}
Expand Down Expand Up @@ -152,7 +153,7 @@
typeof window !== 'undefined'
? window.location.pathname + window.location.search
: '/profiles/me';
window.location.href = `/auth?redirect=${encodeURIComponent(redirect)}`;

Check warning on line 156 in src/components/timeline/TimelineView.tsx

View workflow job for this annotation

GitHub Actions / build-and-smoke

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
}}
className={TIMELINE_SURFACE.buttonPrimary}
>
Expand All @@ -166,6 +167,10 @@
<TimelineComponent
feed={mergedFeed}
onEventUpdate={handleEventUpdate}
// A quote repost made from a card lands in the same optimistic list
// the composer already uses, so it appears where it was made instead
// of only after a reload.
onEventCreated={handleOptimisticEvent}
onLoadMore={handleLoadMore}
showFilters={showFilters}
compact={compact}
Expand Down
8 changes: 7 additions & 1 deletion src/components/timeline/usePostCardActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface UsePostCardActionsParams {
user: User | null | undefined;
profile: Profile | null | undefined;
onUpdate: (updates: Partial<TimelineDisplayEvent>) => void;
/** Where a post created from this card goes — see usePostRepost. */
onAddEvent?: (event: TimelineDisplayEvent) => void;
onDelete?: () => void;
onReplyCreated?: (reply: TimelineDisplayEvent) => void;
isSelectionMode?: boolean;
Expand All @@ -26,6 +28,7 @@ export function usePostCardActions({
user,
profile,
onUpdate,
onAddEvent,
onDelete,
onReplyCreated,
isSelectionMode = false,
Expand All @@ -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;

Expand Down
Loading