Skip to content

feat(thread): iOS-parity depth connectors, reply order, sticky reply bar + publish gating#567

Open
dmnyc wants to merge 9 commits into
barrydeen:mainfrom
dmnyc:feat/thread-connectors
Open

feat(thread): iOS-parity depth connectors, reply order, sticky reply bar + publish gating#567
dmnyc wants to merge 9 commits into
barrydeen:mainfrom
dmnyc:feat/thread-connectors

Conversation

@dmnyc
Copy link
Copy Markdown
Contributor

@dmnyc dmnyc commented May 24, 2026

Summary

iOS-parity refinements for the thread view and compose screen. Ports specific behaviors from wisp-ios/ThreadView.swift, ThreadViewModel.swift, and ComposeView.swift.

Thread depth connectors (fedd321)

Replace the per-depth stacked vertical lines with a single L-shaped outline per non-root reply — matches iOS ReplyConnectorShape:

  • Indent step 12dp per level, capped at depth 5 (matches indentationWidth = min(depth, 5) * 12).
  • Connector vertical at clampedDepth * 12 − 7dp from screen edge (iOS's .padding(.leading, depth*12 − 8) + x = 1 inside the shape).
  • Bottom-left rounded fillet at 8dp; 1dp stroke, outlineVariant @ 50%.
  • PostCard's full-width divider is suppressed for non-root replies (showDivider = false); the connector draws its own partial divider starting at the arc's end so no horizontal extends to the LEFT of the curve.

Thread reply ordering (5b2b120)

ThreadViewModel.kt no longer bubbles the user's own replies to the top within each level. Children sorted purely by created_at oldest-first to match iOS's buildNestedReplies. Threads now order identically across platforms.

Sticky reply bar (5b2b120)

New ThreadReplyBar composable wired into ThreadScreen's Scaffold.bottomBar. Thin 0.5dp outlineVariant @ 50% divider, then an 18dp rounded surfaceVariant @ 50% pill with "Reply…" placeholder + orange Icons.Outlined.Edit icon. Tap-anywhere on the pill opens compose with the thread's focal (first entry in flatThread) as the reply parent. Disabled until flatThread populates. Matches ThreadView.swift::composer 1:1.

Publish button gating (e60fc9f)

ComposeScreen.kt Publish button is disabled until the post has at least one non-whitespace character OR at least one uploaded attachment. Prevents accidental empty posts; matches the iOS composer's send-button gating.

Overlap with #565

This branch was rebased onto current origin/main (which includes PR #546's countdown UI). The first 6 commits in this PR's diff (c33447d through 138701f) are the same conceptual dark-mode polish as PR #565 — they have different SHAs because the two branches were rebased independently. Whichever PR merges second will see those commits collapse to no-ops automatically. The unique commits here are the last 4 (thread + composer).

Test plan

  • Open a deeply nested thread — each non-root reply has a single ╰ L-bracket at its left, indented by 12dp per level, capped at depth 5.
  • No horizontal divider line extends LEFT of the curve.
  • Compare the same thread on iOS and Android — reply order should match identically (oldest first, no "own replies first" anomaly).
  • Bottom of thread shows the sticky "Reply…" pill above the global bottom nav; tap opens compose with the focal as parent.
  • Open the composer — Publish button is greyed out until you type at least one character or attach a file. As soon as either condition becomes true, it enables. With the countdown progress bar (from feat(compose): countdown progress bar + auto-scroll preview #546), starting publish triggers the red-X cancel + capsule progress.

🤖 Generated with Claude Code

dmnyc added 9 commits May 23, 2026 22:17
The default ("custom") Android dark theme rendered noticeably lighter
than iOS, which uses near-black backgrounds. Align with iOS HIG dark
system colors (slight off-black for the base, iOS secondary/tertiary
greys for elevated surfaces) so the two platforms feel like the same
app in dark mode.

- background: #131215 → #0A0A0B  (slight off-black, OLED-friendly
  without the harsh #000 step on LCD)
- surface:    #1F1E21 → #1C1C1E  (iOS secondarySystemBackground)
- surfaceVariant: #2B2A2E → #2C2C2E  (iOS tertiarySystemBackground)
- outline:    #343338 → #38383A  (iOS separator on dark)

Named presets (Nord, Dracula, Gruvbox, …) are left untouched — their
distinctive backgrounds are part of each preset's identity.
Default `TopAppBarDefaults.topAppBarColors` uses `MaterialTheme.color
Scheme.surface`, which sat noticeably lighter than the body after the
preceding dark-mode background darken. iOS uses one near-black across
body + chrome and reserves the lighter "surface" tone for elevated
controls (pills, cards). Switch every screen's TopAppBar container
to `background` so chrome reads as part of the page, not as a raised
layer above it.

30 screens touched; only `containerColor` lines inside
`TopAppBarDefaults.topAppBarColors(...)` blocks are changed, so other
surface usages (cards, dialogs, sheets, the elevated pills the home
top bar overlays) keep their existing tone.
iOS-style cleanup on the home screen's top + bottom chrome:

- `FeedScreen` `CenterAlignedTopAppBar` clamps to 48dp content +
  status-bar inset (was Material's default ~64dp + inset). Drops the
  gap below the icon row that pushed the feed down.
- `BottomBar` `NavigationBar` clamps to 56dp content + gesture inset
  (was Material's default 80dp). The 80dp slot reserves space for the
  label text we don't render — pure waste on small phones.
- Tab indicator pill is suppressed (`indicatorColor = Color.Transparent`).
  The selected-icon orange tint is enough signal; matches iOS where
  the tab bar has no rounded background on the active tab.
- Notification dot uses iOS systemRed (#FF3B30) instead of the app's
  primary accent so it reads as "alert" rather than "branded highlight"
  — same red iOS shows on the bell.
- Filter icon for "All" content types switches from
  `Icons.Outlined.Dashboard` (1 large + 3 small panels) to
  `Icons.Outlined.GridView` (2x2 of equal squares) to match the iOS
  toolbar icon.
Two post-card refinements that move the feed toward the iOS look:

- `PostCard` now wraps content + the inter-post `HorizontalDivider`
  in an outer Column. The content Column keeps its 16dp horizontal
  padding (so post body / action bar / metadata stay inset), but the
  divider sits outside that padding and runs edge-to-edge. Matches
  iOS where the separator spans the full viewport width.
- `ActionBar` gates each of the four counters (`replyCount`,
  `likeCount`, `repostCount`, `zapSats`) on `> 0`. Empty engagement
  no longer shows "0" beside the icon — matches iOS where the
  count text only appears when there's something to show. As soon
  as the count crosses 1, the number reappears.
Two more iOS-parity tweaks:

- `WispTheme` sets `error = #FF3B30` (and `onError = white`) explicitly
  on every color-scheme variant (custom dark/light + preset dark/light).
  Material 3's defaults for `error` render pinkish in dark mode and a
  muted brick red in light mode — neither matches the iOS systemRed used
  by the rest of the destructive UI in this app. With this override,
  every `MaterialTheme.colorScheme.error` consumer (logout button,
  destructive labels, error text) now matches the iOS counterpart and
  the existing #FF3B30 used directly on Disconnect/Switch wallet flows.
- `UserProfileScreen` sticky-header tab strip + the sort-pill row below
  it use `background` (#0A0A0B) instead of `surface` (#1C1C1E). The two
  grey tiers stacked above each other read as visually noisy on the
  profile; the iOS profile uses one near-black across both. Body posts
  below still render with the elevated tier where they need to.
Reduce per-depth indent column from 12dp to 8dp and stroke width from
1.5dp to 1dp, with rounded stroke caps. Aesthetic only — no change to
thread structure or interaction.
Replace the stacked vertical depth lines with a single L-shaped outline
per non-root reply — matches the iOS thread view 1:1.

For each reply (depth > 0):
- Vertical line at `x = depth * indentStep` (16dp per level) running
  from the top of the post box down to the start of the corner arc.
- Counter-clockwise rounded 90° arc (12dp corner radius) at the post's
  bottom-left, curving the vertical into the horizontal divider.
- The horizontal portion of the L is the existing PostCard
  `HorizontalDivider` at the bottom — no extra drawing needed.

Same-depth siblings each get their own self-contained L (no chaining
across siblings, matching iOS). Posts are indented by `depth * 16dp`
so the L's vertical sits at the post's left content edge.

Replaces the earlier per-depth multi-line stack (which drew through
avatars and didn't curve) and the top-right elbow attempt (which
landed at avatar height, not the bottom-left where iOS terminates).
Three changes to match wisp-ios `ThreadView.swift` / `ThreadViewModel.swift`:

Indent + connector geometry
- Per-depth step `12.dp` (was 16) capped at depth 5 (was 8), matching
  `indentationWidth = min(depth, 5) * 12` on iOS.
- Connector vertical sits at `clampedDepth * 12 − 7`dp from the screen
  edge — same math iOS uses (`.padding(.leading, depth*12 − 8)` plus
  `x = 1` inside the shape).
- Corner radius `8.dp` (was 12), `1.dp` stroke.
- PostCard's full-width divider is suppressed for non-root replies
  (`showDivider = false`); the connector draws its own partial divider
  starting at the arc's end so no horizontal line extends to the LEFT
  of the curve.

Reply sort order
- `ThreadViewModel.kt` no longer bubbles the user's own replies to the
  top within each level. Children sorted purely by `created_at`
  oldest-first to match iOS's `buildNestedReplies`. Threads now order
  identically across platforms.

Sticky reply bar
- New `ThreadReplyBar` composable wired into the Scaffold's `bottomBar`.
  Thin 0.5dp `outlineVariant @ 50%` divider, then an 18dp rounded
  `surfaceVariant @ 50%` pill with "Reply…" placeholder text + an
  orange `Icons.Outlined.Edit` icon (closest Material to iOS's
  `square.and.pencil`).
- Tap-anywhere on the pill opens compose with the thread's focal (the
  first entry in `flatThread`) as the reply parent. Disabled until
  flatThread populates.
- Outer padding `12.dp` horizontal × `8.dp` vertical, container background
  `MaterialTheme.colorScheme.background`. Matches
  `ThreadView.swift::composer` 1:1.
Material's filled Button stays fully tappable even when the post body
is empty — easy to fire off an empty note by accident. Gate the
Publish button's `enabled` on having either at least one non-whitespace
character or at least one uploaded attachment. Matches the iOS
composer's send-button gating.

`!publishing && !isMiningBusy` continues to gate against double-taps
during publish / PoW mining.
@dmnyc dmnyc force-pushed the feat/thread-connectors branch from e60fc9f to 7e204b9 Compare May 24, 2026 02:17
dmnyc added a commit to dmnyc/wisp that referenced this pull request May 24, 2026
Round of polish on the compose screen after on-device testing.

## Live preview is always visible
Drop the `!imeVisible` gate on the preview card so it updates while the
keyboard is open. Re-render the "Preview" label as a small grey pill on
the right of the user-info row (display name takes weight(1f)), matching
the iOS layout.

## Inline highlights for #hashtags and URLs
buildMentionAnnotatedString() now also finds `#hashtag` tokens and
`http(s)://…` URLs and styles them in `linkColor` (defaults to
pillForeground). Highlight ranges are merged with mention ranges and
overlap is resolved with mentions winning. The composer no-longer-empty
early-return is dropped so hashtags/URLs still get coloured when no pills
are tracked.

The live preview itself receives the *materialised* draft content — a new
`previewMaterializedContent()` helper on the viewmodel splices the
`@DisplayName` ranges back into `nostr:nprofile1…` URIs so RichContent
can detect them as NostrProfileSegments and render them in the link
colour the same way published notes do.

## Publish gating
Cherry-pick the gating from PR barrydeen#567: the Publish button stays disabled
until `content.text.isNotBlank() || uploadedUrls.isNotEmpty()`. Prevents
accidental empty posts.

## Candidate dropdown + Following pill
Drop `tonalElevation = 3.dp` on the mention candidates Surface (the
elevation overlay was tinting the background with the primary colour).
Use `surfaceContainerHigh` for a neutral grey that matches iOS.

Re-render the per-row "Following" label as a muted grey pill
(`surfaceContainerHighest` background, `onSurfaceVariant` text) instead
of plain primary-coloured text, matching iOS and visually consistent with
the new Preview badge.
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