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.