Skip to content

feat(thread): anchor the thread on the tapped note, never auto-pin#225

Open
Kelbie wants to merge 3 commits into
mainfrom
feat/thread-reverse-scroll
Open

feat(thread): anchor the thread on the tapped note, never auto-pin#225
Kelbie wants to merge 3 commits into
mainfrom
feat/thread-reverse-scroll

Conversation

@Kelbie

@Kelbie Kelbie commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

What & why

Opening a thread on a reply reshuffled content under the thumb. It paints in stages: a seed (own-note / threadSeedCache) or skeletons render the tapped note first, then the full getThread result replaces it with [...parents, target, ...replies]prepending the parent chain above the already-visible note — and rows snap from estimatedItemSize to measured height a frame later. Both moved the focused note. initialScrollIndex only fires at mount, so it couldn't hold the note once parents arrived; the prior defense was an opacity-0-until-handleTargetSettled reveal that masked only the sort-tabs row.

Change

Anchor the thread LegendList on the tapped note (same mechanism the DM screen already uses for prepended older messages):

  • maintainVisibleContentPosition — holds the first visible row (the target), so parents fill in off-screen above and a height snap above the anchor never shifts it.
  • Keep initialScrollIndex landing on the note ("start at the bottom" = scrolled past the parent chain).
  • No maintainScrollAtEnd — a thread is not a chat: replies appended below (initial fill or loadMoreReplies) don't yank the view down.
  • Removed the opacity-reveal hack (revealOpacity/handleTargetSettled + sort-tabs wrapper) — maintainVisibleContentPosition subsumes it (no-shim policy). getFixedItemSize/estimatedItemSize stay (they feed offset math + virtualization, not just shift-masking).

Net: +34 / −43, reduction-heavy.

Not doing (deliberately)

  • No inverted (unsupported by legend-list; breaks animations) and no alignItemsAtEnd/initialScrollAtEnd (those anchor the newest reply, not the tapped note).
  • No shared "anchored list" preset with the DM screen — it wants bottom-pin + auto-follow, the thread wants anchor-on-target + no auto-pin; the only shared prop is maintainVisibleContentPosition. Forced abstraction avoided.
  • No @legendapp/list bump (stays 3.0.0; flagged v3.0.4/3.0.6 fixes as a follow-up).

Verification

  • tsc --noEmit clean · eslint 0 errors (15 pre-existing react-perf warnings) · knip orphaned nothing · jest green except one pre-existing, unrelated failure in naggFeedClient.test.ts (confirmed failing on main with this change stashed).
  • The change is a prop on a native list, so it's verified on-device against the existing thread.shift.* / visualList taxonomy — not unit-testable. Manual checks: open a thread on a reply (seed + skeleton paths), watch parents stream in above and replies load below → no jump, no auto-scroll; change reply sort → anchor holds.

Docs

  • docs/adr/0003-thread-list-anchoring.md — the anchoring decision + alternatives.
  • CONTEXT.md — "Thread reading" glossary (thread anchor, maintainVisibleContentPosition).

🤖 Generated with Claude Code

Opening a thread on a reply painted in stages — a seed/skeleton rendered
the tapped note first, then the full result prepended the parent chain
above it and rows snapped from estimated to measured height — both moving
the focused note under the reader's thumb. The old defense was an
opacity-0-until-settled reveal that masked only the sort-tabs row.

Anchor the LegendList on the target via maintainVisibleContentPosition so
the parent chain fills in off-screen above and a height snap never shifts
it, and keep initialScrollIndex landing on the note. Deliberately omit
maintainScrollAtEnd: replies load below without yanking the view down (a
thread is not a chat). Remove the now-redundant opacity-reveal hack.

Docs: ADR 0003 (thread list anchoring) + CONTEXT.md glossary.
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown

Structural health

# Structural Health Score

Overall: **59/100**
- Architecture: 55/100 (weight 20)
- Module Design: 56/100 (weight 15)
- Code Complexity: 40/100 (weight 15)
- Type Safety: 87/100 (weight 10)
- Component Health: 88/100 (weight 10)
- Hygiene: 69/100 (weight 15)
- Testability: 7/100 (weight 10)
- Conceptual Cohesion: 96/100 (weight 5)

