Skip to content
Open
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
4 changes: 3 additions & 1 deletion .agents/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ This is the main index for all documentation, bug reports, and task management.
### Features
- [DM "delete your own message" (delete for everyone) — mobile](docs/features/dm-delete-own-message.md)
- [DM mute: what we show, what we don't, and how it's built](docs/features/dm-mute-behavior-and-pattern.md)
- [Feed thread previews](docs/features/feed-thread-previews.md)
- [Identity resolution on mobile](docs/features/identity-resolution.md)
- [Mobile Notification System (in-app unified panel)](docs/features/notification-system.md)

Expand All @@ -28,6 +29,7 @@ This is the main index for all documentation, bug reports, and task management.

### In Progress

- 📋 [Make feed conversation threads visually obvious](issues/2026-08-21-feed-conversation-thread-clarity.md)
- 🐛 [Space calls never start, and every failed start leaves a permanent banner](issues/2026-08-10-space-calls-dead-endpoints-and-stale-banner.md)
- 🐛 [Public invite: 'Generate New Link' claims to invalidate the old link, is offered to non-owners, and never propagates the URL](issues/2026-08-11-public-invite-regenerate-copy-and-non-owner-invite-gating.md)
- 🐛 [Broadcast `.q` claims never reach the identity ladder](issues/2026-08-16-broadcast-q-claims-never-render-after-the-identity-migration.md)
Expand Down Expand Up @@ -302,4 +304,4 @@ This is the main index for all documentation, bug reports, and task management.

---

**Last Updated**: 2026-08-21 18:26:25
**Last Updated**: 2026-08-21 18:26:25
54 changes: 54 additions & 0 deletions .agents/docs/features/feed-thread-previews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Feed thread previews

The home feed presents replies as compact conversation units without changing
feed ordering or thread navigation.

## Preview selection

`components/SocialFeed/threadPreview.ts` owns the pure selection rules. A unit
contains at most three casts:

- one cast: the original post;
- two casts: immediate parent and focused reply;
- three casts: oldest useful context, immediate parent, and focused reply;
- deeper paths: root, a gap marker, immediate parent, and focused reply.

When the true root is unavailable, the preview uses the oldest resolved cast
and places a leading gap before it. The gap never counts as a cast.

Self-reply paths already present in a feed page are collapsed without changing
the position or row identity of their newest cast. Branches remain separate
units and therefore cannot collide on FlashList keys. A lone
cross-author reply performs at most two ancestor lookups: parent and
grandparent. React Query deduplicates and caches those lookups.

## Visual semantics

Every displayed cast remains a complete `FeedPostCard`, including its own
avatar. Directly adjacent parent/reply cards share an accent rail with a branch
that terminates at each avatar. The rail is interrupted by a gap marker, so
omitted context is never mistaken for a direct reply.

The gap has a screen-reader label. When the omitted count is known it announces
the count; otherwise it announces that earlier replies were omitted.
Visible direct replies announce both authors; replies following a collapsed gap
are announced as replies in a conversation rather than as original posts.

## Invariants

- Quote cards remain ancestry-blind and continue to render through
`SocialFeed/content/QuoteCast.tsx`.
- Existing card presses, profile presses, reply actions, and thread routing are
unchanged.
- A failed ancestor lookup falls back to the existing single-card reply
presentation.
- The feed never walks an unbounded remote ancestor chain.

## Tests

`__tests__/feedThreadPreview.test.ts` covers the three-cast cap, known and
unknown gaps, reply-bumping order, cross-author separation, branching, and
malformed cycles.

Before release, visually verify the rail, gap, custom skins, light/dark themes,
large text, and failed-avatar fallbacks on both iOS and Android.
68 changes: 68 additions & 0 deletions .agents/issues/2026-08-21-feed-conversation-thread-clarity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
type: task
title: "Make feed conversation threads visually obvious"
status: in-progress
created: 2026-08-21
updated: 2026-08-21
---

# Make feed conversation threads visually obvious

## Goal

Make the home feed communicate parent/reply relationships at a glance while
preserving the existing chronological/reply-bumping behavior and post-click
navigation.

## Current state

`SocialFeedModal` already promotes the immediate parent of a lone reply and
collapses self-reply chains found in the loaded feed. The current presentation
still has three important gaps:

- a self-reply chain can render far more than three casts;
- nested cards use a generic left rail rather than a connector between avatars;
- omitted ancestors have no explicit collapsed-context marker.

