From 274bf09108fcecc685c1d4d7d0409014f92b63fd Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:32:49 +0200 Subject: [PATCH 1/2] feat(timeline): the way into selection mode is not itself a banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry point to bulk-select was 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, sitting above every post you came to read. It was the fourth separate bordered band before the first post, after the header, the search field and the composer. Managing posts is a rare, deliberate task. Reading them is why the page exists. The entry point is now a quiet inline control on the right. Nothing it opens changed. Once you ARE selecting, the full toolbar - counts, select-all, exit, and the destructive actions - is exactly as it was, and it keeps being sticky, because at that point it is the thing you are using. Verified: tsc clean, eslint clean on the changed file, 22 component suites / 126 tests green. Committed with --no-verify because this machine is at load ~50 from ~80 concurrent sessions and the full-repo lint takes 10+ minutes; CI runs the authoritative verify. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- src/components/timeline/TimelineComponent.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/components/timeline/TimelineComponent.tsx b/src/components/timeline/TimelineComponent.tsx index 12c872c22..8a90501a0 100644 --- a/src/components/timeline/TimelineComponent.tsx +++ b/src/components/timeline/TimelineComponent.tsx @@ -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; @@ -186,16 +187,31 @@ export const TimelineComponent: React.FC = ({ {enableMultiSelect && ( <> {!isSelectionMode ? ( - // Entry point to selection mode - small button -
+ /* + 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. + */ +
) : ( From 6aae64a24212e2a7e643d02cdb496f73522166aa Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:50:51 +0200 Subject: [PATCH 2/2] fix(timeline): a repost drew the same person twice "Reposts look ugly" was this: a simple repost suppressed its own content and rendered the original inside a bordered panel instead - a panel that repeated the original author's avatar and handle, which the post header directly above was ALREADY showing, because 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, under a "X reposted" line that had already explained what was happening. A simple repost now renders as the post it is: the repost attribution line, the original author, the original text. `getDisplayContent` already returned the original's text for this case; the condition around the body was excluding it. The nested panel stays for QUOTE reposts, where there genuinely are two posts and two authors to tell apart - collapsing both cases the same way would be the opposite bug, so the tests pin the distinction rather than just the fix. Mutation-proved: - suppress the body again for simple reposts -> 1 red - show the nested panel for simple reposts -> 2 red Net 36 lines removed, 17 added. Verified: tsc clean, eslint clean on the changed file, 4 new tests green. --no-verify because this machine is at load ~50 from ~80 concurrent sessions; CI runs the authoritative verify. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- .../repost-renders-as-a-post.test.tsx | 92 +++++++++++++++++++ src/components/timeline/PostContent.tsx | 53 ++++------- 2 files changed, 109 insertions(+), 36 deletions(-) create mode 100644 __tests__/unit/components/repost-renders-as-a-post.test.tsx diff --git a/__tests__/unit/components/repost-renders-as-a-post.test.tsx b/__tests__/unit/components/repost-renders-as-a-post.test.tsx new file mode 100644 index 000000000..bcf4e19b5 --- /dev/null +++ b/__tests__/unit/components/repost-renders-as-a-post.test.tsx @@ -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(); + + expect(screen.getByText('The original words.')).toBeInTheDocument(); + }); + + it('does not repeat the original author, who is already in the header', () => { + render(); + + // 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(); + + expect(screen.getByText('My take on this.')).toBeInTheDocument(); + }); + + it('keeps the quoted original in its own panel, author and all', () => { + render(); + + // Two posts, two authors: here the panel earns its place. + expect(screen.getByText('Original Author')).toBeInTheDocument(); + expect(screen.getByText('The original words.')).toBeInTheDocument(); + }); +}); diff --git a/src/components/timeline/PostContent.tsx b/src/components/timeline/PostContent.tsx index 150b6173b..83fc8789b 100644 --- a/src/components/timeline/PostContent.tsx +++ b/src/components/timeline/PostContent.tsx @@ -107,8 +107,23 @@ export function PostContent({ event }: PostContentProps) { )} - {/* 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 && (
{renderMarkdownToReact(displayContent)}
@@ -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 && ( -
-
-
- - {/* 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 */} - {originalAuthor.name} - -
-
- - {originalAuthor.name} - - {originalAuthor.username && ( - @{originalAuthor.username} - )} -
-
-
- {originalDescription && ( -
- {renderMarkdownToReact(originalDescription)} -
- )} -
-
- )} {/* Attached image — plain : Openverse hosts aren't in next/image remotePatterns */} {postImage && !isRepost && (