# Repo Analysis: .

Files: 1104  Code: 152244  Cycles: 1  Orphans: 308  Shallow: 49  Pass-through: 44  Complexity hotspots: 308  Component smells: 88  Type-safety hotspots: 338  Hub-spoke: 134  Test gaps: 702  Unused-export files: 59

## Top complexity hotspots
- features/mint/hooks/useMintRebalanceOrchestrator.ts | cognitive=697 cyclomatic=178 nesting=12 code=1146
- features/send/lib/sovranPaymentConfig.ts | cognitive=631 cyclomatic=276 nesting=9 code=1570
- shared/lib/loggerCore.ts | cognitive=582 cyclomatic=263 nesting=7 code=879
- shared/lib/contentShiftLog.ts | cognitive=579 cyclomatic=405 nesting=6 code=1401
- features/feed/data/naggFeedClient.ts | cognitive=541 cyclomatic=333 nesting=6 code=1978
- features/nostrSigner/lib/nip46Engine.ts | cognitive=393 cyclomatic=185 nesting=6 code=840
- features/nearPay/screens/NearPayScreen.tsx | cognitive=352 cyclomatic=207 nesting=5 code=2149
- features/feed/components/nostr/image-overlay/AnimatedImageOverlay.tsx | cognitive=346 cyclomatic=185 nesting=7 code=1371
- features/feed/components/HomeFeed.tsx | cognitive=345 cyclomatic=144 nesting=8 code=1174
- shared/ui/composed/ContactRow.tsx | cognitive=313 cyclomatic=247 nesting=6 code=829
- …and 298 more

## Top shallow modules
- shared/lib/brandColors.ts | depth=1 exports=14 code=14
- features/feed/components/nostr/image-overlay/config.ts | depth=1.2 exports=52 code=62
- features/feed/components/thread-embed/embedConstants.ts | depth=1.2 exports=9 code=11
- scripts/release.config.mjs | depth=1.8 exports=25 code=46
- shared/lib/nav/routeSchemas.ts | depth=1.9 exports=7 code=13
- features/nostrSigner/lib/boundedDisplay.ts | depth=2.2 exports=5 code=11
- features/nostrSigner/lib/nip46Types.ts | depth=2.5 exports=44 code=109
- shared/ui/composed/ScreenFooterContext.tsx | depth=2.5 exports=4 code=10
- shared/ui/composed/CircleActionButton/CircleActionButton.types.ts | depth=3 exports=6 code=18
- features/feed/lib/threadListLayout.ts | depth=3.7 exports=6 code=22
- …and 39 more

## Pass-through suspects
- features/feed/components/nostr/image-overlay/config.ts | ratio=1 exports=52 fanin=5 fanout=0
- features/feed/components/thread-embed/ThreadEmbedProvider.tsx | ratio=1 exports=2 fanin=2 fanout=6
- features/feed/components/HomeFeed.tsx | ratio=1 exports=5 fanin=1 fanout=26
- features/nostrSigner/components/display.tsx | ratio=1 exports=2 fanin=5 fanout=2
- features/nostrSigner/components/SummaryPreviewCards.tsx | ratio=1 exports=7 fanin=2 fanout=17
- features/nostrSigner/hooks/signerApprovalCoordination.ts | ratio=1 exports=2 fanin=3 fanout=0
- features/nostrSigner/lib/boundedDisplay.ts | ratio=1 exports=5 fanin=10 fanout=0
- features/nostrSigner/lib/pairingPreset.ts | ratio=1 exports=2 fanin=4 fanout=1
- features/nostrSigner/lib/rateLimiter.ts | ratio=1 exports=7 fanin=2 fanout=1
- features/send/screens/AmountFlowScreen.tsx | ratio=1 exports=2 fanin=3 fanout=13
- …and 34 more

