Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/src/main/kotlin/com/wisp/app/Navigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,23 @@ 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
if (persistence != null) {
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
if (!nameMatch && !displayMatch) return@mapNotNull null
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
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Range>()

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 {
Expand Down Expand Up @@ -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<Mention>,
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<Highlight>()

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)
}
}
}
Loading