Merge branch 'main' into claude/implement-nip5a-rendering-5ioVq

This commit is contained in:
Vitor Pamplona
2026-03-29 20:43:02 -04:00
committed by GitHub
25 changed files with 1342 additions and 386 deletions
@@ -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.RenderBadgeAward
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent 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.RenderChannelMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile 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.RenderCodeSnippetEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack 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.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent 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.RenderLiveChessGameEnd
import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap
import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent 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.RenderNIP90ContentDiscoveryResponse
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status
import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent 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.RenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent 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.RenderTorrent
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment
import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack 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.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent 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.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
@@ -855,6 +863,20 @@ private fun RenderNoteRow(
RenderLongFormContent(baseNote, accountViewModel, nav) RenderLongFormContent(baseNote, accountViewModel, nav)
} }
is ThreadEvent -> {
RenderThread(
baseNote,
makeItShort,
canPreview,
quotesLeft,
unPackReply,
backgroundColor,
editState,
accountViewModel,
nav,
)
}
is CodeSnippetEvent -> { is CodeSnippetEvent -> {
RenderCodeSnippetEvent(baseNote) RenderCodeSnippetEvent(baseNote)
} }
@@ -1064,6 +1086,18 @@ private fun RenderNoteRow(
) )
} }
is CashuMintEvent -> {
RenderCashuMint(noteEvent)
}
is FedimintEvent -> {
RenderFedimint(noteEvent)
}
is MintRecommendationEvent -> {
RenderMintRecommendation(noteEvent)
}
is ClassifiedsEvent -> { is ClassifiedsEvent -> {
RenderClassifieds( RenderClassifieds(
noteEvent, noteEvent,
@@ -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<String>,
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),
)
}
}
}
}
@@ -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<Color>,
editState: State<GenericLoadable<EditState>>,
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,
)
}
@@ -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.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent 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.RenderChannelMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile
import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetHeaderForThread import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetHeaderForThread
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack 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.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent 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.RenderLiveActivityChatMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap
import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent 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.RenderPinListEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval 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.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent 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.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
@@ -567,6 +574,7 @@ private fun FullBleedNoteCompose(
when (noteEvent) { when (noteEvent) {
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote, accountViewModel) is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote, accountViewModel)
is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent, baseNote, accountViewModel) is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent, baseNote, accountViewModel)
is ThreadEvent -> RenderThreadHeaderForThread(noteEvent)
is WikiNoteEvent -> RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) is WikiNoteEvent -> RenderWikiHeaderForThread(noteEvent, accountViewModel, nav)
is ClassifiedsEvent -> RenderClassifiedsReaderForThread(noteEvent, baseNote, accountViewModel, nav) is ClassifiedsEvent -> RenderClassifiedsReaderForThread(noteEvent, baseNote, accountViewModel, nav)
is CodeSnippetEvent -> RenderCodeSnippetHeaderForThread(noteEvent) is CodeSnippetEvent -> RenderCodeSnippetHeaderForThread(noteEvent)
@@ -761,6 +769,12 @@ private fun FullBleedNoteCompose(
accountViewModel, accountViewModel,
nav, 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) { } else if (noteEvent is PollEvent) {
RenderPoll( RenderPoll(
note = baseNote, 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 @Preview
@Composable @Composable
private fun RenderWikiHeaderForThreadPreview() { private fun RenderWikiHeaderForThreadPreview() {
@@ -77,7 +77,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="zaps">Zapi</string> <string name="zaps">Zapi</string>
<string name="view_count">Števec vpogledov</string> <string name="view_count">Števec vpogledov</string>
<string name="boost">Pošlji naprej</string> <string name="boost">Pošlji naprej</string>
<string name="boosted">Poslano naprej</string> <string name="boosted">poslano naprej</string>
<string name="edited">posodobljeno</string> <string name="edited">posodobljeno</string>
<string name="edited_number">uredi #%1$s</string> <string name="edited_number">uredi #%1$s</string>
<string name="original">original</string> <string name="original">original</string>
@@ -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<ThreadEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
@@ -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)
@@ -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<Array<String>>,
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<ThreadEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
alt(ALT_DESCRIPTION)
title(title)
initializer()
}
}
}
@@ -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>): 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<String>) = urls.map { assemble(it) }
}
}
@@ -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<String>): 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)
}
}
@@ -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<Array<String>>,
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<String>,
network: NetworkType = NetworkType.MAINNET,
metadata: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CashuMintEvent>.() -> Unit = {},
) = eventTemplate(KIND, metadata, createdAt) {
alt(ALT_DESCRIPTION)
dTag(mintPubKey)
mintUrl(mintUrl)
nuts(nuts)
network(network)
initializer()
}
}
}
@@ -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<CashuMintEvent>.dTag(mintPubKey: String) = addUnique(arrayOf("d", mintPubKey))
fun TagArrayBuilder<CashuMintEvent>.mintUrl(url: String) = addUnique(MintUrlTag.assemble(url))
fun TagArrayBuilder<CashuMintEvent>.nuts(nuts: List<String>) = add(NutsTag.assemble(nuts))
fun TagArrayBuilder<CashuMintEvent>.network(network: NetworkType) = addUnique(NetworkTag.assemble(network))
@@ -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<String> = 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)
@@ -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<String>): List<String>? {
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<String>) = arrayOf(TAG_NAME, *nuts.toTypedArray())
}
}
@@ -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<Array<String>>,
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<String>,
modules: List<String>,
network: NetworkType = NetworkType.MAINNET,
metadata: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<FedimintEvent>.() -> Unit = {},
) = eventTemplate(KIND, metadata, createdAt) {
alt(ALT_DESCRIPTION)
dTag(federationId)
inviteCodes(inviteCodes)
modules(modules)
network(network)
initializer()
}
}
}
@@ -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<FedimintEvent>.dTag(federationId: String) = addUnique(arrayOf("d", federationId))
fun TagArrayBuilder<FedimintEvent>.inviteCodes(codes: List<String>) = addAll(MintUrlTag.assemble(codes))
fun TagArrayBuilder<FedimintEvent>.modules(modules: List<String>) = add(ModulesTag.assemble(modules))
fun TagArrayBuilder<FedimintEvent>.network(network: NetworkType) = addUnique(NetworkTag.assemble(network))
@@ -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<String> = mapNotNull(MintUrlTag::parse)
fun TagArray.modules(): List<String> = 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)
@@ -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<String>): List<String>? {
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<String>) = arrayOf(TAG_NAME, *modules.toTypedArray())
}
}
@@ -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<Array<String>>,
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<String> = emptyList(),
mintEventAddress: String? = null,
mintEventRelay: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<MintRecommendationEvent>.() -> Unit = {},
) = eventTemplate(KIND, review, createdAt) {
alt(ALT_DESCRIPTION)
dTag(mintIdentifier)
kTag(mintKind)
mintUrls.forEach { mintUrl(it) }
if (mintEventAddress != null) {
aTag(mintEventAddress, mintEventRelay)
}
initializer()
}
}
}
@@ -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<MintRecommendationEvent>.dTag(mintIdentifier: String) = addUnique(arrayOf("d", mintIdentifier))
fun TagArrayBuilder<MintRecommendationEvent>.kTag(kind: Int) = addUnique(arrayOf("k", kind.toString()))
fun TagArrayBuilder<MintRecommendationEvent>.mintUrl(url: String) = add(MintUrlTag.assemble(url))
fun TagArrayBuilder<MintRecommendationEvent>.aTag(
address: String,
relay: String? = null,
) = if (relay != null) {
add(arrayOf("a", address, relay))
} else {
add(arrayOf("a", address))
}
@@ -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<String> = 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<String> = filter { it.size >= 2 && it[0] == "a" }.map { it[1] }
@@ -156,12 +156,16 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.addressables.AddressableAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.addressables.AddressableAssertionEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.events.EventAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.events.EventAssertionEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.externalIds.ExternalIdAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.externalIds.ExternalIdAssertionEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent 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.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
@@ -230,6 +234,7 @@ class EventFactory {
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
CalendarRSVPEvent.KIND -> CalendarRSVPEvent(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) CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig)
CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig) CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig)
CashuSpendingHistoryEvent.KIND -> CashuSpendingHistoryEvent(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) EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig)
EphemeralChatListEvent.KIND -> EphemeralChatListEvent(id, pubKey, createdAt, tags, content, sig) EphemeralChatListEvent.KIND -> EphemeralChatListEvent(id, pubKey, createdAt, tags, content, sig)
ExternalIdentitiesEvent.KIND -> ExternalIdentitiesEvent(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) FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig) ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig)
FileServersEvent.KIND -> FileServersEvent(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) MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig)
MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig) MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig)
MeetingSpaceEvent.KIND -> MeetingSpaceEvent(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) MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig)
NamedSiteEvent.KIND -> NamedSiteEvent(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) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig)
StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.KIND -> TextNoteEvent(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) TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig)
TokenEvent.KIND -> TokenEvent(id, pubKey, createdAt, tags, content, sig) TokenEvent.KIND -> TokenEvent(id, pubKey, createdAt, tags, content, sig)
TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig) TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig)
@@ -245,95 +245,74 @@ class Url(
} }
/** /**
* Removes dot segments from the given path as stated in * Removes dot segments from the given path per
* ["RFC 3986, 5.2.4. Remove Dot Segments"](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4). * [RFC 3986 §5.2.4](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.
*/ */
fun removeDotSegments(path: String): String { 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 input = path
var output = "" val output = StringBuilder()
// While the input is not empty, loop the following steps.
while (input.isNotEmpty()) { while (input.isNotEmpty()) {
// If the input begins with a prefix of "../" or "./", then when {
// remove that prefix from the input; // A: Remove leading "../" or "./"
if (DOT_DOT_SLASH.find(input) != null) { input.startsWith("../") -> {
input = DOT_DOT_SLASH.replaceFirst(input, "") input = input.substring(3)
continue }
}
// If the input begins with a prefix of "/./" or "/.", where input.startsWith("./") -> {
// "." is a complete path segment, then replace that prefix input = input.substring(2)
// with "/" in the input. }
if (SLASH_DOT_SLASH.find(input) != null) {
input = SLASH_DOT_SLASH.replaceFirst(input, "/")
continue
}
// If the input begins with a prefix of "/../" or "/..", // B: Replace leading "/./" or "/." (end) with "/"
// where ".." is a complete path segment, then replace that input.startsWith("/./") -> {
// prefix with "/" in the input and remove the last segment input = "/" + input.substring(3)
// 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
}
// If the input consists only of "." or "..", then remove input == "/." -> {
// that from the input. input = "/"
if (DOT_OR_DOT_DOT.find(input) != null) { }
input = DOT_OR_DOT_DOT.replaceFirst(input, "")
continue
}
// Move the first path segment in the input buffer to the // C: Replace leading "/../" or "/.." (end) with "/" and drop last output segment
// end of the output, including the initial "/" character input.startsWith("/../") -> {
// (if any) and any subsequent characters up to, but not input = "/" + input.substring(4)
// including, the next "/" character or the end of the input. dropLastSegment(output)
val matchResult = MOVE_REGEX.find(input) }
if (matchResult != null) {
input = matchResult.groups["remaining"]!!.value input == "/.." -> {
output += matchResult.groups["firstsegment"]!!.value input = "/"
continue 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 * Removes the last segment and its preceding "/" from the output buffer.
* optionally the last slash. If the path doesn't contain slash, an empty string * For example, "/a/b" becomes "/a" and "/a" becomes "".
* 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.
*/ */
fun dropLastSegment( private fun dropLastSegment(output: StringBuilder) {
path: String, val lastSlash = output.lastIndexOf('/')
dropLastSlash: Boolean, if (lastSlash >= 0) {
): String { output.delete(lastSlash, output.length)
// The regular expression for the target. } else {
val m = if (dropLastSlash) DROP_LAST_SLASH_REGEX else DROP_LAST_SEGMENT_REGEX output.clear()
}
// Find the target. (Any inputs matches the pattern.)
m.find(path)
// Drop the target.
return m.replaceFirst(path, "")
} }
/** /**
@@ -416,34 +395,6 @@ class Url(
} }
companion object { 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("^(?<firstsegment>\\/?[^/]*)(?<remaining>.*)$")
private const val DEFAULT_SCHEME = "https" private const val DEFAULT_SCHEME = "https"
private val SCHEME_PORT_MAP: Map<String, Int> = private val SCHEME_PORT_MAP: Map<String, Int> =
mapOf( mapOf(
@@ -146,128 +146,132 @@ class DomainNameReader(
private set private set
/** /**
* Reads and parses the current string to make sure the domain name started where it was supposed to, * Validates the buffered domain-name prefix ([current]) that was accumulated before
* and the current domain name is correct. * the caller started reading from the stream. Updates label/dot counters, detects
* @return The next state to use after reading the current. * 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 { private fun readCurrent(): ReaderNextState {
if (current != null) { if (current == null) {
// Handles the case where the string is ".hello" startDomainName = buffer.length
if (current.length == 1 && isDot(current[0])) { return ReaderNextState.ValidDomainName
return ReaderNextState.InvalidDomainName }
} else if (current.length == 3 && current.isDotPercent()) {
// 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 return ReaderNextState.InvalidDomainName
} }
// The location where the domain name started. when {
startDomainName = buffer.length - current.length isDot(ch) -> {
// 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
dots++ dots++
currentLabelLength = 0 currentLabelLength = 0
} else if (curr == '[') { }
ch == '[' -> {
seenBracket = true seenBracket = true
numeric = false 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++ dots++
currentLabelLength = 0 currentLabelLength = 0
} else { } else {
numeric = false numeric = false
} }
index += 2 index += 2
} else if (isAllHexSoFar) { }
// if it's a valid character in the domain that is not numeric
if (!isHex(curr)) { isAllHexSoFar && !isHex(ch) -> {
numeric = false // Thought it was hex but this char isn't — reprocess as non-hex
isAllHexSoFar = false numeric = false
index-- // backtrack to rerun last character knowing it isn't hex. isAllHexSoFar = false
} index-- // backtrack to re-evaluate this char
} 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.
isAscii == lastWasAscii && (isAlpha(ch) || ch == '-' || !isAscii) -> {
// Valid domain character (same script as previous)
numeric = false numeric = false
lastWasAscii = isAscii 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 newStart = index
currentLabelLength = 0 resetDomainCounters()
topLevelLength = 0
numeric = true
dots = 0
// done = true
lastWasAscii = isAscii 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) index == 0 && ch in UrlDetector.CANNOT_BEGIN_URLS_WITH -> {
if (newStart >= current.length || buffer.toString() == ".") { // Invalid leading char — restart after it
return ReaderNextState.InvalidDomainName newStart = index + 1
resetDomainCounters()
} }
} }
} else { index++
startDomainName = buffer.length }
// 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 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 * 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. * if this is a valid domain name.
@@ -527,111 +531,66 @@ class DomainNameReader(
* @return Returns true if it's a valid ipv4 address * @return Returns true if it's a valid ipv4 address
*/ */
private fun isValidIpv4(testDomain: String): Boolean { private fun isValidIpv4(testDomain: String): Boolean {
var valid = false if (testDomain.isEmpty()) return 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..<length) {
val d: Char = testDomain[c]
if ((d < '0' || (d in ':'..<'A') || (d in 'G'..<'a') || d > '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..<length) {
val d: Char = testDomain[c]
if (d !in '0'..'7') {
return false
}
}
value = testDomain.substring(1).toLong(8)
} else { // decimal
// digit must be within ['0', '9']
for (c in 0..<length) {
val d: Char = testDomain[c]
if (d !in '0'..'9') {
return false
}
}
value = testDomain.toLong()
}
valid = value in MIN_NUMERIC_DOMAIN_VALUE..MAX_NUMERIC_DOMAIN_VALUE
} catch (_: NumberFormatException) {
valid = false
}
} else if (dots == 3) {
// Dotted decimal/hex/octal format
val parts: List<String> = splitByDot(testDomain)
valid = true
// check each part of the ip and make sure its valid. // Dotless format: http://2123123123123/path, http://0x8242343/aksdjf
var i = 0 if (dots == 0) {
while (i < parts.size && valid) { val value = parseNumericLiteral(testDomain) ?: return false
val part = parts[i] return value in MIN_NUMERIC_DOMAIN_VALUE..MAX_NUMERIC_DOMAIN_VALUE
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..<partLen) {
val d: Char = part[c]
if ((d < '0' || (d in ':'..<'A') || (d in 'G'..<'a') || d > '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..<partLen) {
val d: Char = part[c]
if (d !in '0'..'7') {
return false
}
}
parsedNum = part.substring(1)
base = 8
} else { // dotted decimal
// digit must be within ['0', '9']
for (c in 0..<partLen) {
val d: Char = part[c]
if (d !in '0'..'9') {
return false
}
}
parsedNum = part
base = 10
}
val section = // Dotted format: must have exactly 4 parts (3 dots)
if (parsedNum.isEmpty()) { if (dots != 3) return false
0
} else { val parts = splitByDot(testDomain)
try { for (part in parts) {
parsedNum.toInt(base) if (part.isEmpty()) return false
} catch (_: NumberFormatException) { val section = parseNumericLiteral(part) ?: return false
return false if (section !in MIN_IP_PART..MAX_IP_PART) return false
} }
} return true
if (section !in MIN_IP_PART..MAX_IP_PART) { }
valid = false
} /**
} else { * Parses a numeric literal that may be decimal, hexadecimal (0x prefix), or octal (0 prefix).
valid = false * Validates digit ranges before parsing to avoid exceptions.
} * @return The parsed value as a Long, or null if the string is not a valid numeric literal.
i++ */
} private fun parseNumericLiteral(s: String): Long? {
if (s.isEmpty()) return 0L
val digits: String
val base: Int
if (s.length > 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
}
} }
/** /**
@@ -315,81 +315,75 @@ class UrlDetector(
while (!reader.eof()) { while (!reader.eof()) {
val curr = reader.read() val curr = reader.read()
// if we match a slash, look for a second one.
if (curr == '/') { if (curr == '/') {
buffer.append(curr) buffer.append(curr)
if (numSlashes == 1) { if (numSlashes == 1) {
// return only if its an approved protocol. This can be expanded to allow others // Two slashes found — check for a valid scheme like "http://"
val schemeStartIndex: Int = findValidSchemeStartIndex(buffer.toString()) val schemeStartIndex = findValidSchemeStartIndex(buffer.toString())
if (schemeStartIndex >= 0) { if (schemeStartIndex >= 0) {
buffer.deleteRange(0, schemeStartIndex) buffer.deleteRange(0, schemeStartIndex)
currentUrlMarker.setIndex(UrlPart.SCHEME, 0) currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
return true return true
} else {
return false
} }
return false
} }
numSlashes++ numSlashes++
} else if (curr == ' ') { } else if (curr == ' ') {
// if we find a space or end of input, then nothing found.
buffer.append(curr) buffer.append(curr)
return false return false
} else if (curr == '[') { // if we're starting to see an ipv6 address } else if (curr == '[') {
reader.goBack() // unread the '[', so that we can start looking for ipv6 // Start of IPv6 — unread and let the caller handle it
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
}
reader.goBack() 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 return false
} }
private fun findValidSchemeStartIndex(optionalScheme: String): Int { /**
val optionalSchemeLowercase = optionalScheme.lowercase() * Attempts to match the buffer as a scheme without slashes (e.g. "nostr:").
return VALID_SCHEMES * If that fails, treats the content as a potential username:password.
.filter(optionalSchemeLowercase::endsWith) */
.map(optionalSchemeLowercase::lastIndexOf) private fun trySchemeNoSlashesOrUserPass(): Boolean {
.firstOrNull() ?: -1 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 { private fun findValidSchemeStartIndex(optionalScheme: String): Int = findSchemeSuffix(optionalScheme, VALID_SCHEMES)
val optionalSchemeLowercase = optionalScheme.lowercase()
return VALID_SCHEMES_NO_SLASHES private fun findValidSchemeNoSlashesStartIndex(optionalScheme: String): Int = findSchemeSuffix(optionalScheme, VALID_SCHEMES_NO_SLASHES)
.filter(optionalSchemeLowercase::endsWith)
.map(optionalSchemeLowercase::lastIndexOf) /**
.firstOrNull() ?: -1 * 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<String>,
): 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 var endsOnASlash = true
while (!reader.eof()) { while (!reader.eof()) {
// read the next char
val curr = reader.read() val curr = reader.read()
if (curr == ' ') { if (curr == ' ') {
// if end of state and we got here, then the url is valid return readEnd(if (isPathValid(endsOnASlash)) ReadEndState.ValidUrl else ReadEndState.InvalidUrl)
// 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)
}
} }
// append the char
buffer.append(curr) buffer.append(curr)
// now see if we move to another state. if (curr == '?') return readQueryString()
if (curr == '?') { if (curr == '#') return readFragment()
// if ? read query string
return readQueryString()
} else if (curr == '#') {
// if # read the fragment
return readFragment()
}
endsOnASlash = curr == '/' endsOnASlash = curr == '/'
} }
// end of input then this url is good. return readEnd(if (isPathValid(endsOnASlash)) ReadEndState.ValidUrl else ReadEndState.InvalidUrl)
// 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 / * A path is valid if the URL has additional context (scheme, port, credentials)
if ( * or if it's not an ambiguous single-level label like "word/word".
currentUrlMarker.hasScheme() || */
private fun isPathValid(endsOnASlash: Boolean): Boolean =
currentUrlMarker.hasScheme() ||
currentUrlMarker.hasPort() || currentUrlMarker.hasPort() ||
currentUrlMarker.hasUsernamePassword() || currentUrlMarker.hasUsernamePassword() ||
!isSingleLevelLabel || !isSingleLevelLabel ||
endsOnASlash 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. * The url has been read to here. Remember the url if its valid, and reset state.