## Hub-spoke coordinators
- shared/ui/primitives/Avatar.tsx | fanin=38 fanout=11 ×=418
- shared/ui/composed/Screen.tsx | fanin=51 fanout=6 ×=306
- shared/ui/primitives/Pressable.tsx | fanin=87 fanout=3 ×=261
- shared/providers/NostrKeysProvider.tsx | fanin=36 fanout=6 ×=216
- features/nostrSigner/data/nip46ConnectionsStore.ts | fanin=26 fanout=6 ×=156
- shared/ui/composed/ButtonHandler.tsx | fanin=22 fanout=7 ×=154
- features/nostrSigner/lib/nip46Engine.ts | fanin=9 fanout=17 ×=153
- shared/ui/composed/AmountFormatter.tsx | fanin=17 fanout=9 ×=153
- shared/ui/composed/ContactRow.tsx | fanin=8 fanout=17 ×=136
- shared/stores/global/settingsStore.ts | fanin=41 fanout=3 ×=123
- …and 124 more

## Type-safety hotspots
- redux/store/store.deprecated.ts | any=29 !=0 as=16 ts-ignore=0
- __tests__/nip46Engine.test.ts | any=0 !=31 as=1 ts-ignore=0
- features/feed/data/naggFeedClient.ts | any=0 !=0 as=34 ts-ignore=0
- __tests__/avatarFallback.test.tsx | any=0 !=16 as=0 ts-ignore=0
- shared/blocks/popup/PopupHost.tsx | any=3 !=1 as=20 ts-ignore=0
- __tests__/sovranPaymentConfig.profile.test.ts | any=0 !=0 as=6 ts-ignore=6
- __tests__/receiveScreenLayout.test.tsx | any=0 !=13 as=0 ts-ignore=0
- features/ai/hooks/useAiSend.ts | any=6 !=0 as=7 ts-ignore=0
- __tests__/loggerChild.test.ts | any=0 !=10 as=3 ts-ignore=0
- shared/lib/typedUpdate.ts | any=7 !=0 as=0 ts-ignore=0
- …and 328 more

## Component smells
- features/nearPay/screens/NearPayScreen.tsx:NearPayScreen | large(804L) hooks(65) effect-deps(19)
- features/feed/screens/NotificationsScreen.tsx:NotificationsScreen | large(548L) hooks(36) bool-state(3)
- features/mint/screens/MintAddScreen.tsx:MintAddScreen | large(529L) hooks(33) effect-deps(7)
- features/contacts/screens/ContactsScreen.tsx:ContactsScreen | large(554L) hooks(27) inline-subcomp(2)
- features/mint/screens/MintRebalancePlanScreen.tsx:MintRebalancePlanScreen | large(531L) hooks(20) effect-deps(16)
- features/nostrSigner/components/PermissionGestureDemo.tsx:PermissionGestureDemo | large(322L) hooks(46)
- shared/blocks/popup/PopupHost.tsx:SheetPopup | large(432L) hooks(34)
- shared/blocks/popup/ActionMenuHost.tsx:ActionMenuHost | large(549L) hooks(19)
- features/feed/screens/NotificationFollowersScreen.tsx:NotificationFollowersScreen | large(441L) hooks(28)
- features/ai/screens/AiChatScreen.tsx:AiChatScreen | large(438L) hooks(28)
- …and 78 more

## Duplicate export names
- ShareRoute in 2 files: app/share.tsx, features/user/screens/ShareRoute.tsx
- NOTE_CONTENT_LINE_HEIGHT in 2 files: features/feed/components/nostr/NoteContent.tsx, features/feed/lib/threadListLayout.ts
- PUBLISH_TIMEOUT_MS in 2 files: features/nostrSigner/lib/nip46Transport.ts, shared/lib/nostr/publish/constants.ts
- isSupported in 2 files: modules/liquid-glass-menu/src/LiquidGlassMenu.tsx, modules/liquid-glass-text/src/LiquidGlassText.tsx
- Avatar in 2 files: shared/ui/primitives/Avatar.tsx, mealection-react-native-boring-avatars.d.ts

## Default+named export clash
- LoadingIndicator in shared/blocks/status/LoadingIndicator.tsx

