From 776bc6d36f21fe9d80c6223f161f89fe32c5e60f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 22:18:02 +0000 Subject: [PATCH 1/4] feat: implement NIP-5A static website event rendering Add Quartz protocol support for NIP-5A Pubkey Static Websites with RootSiteEvent (kind 15128) and NamedSiteEvent (kind 35128), including tag parsers for path, server, title, description, and source. Wire up rendering in NoteCompose and ThreadFeedView to display site metadata. https://claude.ai/code/session_01XTmqQ9QatUA7aPCWt7NHNt --- .../amethyst/model/LocalCache.kt | 16 ++ .../amethyst/ui/note/NoteCompose.kt | 12 + .../amethyst/ui/note/types/StaticWebsite.kt | 207 ++++++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 8 + amethyst/src/main/res/values/strings.xml | 6 + .../nip5aStaticWebsites/NamedSiteEvent.kt | 78 +++++++ .../nip5aStaticWebsites/RootSiteEvent.kt | 73 ++++++ .../nip5aStaticWebsites/TagArrayBuilderExt.kt | 39 ++++ .../quartz/nip5aStaticWebsites/TagArrayExt.kt | 38 ++++ .../tags/DescriptionTag.kt | 39 ++++ .../nip5aStaticWebsites/tags/PathTag.kt | 52 +++++ .../nip5aStaticWebsites/tags/ServerTag.kt | 41 ++++ .../nip5aStaticWebsites/tags/SourceTag.kt | 39 ++++ .../nip5aStaticWebsites/tags/TitleTag.kt | 39 ++++ .../quartz/utils/EventFactory.kt | 4 + 15 files changed, 691 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/DescriptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/PathTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/ServerTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/SourceTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/TitleTag.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 75ea92e42..4a7375179 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -173,6 +173,8 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent @@ -1171,6 +1173,18 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( + event: RootSiteEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: NamedSiteEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( event: ChannelListEvent, relay: NormalizedRelayUrl?, @@ -3178,6 +3192,8 @@ object LocalCache : ILocalCache, ICacheProvider { is GitReplyEvent -> consume(event, relay, wasVerified) is GitPatchEvent -> consume(event, relay, wasVerified) is GitRepositoryEvent -> consume(event, relay, wasVerified) + is RootSiteEvent -> consume(event, relay, wasVerified) + is NamedSiteEvent -> consume(event, relay, wasVerified) is ChessGameEvent -> consume(event, relay, wasVerified) is RelayFeedsListEvent -> consume(event, relay, wasVerified) is JesterEvent -> consume(event, relay, wasVerified) 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 6e9832723..b1d2b820a 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 @@ -137,6 +137,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status +import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll @@ -151,6 +152,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember 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.RenderTorrent @@ -251,6 +253,8 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent @@ -963,6 +967,14 @@ private fun RenderNoteRow( RenderGitRepositoryEvent(baseNote, accountViewModel, nav) } + is RootSiteEvent -> { + RenderRootSiteEvent(baseNote, accountViewModel, nav) + } + + is NamedSiteEvent -> { + RenderNamedSiteEvent(baseNote, accountViewModel, nav) + } + is GitPatchEvent -> { RenderGitPatchEvent( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt new file mode 100644 index 000000000..316e54847 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt @@ -0,0 +1,207 @@ +/* + * 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.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag + +@Composable +fun RenderRootSiteEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = baseNote.event as? RootSiteEvent ?: return + + RenderStaticWebsite( + title = event.title(), + description = event.description(), + source = event.source(), + servers = event.servers(), + paths = event.paths(), + identifier = null, + ) +} + +@Composable +fun RenderNamedSiteEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = baseNote.event as? NamedSiteEvent ?: return + + RenderStaticWebsite( + title = event.title(), + description = event.description(), + source = event.source(), + servers = event.servers(), + paths = event.paths(), + identifier = event.identifier(), + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RenderStaticWebsite( + title: String?, + description: String?, + source: String?, + servers: List, + paths: List, + identifier: String?, +) { + Row( + modifier = + Modifier + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ).padding(Size10dp), + ) { + Column { + val displayTitle = + title + ?: identifier + ?: stringRes(id = R.string.nsite_root_site) + + Text( + text = stringRes(id = R.string.nsite_title, displayTitle), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + description?.let { + Text( + text = it, + modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + HorizontalDivider(thickness = DividerThickness) + + source?.let { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringRes(id = R.string.nsite_source), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + ClickableUrl( + url = it, + urlText = it.removePrefix("https://").removePrefix("http://"), + ) + } + } + + if (servers.isNotEmpty()) { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringRes(id = R.string.nsite_servers), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + Column { + servers.forEach { server -> + ClickableUrl( + url = server, + urlText = server.removePrefix("https://").removePrefix("http://"), + ) + } + } + } + } + + if (paths.isNotEmpty()) { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringRes(id = R.string.nsite_files, paths.size), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + FlowRow( + modifier = Modifier.fillMaxWidth().padding(top = Size5dp), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + paths.take(10).forEach { path -> + Text( + text = path.path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (paths.size > 10) { + Text( + text = stringRes(id = R.string.nsite_more_files, paths.size - 10), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + } + } +} 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 4f855ce20..65859ae5f 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 @@ -160,6 +160,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight 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.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval @@ -171,6 +172,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember +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.RenderTorrent @@ -259,6 +261,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -678,6 +682,10 @@ private fun FullBleedNoteCompose( RenderFhirResource(baseNote, accountViewModel, nav) } else if (noteEvent is GitRepositoryEvent) { RenderGitRepositoryEvent(baseNote, accountViewModel, nav) + } else if (noteEvent is RootSiteEvent) { + RenderRootSiteEvent(baseNote, accountViewModel, nav) + } else if (noteEvent is NamedSiteEvent) { + RenderNamedSiteEvent(baseNote, accountViewModel, nav) } else if (noteEvent is InteractiveStoryBaseEvent) { RenderInteractiveStory( baseNote, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 835a4e4c1..2fe6f2289 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1488,6 +1488,12 @@ Git Repository: %1$s Web: Clone: + Static Website: %1$s + Root Site + Source: + Servers: + %1$d files + +%1$d more OTS: %1$s Timestamp Proof diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt new file mode 100644 index 000000000..3d0a198eb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.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.nip5aStaticWebsites + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +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.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class NamedSiteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun paths() = tags.sitePaths() + + fun servers() = tags.siteServers() + + fun title() = tags.siteTitle() + + fun description() = tags.siteDescription() + + fun source() = tags.siteSource() + + fun identifier() = dTag() + + companion object { + const val KIND = 35128 + const val ALT_DESCRIPTION = "Named Static Website" + + fun build( + identifier: String, + paths: List, + servers: List = emptyList(), + title: String? = null, + description: String? = null, + source: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + dTag(identifier) + sitePaths(paths) + if (servers.isNotEmpty()) siteServers(servers) + title?.let { siteTitle(it) } + description?.let { siteDescription(it) } + source?.let { siteSource(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt new file mode 100644 index 000000000..ba371802e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt @@ -0,0 +1,73 @@ +/* + * 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.nip5aStaticWebsites + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +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.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class RootSiteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun paths() = tags.sitePaths() + + fun servers() = tags.siteServers() + + fun title() = tags.siteTitle() + + fun description() = tags.siteDescription() + + fun source() = tags.siteSource() + + companion object { + const val KIND = 15128 + const val ALT_DESCRIPTION = "Static Website" + + fun build( + paths: List, + servers: List = emptyList(), + title: String? = null, + description: String? = null, + source: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + sitePaths(paths) + if (servers.isNotEmpty()) siteServers(servers) + title?.let { siteTitle(it) } + description?.let { siteDescription(it) } + source?.let { siteSource(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt new file mode 100644 index 000000000..3da5f0964 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/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.nip5aStaticWebsites + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.DescriptionTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.TitleTag + +fun TagArrayBuilder.sitePaths(paths: List) = addAll(PathTag.assemble(paths)) + +fun TagArrayBuilder.siteServers(servers: List) = addAll(ServerTag.assemble(servers)) + +fun TagArrayBuilder.siteTitle(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.siteDescription(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.siteSource(url: String) = addUnique(SourceTag.assemble(url)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt new file mode 100644 index 000000000..f35389389 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt @@ -0,0 +1,38 @@ +/* + * 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.nip5aStaticWebsites + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.DescriptionTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.TitleTag + +fun TagArray.sitePaths() = mapNotNull(PathTag::parse) + +fun TagArray.siteServers() = mapNotNull(ServerTag::parse) + +fun TagArray.siteTitle() = firstNotNullOfOrNull(TitleTag::parse) + +fun TagArray.siteDescription() = firstNotNullOfOrNull(DescriptionTag::parse) + +fun TagArray.siteSource() = firstNotNullOfOrNull(SourceTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/DescriptionTag.kt new file mode 100644 index 000000000..5bd904f6a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/DescriptionTag.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.nip5aStaticWebsites.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + 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(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/PathTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/PathTag.kt new file mode 100644 index 000000000..7b817c469 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/PathTag.kt @@ -0,0 +1,52 @@ +/* + * 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.nip5aStaticWebsites.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +@Stable +class PathTag( + val path: String, + val hash: String, +) { + companion object { + const val TAG_NAME = "path" + + fun parse(tag: Array): PathTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[2].isNotEmpty()) { return null } + return PathTag(tag[1], tag[2]) + } + + fun assemble( + path: String, + hash: String, + ) = arrayOf(TAG_NAME, path, hash) + + fun assemble(pathTag: PathTag) = assemble(pathTag.path, pathTag.hash) + + fun assemble(paths: List) = paths.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/ServerTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/ServerTag.kt new file mode 100644 index 000000000..b0229cebd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/ServerTag.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.nip5aStaticWebsites.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ServerTag { + companion object { + const val TAG_NAME = "server" + + 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/nip5aStaticWebsites/tags/SourceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/SourceTag.kt new file mode 100644 index 000000000..ff1d43174 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/SourceTag.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.nip5aStaticWebsites.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class SourceTag { + companion object { + const val TAG_NAME = "source" + + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/TitleTag.kt new file mode 100644 index 000000000..16df2d969 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/TitleTag.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.nip5aStaticWebsites.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + 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(title: String) = arrayOf(TAG_NAME, title) + } +} 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 9a2898a4b..a377b9a33 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -125,6 +125,8 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent @@ -303,6 +305,7 @@ class EventFactory { MeetingSpaceEvent.KIND -> MeetingSpaceEvent(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) NNSEvent.KIND -> NNSEvent(id, pubKey, createdAt, tags, content, sig) NipTextEvent.KIND -> NipTextEvent(id, pubKey, createdAt, tags, content, sig) NutzapEvent.KIND -> NutzapEvent(id, pubKey, createdAt, tags, content, sig) @@ -342,6 +345,7 @@ class EventFactory { RelayMonitorEvent.KIND -> RelayMonitorEvent(id, pubKey, createdAt, tags, content, sig) RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) + RootSiteEvent.KIND -> RootSiteEvent(id, pubKey, createdAt, tags, content, sig) RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) From 07f16c2efe1db1b7676986e2d076598a6521746a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 00:25:09 +0000 Subject: [PATCH 2/4] refactor: redesign NIP-5A rendering as website preview card Replace the file-listing style with a UrlPreviewCard-like layout showing nsite host URL, title, description, and optional favicon. Remove path rendering and unused string resources. https://claude.ai/code/session_01XTmqQ9QatUA7aPCWt7NHNt --- .../amethyst/ui/note/types/StaticWebsite.kt | 224 +++++++----------- amethyst/src/main/res/values/strings.xml | 5 - 2 files changed, 89 insertions(+), 140 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt index 316e54847..cfd6531ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt @@ -20,38 +20,29 @@ */ 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.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier +import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent -import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag @Composable fun RenderRootSiteEvent( @@ -61,13 +52,18 @@ fun RenderRootSiteEvent( ) { val event = baseNote.event as? RootSiteEvent ?: return - RenderStaticWebsite( + val npub = + remember(event.pubKey) { + event.pubKey.hexToByteArray().toNpub() + } + + RenderStaticWebsitePreview( title = event.title(), description = event.description(), - source = event.source(), - servers = event.servers(), - paths = event.paths(), + npub = npub, identifier = null, + faviconHash = event.paths().firstOrNull { it.path == "/favicon.ico" }?.hash, + servers = event.servers(), ) } @@ -79,129 +75,87 @@ fun RenderNamedSiteEvent( ) { val event = baseNote.event as? NamedSiteEvent ?: return - RenderStaticWebsite( + val npub = + remember(event.pubKey) { + event.pubKey.hexToByteArray().toNpub() + } + + RenderStaticWebsitePreview( title = event.title(), description = event.description(), - source = event.source(), - servers = event.servers(), - paths = event.paths(), + npub = npub, identifier = event.identifier(), + faviconHash = event.paths().firstOrNull { it.path == "/favicon.ico" }?.hash, + servers = event.servers(), ) } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun RenderStaticWebsite( +private fun RenderStaticWebsitePreview( title: String?, description: String?, - source: String?, - servers: List, - paths: List, + npub: String, identifier: String?, + faviconHash: String?, + servers: List, ) { - Row( - modifier = - Modifier - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ).padding(Size10dp), - ) { - Column { - val displayTitle = - title - ?: identifier - ?: stringRes(id = R.string.nsite_root_site) - - Text( - text = stringRes(id = R.string.nsite_title, displayTitle), - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - - description?.let { - Text( - text = it, - modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - HorizontalDivider(thickness = DividerThickness) - - source?.let { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringRes(id = R.string.nsite_source), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - ClickableUrl( - url = it, - urlText = it.removePrefix("https://").removePrefix("http://"), - ) - } - } - - if (servers.isNotEmpty()) { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringRes(id = R.string.nsite_servers), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - Column { - servers.forEach { server -> - ClickableUrl( - url = server, - urlText = server.removePrefix("https://").removePrefix("http://"), - ) - } - } - } - } - - if (paths.isNotEmpty()) { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringRes(id = R.string.nsite_files, paths.size), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - FlowRow( - modifier = Modifier.fillMaxWidth().padding(top = Size5dp), - horizontalArrangement = Arrangement.spacedBy(Size5dp), - verticalArrangement = Arrangement.spacedBy(Size5dp), - ) { - paths.take(10).forEach { path -> - Text( - text = path.path, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (paths.size > 10) { - Text( - text = stringRes(id = R.string.nsite_more_files, paths.size - 10), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - } - } + val nsiteHost = + if (identifier != null) { + "$npub.$identifier.nsite.lol" + } else { + "$npub.nsite.lol" } + + val nsiteUrl = "https://$nsiteHost" + + val faviconUrl = + if (faviconHash != null && servers.isNotEmpty()) { + "${servers.first()}/$faviconHash" + } else { + null + } + + val uri = LocalUriHandler.current + + Column( + modifier = MaterialTheme.colorScheme.innerPostModifier, + ) { + faviconUrl?.let { + AsyncImage( + model = it, + contentDescription = title, + modifier = previewCardImageModifier, + ) + } + + Text( + text = nsiteHost, + style = MaterialTheme.typography.bodySmall, + modifier = MaxWidthWithHorzPadding, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = title ?: identifier ?: stringRes(id = R.string.nsite_root_site), + style = MaterialTheme.typography.bodyMedium, + modifier = MaxWidthWithHorzPadding, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = MaxWidthWithHorzPadding, + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = DoubleVertSpacer) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2fe6f2289..6de478ff4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1488,12 +1488,7 @@ Git Repository: %1$s Web: Clone: - Static Website: %1$s Root Site - Source: - Servers: - %1$d files - +%1$d more OTS: %1$s Timestamp Proof From 869628ae16fd7ff4436bc282ca0e960d72f27260 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 00:37:59 +0000 Subject: [PATCH 3/4] Revert "refactor: redesign NIP-5A rendering as website preview card" This reverts commit 07f16c2efe1db1b7676986e2d076598a6521746a. --- .../amethyst/ui/note/types/StaticWebsite.kt | 214 +++++++++++------- amethyst/src/main/res/values/strings.xml | 5 + 2 files changed, 135 insertions(+), 84 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt index cfd6531ff..316e54847 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt @@ -20,29 +20,38 @@ */ 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.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow -import coil3.compose.AsyncImage +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding -import com.vitorpamplona.amethyst.ui.theme.innerPostModifier -import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag @Composable fun RenderRootSiteEvent( @@ -52,18 +61,13 @@ fun RenderRootSiteEvent( ) { val event = baseNote.event as? RootSiteEvent ?: return - val npub = - remember(event.pubKey) { - event.pubKey.hexToByteArray().toNpub() - } - - RenderStaticWebsitePreview( + RenderStaticWebsite( title = event.title(), description = event.description(), - npub = npub, - identifier = null, - faviconHash = event.paths().firstOrNull { it.path == "/favicon.ico" }?.hash, + source = event.source(), servers = event.servers(), + paths = event.paths(), + identifier = null, ) } @@ -75,87 +79,129 @@ fun RenderNamedSiteEvent( ) { val event = baseNote.event as? NamedSiteEvent ?: return - val npub = - remember(event.pubKey) { - event.pubKey.hexToByteArray().toNpub() - } - - RenderStaticWebsitePreview( + RenderStaticWebsite( title = event.title(), description = event.description(), - npub = npub, - identifier = event.identifier(), - faviconHash = event.paths().firstOrNull { it.path == "/favicon.ico" }?.hash, + source = event.source(), servers = event.servers(), + paths = event.paths(), + identifier = event.identifier(), ) } +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun RenderStaticWebsitePreview( +private fun RenderStaticWebsite( title: String?, description: String?, - npub: String, - identifier: String?, - faviconHash: String?, + source: String?, servers: List, + paths: List, + identifier: String?, ) { - val nsiteHost = - if (identifier != null) { - "$npub.$identifier.nsite.lol" - } else { - "$npub.nsite.lol" - } - - val nsiteUrl = "https://$nsiteHost" - - val faviconUrl = - if (faviconHash != null && servers.isNotEmpty()) { - "${servers.first()}/$faviconHash" - } else { - null - } - - val uri = LocalUriHandler.current - - Column( - modifier = MaterialTheme.colorScheme.innerPostModifier, + Row( + modifier = + Modifier + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ).padding(Size10dp), ) { - faviconUrl?.let { - AsyncImage( - model = it, - contentDescription = title, - modifier = previewCardImageModifier, - ) - } + Column { + val displayTitle = + title + ?: identifier + ?: stringRes(id = R.string.nsite_root_site) - Text( - text = nsiteHost, - style = MaterialTheme.typography.bodySmall, - modifier = MaxWidthWithHorzPadding, - color = Color.Gray, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - Text( - text = title ?: identifier ?: stringRes(id = R.string.nsite_root_site), - style = MaterialTheme.typography.bodyMedium, - modifier = MaxWidthWithHorzPadding, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - description?.let { Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = MaxWidthWithHorzPadding, - color = Color.Gray, - maxLines = 3, + text = stringRes(id = R.string.nsite_title, displayTitle), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), ) - } - Spacer(modifier = DoubleVertSpacer) + description?.let { + Text( + text = it, + modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + HorizontalDivider(thickness = DividerThickness) + + source?.let { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringRes(id = R.string.nsite_source), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + ClickableUrl( + url = it, + urlText = it.removePrefix("https://").removePrefix("http://"), + ) + } + } + + if (servers.isNotEmpty()) { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringRes(id = R.string.nsite_servers), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + Column { + servers.forEach { server -> + ClickableUrl( + url = server, + urlText = server.removePrefix("https://").removePrefix("http://"), + ) + } + } + } + } + + if (paths.isNotEmpty()) { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringRes(id = R.string.nsite_files, paths.size), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + FlowRow( + modifier = Modifier.fillMaxWidth().padding(top = Size5dp), + horizontalArrangement = Arrangement.spacedBy(Size5dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + paths.take(10).forEach { path -> + Text( + text = path.path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (paths.size > 10) { + Text( + text = stringRes(id = R.string.nsite_more_files, paths.size - 10), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6de478ff4..2fe6f2289 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1488,7 +1488,12 @@ Git Repository: %1$s Web: Clone: + Static Website: %1$s Root Site + Source: + Servers: + %1$d files + +%1$d more OTS: %1$s Timestamp Proof From 104b836bbe75ebcf58cfd6a8787d5fe2ebb5db8c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 00:41:00 +0000 Subject: [PATCH 4/4] refactor: remove path listing from NIP-5A rendering Users should click through to view the website in the browser rather than seeing a list of file paths in the note card. https://claude.ai/code/session_01XTmqQ9QatUA7aPCWt7NHNt --- .../amethyst/ui/note/types/StaticWebsite.kt | 43 ------------------- amethyst/src/main/res/values/strings.xml | 4 +- 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt index 316e54847..c3f120226 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt @@ -21,10 +21,7 @@ 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 @@ -51,7 +48,6 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent -import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag @Composable fun RenderRootSiteEvent( @@ -66,7 +62,6 @@ fun RenderRootSiteEvent( description = event.description(), source = event.source(), servers = event.servers(), - paths = event.paths(), identifier = null, ) } @@ -84,19 +79,16 @@ fun RenderNamedSiteEvent( description = event.description(), source = event.source(), servers = event.servers(), - paths = event.paths(), identifier = event.identifier(), ) } -@OptIn(ExperimentalLayoutApi::class) @Composable private fun RenderStaticWebsite( title: String?, description: String?, source: String?, servers: List, - paths: List, identifier: String?, ) { Row( @@ -167,41 +159,6 @@ private fun RenderStaticWebsite( } } } - - if (paths.isNotEmpty()) { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringRes(id = R.string.nsite_files, paths.size), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - FlowRow( - modifier = Modifier.fillMaxWidth().padding(top = Size5dp), - horizontalArrangement = Arrangement.spacedBy(Size5dp), - verticalArrangement = Arrangement.spacedBy(Size5dp), - ) { - paths.take(10).forEach { path -> - Text( - text = path.path, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (paths.size > 10) { - Text( - text = stringRes(id = R.string.nsite_more_files, paths.size - 10), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - } - } } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2fe6f2289..c11be6263 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1492,9 +1492,7 @@ Root Site Source: Servers: - %1$d files - +%1$d more - OTS: %1$s +OTS: %1$s Timestamp Proof There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time.