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/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..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 @@ -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 @@ -76,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 { @@ -124,6 +134,80 @@ class EmojiVisualTransformation( } } - return TransformedText(AnnotatedString(sb.toString()), offsetMapping) + return TransformedText(builder.toAnnotatedString(), offsetMapping) + } +} + +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 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, + 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 + 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, h.start) + } + } + withStyle(h.style) { + append(text, h.start, h.end) + } + lastEnd = h.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..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 @@ -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 @@ -33,7 +32,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 @@ -54,8 +57,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 @@ -84,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 @@ -103,7 +104,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 +134,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 +178,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 +238,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 = { @@ -311,8 +297,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 ---- @@ -658,77 +647,53 @@ 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 GIF keyboard support via BasicTextField(TextFieldState) - val textFieldState = remember { TextFieldState(content.text) } - val interactionSource = remember { MutableInteractionSource() } + // Text field with value-based API for pill rendering via AnnotatedString 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 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) { + val annotated = buildMentionAnnotatedString( + text = content.text, + mentions = mentions, + pillBackground = pillBackground, + pillForeground = pillForeground, + defaultColor = onSurfaceColor, + linkColor = pillForeground + ) + 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)) { + 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), modifier = Modifier .fillMaxWidth() - .height(160.dp) + .heightIn(min = 100.dp) .contentReceiver(object : ReceiveContentListener { override fun onReceive( transferableContent: TransferableContent @@ -747,22 +712,24 @@ 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 -> - OutlinedTextFieldDefaults.DecorationBox( - value = textFieldState.text.toString(), - innerTextField = innerTextField, - enabled = enabled, - singleLine = false, - visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None, - interactionSource = interactionSource, - placeholder = { Text(stringResource(R.string.compose_placeholder)) } - ) + visualTransformation = emojiVisualTransformation, + decorationBox = { innerTextField -> + // 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() + } } ) @@ -881,6 +848,45 @@ 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), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + 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(), @@ -1126,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), @@ -1146,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 ) @@ -1225,7 +1243,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(), @@ -1267,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( @@ -1282,7 +1304,7 @@ fun ComposeScreen( resolvedEmojis = resolvedEmojis ) }, - enabled = !publishing && !isMiningBusy, + enabled = !publishing && !isMiningBusy && hasContent, modifier = Modifier.fillMaxWidth().height(44.dp), contentPadding = PaddingValues(0.dp) ) { @@ -1414,13 +1436,165 @@ 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) + ) + } + } + } +} + +/** 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 + * - 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) 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) { 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..760a524b 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+"), "_") } @@ -908,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 @@ -975,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> { @@ -988,6 +1068,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 +1190,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