## Unused export sets
- features/feed/data/naggSchemas.ts | unused: NaggNoteMetrics, NaggFeedEvent, NaggProfileInfo, NaggFeedItem, …
- shared/styles/tokens.ts | unused: minTouchTarget, Spacing, Radius, Alpha, …
- features/mint/hooks/useMintRebalanceOrchestrator.ts | unused: RebalanceRunStatus, MintLite, UseMintRebalanceOrchestratorArgs, UseMintRebalanceOrchestratorResult
- features/nostrSigner/data/nip46ConnectionsStore.ts | unused: ConnectionOrigin, ConnectionEncryption, Nip46Grant, PeerDecryptGrant
- shared/lib/nostr/nip11.ts | unused: fetchRelayInformation, RelayInfoError, RelayLimitation, RelayInformation
- features/feed/components/nostr/feedParse.ts | unused: getFirstTagValue, IMAGE_EXT, ImetaInfo
- features/feed/data/recentPeopleProfiles.ts | unused: buildRecentPeopleProfilesGraphqlBody, mapRecentPeopleProfileEvents, RECENT_PEOPLE_PROFILES_QUERY
- features/nostrSigner/lib/nip46Engine.ts | unused: OnUserVerdictNeeded, Nip46EngineStartConfig, Nip46EngineTransport
- features/nostrSigner/lib/permissionPolicy.ts | unused: PolicyAllowReason, PolicyDenyReason, PolicyAskReason
- features/nostrSigner/lib/requestSummary.ts | unused: SummaryReaction, SummaryCopySegment, SummaryCopyContext
- …and 49 more

## Test gaps
- features/nearPay/screens/NearPayScreen.tsx | exports=1 code=2149
- features/send/lib/sovranPaymentConfig.ts | exports=6 code=1570
- shared/lib/popup/popups/emojiData.ts | exports=4 code=1440
- features/feed/components/nostr/image-overlay/AnimatedImageOverlay.tsx | exports=1 code=1371
- features/feed/screens/NotificationsScreen.tsx | exports=1 code=1254
- features/user/screens/UserProfileScreen.tsx | exports=1 code=1181
- features/feed/components/HomeFeed.tsx | exports=5 code=1174
- features/mint/hooks/useMintRebalanceOrchestrator.ts | exports=5 code=1146
- features/feed/components/UserFeed.tsx | exports=2 code=1104
- features/feed/components/nostr/image-overlay/provider.tsx | exports=6 code=983
- …and 692 more

## Cycles
- (2 files) shared/stores/runtime/mockDataStore.ts → features/payments/hooks/useNip17RecentContacts.ts

## Instability per folder
- __tests__ | I=1.00 Ce=235 Ca=0
- scripts | I=1.00 Ce=1 Ca=0
- app | I=1.00 Ce=288 Ca=1
- features | I=0.86 Ce=1761 Ca=282
- navigation | I=0.50 Ce=6 Ca=6
- redux | I=0.42 Ce=5 Ca=7
- assets | I=0.27 Ce=3 Ca=8
- config | I=0.15 Ce=3 Ca=17
- shared | I=0.03 Ce=53 Ca=2027
- codereview | I=0.00 Ce=0 Ca=1
- …and 2 more

## Re-export depth (barrel hops)
- __tests__/nip46PairingEntry.test.ts | depth=2 exports=0
- __tests__/nip46SignerService.test.ts | depth=2 exports=0
- __tests__/nip46SwitchProfileAndPair.test.ts | depth=2 exports=0
- __tests__/sovranPaymentConfig.profile.test.ts | depth=2 exports=0
- app/(drawer)/(tabs)/index/index.tsx | depth=2 exports=1
- __tests__/loggerChild.test.ts | depth=1 exports=0
- __tests__/loggerFile.test.ts | depth=1 exports=0
- __tests__/mintRebalanceRunState.test.ts | depth=1 exports=0
- __tests__/paymentStatusStore.test.ts | depth=1 exports=0
- __tests__/publishComposed.test.ts | depth=1 exports=0
- …and 16 more

## Concept locality
- target | folders=5 files=55
- not | folders=4 files=71
- pending | folders=4 files=39
- resolveOn modes | folders=3 files=8
- resolveOn | folders=3 files=8
- confirmed | folders=3 files=6
- optimistic | folders=3 files=5
- ingestOwnContent | folders=3 files=4
- maintainVisibleContentPosition | folders=2 files=4
- Publish seam | folders=2 files=3
- …and 34 more