Quote cards already render independently of their quoted cast's ancestry and
must remain that way.

## Implementation

- Extract a pure, deterministic thread-preview selector.
- Limit a home-feed unit to three casts, preferring root, immediate parent, and
focused/bumped reply.
- Insert a visual gap marker whenever intermediate ancestors are omitted.
- Draw reply connectors in the avatar column and interrupt them across a gap.
- Keep every displayed cast's avatar and existing click behavior.
- Preserve graceful single-card fallback when parent data cannot be resolved.
- Add focused tests for deep chains, missing context, branching, malformed
parent graphs, and quote-card isolation.

## Acceptance checks

- Original casts render unchanged.
- Direct replies render parent then reply with both avatars visible.
- Deep chains never display more than three casts in one feed unit.
- A gap marker distinguishes omitted context from a direct reply.
- Quote casts do not display reply ancestry inside the quote card.
- Existing cast and reply navigation targets do not change.
- Light/dark themes, large text, iOS, and Android remain legible.

## Verification

- Focused Jest tests: 12 passing across preview selection, reply-bumping row
identity, branching, cycles, navigation targets, and quote isolation.
- Neighboring avatar fallback and thread-fetch-bound suites also pass; together
the relevant regression run covers 21 tests. The existing thread test leaves
an open Jest handle after completion, so that combined process requires manual
termination even though every assertion passes.
- Targeted ESLint: zero errors (pre-existing warnings remain in
`SocialFeedModal`).
- `npx tsc --noEmit`: no errors in changed files; the repository retains its
unrelated pre-existing TypeScript backlog.
- `yarn lint`: repository-wide command remains red from unrelated pre-existing
errors; targeted lint for changed files is clean.
- Native visual pass on Android and iOS remains required before release. No
Android SDK/ADB or iOS environment is available in the current Windows
workspace.
187 changes: 187 additions & 0 deletions __tests__/feedThreadPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import {
buildThreadPreview,
collapseSelfReplyChains,
selectFeedThreadTarget,
} from '@/components/SocialFeed/threadPreview';
import type { FeedPost } from '@/components/SocialFeed/types';

function post(hash: string, parentHash?: string, authorFid = 1): FeedPost {
return {
id: hash,
hash,
parentHash,
username: `user${authorFid}`,
authorFid,
authorName: `User ${authorFid}`,
authorHandle: `@user${authorFid}`,
time: 'now',
content: hash,
stats: { likes: '0', replies: '0', shares: '0' },
tags: [],
mediaUrls: [],
videos: [],
urlPreviews: [],
quoteCasts: [],
frameEmbeds: [],
filter: 'all',
};
}

describe('buildThreadPreview', () => {
it('keeps one-, two-, and three-cast paths intact', () => {
const chain = [post('a'), post('b', 'a'), post('c', 'b')];
expect(buildThreadPreview(chain).map((entry) => entry.type)).toEqual([
'cast',
'cast',
'cast',
]);
});

it('caps a deep path at three casts and reports omitted context', () => {
const chain = [
post('a'),
post('b', 'a'),
post('c', 'b'),
post('d', 'c'),
post('e', 'd'),
];
const preview = buildThreadPreview(chain);
expect(preview.map((entry) => entry.type)).toEqual(['cast', 'gap', 'cast', 'cast']);
expect(preview.filter((entry) => entry.type === 'cast').map((entry) => entry.cast.hash))
.toEqual(['a', 'd', 'e']);
expect(preview[1]).toEqual({ type: 'gap', omittedCount: 2 });
});

it('marks unknown earlier context without inventing an omitted count', () => {
const chain = [post('c', 'b'), post('d', 'c'), post('e', 'd')];
expect(buildThreadPreview(chain, { hasEarlierContext: true })[0]).toEqual({ type: 'gap' });
});

it('preserves both unknown earlier context and a known internal omission', () => {
const chain = [
post('c', 'b'),
post('d', 'c'),
post('e', 'd'),
post('f', 'e'),
post('g', 'f'),
];
const preview = buildThreadPreview(chain, { hasEarlierContext: true });

expect(preview.map((entry) => entry.type)).toEqual([
'gap',
'cast',
'gap',
'cast',
'cast',
]);
expect(preview[0]).toEqual({ type: 'gap' });
expect(preview[2]).toEqual({ type: 'gap', omittedCount: 2 });
expect(preview.filter((entry) => entry.type === 'cast').map((entry) => entry.cast.hash))
.toEqual(['c', 'f', 'g']);
});

it('never exceeds three casts at any supported depth', () => {
for (let depth = 1; depth <= 64; depth += 1) {
const chain = Array.from({ length: depth }, (_, index) =>
post(`cast-${index}`, index > 0 ? `cast-${index - 1}` : undefined),
);
for (const hasEarlierContext of [false, true]) {
const preview = buildThreadPreview(chain, { hasEarlierContext });
const visible = preview.filter((entry) => entry.type === 'cast');
expect(visible.length).toBeLessThanOrEqual(3);
expect(visible.at(-1)?.cast.hash).toBe(`cast-${depth - 1}`);
}
}
});
});

