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
92 changes: 92 additions & 0 deletions __tests__/unit/components/repost-renders-as-a-post.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* A repost is shown as the post it is; a quote repost shows two posts.
*
* A simple repost used to suppress its own content and render the original
* inside a bordered panel instead — a panel repeating the original author's
* avatar and handle, which the post header directly above was ALREADY showing
* (PostCard swaps the reposter for the original author on a simple repost).
* One repost drew the same person twice, two lines apart, with the actual text
* boxed off underneath.
*
* The distinction these tests hold: a SIMPLE repost has one author and one
* body, so it renders flat. A QUOTE repost genuinely has two of each, so it
* keeps the nested panel. Collapsing both cases the same way would be the
* opposite bug.
*/

import { render, screen } from '@testing-library/react';
import { PostContent } from '@/components/timeline/PostContent';
import type { TimelineDisplayEvent } from '@/types/timeline';

jest.mock('@/utils/markdown', () => ({
renderMarkdownToReact: (text: string) => text,
}));

const base = {
id: 'e1',
actor: { id: 'a1', name: 'Reposter', username: 'reposter', type: 'user' },
description: '',
metadata: {},
} as unknown as TimelineDisplayEvent;

function simpleRepost(): TimelineDisplayEvent {
return {
...base,
description: '',
metadata: {
is_repost: true,
original_event_id: 'orig-1',
original_actor_name: 'Original Author',
original_actor_username: 'original',
original_description: 'The original words.',
},
} as unknown as TimelineDisplayEvent;
}

function quoteRepost(): TimelineDisplayEvent {
return {
...base,
description: 'My take on this.',
metadata: {
is_repost: true,
is_quote_repost: true,
original_event_id: 'orig-1',
original_actor_name: 'Original Author',
original_actor_username: 'original',
original_description: 'The original words.',
},
} as unknown as TimelineDisplayEvent;
}

describe('a simple repost', () => {
it('shows the original text as the post body', () => {
render(<PostContent event={simpleRepost()} />);

expect(screen.getByText('The original words.')).toBeInTheDocument();
});

it('does not repeat the original author, who is already in the header', () => {
render(<PostContent event={simpleRepost()} />);

// PostCard renders the original author in the post header for a simple
// repost. PostContent must not draw them a second time.
expect(screen.queryByText('Original Author')).not.toBeInTheDocument();
expect(screen.queryByText('@original')).not.toBeInTheDocument();
});
});

describe('a quote repost', () => {
it('shows the quoter’s own words as the post body', () => {
render(<PostContent event={quoteRepost()} />);

expect(screen.getByText('My take on this.')).toBeInTheDocument();
});

it('keeps the quoted original in its own panel, author and all', () => {
render(<PostContent event={quoteRepost()} />);

// Two posts, two authors: here the panel earns its place.
expect(screen.getByText('Original Author')).toBeInTheDocument();
expect(screen.getByText('The original words.')).toBeInTheDocument();
});
});
53 changes: 17 additions & 36 deletions src/components/timeline/PostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,23 @@ export function PostContent({ event }: PostContentProps) {
</Link>
)}

{/* Event Description/Content */}
{!articleSlug && displayContent && (!isRepost || isQuoteRepost) && (
{/*
A reposted post is shown as the post it is.

A simple repost used to suppress its own content here and render the
original inside a bordered panel below instead — a panel that repeated
the original author's avatar and handle, which the post header directly
above was ALREADY showing (PostCard swaps the reposter for the original
author on a simple repost). So one repost drew the same person twice,
two lines apart, with the actual text boxed off underneath. That is
what "reposts look ugly" was.

`getDisplayContent` already returns the original's text for a simple
repost, so it renders here like any other post. The nested panel is
kept for QUOTE reposts, where there genuinely are two posts and two
authors to tell apart.
*/}
{!articleSlug && displayContent && (
<div className="text-fg-primary text-[15px] leading-relaxed whitespace-pre-line break-words">
{renderMarkdownToReact(displayContent)}
</div>
Expand Down Expand Up @@ -206,40 +221,6 @@ export function PostContent({ event }: PostContentProps) {
)}

{/* Simple Repost: show original post inside a quoted card for consistency */}
{isRepost && !isQuoteRepost && event.metadata?.original_event_id && (
<div className={`mt-2 overflow-hidden ${TIMELINE_SURFACE.panel}`}>
<div className="p-3 sm:p-4 space-y-2">
<div className="flex items-start gap-3">
<Link href={`/profiles/${originalAuthor.username}`} className="flex-shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element -- avatar_url is a free-form user URL (any host); next/image would throw for hosts outside images.remotePatterns */}
<img
src={originalAuthor.avatar}
alt={originalAuthor.name}
className="w-9 h-9 rounded-full"
/>
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 flex-wrap">
<Link
href={`/profiles/${originalAuthor.username}`}
className="font-semibold text-fg-primary hover:underline"
>
{originalAuthor.name}
</Link>
{originalAuthor.username && (
<span className="text-fg-secondary text-sm">@{originalAuthor.username}</span>
)}
</div>
</div>
</div>
{originalDescription && (
<div className="text-fg-primary text-sm leading-relaxed whitespace-pre-line break-words">
{renderMarkdownToReact(originalDescription)}
</div>
)}
</div>
</div>
)}

{/* Attached image — plain <img>: Openverse hosts aren't in next/image remotePatterns */}
{postImage && !isRepost && (
Expand Down
26 changes: 21 additions & 5 deletions src/components/timeline/TimelineComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { usePostSelection } from '@/hooks/usePostSelection';
import EmptyState from '@/components/ui/EmptyState';
import { BulkActionsToolbar } from './BulkActionsToolbar';
import { BulkDeleteConfirmDialog } from './BulkDeleteConfirmDialog';
import { TIMELINE_SURFACE } from '@/config/timeline';

interface TimelineComponentProps {
feed: TimelineFeedResponse;
Expand Down Expand Up @@ -186,16 +187,31 @@ export const TimelineComponent: React.FC<TimelineComponentProps> = ({
{enableMultiSelect && (
<>
{!isSelectionMode ? (
// Entry point to selection mode - small button
<div className="sticky top-16 z-10 border-b border-subtle bg-surface-page/90 px-4 py-2.5 backdrop-blur-xl">
/*
The way IN to selection mode is not itself worth a banner.

This used to be a full-width bar with its own border, background
and `sticky top-16` — so a control for bulk-deleting old posts
followed you down the entire feed, above every post you came to
read. It was the fourth separate bordered band before the first
post.

Managing posts is a rare, deliberate task; reading them is the
reason the page exists. So the entry point is a quiet inline
control, and everything it opens — the full toolbar with counts,
select-all and the destructive actions — is unchanged, because
once you ARE selecting, that toolbar is the thing you need and
it earns being sticky.
*/
<div className="flex justify-end px-4 py-2">
<Button
variant="outline"
variant="ghost"
size="sm"
onClick={toggleSelectionMode}
className="flex items-center gap-2 text-sm"
className={TIMELINE_SURFACE.chip}
>
<CheckSquare className="w-4 h-4" />
<span>Select Posts</span>
<span>Select</span>
</Button>
</div>
) : (
Expand Down
Loading