## Churn × cognitive (history)
- features/send/lib/sovranPaymentConfig.ts | commits=32 cognitive=631 ×=20192
- features/nearPay/screens/NearPayScreen.tsx | commits=41 cognitive=352 ×=14432
- features/feed/data/naggFeedClient.ts | commits=16 cognitive=541 ×=8656
- app/_layout.tsx | commits=76 cognitive=104 ×=7904
- features/mint/hooks/useMintRebalanceOrchestrator.ts | commits=7 cognitive=697 ×=4879
- features/feed/components/HomeFeed.tsx | commits=14 cognitive=345 ×=4830
- shared/lib/loggerCore.ts | commits=8 cognitive=582 ×=4656
- shared/ui/composed/ContactRow.tsx | commits=14 cognitive=313 ×=4382
- features/transactions/components/Transactions.tsx | commits=23 cognitive=184 ×=4232
- shared/lib/cashu/manager.ts | commits=14 cognitive=298 ×=4172
- …and 793 more

## Temporal coupling (history)
- package.json ↔ yarn.lock | co=40
- package-lock.json ↔ package.json | co=35
- bun.lock ↔ package.json | co=20
- package-lock.json ↔ yarn.lock | co=16
- metro.config.js ↔ package.json | co=15
- app/ecashSendConfirmation.tsx ↔ app/lightningReceiveConfirmation.tsx | co=14
- app/ecashReceiveConfirmation.tsx ↔ app/ecashSendConfirmation.tsx | co=13
- app/currency.tsx ↔ helper/payment-handler/handlers.ts | co=11
- features/nearPay/lib/peerLayout.ts ↔ features/nearPay/screens/NearPayScreen.tsx | co=10
- __tests__/nearPayPeerLayout.test.ts ↔ features/nearPay/screens/NearPayScreen.tsx | co=10
- …and 226 more

## Stale files (history)
- prettier.config.js | last=2025-04-16 code=9

14 |     "moduleSuffixes": [".ios", ".android", ""],
         ^
warn: moduleSuffixes is not supported yet
   at /home/runner/work/Sovran/Sovran/sovran-app/tsconfig.json:14:5

Kelbie added 2 commits June 21, 2026 13:18
maintainVisibleContentPosition holds the anchor against row resize and
reply append, but not against the parent chain arriving after the target
has painted: a prepended parent first lays out at the flat
estimatedItemSize, the data-anchor corrects against that estimate, then
the parent measures to its real (variable, taller) height and the delta
shoves the focused note down — a known upstream gap for variable-height
prepends.

Drive an explicit one-shot scrollToIndex(targetIndex) when parents first
appear. An active scroll target is re-resolved on every layout pass, so it
pins the note through the measurement settle. Fires once per thread and
bails if the reader has already dragged the list (onScrollBeginDrag). The
DM screen doesn't need this — uniform short bubbles make the estimate
error negligible.
The one-shot scrollToIndex completes against the parent's estimated height,
so when the parent then measures to its real (taller) height the focused
note still drops — the library's size-anchor corrects against the estimate,
not the measurement (a known upstream gap).

Re-assert the pin from onItemSizeChanged whenever a row ABOVE the target
changes size, i.e. as the parent chain measures. The height delta then
fills in off-screen above instead of shoving the note down. Self-correcting
(scrollToIndex is absolute, no drift), stops once parents settle, and bails
once the reader has dragged the list.
Kelbie added a commit that referenced this pull request Jun 22, 2026
Live on-device trace (now that the build actually runs this code) showed the
residual: mVCP scrolls to hold the focused note when the parent prepends, but it
anchors against the parent's estimatedItemSize (200) and never reconciles the gap
when the parent measures to its real height (145) — leaving the note off by
exactly estimate-measured (here +55px up; an earlier media parent measured 523 ->
-323px down). A single global estimate can't cover parents ranging ~145-523px, and
v3 has no per-item estimate.

Own that one reconciliation: when a row above the note changes size before the
reader has scrolled, counter-scroll by the delta (scrollToOffset(scroll + size -
previous)). One-time per parent measure (not a sustained re-pin, so it doesn't
race mVCP like PR #225 did); onScrollBeginDrag hands control back to the user.

ADR 0004 updated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant