Skip to content

feat(compose): iOS-parity composer redesign — pills, inline highlights, layout#568

Open
dmnyc wants to merge 5 commits into
barrydeen:mainfrom
dmnyc:feat/compose-mention-pills
Open

feat(compose): iOS-parity composer redesign — pills, inline highlights, layout#568
dmnyc wants to merge 5 commits into
barrydeen:mainfrom
dmnyc:feat/compose-mention-pills

Conversation

@dmnyc
Copy link
Copy Markdown
Contributor

@dmnyc dmnyc commented May 24, 2026

Ports the iOS compose-mention-pills work (iOS PRs #131 + #132) and then keeps going on Android-only composer UX issues that surfaced during on-device testing.

Summary

  • @mention pills — value-based BasicTextField rendering AnnotatedString with SpanStyle pill backgrounds for tracked mention ranges; atomic-editing handler (backspace nukes the whole pill, typing inside is rejected, cursor snaps out)
  • Borderless composer — drop OutlinedTextFieldDefaults.DecorationBox; cursor + pills sit on the screen background (iOS look)
  • Layout — Publish bar pinned to the keyboard via windowInsetsPadding(ime ∪ navigationBars) instead of the original consumeWindowInsets(navigationBars).imePadding() combo that under-padded once the parent NavHost's bottom nav was hidden; Routes.COMPOSE added to hideBottomBarRoutes so the parent nav doesn't fight the IME
  • Candidates below the toolbar — mention + emoji popups moved from above the text field to between toolbar and hashtag chips; composer + toolbar stay visible while searching
  • Inline highlights#hashtag and http(s)://… ranges painted in the link colour inside the composer; live preview now uses materialised content so @DisplayName ranges resolve through RichContent as NostrProfileSegments
  • Live preview always visible — drop the !imeVisible gate so the preview updates as you type; "Preview" rendered as a right-aligned pill badge matching iOS
  • Auto-trailing-space — pasting/typing a word char immediately after a pill inserts a space first (.,!?;:)]}\"'…/- and newline are allowed to abut)
  • Adjacent-pill defence — deleting the single space between two pills is silently absorbed; cursor parks at the space position
  • Publish gating (cherry-picked from feat(thread): iOS-parity depth connectors, reply order, sticky reply bar + publish gating #567) — Publish disabled until content.isNotBlank() || uploadedUrls.isNotEmpty()
  • Visual polish — candidate dropdown switched from tonal-elevation primary tint to neutral surfaceContainerHigh grey; "Following" rendered as muted grey pill instead of primary text

Bug fixes uncovered in review

  • EmojiVisualTransformation rebuilt the transformed text as a plain AnnotatedString, stripping every SpanStyle from the input — meaning pills vanished as soon as a :shortcode: appeared. Rebuilt with AnnotatedString.Builder + text.subSequence(...) so incoming spans carry through.
  • ComposeViewModel.publishNote() non-PoW success path didn't clear _mentions / draft_mentions (scheduled-publish and PoW paths already did) — stale ranges leaked into the next compose session. Now clears both.
  • MentionSearchRepository pubkey dedup now lowercases keys defensively.
  • materializeMentions() adjacent-URI regex tightened so a greedy bech32 alphabet doesn't eat the next nostr: prefix.

Test plan

  • @mention search → pick a candidate → confirm pill renders with background; combine with :shortcode: to confirm pill survives the emoji transform
  • Type a word right after a pill → space auto-inserted
  • Type a , or . right after a pill → no space inserted
  • Two adjacent pills → backspace the space between → deletion absorbed, cursor at space
  • Place cursor inside a pill → typing rejected; backspace removes whole pill atomically
  • Publish a note with two mentions → event content has both nostr:nprofile1… URIs with proper spacing and both pubkeys in p tags
  • Reopen Compose after publish → empty draft, no stale @DisplayName
  • Paste nostr:nprofile1… for a cached profile → rehydrates to a pill
  • Type #bitcoin and https://example.com → both highlighted in link colour in composer AND preview
  • Keyboard-open: Publish flush against keyboard top, composer + candidates visible
  • Keyboard-closed: Publish above gesture nav with breathing room, no overlap
  • Publish disabled until typing or attachment present

dmnyc added 5 commits May 24, 2026 07:47
…rydeen#131 + barrydeen#132)

Port iOS PRs barrydeen#131 (compose-mention-pills) and barrydeen#132 (mention-relay-fallback)
as a single Android commit.

## Visual pills (PR barrydeen#131)
- Switch compose BasicTextField from state-based to value-based API so
  AnnotatedString spans can carry SpanStyle pill backgrounds
- buildMentionAnnotatedString() wraps tracked @mention ranges with
  SpanStyle(background=primary@18%, color=primary, fontWeight=Medium)
- contentWithSpans derived from ViewModel content + mentions StateFlow;
  rebuilds on every mention or text change

## Atomic editing (PR barrydeen#131)
- handleAtomicMentionEdit() in ComposeScreen intercepts onValueChange:
  - Backspace/delete inside pill: deletes entire mention, cursor at start
  - Typing inside pill: rejected, cursor snapped to pill end
  - Cursor move (arrow key / tap) into pill: snapped to pill end (right)
    or start (left), based on direction

## Paste / draft rehydration (PR barrydeen#131)
- rehydrateMentionsFromContent() converts pasted nostr:nprofile1/npub1
  URIs to @DisplayName + seeds tracked Mention entries, so pasted
  profile links get pill rendering and materialize correctly at publish
- Called in updateContent() (paste detection), init() (process-death
  draft restore), and loadDraft()
- profileRepoRef stored in ViewModel to support rehydration lookup

## Search / compose fixes (PR barrydeen#132)
- MentionSearchRepository: normalize all pubkeys to .lowercase() before
  adding to seenPubkeys set (dedup by canonical hex)
- sanitizeMentionDisplay: strip \n/\r from display names before
  underscore-joining whitespace
- materializeMentions: replace ADJACENT_NOSTR_URI_REGEX to insert a
  space between back-to-back nostr: URIs so parsers see them separately
- SearchViewModel.updateQuery / searchAuthors / search: strip leading
  '@' from query before sending to relay (People search parity)
Layout cleanup after the @mention pill rendering landed. Three classes of
bug surfaced on device once typing was actively driving keyboard state:

## Bottom inset / Publish positioning
- ComposeScreen lives inside WispNavHost's Scaffold, whose own bottomBar
  was reserving its height as bottom padding for the screen. Combined
  with imePadding(), the Publish button sat a full nav-bar height above
  the keyboard. Add Routes.COMPOSE to hideBottomBarRoutes so the parent
  bar disappears while composing and ComposeScreen owns the whole bottom.
- Replace consumeWindowInsets(navBars).imePadding() with
  windowInsetsPadding(WindowInsets.ime.union(navigationBars).only(Bottom))
  so the keyboard-closed state still clears the gesture indicator and
  the keyboard-open state pins Publish flush to the IME top.
- Bottom-bar Column padding tightened from vertical=12.dp to 8.dp top/bottom
  for visual breathing room without a dead gap.

## Candidate dropdown placement
- Move mention + emoji autocomplete panels from above the BasicTextField
  to below the toolbar row (iOS layout). Composer + toolbar stay visible
  while the user is searching instead of being shoved off-screen.
- BasicTextField switches from fixed height(160.dp) to heightIn(min=100.dp)
  so it stays compact when the keyboard is open and grows naturally with
  multi-line content.

## Bugs uncovered in review
- ComposeViewModel.publishNote() non-PoW success path didn't clear
  _mentions / draft_mentions, leaking stale ranges into the next compose
  session. Scheduled-publish and PoW paths already did; add it here too.
- EmojiVisualTransformation rebuilt the transformed text as a plain
  AnnotatedString, stripping every SpanStyle from the input — meaning
  mention pills vanished as soon as a :shortcode: appeared in the note.
  Switch to AnnotatedString.Builder + text.subSequence(...) so incoming
  spans carry through the transformation.
Three small polishes to the compose text field that came out of on-device
testing of the @mention pill work.

## Borderless composer (matches iOS)
Drop OutlinedTextFieldDefaults.DecorationBox in favour of a minimal Box
decoration: just the inner text field with a placeholder overlay when the
content is empty. No outline, no surface tint — the cursor and pills now
sit on the screen background, the way iOS draws it.

## Auto-space after a pill
enforceMentionTrailingSpace() runs on every onValueChange. When the user
inserts (typing or pasting) text whose first character is a non-whitespace,
non-punctuation char exactly at a pill's end offset, a space is slipped in
before it. Result: "@alice" + "hi" → "@alice hi". Punctuation
(`.,!?;:)]}"'…/-` and newline) is intentionally allowed to abut so users
can still write "@alice, hi" or "@alice." without the space.

## Adjacent-pill collision defense
preventPillCollision() rejects the deletion of the single space character
between two adjacent pill ranges. Removing it would leave the pills
visually merged and would force materializeMentions() to inject a separator
anyway. The backspace is silently absorbed and the cursor parks at the
space position so the user sees the input was a no-op.

Both helpers chain in onValueChange before handleAtomicMentionEdit so a
single keystroke flows: collision check → atomic-pill edit → trailing-space
enforcement → viewmodel update.
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.
DmListScreen's FAB took the M3 default shape (`RoundedCornerShape(16.dp)`)
while FeedScreen explicitly set `CircleShape`. Same Scaffold bottom-end
slot, same primary container — but the different shape made them look
mis-positioned. Add `shape = CircleShape` so both FABs now land 1:1 in
position and geometry, with only the glyph (`Edit` vs `GroupAdd`)
distinguishing them.
@dmnyc dmnyc force-pushed the feat/compose-mention-pills branch from 5da57d4 to 414eea6 Compare May 24, 2026 21:44
@dmnyc dmnyc closed this May 26, 2026
@dmnyc dmnyc deleted the feat/compose-mention-pills branch May 26, 2026 13:04
@dmnyc dmnyc restored the feat/compose-mention-pills branch May 26, 2026 13:07
@dmnyc dmnyc reopened this May 26, 2026
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