describe('selectFeedThreadTarget', () => {
it('preserves the existing parent-thread destination for a reply', () => {
expect(selectFeedThreadTarget(post('reply', 'parent'), false)).toEqual({
hash: 'parent',
username: '',
opensParent: true,
});
});

it('opens a promoted context card on its own thread', () => {
expect(selectFeedThreadTarget(post('parent', 'root'), true)).toEqual({
hash: 'parent',
username: 'user1',
opensParent: false,
});
});
});

describe('collapseSelfReplyChains', () => {
it('keeps reply-bumping order while attaching the full path to its tip', () => {
const result = collapseSelfReplyChains([
post('a'),
post('unrelated', undefined, 2),
post('b', 'a'),
post('c', 'b'),
]);
expect(result.map((item) => item.hash)).toEqual(['unrelated', 'c']);
expect(result[1].__chain?.map((item) => item.hash)).toEqual(['a', 'b', 'c']);
});

it('does not merge a cross-author reply', () => {
const result = collapseSelfReplyChains([post('a'), post('b', 'a', 2)]);
expect(result).toHaveLength(2);
expect(result.every((item) => item.__chain === undefined)).toBe(true);
});

it('matches Farcaster hashes case-insensitively', () => {
const result = collapseSelfReplyChains([post('0xABCD'), post('reply', '0xabcd')]);

expect(result).toHaveLength(1);
expect(result[0].hash).toBe('reply');
expect(result[0].__chain?.map((item) => item.hash)).toEqual(['0xABCD', 'reply']);
});

it('does not mutate the loaded feed array or its casts', () => {
const root = post('root');
const reply = post('reply', 'root');
const posts = [root, reply];

collapseSelfReplyChains(posts);

expect(posts).toEqual([root, reply]);
expect(root).not.toHaveProperty('__chain');
expect(reply).not.toHaveProperty('__chain');
});

it('keeps branches as separate feed units', () => {
const result = collapseSelfReplyChains([
post('a'),
post('b', 'a'),
post('c', 'a'),
]);
expect(result).toHaveLength(2);
expect(result.map((item) => item.hash)).toEqual(['b', 'c']);
expect(new Set(result.map((item) => item.id)).size).toBe(2);
expect(result.map((item) => item.__chain?.map((member) => member.hash))).toEqual([
['a', 'b'],
['a', 'c'],
]);
});

it('terminates a malformed cycle', () => {
const result = collapseSelfReplyChains([post('a', 'b'), post('b', 'a')]);
expect(result).toHaveLength(2);
});

it('keeps unrelated conversations distinct and in bumped order', () => {
const result = collapseSelfReplyChains([
post('a1', undefined, 1),
post('x1', undefined, 2),
post('a2', 'a1', 1),
post('x2', 'x1', 2),
]);
expect(result.map((item) => item.hash)).toEqual(['a2', 'x2']);
expect(result.map((item) => item.__chain?.map((member) => member.hash))).toEqual([
['a1', 'a2'],
['x1', 'x2'],
]);
});
});
16 changes: 16 additions & 0 deletions __tests__/quoteCastThreadIsolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import fs from 'node:fs';
import path from 'node:path';

describe('quote-cast thread isolation', () => {
it('keeps quoted replies ancestry-blind while retaining the quoted PFP', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'components/SocialFeed/content/QuoteCast.tsx'),
'utf8',
);

expect(source).toContain('<CachedAvatar');
expect(source).not.toContain('ParentContextLine');
expect(source).not.toContain('parentHash');
expect(source).not.toContain('threadConnector');
});
});
Loading