From 8d8baca1e51e397ac7f9d76d0a46803f76d09ed0 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sun, 24 May 2026 07:47:44 -0400 Subject: [PATCH 1/5] feat(compose): @mention pill rendering + atomic editing (port iOS #131 + #132) Port iOS PRs #131 (compose-mention-pills) and #132 (mention-relay-fallback) as a single Android commit. ## Visual pills (PR #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 #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 #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 #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) --- .../wisp/app/repo/MentionSearchRepository.kt | 8 +- .../component/MentionVisualTransformation.kt | 45 ++++++ .../com/wisp/app/ui/screen/ComposeScreen.kt | 142 ++++++++++++------ .../wisp/app/viewmodel/ComposeViewModel.kt | 94 +++++++++++- .../com/wisp/app/viewmodel/SearchViewModel.kt | 6 +- 5 files changed, 238 insertions(+), 57 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/repo/MentionSearchRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/MentionSearchRepository.kt index 4b4e3ae6..e0e4be50 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/MentionSearchRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/MentionSearchRepository.kt @@ -60,7 +60,7 @@ class MentionSearchRepository( return } - val seenPubkeys = contactResults.map { it.profile.pubkey }.toMutableSet() + val seenPubkeys = contactResults.map { it.profile.pubkey.lowercase() }.toMutableSet() var localResults = contactResults val persistence = eventPersistence @@ -68,7 +68,7 @@ class MentionSearchRepository( val remaining = 5 - contactResults.size val events = persistence.searchProfiles(query, limit = 500) val dbResults = events.mapNotNull { event -> - if (!seenPubkeys.add(event.pubkey)) return@mapNotNull null + if (!seenPubkeys.add(event.pubkey.lowercase())) return@mapNotNull null val profile = ProfileData.fromEvent(event) ?: return@mapNotNull null val nameMatch = profile.name?.lowercase()?.contains(lowerQuery) == true val displayMatch = profile.displayName?.lowercase()?.contains(lowerQuery) == true @@ -76,7 +76,7 @@ class MentionSearchRepository( MentionCandidate(profile, isContact = false) }.take(remaining) localResults = contactResults + dbResults - seenPubkeys.addAll(dbResults.map { it.profile.pubkey }) + seenPubkeys.addAll(dbResults.map { it.profile.pubkey.lowercase() }) } _candidates.value = localResults @@ -93,7 +93,7 @@ class MentionSearchRepository( relayPool.relayEvents.collect { ev -> if (ev.subscriptionId != subId) return@collect val profile = ProfileData.fromEvent(ev.event) ?: return@collect - if (seenPubkeys.add(profile.pubkey)) { + if (seenPubkeys.add(profile.pubkey.lowercase())) { relayResults += MentionCandidate(profile, isContact = false) _candidates.value = capturedLocal + relayResults } diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt b/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt index 4821df34..f2d46033 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt @@ -2,10 +2,16 @@ package com.wisp.app.ui.component import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.withStyle +import com.wisp.app.viewmodel.Mention // Mentions are now stored in the compose text as plain `@Name`, tracked out-of-band by // ComposeViewModel and spliced to nostr:nprofile URIs at publish time. The OutputTransformation @@ -127,3 +133,42 @@ class EmojiVisualTransformation( return TransformedText(AnnotatedString(sb.toString()), offsetMapping) } } + +/** + * Builds an [AnnotatedString] from [text] with [SpanStyle] pill backgrounds applied to + * each tracked [Mention] range. Non-mention text is rendered with [defaultColor]. + */ +fun buildMentionAnnotatedString( + text: String, + mentions: List, + pillBackground: Color, + pillForeground: Color, + defaultColor: Color +): AnnotatedString = buildAnnotatedString { + var lastEnd = 0 + val sorted = mentions + .filter { it.start >= 0 && it.end <= text.length && it.start < it.end } + .sortedBy { it.start } + for (m in sorted) { + if (m.start >= lastEnd) { + withStyle(SpanStyle(color = defaultColor)) { + append(text, lastEnd, m.start) + } + withStyle( + SpanStyle( + background = pillBackground, + color = pillForeground, + fontWeight = FontWeight.Medium + ) + ) { + append(text, m.start, m.end) + } + lastEnd = m.end + } + } + if (lastEnd < text.length) { + withStyle(SpanStyle(color = defaultColor)) { + append(text, lastEnd, text.length) + } + } +} diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt index 168c6235..b9e635b9 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt @@ -54,8 +54,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.text.TextRange import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -103,7 +102,6 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -134,7 +132,7 @@ import com.wisp.app.repo.PowPreferences import com.wisp.app.R import com.wisp.app.ui.component.EmojiShortcodePopup import com.wisp.app.ui.component.EmojiVisualTransformation -import com.wisp.app.ui.component.MentionOutputTransformation +import com.wisp.app.ui.component.buildMentionAnnotatedString import com.wisp.app.ui.component.ProfilePicture import com.wisp.app.ui.component.RichContent import com.wisp.app.ui.component.detectEmojiAutocomplete @@ -178,6 +176,7 @@ fun ComposeScreen( val countdownStartedAt by viewModel.countdownStartedAt.collectAsState() val mentionCandidates by viewModel.mentionCandidates.collectAsState() val mentionQuery by viewModel.mentionQuery.collectAsState() + val mentions by viewModel.mentions.collectAsState() val explicit by viewModel.explicit.collectAsState() val hashtags by viewModel.hashtags.collectAsState() val powEnabled by viewModel.powEnabled.collectAsState() @@ -237,21 +236,6 @@ fun ComposeScreen( if (uris.isNotEmpty()) viewModel.uploadMedia(uris, context.contentResolver, signer) } - val outputTransformation = remember(profileRepo, resolvedEmojis) { - MentionOutputTransformation( - resolveDisplayName = { bech32 -> - if (profileRepo == null) return@MentionOutputTransformation null - try { - val data = Nip19.decodeNostrUri("nostr:$bech32") - if (data is com.wisp.app.nostr.NostrUriData.ProfileRef) { - profileRepo.get(data.pubkey)?.displayString - } else null - } catch (_: Exception) { null } - }, - resolvedEmojis = resolvedEmojis - ) - } - Scaffold( contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { @@ -697,34 +681,44 @@ fun ComposeScreen( ) } - // Text field with GIF keyboard support via BasicTextField(TextFieldState) - val textFieldState = remember { TextFieldState(content.text) } + // Text field with value-based API for pill rendering via AnnotatedString val interactionSource = remember { MutableInteractionSource() } val enabled = !publishing && countdownSeconds == null - // Sync ViewModel -> TextFieldState (for programmatic updates: upload URL, mention select, etc.) - LaunchedEffect(content) { - if (textFieldState.text.toString() != content.text) { - textFieldState.edit { - replace(0, length, content.text) - selection = content.selection - } + val pillBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + val pillForeground = MaterialTheme.colorScheme.primary + val onSurfaceColor = MaterialTheme.colorScheme.onSurface + + // Build AnnotatedString with pill spans for tracked mentions + val contentWithSpans = remember(content, mentions) { + if (mentions.isEmpty()) { + content + } else { + val annotated = buildMentionAnnotatedString( + text = content.text, + mentions = mentions, + pillBackground = pillBackground, + pillForeground = pillForeground, + defaultColor = onSurfaceColor + ) + content.copy(annotatedString = annotated) } } - // Sync TextFieldState -> ViewModel (for user typing) - LaunchedEffect(textFieldState) { - snapshotFlow { - textFieldState.text.toString() to textFieldState.selection - }.collect { (text, selection) -> - if (text != content.text) { - viewModel.updateContent(TextFieldValue(text, selection)) - } - } + val emojiVisualTransformation = remember(resolvedEmojis) { + EmojiVisualTransformation(resolvedEmojis) } BasicTextField( - state = textFieldState, + value = contentWithSpans, + onValueChange = { new -> + // Block nsec pastes + if (!com.wisp.app.ui.component.NsecPasteGuard.blockIfNsec(content.text, new.text)) { + // Atomic mention editing: if edit overlaps a pill, handle atomically + val handled = handleAtomicMentionEdit(content, new, viewModel.mentions.value) + viewModel.updateContent(handled) + } + }, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), modifier = Modifier .fillMaxWidth() @@ -747,15 +741,13 @@ fun ComposeScreen( }), enabled = enabled, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), - inputTransformation = com.wisp.app.ui.component.NsecPasteGuard.inputTransformation, - lineLimits = TextFieldLineLimits.MultiLine(), - outputTransformation = outputTransformation, textStyle = MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.onSurface ), - decorator = { innerTextField -> + visualTransformation = emojiVisualTransformation, + decorationBox = { innerTextField -> OutlinedTextFieldDefaults.DecorationBox( - value = textFieldState.text.toString(), + value = content.text, innerTextField = innerTextField, enabled = enabled, singleLine = false, @@ -1423,6 +1415,70 @@ private fun MentionCandidateRow( } } +/** + * Handles atomic editing of @mention pills. If the edit overlaps a tracked mention: + * - Deletion inside pill → delete entire pill, cursor at pill start + * - Typing inside pill → reject edit, snap cursor to pill end + * - Cursor move into pill → snap to pill end (or start if moving left) + * Otherwise returns [new] unchanged. + */ +private fun handleAtomicMentionEdit( + old: TextFieldValue, + new: TextFieldValue, + mentions: List +): TextFieldValue { + if (mentions.isEmpty()) return new + + // Pure cursor/selection move (no text change) + if (old.text == new.text) { + val newCursor = if (new.selection.collapsed) new.selection.start else return new + val oldCursor = if (old.selection.collapsed) old.selection.start else newCursor + for (m in mentions) { + if (newCursor > m.start && newCursor < m.end) { + // Snap: moving right snaps to end, moving left snaps to start + val snapTo = if (newCursor >= oldCursor) m.end else m.start + return old.copy(selection = TextRange(snapTo)) + } + } + return new + } + + // Text changed: find edit range + val oldText = old.text + val newText = new.text + if (oldText == newText) return new + + val maxPrefix = minOf(oldText.length, newText.length) + var prefix = 0 + while (prefix < maxPrefix && oldText[prefix] == newText[prefix]) prefix++ + var suffix = 0 + val maxSuffix = minOf(oldText.length - prefix, newText.length - prefix) + while (suffix < maxSuffix && + oldText[oldText.length - 1 - suffix] == newText[newText.length - 1 - suffix]) suffix++ + val editStart = prefix + val oldEditEnd = oldText.length - suffix // exclusive end in old text + val newEditEnd = newText.length - suffix // exclusive end in new text + + for (m in mentions) { + // Check if this edit overlaps the mention range in old text + if (editStart >= m.end || oldEditEnd <= m.start) continue + + val isDeletion = newEditEnd == editStart // pure deletion (no insertion) + val isInsideOnly = editStart >= m.start && oldEditEnd <= m.end + + if (isDeletion && isInsideOnly) { + // Backspace/delete inside pill → remove entire pill + val newT = oldText.removeRange(m.start, m.end) + return TextFieldValue(newT, TextRange(m.start)) + } + + // Any other overlap (typing inside, selection spanning boundary) → reject edit + return old.copy(selection = TextRange(m.end)) + } + + return new +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun GalleryComposeSection( diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt index 5fb81143..805683f0 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt @@ -57,6 +57,10 @@ private val NOSTR_URI_REGEX = Regex("nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+ // Matches bare bech32 IDs not already preceded by "nostr:" or embedded in a URL private val BARE_BECH32_REGEX = Regex("(? Unit)? = null private var mentionSearchRepo: MentionSearchRepository? = null + private var profileRepoRef: ProfileRepository? = null private var eventRepo: EventRepository? = null private var dmRepo: DmRepository? = null private var relayListRepo: RelayListRepository? = null @@ -288,6 +293,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat this.eventRepo = eventRepo this.dmRepo = dmRepo this.relayListRepo = relayListRepo + this.profileRepoRef = profileRepo mentionSearchRepo = MentionSearchRepository(profileRepo, contactRepo, relayPool, keyRepo).also { it.eventPersistence = eventPersistence } @@ -295,6 +301,15 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat viewModelScope.launch { mentionSearchRepo!!.candidates.collect { _mentionCandidates.value = it } } + // Rehydrate draft that survived process death: convert nostr:nprofile URIs → @displayName + val initial = _content.value + if (initial.text.contains("nostr:")) { + val hydrated = rehydrateMentionsFromContent(initial) + if (hydrated.text != initial.text) { + _content.value = hydrated + savedStateHandle["draft_content"] = hydrated.text + } + } } fun uploadMedia(uris: List, contentResolver: ContentResolver, signer: NostrSigner? = null) { @@ -394,10 +409,67 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat // Auto-prefix bare bech32 IDs with nostr: val prefixed = prefixBareBech32(value) - _content.value = prefixed - savedStateHandle["draft_content"] = prefixed.text - detectMentionQuery(prefixed) - detectHashtags(prefixed.text) + // Rehydrate pasted nostr:nprofile/npub URIs → @displayName + tracked mention. + // Guard on "nostr:" so we don't pay the regex cost on every keystroke. + val rehydrated = if (prefixed.text.contains("nostr:")) { + rehydrateMentionsFromContent(prefixed) + } else prefixed + _content.value = rehydrated + savedStateHandle["draft_content"] = rehydrated.text + detectMentionQuery(rehydrated) + detectHashtags(rehydrated.text) + } + + /** Converts any `nostr:nprofile1…` / `nostr:npub1…` tokens in [value]'s text to + * `@displayName` and seeds tracked [Mention]s for them, so pasted or loaded profile + * URIs get pill-rendered and materialize correctly at publish time. Profiles not in + * cache are left as-is so the URI still publishes via [extractNostrRefs]. */ + private fun rehydrateMentionsFromContent(value: TextFieldValue): TextFieldValue { + val pr = profileRepoRef ?: return value + val text = value.text + data class Hit(val start: Int, val end: Int, val pubkey: String, val display: String) + val hits = mutableListOf() + for (match in NOSTR_PROFILE_URI_REGEX.findAll(text)) { + val bech32 = match.groupValues[1] + val data = try { Nip19.decodeNostrUri("nostr:$bech32") } catch (_: Exception) { null } + if (data !is com.wisp.app.nostr.NostrUriData.ProfileRef) continue + val profile = pr.get(data.pubkey) ?: continue + val raw = profile.displayName?.takeIf { it.isNotBlank() } + ?: profile.name?.takeIf { it.isNotBlank() } + ?: continue + val display = "@" + raw.trim().replace(Regex("[\n\r]+"), " ").removePrefix("@").replace(Regex("\\s+"), "_") + hits.add(Hit(match.range.first, match.range.last + 1, data.pubkey, display)) + } + if (hits.isEmpty()) return value + + val sb = StringBuilder() + var lastEnd = 0 + val newMentions = mutableListOf() + val origCursor = value.selection.start + var cursorDelta = 0 + for (h in hits) { + sb.append(text, lastEnd, h.start) + val mStart = sb.length + sb.append(h.display) + val mEnd = sb.length + newMentions.add(Mention(mStart, mEnd, h.pubkey)) + if (origCursor >= h.end) cursorDelta += h.display.length - (h.end - h.start) + lastEnd = h.end + } + sb.append(text, lastEnd, text.length) + val newText = sb.toString() + val newCursor = (origCursor + cursorDelta).coerceIn(0, newText.length) + + // Carry forward existing mentions outside rehydrated URI ranges (shifted for earlier hits) + val kept = _mentions.value.filter { m -> hits.none { h -> m.start < h.end && m.end > h.start } } + val shifted = kept.map { m -> + var delta = 0 + for (h in hits) if (h.end <= m.start) delta += h.display.length - (h.end - h.start) + m.copy(start = m.start + delta, end = m.end + delta) + } + _mentions.value = (shifted + newMentions).sortedBy { it.start } + saveMentionsToState() + return TextFieldValue(newText, TextRange(newCursor)) } /** Returns (editStart, oldEnd, newEnd) for the minimal edit between [old] and [new]. @@ -518,7 +590,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat ?: return candidate.profile.pubkey.toNpub().let { "${it.take(12)}...${it.takeLast(4)}" } // Strip whitespace and leading @ so the mention remains a single token and mention detection // can't re-trigger on a name that itself contains spaces. - return raw.trim().removePrefix("@").replace(Regex("\\s+"), "_") + return raw.trim().replace(Regex("[\n\r]+"), " ").removePrefix("@").replace(Regex("\\s+"), "_") } @@ -988,6 +1060,9 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat out = out.substring(0, m.start) + uri + out.substring(m.end) pubkeys.add(m.pubkey) } + // Adjacent pills produce back-to-back URIs (`nostr:Xnostr:Y`); parsers' greedy + // bech32 regex would over-consume. Insert a space so each is parsed as a distinct token. + out = ADJACENT_NOSTR_URI_REGEX.replace(out, "$1 ") return out to pubkeys } @@ -1107,8 +1182,13 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat fun loadDraft(draft: Nip37.Draft) { currentDraftId = draft.dTag val text = draft.content - _content.value = TextFieldValue(text, TextRange(text.length)) - savedStateHandle["draft_content"] = text + // Reset mention tracking before rehydration so we don't carry stale ranges from a prior session. + _mentions.value = emptyList() + saveMentionsToState() + val initial = TextFieldValue(text, TextRange(text.length)) + val hydrated = if (text.contains("nostr:")) rehydrateMentionsFromContent(initial) else initial + _content.value = hydrated + savedStateHandle["draft_content"] = hydrated.text } fun saveDraft( diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/SearchViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/SearchViewModel.kt index 3a868876..130e35a8 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/SearchViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/SearchViewModel.kt @@ -129,7 +129,7 @@ class SearchViewModel(app: Application) : AndroidViewModel(app) { autoSearchJob?.cancel() val pool = relayPool ?: return val repo = eventRepoRef ?: return - val trimmed = newQuery.trim().removePrefix("nostr:") + val trimmed = newQuery.trim().removePrefix("@").removePrefix("nostr:") if (trimmed.length < 2) return autoSearchJob = viewModelScope.launch { delay(500) @@ -153,7 +153,7 @@ class SearchViewModel(app: Application) : AndroidViewModel(app) { } fun searchAuthors(query: String, relayPool: RelayPool, eventRepo: EventRepository) { - val trimmed = query.trim().removePrefix("nostr:") + val trimmed = query.trim().removePrefix("@").removePrefix("nostr:") if (trimmed.isEmpty()) { _authorSearchResults.value = emptyList() return @@ -213,7 +213,7 @@ class SearchViewModel(app: Application) : AndroidViewModel(app) { } fun search(query: String, relayPool: RelayPool, eventRepo: EventRepository, muteRepo: MuteRepository? = null) { - val trimmed = query.trim().removePrefix("nostr:") + val trimmed = query.trim().removePrefix("@").removePrefix("nostr:") if (trimmed.isEmpty()) { clear() return From 9225054e7ab14816000fecafd34daff9de01f2d1 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sun, 24 May 2026 10:28:39 -0400 Subject: [PATCH 2/5] fix(compose): pin Publish to keyboard + reorder candidates below toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../main/kotlin/com/wisp/app/Navigation.kt | 2 +- .../component/MentionVisualTransformation.kt | 24 +++-- .../com/wisp/app/ui/screen/ComposeScreen.kt | 94 ++++++++++--------- .../wisp/app/viewmodel/ComposeViewModel.kt | 2 + 4 files changed, 68 insertions(+), 54 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index 80c11255..f465cdab 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -386,7 +386,7 @@ fun WispNavHost( val currentRoute = navBackStackEntry?.destination?.route val nonAppRoutes = setOf(Routes.SPLASH, Routes.AUTH, Routes.GOOGLE_AUTH, Routes.LOADING, Routes.ONBOARDING_PROFILE, Routes.ONBOARDING_SUGGESTIONS, Routes.ONBOARDING_TOPICS, Routes.ONBOARDING_FIRST_POST, Routes.EXISTING_USER_ONBOARDING, Routes.WATCH_ONLY_ONBOARDING) - val hideBottomBarRoutes = nonAppRoutes + Routes.DM_CONVERSATION + Routes.DM_CONVERSATION_GROUP + Routes.CONTACT_PICKER + Routes.GROUP_ROOM + Routes.GROUP_DETAIL + Routes.LIVE_STREAM + val hideBottomBarRoutes = nonAppRoutes + Routes.DM_CONVERSATION + Routes.DM_CONVERSATION_GROUP + Routes.CONTACT_PICKER + Routes.GROUP_ROOM + Routes.GROUP_DETAIL + Routes.LIVE_STREAM + Routes.COMPOSE val socialGraphDiscoveryState by feedViewModel.extendedNetworkRepo.discoveryState.collectAsState() val socialGraphComputing = currentRoute == Routes.SOCIAL_GRAPH && ( socialGraphDiscoveryState is com.wisp.app.repo.DiscoveryState.FetchingFollowLists || diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt b/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt index f2d46033..e9ce32ab 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt @@ -82,24 +82,28 @@ class EmojiVisualTransformation( .toList() if (matches.isEmpty()) return TransformedText(text, OffsetMapping.Identity) - val sb = StringBuilder() - // Maps: original index -> transformed index offset - // Each replacement changes `:shortcode:` (len = shortcode.length + 2) to `⬡shortcode` (len = shortcode.length + 1) - // So each replacement removes 1 character + // Build a new AnnotatedString via Builder so any incoming SpanStyles (e.g. mention pills) + // are preserved across the transformation. Appending AnnotatedString sub-sequences carries + // their spans; appending raw replacement strings (like "⬡name") leaves them unstyled. + val builder = androidx.compose.ui.text.AnnotatedString.Builder() var lastEnd = 0 data class Range(val origStart: Int, val origEnd: Int, val transStart: Int, val transEnd: Int) val ranges = mutableListOf() for (match in matches) { val shortcode = match.groupValues[1] - sb.append(original, lastEnd, match.range.first) - val transStart = sb.length + if (match.range.first > lastEnd) { + builder.append(text.subSequence(lastEnd, match.range.first)) + } + val transStart = builder.length val display = "⬡$shortcode" - sb.append(display) - ranges.add(Range(match.range.first, match.range.last + 1, transStart, sb.length)) + builder.append(display) + ranges.add(Range(match.range.first, match.range.last + 1, transStart, builder.length)) lastEnd = match.range.last + 1 } - sb.append(original, lastEnd, original.length) + if (lastEnd < original.length) { + builder.append(text.subSequence(lastEnd, original.length)) + } val offsetMapping = object : OffsetMapping { override fun originalToTransformed(offset: Int): Int { @@ -130,7 +134,7 @@ class EmojiVisualTransformation( } } - return TransformedText(AnnotatedString(sb.toString()), offsetMapping) + return TransformedText(builder.toAnnotatedString(), offsetMapping) } } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt index b9e635b9..06b0c7b4 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt @@ -33,7 +33,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -295,8 +299,11 @@ fun ComposeScreen( Column( modifier = Modifier .padding(padding) - .consumeWindowInsets(WindowInsets.navigationBars) - .imePadding() + .windowInsetsPadding( + WindowInsets.ime + .union(WindowInsets.navigationBars) + .only(WindowInsetsSides.Bottom) + ) ) { if (galleryMode) { // ---- Gallery mode: completely separate layout ---- @@ -642,45 +649,6 @@ fun ComposeScreen( } } - // Mention autocomplete dropdown - AnimatedVisibility( - visible = mentionQuery != null && mentionCandidates.isNotEmpty(), - enter = fadeIn() + slideInVertically(), - exit = fadeOut() + slideOutVertically() - ) { - Surface( - shape = RoundedCornerShape(8.dp), - tonalElevation = 3.dp, - shadowElevation = 2.dp, - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 200.dp) - .padding(bottom = 4.dp) - ) { - LazyColumn { - items(mentionCandidates, key = { it.profile.pubkey }) { candidate -> - MentionCandidateRow( - candidate = candidate, - onClick = { viewModel.selectMention(candidate) } - ) - } - } - } - } - - // Emoji shortcode autocomplete - val emojiState = remember(content) { detectEmojiAutocomplete(content) } - if (emojiState != null && mentionQuery == null) { - EmojiShortcodePopup( - query = emojiState.query, - resolvedEmojis = resolvedEmojis, - onSelect = { shortcode -> - val newTfv = insertEmojiShortcode(content, emojiState.triggerIndex, shortcode) - viewModel.updateContent(newTfv) - } - ) - } - // Text field with value-based API for pill rendering via AnnotatedString val interactionSource = remember { MutableInteractionSource() } val enabled = !publishing && countdownSeconds == null @@ -722,7 +690,7 @@ fun ComposeScreen( cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), modifier = Modifier .fillMaxWidth() - .height(160.dp) + .heightIn(min = 100.dp) .contentReceiver(object : ReceiveContentListener { override fun onReceive( transferableContent: TransferableContent @@ -873,6 +841,46 @@ fun ComposeScreen( } } + // Mention autocomplete dropdown — appears below the toolbar so the + // composer + toolbar stay visible while the user is searching. + AnimatedVisibility( + visible = mentionQuery != null && mentionCandidates.isNotEmpty(), + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically() + ) { + Surface( + shape = RoundedCornerShape(8.dp), + tonalElevation = 3.dp, + shadowElevation = 2.dp, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .padding(top = 4.dp) + ) { + LazyColumn { + items(mentionCandidates, key = { it.profile.pubkey }) { candidate -> + MentionCandidateRow( + candidate = candidate, + onClick = { viewModel.selectMention(candidate) } + ) + } + } + } + } + + // Emoji shortcode autocomplete — same placement as mentions. + val emojiState = remember(content) { detectEmojiAutocomplete(content) } + if (emojiState != null && mentionQuery == null) { + EmojiShortcodePopup( + query = emojiState.query, + resolvedEmojis = resolvedEmojis, + onSelect = { shortcode -> + val newTfv = insertEmojiShortcode(content, emojiState.triggerIndex, shortcode) + viewModel.updateContent(newTfv) + } + ) + } + // Hashtag chips AnimatedVisibility( visible = hashtags.isNotEmpty(), @@ -1217,7 +1225,7 @@ fun ComposeScreen( } // Bottom bar — always visible above keyboard (shared by both modes) - Column(modifier = Modifier.padding(horizontal = 16.dp).padding(vertical = 12.dp)) { + Column(modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 8.dp)) { if (countdownSeconds != null) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt index 805683f0..23c706e4 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt @@ -980,7 +980,9 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat } deleteDraftOnPublish(relayPool, signer) _content.value = TextFieldValue() + _mentions.value = emptyList() savedStateHandle.remove("draft_content") + savedStateHandle.remove>("draft_mentions") _uploadedUrls.value = emptyList() _uploadedMediaMeta.clear() _error.value = null From c5eac6115ec101d17b7de60a73248e1d4854847a Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sun, 24 May 2026 11:03:59 -0400 Subject: [PATCH 3/5] feat(compose): borderless field + auto-space + adjacent-pill defense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../com/wisp/app/ui/screen/ComposeScreen.kt | 119 +++++++++++++++--- 1 file changed, 104 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt index 06b0c7b4..a1b36b2c 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.content.TransferableContent import androidx.compose.foundation.content.consume import androidx.compose.foundation.content.contentReceiver import androidx.compose.foundation.content.hasMediaType -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Box @@ -87,7 +86,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -650,7 +648,6 @@ fun ComposeScreen( } // Text field with value-based API for pill rendering via AnnotatedString - val interactionSource = remember { MutableInteractionSource() } val enabled = !publishing && countdownSeconds == null val pillBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) @@ -682,9 +679,16 @@ fun ComposeScreen( onValueChange = { new -> // Block nsec pastes if (!com.wisp.app.ui.component.NsecPasteGuard.blockIfNsec(content.text, new.text)) { - // Atomic mention editing: if edit overlaps a pill, handle atomically - val handled = handleAtomicMentionEdit(content, new, viewModel.mentions.value) - viewModel.updateContent(handled) + val mentions = viewModel.mentions.value + // 1. Reject deletion of the lone space between two adjacent pills, + // which would otherwise leave the pills colliding. + var step = preventPillCollision(content, new, mentions) + // 2. Atomic mention editing: if edit overlaps a pill, handle atomically. + step = handleAtomicMentionEdit(content, step, mentions) + // 3. Auto-insert a separator space when the user types a word char + // immediately after a pill, so the pill and following text don't collide. + step = enforceMentionTrailingSpace(content, step, mentions) + viewModel.updateContent(step) } }, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), @@ -714,15 +718,19 @@ fun ComposeScreen( ), visualTransformation = emojiVisualTransformation, decorationBox = { innerTextField -> - OutlinedTextFieldDefaults.DecorationBox( - value = content.text, - innerTextField = innerTextField, - enabled = enabled, - singleLine = false, - visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None, - interactionSource = interactionSource, - placeholder = { Text(stringResource(R.string.compose_placeholder)) } - ) + // Borderless composer (matches iOS): just the inner field with a + // placeholder overlay when empty. No outline, no surface tint. + Box(modifier = Modifier.fillMaxWidth()) { + if (content.text.isEmpty()) { + Text( + text = stringResource(R.string.compose_placeholder), + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + innerTextField() + } } ) @@ -1423,6 +1431,87 @@ private fun MentionCandidateRow( } } +/** Punctuation characters allowed to abut a pill without forcing a space in between. */ +private val MENTION_TRAILING_PUNCTUATION = setOf( + '.', ',', '!', '?', ';', ':', ')', ']', '}', '"', '\'', '’', '…', '/', '-', '\n' +) + +/** + * Rejects the deletion of the single space character sitting between two adjacent pills. + * Removing that space would leave the pills visually collided and risks downstream parsers + * mis-tokenising the published URIs. The deletion is silently dropped; the cursor lands at + * the start of the space so the user understands their backspace was absorbed. + */ +private fun preventPillCollision( + old: TextFieldValue, + new: TextFieldValue, + mentions: List +): TextFieldValue { + if (mentions.size < 2) return new + if (old.text == new.text) return new + if (new.text.length != old.text.length - 1) return new // not a single-char deletion + + val oldText = old.text + val newText = new.text + val maxPrefix = minOf(oldText.length, newText.length) + var prefix = 0 + while (prefix < maxPrefix && oldText[prefix] == newText[prefix]) prefix++ + if (prefix >= oldText.length) return new + + val deletedChar = oldText[prefix] + if (deletedChar != ' ') return new + + val pillBefore = mentions.any { it.end == prefix } + val pillAfter = mentions.any { it.start == prefix + 1 } + if (!pillBefore || !pillAfter) return new + + // Reject — keep old text, drop cursor at the space so the user sees no movement happened. + return old.copy(selection = TextRange(prefix)) +} + +/** + * When the user inserts a non-punctuation character (or pastes a string starting with one) + * immediately after a pill's end, slip a space in between so the pill and the new text stay + * visually distinct and downstream parsers see the URI as a complete token. Punctuation + * (commas, periods, etc.) is allowed to abut so users can still write "@Alice, hi". + */ +private fun enforceMentionTrailingSpace( + old: TextFieldValue, + new: TextFieldValue, + mentions: List +): TextFieldValue { + if (mentions.isEmpty()) return new + if (new.text.length <= old.text.length) return new + + val oldText = old.text + val newText = new.text + val maxPrefix = minOf(oldText.length, newText.length) + var prefix = 0 + while (prefix < maxPrefix && oldText[prefix] == newText[prefix]) prefix++ + var suffix = 0 + val maxSuffix = minOf(oldText.length - prefix, newText.length - prefix) + while (suffix < maxSuffix && + oldText[oldText.length - 1 - suffix] == newText[newText.length - 1 - suffix]) suffix++ + + val editStart = prefix + val oldEditEnd = oldText.length - suffix + val newEditEnd = newText.length - suffix + // Only handle pure insertions; replacements / deletions are out of scope here. + if (oldEditEnd != editStart) return new + if (newEditEnd <= editStart) return new + + val firstInsertedChar = newText[editStart] + if (firstInsertedChar.isWhitespace()) return new + if (firstInsertedChar in MENTION_TRAILING_PUNCTUATION) return new + + val abuts = mentions.any { it.end == editStart } + if (!abuts) return new + + val spaced = newText.substring(0, editStart) + " " + newText.substring(editStart) + val newCursor = (new.selection.start + 1).coerceAtMost(spaced.length) + return TextFieldValue(spaced, TextRange(newCursor)) +} + /** * Handles atomic editing of @mention pills. If the edit overlaps a tracked mention: * - Deletion inside pill → delete entire pill, cursor at pill start From cf6cd5bd8488333ffc4e1142bad42609bdf98ef5 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sun, 24 May 2026 11:17:28 -0400 Subject: [PATCH 4/5] feat(compose): live preview, inline highlights, publish gating, polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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. --- .../component/MentionVisualTransformation.kt | 73 ++++++++++++---- .../com/wisp/app/ui/screen/ComposeScreen.kt | 87 ++++++++++++------- .../wisp/app/viewmodel/ComposeViewModel.kt | 6 ++ 3 files changed, 114 insertions(+), 52 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt b/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt index e9ce32ab..e0fee8f9 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/MentionVisualTransformation.kt @@ -138,37 +138,72 @@ class EmojiVisualTransformation( } } +private val COMPOSE_HASHTAG_REGEX = Regex("(?:^|(?<=\\s))#([a-zA-Z0-9_]+)") +private val COMPOSE_URL_REGEX = Regex("""https?://\S+""") + /** - * Builds an [AnnotatedString] from [text] with [SpanStyle] pill backgrounds applied to - * each tracked [Mention] range. Non-mention text is rendered with [defaultColor]. + * Builds an [AnnotatedString] from [text] with three kinds of inline highlight: + * - tracked [mentions] render as pills (background + foreground colors) + * - `#hashtag` tokens render in [linkColor] + * - `http(s)://…` URLs render in [linkColor] + * + * Highlight ranges are merged and applied in left-to-right order. Mentions win when ranges + * overlap; URLs win over hashtags when a URL contains a `#` fragment. */ fun buildMentionAnnotatedString( text: String, mentions: List, pillBackground: Color, pillForeground: Color, - defaultColor: Color + defaultColor: Color, + linkColor: Color = pillForeground ): AnnotatedString = buildAnnotatedString { + data class Highlight(val start: Int, val end: Int, val style: SpanStyle) + + val pillStyle = SpanStyle( + background = pillBackground, + color = pillForeground, + fontWeight = FontWeight.Medium + ) + val linkStyle = SpanStyle(color = linkColor) + + val highlights = mutableListOf() + + for (m in mentions) { + if (m.start in 0..text.length && m.end in m.start..text.length && m.start < m.end) { + highlights += Highlight(m.start, m.end, pillStyle) + } + } + + fun overlapsExisting(start: Int, end: Int) = + highlights.any { it.start < end && it.end > start } + + for (match in COMPOSE_URL_REGEX.findAll(text)) { + val s = match.range.first + val e = match.range.last + 1 + if (!overlapsExisting(s, e)) highlights += Highlight(s, e, linkStyle) + } + + for (match in COMPOSE_HASHTAG_REGEX.findAll(text)) { + val s = match.range.first + val e = match.range.last + 1 + if (!overlapsExisting(s, e)) highlights += Highlight(s, e, linkStyle) + } + + highlights.sortBy { it.start } + var lastEnd = 0 - val sorted = mentions - .filter { it.start >= 0 && it.end <= text.length && it.start < it.end } - .sortedBy { it.start } - for (m in sorted) { - if (m.start >= lastEnd) { + for (h in highlights) { + if (h.start < lastEnd) continue // skip any range we already covered + if (h.start > lastEnd) { withStyle(SpanStyle(color = defaultColor)) { - append(text, lastEnd, m.start) - } - withStyle( - SpanStyle( - background = pillBackground, - color = pillForeground, - fontWeight = FontWeight.Medium - ) - ) { - append(text, m.start, m.end) + append(text, lastEnd, h.start) } - lastEnd = m.end } + withStyle(h.style) { + append(text, h.start, h.end) + } + lastEnd = h.end } if (lastEnd < text.length) { withStyle(SpanStyle(color = defaultColor)) { diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt index a1b36b2c..b36c70ec 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt @@ -654,20 +654,19 @@ fun ComposeScreen( val pillForeground = MaterialTheme.colorScheme.primary val onSurfaceColor = MaterialTheme.colorScheme.onSurface - // Build AnnotatedString with pill spans for tracked mentions + // Build AnnotatedString with pill spans for mentions plus inline highlights + // for #hashtags and http(s) URLs. The helper handles the no-mentions case + // (still need to colour hashtags / URLs even when no pills are tracked). val contentWithSpans = remember(content, mentions) { - if (mentions.isEmpty()) { - content - } else { - val annotated = buildMentionAnnotatedString( - text = content.text, - mentions = mentions, - pillBackground = pillBackground, - pillForeground = pillForeground, - defaultColor = onSurfaceColor - ) - content.copy(annotatedString = annotated) - } + val annotated = buildMentionAnnotatedString( + text = content.text, + mentions = mentions, + pillBackground = pillBackground, + pillForeground = pillForeground, + defaultColor = onSurfaceColor, + linkColor = pillForeground + ) + content.copy(annotatedString = annotated) } val emojiVisualTransformation = remember(resolvedEmojis) { @@ -858,8 +857,7 @@ fun ComposeScreen( ) { Surface( shape = RoundedCornerShape(8.dp), - tonalElevation = 3.dp, - shadowElevation = 2.dp, + color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = Modifier .fillMaxWidth() .heightIn(max = 200.dp) @@ -1134,9 +1132,9 @@ fun ComposeScreen( previewTopOffsetPx = coords.positionInParent().y.toInt() }) - // Live preview + // Live preview — always visible while composing (matches iOS). AnimatedVisibility( - visible = !imeVisible && (content.text.isNotBlank() || (pollEnabled && pollOptions.any { it.isNotBlank() })) && eventRepo != null + visible = (content.text.isNotBlank() || (pollEnabled && pollOptions.any { it.isNotBlank() })) && eventRepo != null ) { Surface( shape = RoundedCornerShape(8.dp), @@ -1154,26 +1152,38 @@ fun ComposeScreen( val userProfile = userPubkey?.let { profileRepo?.get(it) } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) ) { ProfilePicture(url = userProfile?.picture, size = 32) Spacer(Modifier.width(8.dp)) - Column { - Text( - text = userProfile?.displayString ?: "You", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) + Text( + text = userProfile?.displayString ?: "You", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + modifier = Modifier.weight(1f) + ) + // "Preview" badge on the right, matching iOS layout. + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { Text( text = "Preview", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) ) } } + // Materialise @mentions to nostr:nprofile URIs so RichContent can + // colour them as profile links (otherwise the @DisplayName is just + // plain text and renders in the default color). + val previewContent = remember(content, mentions) { + viewModel.previewMaterializedContent() + } RichContent( - content = content.text, + content = previewContent, emojiMap = resolvedEmojis, eventRepo = eventRepo ) @@ -1275,6 +1285,10 @@ fun ComposeScreen( } } } else { + // Publish is disabled until the post has at least one character of text + // OR at least one uploaded attachment. Prevents accidental empty posts and + // matches the iOS composer's send-button gating. + val hasContent = content.text.isNotBlank() || uploadedUrls.isNotEmpty() Button( onClick = { viewModel.publish( @@ -1290,7 +1304,7 @@ fun ComposeScreen( resolvedEmojis = resolvedEmojis ) }, - enabled = !publishing && !isMiningBusy, + enabled = !publishing && !isMiningBusy && hasContent, modifier = Modifier.fillMaxWidth().height(44.dp), contentPadding = PaddingValues(0.dp) ) { @@ -1422,11 +1436,18 @@ private fun MentionCandidateRow( } } if (candidate.isContact) { - Text( - text = "Following", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary - ) + // Muted "Following" pill — matches iOS (grey, not primary tint). + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceContainerHighest + ) { + Text( + text = "Following", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } } } } diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt index 23c706e4..760a524b 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt @@ -1049,6 +1049,12 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat savedStateHandle["draft_mentions"] = _mentions.value.map { "${it.start},${it.end},${it.pubkey}" }.toTypedArray() } + /** Renders the current draft as the string that would actually be published — `@DisplayName` + * tracked ranges are spliced into `nostr:nprofile1…` URIs. Used by the live preview so + * RichContent can parse mentions out of plain text the same way it does for published notes. */ + fun previewMaterializedContent(): String = + materializeMentions(_content.value.text, _mentions.value).first + /** Builds the publish-ready content by splicing tracked mention ranges into nostr:nprofile URIs. * Stale ranges (beyond text length) are skipped defensively. */ private fun materializeMentions(text: String, mentions: List): Pair> { From 414eea6c063e23201251cf01022b95a50d97190f Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sun, 24 May 2026 17:44:28 -0400 Subject: [PATCH 5/5] fix(dm): make DM FAB circular to match new-post FAB XY/geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/src/main/kotlin/com/wisp/app/ui/screen/DmListScreen.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/DmListScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/DmListScreen.kt index 80d26b6a..20b3ae0b 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/DmListScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/DmListScreen.kt @@ -127,11 +127,16 @@ fun DmListScreen( }, floatingActionButton = { Box { + // Match the FeedScreen new-post FAB exactly: same CircleShape and primary + // container, so both FABs land at the same XY (Scaffold's bottom-end slot) + // with identical visual geometry. The DM-tab action is "new group DM" — + // GroupAdd communicates that better than a send glyph. FloatingActionButton( onClick = { if (selectedTab == 0) onNewGroupDm() else showFabMenu = true }, + shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary ) { if (selectedTab == 0) {