From ace1401a2750eae36771eafb4f3b181aca68ab02 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 28 Feb 2026 17:44:04 -0500 Subject: [PATCH 1/5] fix: suppress parent thread in quoted notes Adds unPackReply = false to both DisplayFullNote (nostr: bech links) and DisplayNoteFromTag (tag references) so that quoted notes only show the quoted post itself without its parent thread context. --- .../com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 73aa212b6..7870428f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -595,6 +595,7 @@ fun DisplayFullNote( baseNote = note, modifier = MaterialTheme.colorScheme.innerPostModifier, isQuotedNote = true, + unPackReply = false, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, accountViewModel = accountViewModel, @@ -867,6 +868,7 @@ private fun DisplayNoteFromTag( baseNote = baseNote, modifier = MaterialTheme.colorScheme.innerPostModifier, isQuotedNote = true, + unPackReply = false, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, accountViewModel = accountViewModel, From eb3b7077fd59488ad03f8c35230906f7a6afc972 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 28 Feb 2026 18:53:32 -0500 Subject: [PATCH 2/5] feat: show "Replying to" label in quoted notes Follow-up to #1723. When a quoted note is itself a reply, display a compact "Replying to [username]" label above the content instead of showing nothing. Uses the event's p-tags to resolve reply targets directly, matching the Damus-style display. --- .../amethyst/ui/components/RichTextViewer.kt | 2 ++ .../amethyst/ui/note/ReplyInformation.kt | 25 +++++++++++++++++++ .../amethyst/ui/note/types/Text.kt | 15 +++++++++++ 3 files changed, 42 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 73aa212b6..7870428f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -595,6 +595,7 @@ fun DisplayFullNote( baseNote = note, modifier = MaterialTheme.colorScheme.innerPostModifier, isQuotedNote = true, + unPackReply = false, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, accountViewModel = accountViewModel, @@ -867,6 +868,7 @@ private fun DisplayNoteFromTag( baseNote = baseNote, modifier = MaterialTheme.colorScheme.innerPostModifier, isQuotedNote = true, + unPackReply = false, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 5dd151792..1a50d6e23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -116,6 +116,31 @@ fun ReplyInformationChannel( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ReplyToLabel( + replyingDirectlyTo: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val user = replyingDirectlyTo.author ?: return + + FlowRow { + Text( + stringRes(id = R.string.replying_to), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + + ReplyInfoMention( + user = user, + prefix = "", + accountViewModel = accountViewModel, + onUserTagClick = { nav.nav(routeFor(it)) }, + ) + } +} + @Composable private fun ReplyInfoMention( user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index fc3a43e49..4103576bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent +import com.vitorpamplona.amethyst.ui.note.ReplyInformationChannel import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -49,10 +50,13 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList @Composable fun RenderTextEvent( @@ -98,6 +102,17 @@ fun RenderTextEvent( ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) Spacer(modifier = StdVertSpacer) } + } else if (!unPackReply && !makeItShort && noteEvent is BaseThreadedEvent && noteEvent.hasAnyTaggedUser()) { + val mentions = + remember(noteEvent) { + noteEvent.taggedUsers().map { it.pubKey }.toImmutableList() + } + ReplyInformationChannel( + replyTo = persistentListOf(note), + mentions = mentions, + accountViewModel = accountViewModel, + nav = nav, + ) } // Check if this is an audio-only event (content is just an audio URL with waveform IMeta) From 2b36858c12d6662f61c17138b6f8d707a3ca9a5f Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 28 Feb 2026 20:10:17 -0500 Subject: [PATCH 3/5] Simplify profile edit screen layout Reduce visual clutter by reordering fields, improving labels, and collapsing rarely-used social proof fields into an expandable section. - Rename "Name" field to "Username" with @ prefix - Reorder: Lightning Address, Nostr Address (NIP-05), Website, Pronouns - Remove deprecated LN URL (lud06) field - Move social proof fields (Twitter, GitHub, Mastodon) into a collapsible "Social proof" section (auto-expands if data exists) - Clearer labels: "Lightning Address", "Nostr Address (NIP-05)" --- .../ui/actions/NewUserMetadataScreen.kt | 211 +++++++++++------- 1 file changed, 131 insertions(+), 80 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt index b96b7098b..9603b9b11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt @@ -20,7 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.actions +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth @@ -30,16 +35,24 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -61,8 +74,15 @@ fun NewUserMetadataScreen( postViewModel.init(accountViewModel) val context = LocalContext.current + val socialExpanded = rememberSaveable { mutableStateOf(false) } + LaunchedEffect(postViewModel, accountViewModel) { postViewModel.load() + + // Auto-expand social proofs if any have data + if (postViewModel.twitter.value.isNotBlank() || postViewModel.mastodon.value.isNotBlank() || postViewModel.github.value.isNotBlank()) { + socialExpanded.value = true + } } Scaffold( @@ -95,8 +115,9 @@ fun NewUserMetadataScreen( Column( modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), ) { + // -- Profile (always visible) -- OutlinedTextField( - label = { Text(text = stringRes(R.string.profile_name_with_explainer)) }, + label = { Text(text = stringRes(R.string.username)) }, modifier = Modifier.fillMaxWidth(), value = postViewModel.name.value, onValueChange = { postViewModel.name.value = it }, @@ -106,6 +127,7 @@ fun NewUserMetadataScreen( color = MaterialTheme.colorScheme.placeholderText, ) }, + prefix = { Text("@") }, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -195,7 +217,7 @@ fun NewUserMetadataScreen( SelectSingleFromGallery( isUploading = postViewModel.isUploadingImageForBanner, tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 5.dp).align(Alignment.CenterHorizontally), + modifier = Modifier.padding(start = 5.dp), ) { postViewModel.uploadForBanner(it, context, onError = accountViewModel.toastManager::toast) } @@ -206,13 +228,29 @@ fun NewUserMetadataScreen( Spacer(modifier = Modifier.height(10.dp)) OutlinedTextField( - label = { Text(text = stringRes(R.string.pronouns)) }, + label = { Text(text = stringRes(R.string.lightning_address)) }, modifier = Modifier.fillMaxWidth(), - value = postViewModel.pronouns.value, - onValueChange = { postViewModel.pronouns.value = it }, + value = postViewModel.lnAddress.value, + onValueChange = { postViewModel.lnAddress.value = it }, placeholder = { Text( - text = "they/them, ...", + text = "me@mylightningnode.com", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.nip_05) + " (NIP-05)") }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.nip05.value, + onValueChange = { postViewModel.nip05.value = it }, + placeholder = { + Text( + text = "_@mywebsite.com", color = MaterialTheme.colorScheme.placeholderText, ) }, @@ -238,94 +276,107 @@ fun NewUserMetadataScreen( Spacer(modifier = Modifier.height(10.dp)) OutlinedTextField( - label = { Text(text = stringRes(R.string.nip_05)) }, + label = { Text(text = stringRes(R.string.pronouns)) }, modifier = Modifier.fillMaxWidth(), - value = postViewModel.nip05.value, - onValueChange = { postViewModel.nip05.value = it }, + value = postViewModel.pronouns.value, + onValueChange = { postViewModel.pronouns.value = it }, placeholder = { Text( - text = "_@mywebsite.com", + text = "they/them, ...", color = MaterialTheme.colorScheme.placeholderText, ) }, singleLine = true, ) - Spacer(modifier = Modifier.height(10.dp)) - OutlinedTextField( - label = { Text(text = stringRes(R.string.ln_address)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.lnAddress.value, - onValueChange = { postViewModel.lnAddress.value = it }, - placeholder = { - Text( - text = "me@mylightningnode.com", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - ) + // -- Social Proofs -- + ExpandableSection( + title = "Social proof", + expanded = socialExpanded, + ) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.twitter)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.twitter.value, + onValueChange = { postViewModel.twitter.value = it }, + placeholder = { + Text( + text = stringRes(R.string.twitter_proof_url_template), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(10.dp)) - OutlinedTextField( - label = { Text(text = stringRes(R.string.ln_url_outdated)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.lnURL.value, - onValueChange = { postViewModel.lnURL.value = it }, - placeholder = { - Text( - text = stringRes(R.string.lnurl), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) + OutlinedTextField( + label = { Text(text = stringRes(R.string.mastodon)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.mastodon.value, + onValueChange = { postViewModel.mastodon.value = it }, + placeholder = { + Text( + text = stringRes(R.string.mastodon_proof_url_template), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(10.dp)) - OutlinedTextField( - label = { Text(text = stringRes(R.string.twitter)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.twitter.value, - onValueChange = { postViewModel.twitter.value = it }, - placeholder = { - Text( - text = stringRes(R.string.twitter_proof_url_template), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringRes(R.string.mastodon)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.mastodon.value, - onValueChange = { postViewModel.mastodon.value = it }, - placeholder = { - Text( - text = stringRes(R.string.mastodon_proof_url_template), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringRes(R.string.github)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.github.value, - onValueChange = { postViewModel.github.value = it }, - placeholder = { - Text( - text = stringRes(R.string.github_proof_url_template), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) + OutlinedTextField( + label = { Text(text = stringRes(R.string.github)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.github.value, + onValueChange = { postViewModel.github.value = it }, + placeholder = { + Text( + text = stringRes(R.string.github_proof_url_template), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } } } } } + +@Composable +private fun ExpandableSection( + title: String, + expanded: MutableState, + content: @Composable () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { expanded.value = !expanded.value } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (expanded.value) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded.value) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + + AnimatedVisibility( + visible = expanded.value, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + content() + Spacer(modifier = Modifier.height(10.dp)) + } + } +} From fd28d354e7458c7e07cc8fd880a7d834ddea1ccb Mon Sep 17 00:00:00 2001 From: kojira Date: Sun, 1 Mar 2026 23:24:19 +0900 Subject: [PATCH 4/5] fix: treat multibyte characters as URL terminators in RichTextParser --- .../commons/richtext/RichTextParser.kt | 19 ++- .../richtext/RichTextParserMultibyteTest.kt | 132 ++++++++++++++++++ 2 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 615d64ce8..8cde06a74 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -175,7 +175,7 @@ class RichTextParser { lines.forEach { paragraph -> val isRTL = isArabic(paragraph) - val wordList = paragraph.trimEnd().split(' ') + val wordList = paragraph.trimEnd().split(wordBoundaryRegex).filter { it.isNotEmpty() } val segments = ArrayList(wordList.size) wordList.forEach { word -> segments.add(wordIdentifier(word, images, videos, urls, emojis, tags)) @@ -334,17 +334,14 @@ class RichTextParser { val shortDatePattern: Regex = Regex("^\\d{2}-\\d{2}-\\d{2}$") val numberPattern: Regex = Regex("^(-?[\\d.]+)([a-zA-Z%]*)$") - // Android9 seems to have an issue starting this regex. val noProtocolUrlValidator = - try { - Regex( - "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)", - ) - } catch (e: Exception) { - Regex( - "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)", - ) - } + Regex( + "(([a-zA-Z0-9_-]+\\.)*[a-zA-Z][a-zA-Z0-9_-]+[\\.\\:][a-zA-Z0-9_]+([\\/ \\?\\=\\&\\#\\.]?[a-zA-Z0-9_-]+)*\\/?)(.*)", + ) + + // Splits at spaces AND at ASCII/multibyte character boundaries + // e.g. "ああexample.com" -> ["ああ", "example.com"] + private val wordBoundaryRegex = Regex("(?<=[\\u0000-\\u007F])(?=[\\u0080-\\uFFFF])|(?<=[\\u0080-\\uFFFF])(?=[\\u0000-\\u007F])| +") val additionalUrlSchema = """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""" diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt new file mode 100644 index 000000000..75af7b8a0 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import org.junit.Assert.assertTrue +import org.junit.Test + +class RichTextParserMultibyteTest { + @Test + fun testFullTextWithMultibyte() { + // Multibyte characters around an email address should not produce URL/Link segments + val text = + "マルチバイト文字テストuser@example.com ほげほげ" + + val state = + RichTextParser() + .parseText(text, EmptyTagList, null) + + val allSegments = + state.paragraphs + .flatMap { it.words } + + // user@example.com should be EmailSegment + assertTrue( + "user@example.com should be EmailSegment", + allSegments.any { it is EmailSegment && it.segmentText == "user@example.com" }, + ) + + // user@example.com should NOT be a LinkSegment + assertTrue( + "user@example.com should not be a LinkSegment", + allSegments.none { it is LinkSegment && it.segmentText == "user@example.com" }, + ) + + // user@example.com should not be in urlSet + assertTrue( + "user@example.com should not be in urlSet", + !state.urlSet.contains("user@example.com"), + ) + } + + @Test + fun testEmailSegmentStandalone() { + val text = "user@example.com" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val allSegments = state.paragraphs.flatMap { it.words } + assertTrue( + "user@example.com should be EmailSegment", + allSegments.any { it is EmailSegment && it.segmentText == "user@example.com" }, + ) + } + + @Test + fun testMultibytePrefix_SchemelessUrl() { + // ああexample.com → RegularText(ああ) + SchemelessUrl(example.com) + val text = "ああexample.com" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val allSegments = state.paragraphs.flatMap { it.words } + + val urlSegments = allSegments.filterIsInstance() + assertTrue("Should have SchemelessUrlSegment", urlSegments.isNotEmpty()) + assertTrue("URL should be example.com", urlSegments.any { it.url == "example.com" }) + + val textSegments = allSegments.filterIsInstance() + assertTrue("Should have prefix ああ", textSegments.any { it.segmentText == "ああ" }) + } + + @Test + fun testMultibyteSuffix_SchemelessUrl() { + // example.comああ → SchemelessUrl(example.com) + RegularText(ああ) + val text = "example.comああ" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val allSegments = state.paragraphs.flatMap { it.words } + + val urlSegments = allSegments.filterIsInstance() + assertTrue("Should have SchemelessUrlSegment", urlSegments.isNotEmpty()) + assertTrue("URL should be example.com", urlSegments.any { it.url == "example.com" }) + + val textSegments = allSegments.filterIsInstance() + assertTrue("Should have suffix ああ", textSegments.any { it.segmentText == "ああ" }) + } + + @Test + fun testMultibytePrefix_Email() { + // ほむほむuser@example.comほげほげ → RegularText(ほむほむ) + Email(user@example.com) + RegularText(ほげほげ) + val text = "ほむほむuser@example.comほげほげ" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val allSegments = state.paragraphs.flatMap { it.words } + + val emailSegment = allSegments.filterIsInstance() + assertTrue("Should have EmailSegment", emailSegment.isNotEmpty()) + assertTrue("Email should be user@example.com", emailSegment.any { it.segmentText == "user@example.com" }) + + val textSegments = allSegments.filterIsInstance() + assertTrue("Should have prefix ほむほむ", textSegments.any { it.segmentText == "ほむほむ" }) + assertTrue("Should have suffix ほげほげ", textSegments.any { it.segmentText == "ほげほげ" }) + } + + @Test + fun testEmailWithSpaceAndMultibyteText() { + // user@example.com ふがふが → Email(user@example.com) + RegularText(ふがふが) + val text = "user@example.com ふがふが" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val allSegments = state.paragraphs.flatMap { it.words } + + val emailSegment = allSegments.filterIsInstance() + assertTrue("Should have EmailSegment", emailSegment.isNotEmpty()) + assertTrue("Email should be user@example.com", emailSegment.any { it.segmentText == "user@example.com" }) + + val textSegments = allSegments.filterIsInstance() + assertTrue("Should have suffix ふがふが", textSegments.any { it.segmentText == "ふがふが" }) + } +} From 304c348736261bd1188f68b43ea86a659dcc7743 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 16:33:58 +0000 Subject: [PATCH 5/5] feat: add RelayUrlSegment for ws:// and wss:// relay URIs Adds a new RelayUrlSegment type to RichTextParser that detects and labels relay URIs (ws:// and wss:// schemes) as a distinct segment type, separate from generic LinkSegments. https://claude.ai/code/session_01U21sGdEEMLo4hY8dwxNzPr --- .../amethyst/commons/richtext/RichTextParser.kt | 2 ++ .../amethyst/commons/richtext/RichTextParserSegments.kt | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 22ac5bc37..9d9df3b99 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -245,6 +245,8 @@ class RichTextParser { if (videos.contains(word)) return VideoSegment(word) + if (word.startsWith("ws://", ignoreCase = true) || word.startsWith("wss://", ignoreCase = true)) return RelayUrlSegment(word) + if (urls.contains(word)) return LinkSegment(word) if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 5d34859c3..292b07ddc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -146,6 +146,11 @@ class SchemelessUrlSegment( val extras: String?, ) : Segment(segment) +@Immutable +class RelayUrlSegment( + segment: String, +) : Segment(segment) + @Immutable class RegularTextSegment( segment: String,