diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
index b1d2b820a..6330fa558 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
@@ -114,6 +114,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile
@@ -122,6 +123,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds
import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
+import com.vitorpamplona.amethyst.ui.note.types.RenderFedimint
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
@@ -135,6 +137,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessChallenge
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessGameEnd
import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap
import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent
+import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status
import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent
@@ -155,6 +158,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderReport
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderThread
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment
import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack
@@ -270,7 +274,11 @@ import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
+import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
+import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
+import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
+import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
@@ -855,6 +863,20 @@ private fun RenderNoteRow(
RenderLongFormContent(baseNote, accountViewModel, nav)
}
+ is ThreadEvent -> {
+ RenderThread(
+ baseNote,
+ makeItShort,
+ canPreview,
+ quotesLeft,
+ unPackReply,
+ backgroundColor,
+ editState,
+ accountViewModel,
+ nav,
+ )
+ }
+
is CodeSnippetEvent -> {
RenderCodeSnippetEvent(baseNote)
}
@@ -1064,6 +1086,18 @@ private fun RenderNoteRow(
)
}
+ is CashuMintEvent -> {
+ RenderCashuMint(noteEvent)
+ }
+
+ is FedimintEvent -> {
+ RenderFedimint(noteEvent)
+ }
+
+ is MintRecommendationEvent -> {
+ RenderMintRecommendation(noteEvent)
+ }
+
is ClassifiedsEvent -> {
RenderClassifieds(
noteEvent,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/EcashMint.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/EcashMint.kt
new file mode 100644
index 000000000..c822a4137
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/EcashMint.kt
@@ -0,0 +1,205 @@
+/*
+ * 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.ui.note.types
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
+import com.vitorpamplona.amethyst.ui.theme.SmallBorder
+import com.vitorpamplona.amethyst.ui.theme.subtleBorder
+import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
+import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
+import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
+
+@Composable
+fun RenderCashuMint(noteEvent: CashuMintEvent) {
+ val mintUrl = remember(noteEvent) { noteEvent.mintUrl() }
+ val nuts = remember(noteEvent) { noteEvent.nuts() }
+ val network = remember(noteEvent) { noteEvent.network() }
+
+ MintAnnouncementCard(
+ title = "Cashu Mint",
+ url = mintUrl,
+ network = network.code,
+ capabilities = nuts,
+ capabilitiesLabel = "NUTs",
+ metadata = noteEvent.content.ifBlank { null },
+ )
+}
+
+@Composable
+fun RenderFedimint(noteEvent: FedimintEvent) {
+ val inviteCodes = remember(noteEvent) { noteEvent.inviteCodes() }
+ val modules = remember(noteEvent) { noteEvent.modules() }
+ val network = remember(noteEvent) { noteEvent.network() }
+
+ MintAnnouncementCard(
+ title = "Fedimint",
+ url = inviteCodes.firstOrNull(),
+ network = network.code,
+ capabilities = modules,
+ capabilitiesLabel = "Modules",
+ metadata = noteEvent.content.ifBlank { null },
+ )
+}
+
+@Composable
+fun RenderMintRecommendation(noteEvent: MintRecommendationEvent) {
+ val mintUrls = remember(noteEvent) { noteEvent.mintUrls() }
+ val mintType =
+ remember(noteEvent) {
+ when {
+ noteEvent.isCashuRecommendation() -> "Cashu Mint"
+ noteEvent.isFedimintRecommendation() -> "Fedimint"
+ else -> "Ecash Mint"
+ }
+ }
+
+ MintAnnouncementCard(
+ title = "$mintType Recommendation",
+ url = mintUrls.firstOrNull(),
+ network = null,
+ capabilities = emptyList(),
+ capabilitiesLabel = null,
+ metadata = noteEvent.content.ifBlank { null },
+ )
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun MintAnnouncementCard(
+ title: String,
+ url: String?,
+ network: String?,
+ capabilities: List,
+ capabilitiesLabel: String?,
+ metadata: String?,
+) {
+ Row(
+ modifier =
+ Modifier
+ .clip(shape = QuoteBorder)
+ .border(
+ 1.dp,
+ MaterialTheme.colorScheme.subtleBorder,
+ QuoteBorder,
+ ).fillMaxWidth(),
+ ) {
+ Column(
+ modifier = Modifier.padding(10.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+
+ if (network != null) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = network,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier =
+ Modifier
+ .clip(SmallBorder)
+ .border(1.dp, MaterialTheme.colorScheme.primary, SmallBorder)
+ .padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+ }
+ }
+
+ url?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+
+ if (capabilities.isNotEmpty() && capabilitiesLabel != null) {
+ FlowRow(
+ modifier = Modifier.padding(top = 6.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = "$capabilitiesLabel: ",
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.Gray,
+ )
+ capabilities.forEach { capability ->
+ Text(
+ text = capability,
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.Gray,
+ modifier =
+ Modifier
+ .clip(SmallBorder)
+ .border(1.dp, Color.Gray.copy(alpha = 0.3f), SmallBorder)
+ .padding(horizontal = 4.dp, vertical = 1.dp),
+ )
+ }
+ }
+ }
+
+ metadata?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 6.dp),
+ )
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Thread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Thread.kt
new file mode 100644
index 000000000..59dc332ae
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Thread.kt
@@ -0,0 +1,85 @@
+/*
+ * 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.ui.note.types
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.GenericLoadable
+import com.vitorpamplona.amethyst.ui.navigation.navs.INav
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
+import com.vitorpamplona.amethyst.ui.theme.replyModifier
+import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
+
+@Composable
+fun RenderThread(
+ note: Note,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ quotesLeft: Int,
+ unPackReply: ReplyRenderType,
+ backgroundColor: MutableState,
+ editState: State>,
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ val noteEvent = note.event as? ThreadEvent ?: return
+ val title = noteEvent.title()
+
+ if (title != null) {
+ Column(MaterialTheme.colorScheme.replyModifier) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Bold,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp),
+ )
+ }
+ Spacer(modifier = StdVertSpacer)
+ }
+
+ RenderTextEvent(
+ note,
+ makeItShort,
+ canPreview,
+ quotesLeft,
+ unPackReply,
+ backgroundColor,
+ editState,
+ accountViewModel,
+ nav,
+ )
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
index 65859ae5f..11ac8e9fc 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
@@ -147,10 +147,12 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile
import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetHeaderForThread
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
+import com.vitorpamplona.amethyst.ui.note.types.RenderFedimint
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
@@ -161,6 +163,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap
import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent
+import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
@@ -271,7 +274,11 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
+import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
+import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
+import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
+import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
@@ -567,6 +574,7 @@ private fun FullBleedNoteCompose(
when (noteEvent) {
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote, accountViewModel)
is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent, baseNote, accountViewModel)
+ is ThreadEvent -> RenderThreadHeaderForThread(noteEvent)
is WikiNoteEvent -> RenderWikiHeaderForThread(noteEvent, accountViewModel, nav)
is ClassifiedsEvent -> RenderClassifiedsReaderForThread(noteEvent, baseNote, accountViewModel, nav)
is CodeSnippetEvent -> RenderCodeSnippetHeaderForThread(noteEvent)
@@ -761,6 +769,12 @@ private fun FullBleedNoteCompose(
accountViewModel,
nav,
)
+ } else if (noteEvent is CashuMintEvent) {
+ RenderCashuMint(noteEvent)
+ } else if (noteEvent is FedimintEvent) {
+ RenderFedimint(noteEvent)
+ } else if (noteEvent is MintRecommendationEvent) {
+ RenderMintRecommendation(noteEvent)
} else if (noteEvent is PollEvent) {
RenderPoll(
note = baseNote,
@@ -1132,6 +1146,20 @@ private fun RenderLongFormHeaderForThread(
}
}
+@Composable
+private fun RenderThreadHeaderForThread(noteEvent: ThreadEvent) {
+ noteEvent.title()?.let {
+ Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) {
+ Text(
+ text = it,
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+}
+
@Preview
@Composable
private fun RenderWikiHeaderForThreadPreview() {
diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml
index 1193db6e4..c9b1a3632 100644
--- a/amethyst/src/main/res/values-sl-rSI/strings.xml
+++ b/amethyst/src/main/res/values-sl-rSI/strings.xml
@@ -77,7 +77,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem
Zapi
Števec vpogledov
Pošlji naprej
- Poslano naprej
+ poslano naprej
posodobljeno
uredi #%1$s
original
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/TagArrayBuilderExt.kt
new file mode 100644
index 000000000..6273b45b8
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/TagArrayBuilderExt.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.quartz.nip7DThreads
+
+import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
+import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
+
+fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title))
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/TagArrayExt.kt
new file mode 100644
index 000000000..dafe7bf2b
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/TagArrayExt.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.quartz.nip7DThreads
+
+import com.vitorpamplona.quartz.nip01Core.core.TagArray
+import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
+
+fun TagArray.title() = firstNotNullOfOrNull(TitleTag::parse)
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/ThreadEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/ThreadEvent.kt
new file mode 100644
index 000000000..9d3a755b1
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip7DThreads/ThreadEvent.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.quartz.nip7DThreads
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
+import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
+import com.vitorpamplona.quartz.nip22Comments.RootScope
+import com.vitorpamplona.quartz.nip31Alts.alt
+import com.vitorpamplona.quartz.nip50Search.SearchableEvent
+import com.vitorpamplona.quartz.utils.TimeUtils
+
+@Immutable
+class ThreadEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: Array>,
+ content: String,
+ sig: HexKey,
+) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
+ RootScope,
+ SearchableEvent {
+ override fun indexableContent() = "title: " + title() + "\n" + content
+
+ fun title() = tags.title()
+
+ companion object {
+ const val KIND = 11
+ const val ALT_DESCRIPTION = "Thread"
+
+ fun build(
+ content: String,
+ title: String,
+ createdAt: Long = TimeUtils.now(),
+ initializer: TagArrayBuilder.() -> Unit = {},
+ ) = eventTemplate(KIND, content, createdAt) {
+ alt(ALT_DESCRIPTION)
+ title(title)
+ initializer()
+ }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintUrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintUrlTag.kt
new file mode 100644
index 000000000..6fe198cb1
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintUrlTag.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.quartz.nip87Ecash
+
+import com.vitorpamplona.quartz.nip01Core.core.has
+import com.vitorpamplona.quartz.utils.ensure
+
+class MintUrlTag {
+ companion object {
+ const val TAG_NAME = "u"
+
+ fun parse(tag: Array): String? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].isNotEmpty()) { return null }
+ return tag[1]
+ }
+
+ fun assemble(url: String) = arrayOf(TAG_NAME, url)
+
+ fun assemble(urls: List) = urls.map { assemble(it) }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/NetworkTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/NetworkTag.kt
new file mode 100644
index 000000000..6c8898eb5
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/NetworkTag.kt
@@ -0,0 +1,53 @@
+/*
+ * 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.quartz.nip87Ecash
+
+import com.vitorpamplona.quartz.nip01Core.core.has
+import com.vitorpamplona.quartz.utils.ensure
+
+enum class NetworkType(
+ val code: String,
+) {
+ MAINNET("mainnet"),
+ TESTNET("testnet"),
+ SIGNET("signet"),
+ REGTEST("regtest"),
+ ;
+
+ companion object {
+ fun fromCode(code: String) = entries.firstOrNull { it.code == code }
+ }
+}
+
+class NetworkTag {
+ companion object {
+ const val TAG_NAME = "n"
+
+ fun parse(tag: Array): NetworkType? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].isNotEmpty()) { return null }
+ return NetworkType.fromCode(tag[1])
+ }
+
+ fun assemble(network: NetworkType) = arrayOf(TAG_NAME, network.code)
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/CashuMintEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/CashuMintEvent.kt
new file mode 100644
index 000000000..12edf1913
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/CashuMintEvent.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.quartz.nip87Ecash.cashu
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
+import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
+import com.vitorpamplona.quartz.nip31Alts.alt
+import com.vitorpamplona.quartz.nip87Ecash.NetworkType
+import com.vitorpamplona.quartz.utils.TimeUtils
+
+@Immutable
+class CashuMintEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: Array>,
+ content: String,
+ sig: HexKey,
+) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
+ fun mintUrl() = tags.mintUrl()
+
+ fun nuts() = tags.nuts()
+
+ fun network() = tags.network()
+
+ fun dTag() = tags.dTag()
+
+ companion object {
+ const val KIND = 38172
+ const val ALT_DESCRIPTION = "Cashu Mint Announcement"
+
+ fun build(
+ mintPubKey: String,
+ mintUrl: String,
+ nuts: List,
+ network: NetworkType = NetworkType.MAINNET,
+ metadata: String = "",
+ createdAt: Long = TimeUtils.now(),
+ initializer: TagArrayBuilder.() -> Unit = {},
+ ) = eventTemplate(KIND, metadata, createdAt) {
+ alt(ALT_DESCRIPTION)
+ dTag(mintPubKey)
+ mintUrl(mintUrl)
+ nuts(nuts)
+ network(network)
+ initializer()
+ }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/TagArrayBuilderExt.kt
new file mode 100644
index 000000000..a4a1c7aa4
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/TagArrayBuilderExt.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.quartz.nip87Ecash.cashu
+
+import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
+import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
+import com.vitorpamplona.quartz.nip87Ecash.NetworkTag
+import com.vitorpamplona.quartz.nip87Ecash.NetworkType
+import com.vitorpamplona.quartz.nip87Ecash.cashu.tags.NutsTag
+
+fun TagArrayBuilder.dTag(mintPubKey: String) = addUnique(arrayOf("d", mintPubKey))
+
+fun TagArrayBuilder.mintUrl(url: String) = addUnique(MintUrlTag.assemble(url))
+
+fun TagArrayBuilder.nuts(nuts: List) = add(NutsTag.assemble(nuts))
+
+fun TagArrayBuilder.network(network: NetworkType) = addUnique(NetworkTag.assemble(network))
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/TagArrayExt.kt
new file mode 100644
index 000000000..a91a8e2ba
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/TagArrayExt.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.quartz.nip87Ecash.cashu
+
+import com.vitorpamplona.quartz.nip01Core.core.TagArray
+import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
+import com.vitorpamplona.quartz.nip87Ecash.NetworkTag
+import com.vitorpamplona.quartz.nip87Ecash.NetworkType
+import com.vitorpamplona.quartz.nip87Ecash.cashu.tags.NutsTag
+
+fun TagArray.mintUrl(): String? = firstNotNullOfOrNull(MintUrlTag::parse)
+
+fun TagArray.nuts(): List = firstNotNullOfOrNull(NutsTag::parse) ?: emptyList()
+
+fun TagArray.network(): NetworkType = firstNotNullOfOrNull(NetworkTag::parse) ?: NetworkType.MAINNET
+
+fun TagArray.dTag(): String? = firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/tags/NutsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/tags/NutsTag.kt
new file mode 100644
index 000000000..ad7dd782c
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/tags/NutsTag.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.quartz.nip87Ecash.cashu.tags
+
+import com.vitorpamplona.quartz.nip01Core.core.has
+import com.vitorpamplona.quartz.utils.ensure
+
+class NutsTag {
+ companion object {
+ const val TAG_NAME = "nuts"
+
+ fun parse(tag: Array): List? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].isNotEmpty()) { return null }
+ return tag.drop(1)
+ }
+
+ fun assemble(nuts: List) = arrayOf(TAG_NAME, *nuts.toTypedArray())
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/FedimintEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/FedimintEvent.kt
new file mode 100644
index 000000000..ba6f81130
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/FedimintEvent.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.quartz.nip87Ecash.fedimint
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
+import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
+import com.vitorpamplona.quartz.nip31Alts.alt
+import com.vitorpamplona.quartz.nip87Ecash.NetworkType
+import com.vitorpamplona.quartz.utils.TimeUtils
+
+@Immutable
+class FedimintEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: Array>,
+ content: String,
+ sig: HexKey,
+) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
+ fun inviteCodes() = tags.inviteCodes()
+
+ fun modules() = tags.modules()
+
+ fun network() = tags.network()
+
+ fun dTag() = tags.dTag()
+
+ companion object {
+ const val KIND = 38173
+ const val ALT_DESCRIPTION = "Fedimint Announcement"
+
+ fun build(
+ federationId: String,
+ inviteCodes: List,
+ modules: List,
+ network: NetworkType = NetworkType.MAINNET,
+ metadata: String = "",
+ createdAt: Long = TimeUtils.now(),
+ initializer: TagArrayBuilder.() -> Unit = {},
+ ) = eventTemplate(KIND, metadata, createdAt) {
+ alt(ALT_DESCRIPTION)
+ dTag(federationId)
+ inviteCodes(inviteCodes)
+ modules(modules)
+ network(network)
+ initializer()
+ }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/TagArrayBuilderExt.kt
new file mode 100644
index 000000000..bcfab7083
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/TagArrayBuilderExt.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.quartz.nip87Ecash.fedimint
+
+import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
+import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
+import com.vitorpamplona.quartz.nip87Ecash.NetworkTag
+import com.vitorpamplona.quartz.nip87Ecash.NetworkType
+import com.vitorpamplona.quartz.nip87Ecash.fedimint.tags.ModulesTag
+
+fun TagArrayBuilder.dTag(federationId: String) = addUnique(arrayOf("d", federationId))
+
+fun TagArrayBuilder.inviteCodes(codes: List) = addAll(MintUrlTag.assemble(codes))
+
+fun TagArrayBuilder.modules(modules: List) = add(ModulesTag.assemble(modules))
+
+fun TagArrayBuilder.network(network: NetworkType) = addUnique(NetworkTag.assemble(network))
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/TagArrayExt.kt
new file mode 100644
index 000000000..f0f34e986
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/TagArrayExt.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.quartz.nip87Ecash.fedimint
+
+import com.vitorpamplona.quartz.nip01Core.core.TagArray
+import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
+import com.vitorpamplona.quartz.nip87Ecash.NetworkTag
+import com.vitorpamplona.quartz.nip87Ecash.NetworkType
+import com.vitorpamplona.quartz.nip87Ecash.fedimint.tags.ModulesTag
+
+fun TagArray.inviteCodes(): List = mapNotNull(MintUrlTag::parse)
+
+fun TagArray.modules(): List = firstNotNullOfOrNull(ModulesTag::parse) ?: emptyList()
+
+fun TagArray.network(): NetworkType = firstNotNullOfOrNull(NetworkTag::parse) ?: NetworkType.MAINNET
+
+fun TagArray.dTag(): String? = firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/tags/ModulesTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/tags/ModulesTag.kt
new file mode 100644
index 000000000..fa47e3b33
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/tags/ModulesTag.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.quartz.nip87Ecash.fedimint.tags
+
+import com.vitorpamplona.quartz.nip01Core.core.has
+import com.vitorpamplona.quartz.utils.ensure
+
+class ModulesTag {
+ companion object {
+ const val TAG_NAME = "modules"
+
+ fun parse(tag: Array): List? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].isNotEmpty()) { return null }
+ return tag.drop(1)
+ }
+
+ fun assemble(modules: List) = arrayOf(TAG_NAME, *modules.toTypedArray())
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt
new file mode 100644
index 000000000..d586cf7d2
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.quartz.nip87Ecash.recommendation
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
+import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
+import com.vitorpamplona.quartz.nip31Alts.alt
+import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
+import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
+import com.vitorpamplona.quartz.utils.TimeUtils
+
+@Immutable
+class MintRecommendationEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: Array>,
+ content: String,
+ sig: HexKey,
+) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
+ fun mintUrls() = tags.mintUrls()
+
+ fun mintEventKind() = tags.mintEventKind()
+
+ fun dTag() = tags.dTag()
+
+ fun mintEventAddresses() = tags.mintEventAddresses()
+
+ fun isCashuRecommendation() = mintEventKind() == CashuMintEvent.KIND
+
+ fun isFedimintRecommendation() = mintEventKind() == FedimintEvent.KIND
+
+ companion object {
+ const val KIND = 38000
+ const val ALT_DESCRIPTION = "Mint Recommendation"
+
+ fun build(
+ mintIdentifier: String,
+ mintKind: Int,
+ review: String = "",
+ mintUrls: List = emptyList(),
+ mintEventAddress: String? = null,
+ mintEventRelay: String? = null,
+ createdAt: Long = TimeUtils.now(),
+ initializer: TagArrayBuilder.() -> Unit = {},
+ ) = eventTemplate(KIND, review, createdAt) {
+ alt(ALT_DESCRIPTION)
+ dTag(mintIdentifier)
+ kTag(mintKind)
+ mintUrls.forEach { mintUrl(it) }
+ if (mintEventAddress != null) {
+ aTag(mintEventAddress, mintEventRelay)
+ }
+ initializer()
+ }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/TagArrayBuilderExt.kt
new file mode 100644
index 000000000..60c25f096
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/TagArrayBuilderExt.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.quartz.nip87Ecash.recommendation
+
+import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
+import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
+
+fun TagArrayBuilder.dTag(mintIdentifier: String) = addUnique(arrayOf("d", mintIdentifier))
+
+fun TagArrayBuilder.kTag(kind: Int) = addUnique(arrayOf("k", kind.toString()))
+
+fun TagArrayBuilder.mintUrl(url: String) = add(MintUrlTag.assemble(url))
+
+fun TagArrayBuilder.aTag(
+ address: String,
+ relay: String? = null,
+) = if (relay != null) {
+ add(arrayOf("a", address, relay))
+} else {
+ add(arrayOf("a", address))
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/TagArrayExt.kt
new file mode 100644
index 000000000..8d6caafd8
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/TagArrayExt.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.quartz.nip87Ecash.recommendation
+
+import com.vitorpamplona.quartz.nip01Core.core.TagArray
+import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
+
+fun TagArray.mintUrls(): List = mapNotNull(MintUrlTag::parse)
+
+fun TagArray.mintEventKind(): Int? = firstOrNull { it.size >= 2 && it[0] == "k" }?.get(1)?.toIntOrNull()
+
+fun TagArray.dTag(): String? = firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
+
+fun TagArray.mintEventAddresses(): List = filter { it.size >= 2 && it[0] == "a" }.map { it[1] }
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
index a377b9a33..a149f84a5 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
@@ -156,12 +156,16 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
+import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.addressables.AddressableAssertionEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.events.EventAssertionEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.externalIds.ExternalIdAssertionEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
+import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
+import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
+import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
@@ -230,6 +234,7 @@ class EventFactory {
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig)
+ CashuMintEvent.KIND -> CashuMintEvent(id, pubKey, createdAt, tags, content, sig)
CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig)
CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig)
CashuSpendingHistoryEvent.KIND -> CashuSpendingHistoryEvent(id, pubKey, createdAt, tags, content, sig)
@@ -265,6 +270,7 @@ class EventFactory {
EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig)
EphemeralChatListEvent.KIND -> EphemeralChatListEvent(id, pubKey, createdAt, tags, content, sig)
ExternalIdentitiesEvent.KIND -> ExternalIdentitiesEvent(id, pubKey, createdAt, tags, content, sig)
+ FedimintEvent.KIND -> FedimintEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig)
FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig)
@@ -303,6 +309,7 @@ class EventFactory {
MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig)
MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig)
MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig)
+ MintRecommendationEvent.KIND -> MintRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig)
NamedSiteEvent.KIND -> NamedSiteEvent(id, pubKey, createdAt, tags, content, sig)
@@ -352,6 +359,7 @@ class EventFactory {
SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig)
StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
+ ThreadEvent.KIND -> ThreadEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig)
TokenEvent.KIND -> TokenEvent(id, pubKey, createdAt, tags, content, sig)
TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig)
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt
index 8782c16e9..ed13d8dd8 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt
@@ -245,95 +245,74 @@ class Url(
}
/**
- * Removes dot segments from the given path as stated in
- * ["RFC 3986, 5.2.4. Remove Dot Segments"](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4).
- *
- * @param path
- * The path from which dot segments are to be removed.
- *
- * @return
- * The path from which dot segments are removed.
+ * Removes dot segments from the given path per
+ * [RFC 3986 §5.2.4](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4).
*/
fun removeDotSegments(path: String): String {
- // Initialize the input with the no-appended path components and the output
- // with the empty string.
var input = path
- var output = ""
+ val output = StringBuilder()
- // While the input is not empty, loop the following steps.
while (input.isNotEmpty()) {
- // If the input begins with a prefix of "../" or "./", then
- // remove that prefix from the input;
- if (DOT_DOT_SLASH.find(input) != null) {
- input = DOT_DOT_SLASH.replaceFirst(input, "")
- continue
- }
+ when {
+ // A: Remove leading "../" or "./"
+ input.startsWith("../") -> {
+ input = input.substring(3)
+ }
- // If the input begins with a prefix of "/./" or "/.", where
- // "." is a complete path segment, then replace that prefix
- // with "/" in the input.
- if (SLASH_DOT_SLASH.find(input) != null) {
- input = SLASH_DOT_SLASH.replaceFirst(input, "/")
- continue
- }
+ input.startsWith("./") -> {
+ input = input.substring(2)
+ }
- // If the input begins with a prefix of "/../" or "/..",
- // where ".." is a complete path segment, then replace that
- // prefix with "/" in the input and remove the last segment
- // and its preceding "/" (if any) from the output.
- if (SLASH_DOT_DOT_SLASH.find(input) != null) {
- input = SLASH_DOT_DOT_SLASH.replaceFirst(input, "/")
- output = dropLastSegment(output, true)
- continue
- }
+ // B: Replace leading "/./" or "/." (end) with "/"
+ input.startsWith("/./") -> {
+ input = "/" + input.substring(3)
+ }
- // If the input consists only of "." or "..", then remove
- // that from the input.
- if (DOT_OR_DOT_DOT.find(input) != null) {
- input = DOT_OR_DOT_DOT.replaceFirst(input, "")
- continue
- }
+ input == "/." -> {
+ input = "/"
+ }
- // Move the first path segment in the input buffer to the
- // end of the output, including the initial "/" character
- // (if any) and any subsequent characters up to, but not
- // including, the next "/" character or the end of the input.
- val matchResult = MOVE_REGEX.find(input)
- if (matchResult != null) {
- input = matchResult.groups["remaining"]!!.value
- output += matchResult.groups["firstsegment"]!!.value
- continue
+ // C: Replace leading "/../" or "/.." (end) with "/" and drop last output segment
+ input.startsWith("/../") -> {
+ input = "/" + input.substring(4)
+ dropLastSegment(output)
+ }
+
+ input == "/.." -> {
+ input = "/"
+ dropLastSegment(output)
+ }
+
+ // D: Input is just "." or ".."
+ input == "." || input == ".." -> {
+ input = ""
+ }
+
+ // E: Move the first path segment to output
+ else -> {
+ val startIdx = if (input.startsWith("/")) 1 else 0
+ val idx = input.indexOf('/', startIdx)
+ val segEnd = if (idx == -1) input.length else idx
+ output.append(input, 0, segEnd)
+ input = input.substring(segEnd)
+ }
}
}
- return output
+ return output.toString()
}
/**
- * Drops the last segment (= characters after the last slash) of a path and
- * optionally the last slash. If the path doesn't contain slash, an empty string
- * is returned.
- *
- * @param path
- * The path.
- *
- * @param dropLastSlash
- * Whether or not to drop the last slash if present.
- *
- * @return The path from which the last segment is removed.
+ * Removes the last segment and its preceding "/" from the output buffer.
+ * For example, "/a/b" becomes "/a" and "/a" becomes "".
*/
- fun dropLastSegment(
- path: String,
- dropLastSlash: Boolean,
- ): String {
- // The regular expression for the target.
- val m = if (dropLastSlash) DROP_LAST_SLASH_REGEX else DROP_LAST_SEGMENT_REGEX
-
- // Find the target. (Any inputs matches the pattern.)
- m.find(path)
-
- // Drop the target.
- return m.replaceFirst(path, "")
+ private fun dropLastSegment(output: StringBuilder) {
+ val lastSlash = output.lastIndexOf('/')
+ if (lastSlash >= 0) {
+ output.delete(lastSlash, output.length)
+ } else {
+ output.clear()
+ }
}
/**
@@ -416,34 +395,6 @@ class Url(
}
companion object {
- val DROP_LAST_SLASH_REGEX = Regex("\\/?[^/]*$")
- val DROP_LAST_SEGMENT_REGEX = Regex("[^/]*$")
-
- // If the input begins with a prefix of "../" or "./", then
- // remove that prefix from the input;
- val DOT_DOT_SLASH = Regex("^\\.?\\./")
-
- // If the input begins with a prefix of "/./" or "/.", where
- // "." is a complete path segment, then replace that prefix
- // with "/" in the input.
- val SLASH_DOT_SLASH = Regex("^\\/\\.(\\/|$)")
-
- // If the input begins with a prefix of "/../" or "/..",
- // where ".." is a complete path segment, then replace that
- // prefix with "/" in the input and remove the last segment
- // and its preceding "/" (if any) from the output.
- val SLASH_DOT_DOT_SLASH = Regex("^\\/\\.\\.(\\/|$)")
-
- // If the input consists only of "." or "..", then remove
- // that from the input.
- val DOT_OR_DOT_DOT = Regex("^\\.?\\.$")
-
- // Move the first path segment in the input buffer to the
- // end of the output, including the initial "/" character
- // (if any) and any subsequent characters up to, but not
- // including, the next "/" character or the end of the input.
- val MOVE_REGEX = Regex("^(?\\/?[^/]*)(?.*)$")
-
private const val DEFAULT_SCHEME = "https"
private val SCHEME_PORT_MAP: Map =
mapOf(
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt
index d91147172..a03042c28 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt
@@ -146,128 +146,132 @@ class DomainNameReader(
private set
/**
- * Reads and parses the current string to make sure the domain name started where it was supposed to,
- * and the current domain name is correct.
- * @return The next state to use after reading the current.
+ * Validates the buffered domain-name prefix ([current]) that was accumulated before
+ * the caller started reading from the stream. Updates label/dot counters, detects
+ * hex-numeric IPs, brackets for IPv6, and ASCII/international char boundaries.
+ *
+ * If an invalid character is found mid-string the domain is restarted from that
+ * position (e.g. `asdf%asdf.google.com` → restart at `asdf.google.com`).
+ *
+ * @return [ReaderNextState.ValidDomainName] when the prefix is acceptable,
+ * [ReaderNextState.InvalidDomainName] otherwise.
*/
private fun readCurrent(): ReaderNextState {
- if (current != null) {
- // Handles the case where the string is ".hello"
- if (current.length == 1 && isDot(current[0])) {
- return ReaderNextState.InvalidDomainName
- } else if (current.length == 3 && current.isDotPercent()) {
+ if (current == null) {
+ startDomainName = buffer.length
+ return ReaderNextState.ValidDomainName
+ }
+
+ // A lone dot or percent-encoded dot is never a valid domain start.
+ if ((current.length == 1 && isDot(current[0])) ||
+ (current.length == 3 && current.isDotPercent())
+ ) {
+ return ReaderNextState.InvalidDomainName
+ }
+
+ startDomainName = buffer.length - current.length
+ numeric = true
+
+ // Index into `current` where we'd restart the domain if we hit an invalid char.
+ var newStart = 0
+
+ val chars = current.toCharArray()
+ val length = chars.size
+
+ // Detect hex literal prefix (0x...)
+ var isAllHexSoFar = length > 2 && chars[0] == '0' && (chars[1] == 'x' || chars[1] == 'X')
+ var lastWasAscii = length > 0 && chars[0].code < INTERNATIONAL_CHAR_START
+
+ var index = if (isAllHexSoFar) 2 else 0
+
+ while (index < length) {
+ val ch = chars[index]
+ val isAscii = ch.code < INTERNATIONAL_CHAR_START
+
+ currentLabelLength++
+ topLevelLength = currentLabelLength
+
+ if (currentLabelLength > MAX_LABEL_LENGTH) {
return ReaderNextState.InvalidDomainName
}
- // The location where the domain name started.
- startDomainName = buffer.length - current.length
-
- // flag that the domain is currently all numbers and/or dots.
- numeric = true
-
- // If an invalid char is found, we can just restart the domain from there.
- var newStart = 0
-
- val currArray = current.toCharArray()
- val length = currArray.size
-
- // hex special case
- var isAllHexSoFar =
- length > 2 && (currArray[0] == '0' && (currArray[1] == 'x' || currArray[1] == 'X'))
-
- var lastWasAscii = length > 0 && currArray[0].code < INTERNATIONAL_CHAR_START
-
- var index = if (isAllHexSoFar) 2 else 0
- var done = false
- var isAscii = false
-
- while (index < length && !done) {
- // get the current character and update length counts.
- val curr = currArray[index]
- isAscii = curr.code < INTERNATIONAL_CHAR_START
-
- currentLabelLength++
- topLevelLength = currentLabelLength
-
- // Is the length of the last part > 64 (plus one since we just incremented)
- if (currentLabelLength > MAX_LABEL_LENGTH) {
- return ReaderNextState.InvalidDomainName
- } else if (isDot(curr)) {
- // found a dot. Increment dot count, and reset last length
+ when {
+ isDot(ch) -> {
dots++
currentLabelLength = 0
- } else if (curr == '[') {
+ }
+
+ ch == '[' -> {
seenBracket = true
numeric = false
- } else if (curr == '%' && index + 2 < length && isHex(currArray[index + 1]) && isHex(currArray[index + 2])) {
- // handle url encoded dot
- if (currArray[index + 1] == '2' && currArray[index + 2] == 'e') {
+ }
+
+ ch == '%' && index + 2 < length && isHex(chars[index + 1]) && isHex(chars[index + 2]) -> {
+ // Percent-encoded byte; check for encoded dot (%2e)
+ if (chars[index + 1] == '2' && chars[index + 2] == 'e') {
dots++
currentLabelLength = 0
} else {
numeric = false
}
index += 2
- } else if (isAllHexSoFar) {
- // if it's a valid character in the domain that is not numeric
- if (!isHex(curr)) {
- numeric = false
- isAllHexSoFar = false
- index-- // backtrack to rerun last character knowing it isn't hex.
- }
- } else if (isAscii == lastWasAscii && (isAlpha(curr) || curr == '-' || !isAscii)) {
- // we don't allow mixed domains: doesn't come here if it changed form ascii to not ascii.
+ }
+
+ isAllHexSoFar && !isHex(ch) -> {
+ // Thought it was hex but this char isn't — reprocess as non-hex
+ numeric = false
+ isAllHexSoFar = false
+ index-- // backtrack to re-evaluate this char
+ }
+
+ isAscii == lastWasAscii && (isAlpha(ch) || ch == '-' || !isAscii) -> {
+ // Valid domain character (same script as previous)
numeric = false
lastWasAscii = isAscii
- } else if (isAscii != lastWasAscii) {
- // if its not _numeric and not alphabetical, then restart searching for a domain from this point.
+ }
+
+ isAscii != lastWasAscii -> {
+ // Script boundary (ASCII ↔ international) — restart domain from here
newStart = index
- currentLabelLength = 0
- topLevelLength = 0
- numeric = true
- dots = 0
- // done = true
-
+ resetDomainCounters()
lastWasAscii = isAscii
- } else if (index == 0) {
- if (curr in UrlDetector.CANNOT_BEGIN_URLS_WITH) {
- newStart = index + 1
- currentLabelLength = 0
- topLevelLength = 0
- numeric = true
- dots = 0
- }
- }
- index++
- }
-
- // An invalid character for the domain was found somewhere in the current buffer.
- // cut the first part of the domain out. For example:
- // http://asdf%asdf.google.com <- asdf.google.com is still valid, so restart from the %
- if (newStart > 0) {
- // make sure the location is not at the end. Otherwise the thing is just invalid.
-
- if (newStart < current.length) {
- buffer.clear()
- buffer.append(current.substring(newStart))
-
- // cut out the previous part, so now the domain name has to be from here.
- startDomainName = 0
}
- // now after cutting if the buffer is just "." newStart > current (last character in current is invalid)
- if (newStart >= current.length || buffer.toString() == ".") {
- return ReaderNextState.InvalidDomainName
+ index == 0 && ch in UrlDetector.CANNOT_BEGIN_URLS_WITH -> {
+ // Invalid leading char — restart after it
+ newStart = index + 1
+ resetDomainCounters()
}
}
- } else {
- startDomainName = buffer.length
+ index++
+ }
+
+ // If we found an invalid region, trim the buffer to start after it.
+ if (newStart > 0) {
+ if (newStart < current.length) {
+ buffer.clear()
+ buffer.append(current.substring(newStart))
+ startDomainName = 0
+ }
+
+ if (newStart >= current.length || buffer.toString() == ".") {
+ return ReaderNextState.InvalidDomainName
+ }
}
- // all else is good, return OK
return ReaderNextState.ValidDomainName
}
+ /**
+ * Resets domain tracking counters when the domain start is being moved forward.
+ */
+ private fun resetDomainCounters() {
+ currentLabelLength = 0
+ topLevelLength = 0
+ numeric = true
+ dots = 0
+ }
+
/**
* Reads the Dns and returns the next state the state machine should take in throwing this out, or continue processing
* if this is a valid domain name.
@@ -527,111 +531,66 @@ class DomainNameReader(
* @return Returns true if it's a valid ipv4 address
*/
private fun isValidIpv4(testDomain: String): Boolean {
- var valid = false
- val length: Int = testDomain.length
- if (length > 0) {
- // handling format without dots. Ex: http://2123123123123/path/a, http://0x8242343/aksdjf
- if (dots == 0) {
- try {
- val value: Long
- if (length > 2 && testDomain[0] == '0' && testDomain[1] == 'x') { // hex
- // digit must be within ['0', '9'] or ['A', 'F'] or ['a', 'f']
- for (c in 2.. 'f')) {
- return false
- }
- }
- value = testDomain.substring(2).toLong(16)
- } else if (testDomain[0] == '0') { // octal
- // digit must be within ['0', '7']
- for (c in 1.. = splitByDot(testDomain)
- valid = true
+ if (testDomain.isEmpty()) return false
- // check each part of the ip and make sure its valid.
- var i = 0
- while (i < parts.size && valid) {
- val part = parts[i]
- val partLen: Int = part.length
- if (partLen > 0) {
- val parsedNum: String
- val base: Int
- if (partLen > 2 && part[0] == '0' && part[1] == 'x') { // dotted hex
- // digit must be within ['0', '9'] or ['A', 'F'] or ['a', 'f']
- for (c in 2.. 'f')) {
- return false
- }
- }
- parsedNum = part.substring(2)
- base = 16
- } else if (part[0] == '0') { // dotted octal
- // digit must be within ['0', '7']
- for (c in 1.. 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
+ // Hexadecimal
+ digits = s.substring(2)
+ base = 16
+ for (c in digits) {
+ if (!isHex(c)) return null
+ }
+ } else if (s[0] == '0') {
+ // Octal
+ digits = s.substring(1)
+ base = 8
+ for (c in digits) {
+ if (c !in '0'..'7') return null
+ }
+ } else {
+ // Decimal
+ digits = s
+ base = 10
+ for (c in digits) {
+ if (c !in '0'..'9') return null
}
}
- return valid
+
+ if (digits.isEmpty()) return 0L
+ return try {
+ digits.toLong(base)
+ } catch (_: NumberFormatException) {
+ null
+ }
}
/**
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt
index 9b90e9c03..7915ec9ed 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt
@@ -315,81 +315,75 @@ class UrlDetector(
while (!reader.eof()) {
val curr = reader.read()
- // if we match a slash, look for a second one.
if (curr == '/') {
buffer.append(curr)
if (numSlashes == 1) {
- // return only if its an approved protocol. This can be expanded to allow others
- val schemeStartIndex: Int = findValidSchemeStartIndex(buffer.toString())
+ // Two slashes found — check for a valid scheme like "http://"
+ val schemeStartIndex = findValidSchemeStartIndex(buffer.toString())
if (schemeStartIndex >= 0) {
buffer.deleteRange(0, schemeStartIndex)
currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
return true
- } else {
- return false
}
+ return false
}
numSlashes++
} else if (curr == ' ') {
- // if we find a space or end of input, then nothing found.
buffer.append(curr)
return false
- } else if (curr == '[') { // if we're starting to see an ipv6 address
- reader.goBack() // unread the '[', so that we can start looking for ipv6
- return false
- } else if (originalLength > 0 && numSlashes == 0 && CharUtils.isAlpha(curr)) {
- // If we had already read something before the : and we are matching regardless of slashes, assume it's a scheme
-
- // Add the slashes to the end of the scheme so it matches what's in the scheme list
- val schemeStartIndex = findValidSchemeNoSlashesStartIndex(buffer.toString())
- if (schemeStartIndex >= 0) {
- if (schemeStartIndex > 0) {
- buffer.deleteRange(0, schemeStartIndex)
- }
- currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
- reader.goBack()
- return true
- } else {
- reader.goBack()
- return readUserPass(0)
- }
- // If this didn't match a defined scheme, continue processing as usual
- } else if (originalLength > 0 || numSlashes > 0 || !CharUtils.isAlpha(curr)) {
- // if it's not a character a-z or A-Z then assume we aren't matching scheme, but instead
- // matching username and password.
- // Add the slashes to the end of the scheme so it matches what's in the scheme list
- val schemeStartIndex = findValidSchemeNoSlashesStartIndex(buffer.toString())
- if (schemeStartIndex >= 0) {
- if (schemeStartIndex > 0) {
- buffer.deleteRange(0, schemeStartIndex)
- }
- currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
- reader.goBack()
- return true
- }
-
+ } else if (curr == '[') {
+ // Start of IPv6 — unread and let the caller handle it
reader.goBack()
- return readUserPass(0)
+ return false
+ } else if (originalLength > 0 || numSlashes > 0 || !CharUtils.isAlpha(curr)) {
+ // Not a plain alpha char continuing a potential scheme name, or we already
+ // had content before the colon / had slashes. Try matching a scheme without
+ // slashes (e.g. "nostr:npub1...") then fall back to username:password.
+ return trySchemeNoSlashesOrUserPass()
}
}
return false
}
- private fun findValidSchemeStartIndex(optionalScheme: String): Int {
- val optionalSchemeLowercase = optionalScheme.lowercase()
- return VALID_SCHEMES
- .filter(optionalSchemeLowercase::endsWith)
- .map(optionalSchemeLowercase::lastIndexOf)
- .firstOrNull() ?: -1
+ /**
+ * Attempts to match the buffer as a scheme without slashes (e.g. "nostr:").
+ * If that fails, treats the content as a potential username:password.
+ */
+ private fun trySchemeNoSlashesOrUserPass(): Boolean {
+ val schemeStartIndex = findValidSchemeNoSlashesStartIndex(buffer.toString())
+ if (schemeStartIndex >= 0) {
+ if (schemeStartIndex > 0) {
+ buffer.deleteRange(0, schemeStartIndex)
+ }
+ currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
+ reader.goBack()
+ return true
+ }
+ reader.goBack()
+ return readUserPass(0)
}
- private fun findValidSchemeNoSlashesStartIndex(optionalScheme: String): Int {
- val optionalSchemeLowercase = optionalScheme.lowercase()
- return VALID_SCHEMES_NO_SLASHES
- .filter(optionalSchemeLowercase::endsWith)
- .map(optionalSchemeLowercase::lastIndexOf)
- .firstOrNull() ?: -1
+ private fun findValidSchemeStartIndex(optionalScheme: String): Int = findSchemeSuffix(optionalScheme, VALID_SCHEMES)
+
+ private fun findValidSchemeNoSlashesStartIndex(optionalScheme: String): Int = findSchemeSuffix(optionalScheme, VALID_SCHEMES_NO_SLASHES)
+
+ /**
+ * Checks if [buffer] ends with any of the [schemes] (case-insensitive)
+ * and returns the start index of the match, or -1 if none match.
+ */
+ private fun findSchemeSuffix(
+ buffer: String,
+ schemes: List,
+ ): Int {
+ val len = buffer.length
+ for (scheme in schemes) {
+ val schemeLen = scheme.length
+ if (len >= schemeLen && buffer.regionMatches(len - schemeLen, scheme, 0, schemeLen, ignoreCase = true)) {
+ return len - schemeLen
+ }
+ }
+ return -1
}
/**
@@ -618,57 +612,33 @@ class UrlDetector(
var endsOnASlash = true
while (!reader.eof()) {
- // read the next char
val curr = reader.read()
if (curr == ' ') {
- // if end of state and we got here, then the url is valid
- // if it is not just a word/word
- if (
- currentUrlMarker.hasScheme() ||
- currentUrlMarker.hasPort() ||
- currentUrlMarker.hasUsernamePassword() ||
- !isSingleLevelLabel ||
- endsOnASlash
- ) {
- return readEnd(ReadEndState.ValidUrl)
- } else {
- return readEnd(ReadEndState.InvalidUrl)
- }
+ return readEnd(if (isPathValid(endsOnASlash)) ReadEndState.ValidUrl else ReadEndState.InvalidUrl)
}
- // append the char
buffer.append(curr)
- // now see if we move to another state.
- if (curr == '?') {
- // if ? read query string
- return readQueryString()
- } else if (curr == '#') {
- // if # read the fragment
- return readFragment()
- }
+ if (curr == '?') return readQueryString()
+ if (curr == '#') return readFragment()
endsOnASlash = curr == '/'
}
- // end of input then this url is good.
- // if end of state and we got here, then the url is valid
- // if it is not just a word/word
- // no need to check for query and fragments
- // here we accept urls that end in /
- if (
- currentUrlMarker.hasScheme() ||
+ return readEnd(if (isPathValid(endsOnASlash)) ReadEndState.ValidUrl else ReadEndState.InvalidUrl)
+ }
+
+ /**
+ * A path is valid if the URL has additional context (scheme, port, credentials)
+ * or if it's not an ambiguous single-level label like "word/word".
+ */
+ private fun isPathValid(endsOnASlash: Boolean): Boolean =
+ currentUrlMarker.hasScheme() ||
currentUrlMarker.hasPort() ||
currentUrlMarker.hasUsernamePassword() ||
!isSingleLevelLabel ||
endsOnASlash
- ) {
- return readEnd(ReadEndState.ValidUrl)
- } else {
- return readEnd(ReadEndState.InvalidUrl)
- }
- }
/**
* The url has been read to here. Remember the url if its valid, and reset state.