Merge pull request #3036 from vitorpamplona/claude/affectionate-gauss-UWDJ4

Add NIP-82 Software Applications support with dedicated feed
This commit is contained in:
Vitor Pamplona
2026-05-23 12:00:08 -04:00
committed by GitHub
18 changed files with 1214 additions and 0 deletions
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.Publ
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfoNip66FilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssembler
@@ -122,6 +123,7 @@ class RelaySubscriptionsCoordinator(
val nestRoomLiveness = NestRoomLivenessAssembler(client)
val longs = LongsFilterAssembler(client)
val articles = ArticlesFilterAssembler(client)
val softwareApps = SoftwareAppsFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
val profileBadges = ProfileBadgesFilterAssembler(client)
val browseEmojiSets = BrowseEmojiSetsFilterAssembler(client)
@@ -154,6 +156,7 @@ class RelaySubscriptionsCoordinator(
nestRoomLiveness,
longs,
articles,
softwareApps,
badges,
profileBadges,
browseEmojiSets,
@@ -182,6 +182,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen
@@ -259,6 +260,7 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarReminderSettings> { CalendarReminderSettingsScreen(nav) }
@@ -50,6 +50,7 @@ enum class NavBarItem {
COMMUNITIES,
ARTICLES,
PICTURES,
SOFTWARE_APPS,
CALENDARS,
CALENDAR_COLLECTIONS,
SHORTS,
@@ -201,6 +202,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Photo,
resolveRoute = { Route.Pictures },
),
NavBarItem.SOFTWARE_APPS to
NavBarItemDef(
id = NavBarItem.SOFTWARE_APPS,
labelRes = R.string.software_apps,
icon = MaterialSymbols.Apps,
resolveRoute = { Route.SoftwareApps },
),
NavBarItem.CALENDARS to
NavBarItemDef(
id = NavBarItem.CALENDARS,
@@ -334,6 +342,7 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.COMMUNITIES,
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.SOFTWARE_APPS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SHORTS,
@@ -81,6 +81,8 @@ sealed class Route {
@Serializable object Pictures : Route()
@Serializable object SoftwareApps : Route()
@Serializable object Calendars : Route()
@Serializable object CalendarCollections : Route()
@@ -167,6 +167,9 @@ 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.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderThread
@@ -212,6 +215,9 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
@@ -256,6 +262,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -870,6 +877,23 @@ private fun RenderNoteRow(
RenderAppDefinition(baseNote, accountViewModel, nav)
}
is SoftwareApplicationEvent -> {
RenderSoftwareApplication(baseNote, accountViewModel, nav)
}
is SoftwareAssetEvent -> {
RenderSoftwareAsset(baseNote, accountViewModel, nav)
}
is ReleaseArtifactSetEvent -> {
// kind 30063 is used by both NIP-51 (ReleaseArtifactSet) and NIP-82 (SoftwareRelease).
// The EventFactory always materializes the NIP-51 class; dispatch to NIP-82 when the
// tag signature matches.
if (noteEvent.isNip82SoftwareRelease()) {
RenderSoftwareRelease(baseNote, accountViewModel, nav)
}
}
is AttestationEvent -> {
RenderAttestation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
@@ -0,0 +1,539 @@
/*
* 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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.produceState
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.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LinkIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.asSoftwareRelease
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
/**
* NIP-82 kind 32267 — Software Application card. Renders icon, name, summary,
* a horizontal screenshot strip, hashtag/platform chips, and quick links.
*/
@Composable
fun RenderSoftwareApplication(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? SoftwareApplicationEvent ?: return
val uri = LocalUriHandler.current
val icon = remember(event) { event.icon() }
val name = remember(event) { event.name() ?: event.appId().orEmpty() }
val summary = remember(event) { event.summary() }
val images = remember(event) { event.images() }
val topics = remember(event) { event.topics() }
val platforms = remember(event) { event.platforms() }
val website = remember(event) { event.url() }
val repo = remember(event) { event.repository() }
val license = remember(event) { event.license() }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(top = Size5dp)
.clip(QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
.padding(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(56.dp)
.clip(RoundedCornerShape(12.dp))
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(12.dp)),
) {
icon?.let {
AsyncImage(
model = it,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(56.dp),
)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
if (name.isNotBlank()) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
summary?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
if (images.isNotEmpty()) {
Spacer(StdVertSpacer)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.height(180.dp),
) {
items(images) { imageUrl ->
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier =
Modifier
.height(180.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(8.dp)),
)
}
}
}
if (platforms.isNotEmpty() || topics.isNotEmpty() || license != null) {
Spacer(StdVertSpacer)
ChipFlowRow(platforms = platforms, topics = topics, license = license)
}
if (website != null || repo != null) {
Spacer(StdVertSpacer)
Column {
website?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
ClickableTextPrimary(
text = it.removePrefix("https://").removePrefix("http://"),
onClick = { runCatching { uri.openUri(it) } },
modifier = Modifier.padding(start = 5.dp),
)
}
}
repo?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
ClickableTextPrimary(
text = stringRes(R.string.nip82_repository_label, it.removePrefix("https://").removePrefix("http://")),
onClick = { runCatching { uri.openUri(it) } },
modifier = Modifier.padding(start = 5.dp),
)
}
}
}
}
}
}
@Composable
private fun ChipFlowRow(
platforms: List<String>,
topics: List<String>,
license: String?,
) {
// FlowRow is in compose foundation but we use a simple LazyRow to keep this compatible.
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(platforms) { Chip(it) }
items(topics) { Chip("#$it") }
license?.let { item { Chip(it, tint = MaterialTheme.colorScheme.secondaryContainer) } }
}
}
@Composable
private fun Chip(
text: String,
tint: Color = MaterialTheme.colorScheme.surfaceVariant,
) {
Box(
Modifier
.clip(RoundedCornerShape(12.dp))
.background(tint)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(text = text, fontSize = 11.sp, style = MaterialTheme.typography.labelSmall)
}
}
/**
* NIP-82 kind 30063 — Software Release card. Renders the version and channel as
* a header, the aggregated platforms as chips, the release notes (markdown), and
* a count of bundled assets.
*/
@Composable
fun RenderSoftwareRelease(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
// kind 30063 is shared between NIP-51 ReleaseArtifactSetEvent and NIP-82 SoftwareReleaseEvent;
// EventFactory always returns the NIP-51 class, so we re-parse the same tag array as the
// NIP-82 form when the tag signature matches.
val rawEvent = note.event ?: return
val event: SoftwareReleaseEvent =
if (rawEvent is SoftwareReleaseEvent) {
rawEvent
} else if (rawEvent.isNip82SoftwareRelease()) {
rawEvent.asSoftwareRelease()
} else {
return
}
val appId = remember(event) { event.appId() }
val version = remember(event) { event.version() }
val channel = remember(event) { event.channel() }
val assets = remember(event) { event.assets() }
val notes = remember(event) { event.content }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(top = Size5dp)
.clip(QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
.padding(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
appId?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
version?.let {
Text(
text = stringRes(R.string.nip82_version_label, it),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
}
channel?.let {
Chip(it.uppercase(), tint = MaterialTheme.colorScheme.tertiaryContainer)
}
}
if (notes.isNotBlank()) {
Spacer(StdVertSpacer)
Text(
text = notes,
style = MaterialTheme.typography.bodyMedium,
maxLines = 6,
overflow = TextOverflow.Ellipsis,
)
}
if (assets.isNotEmpty()) {
Spacer(StdVertSpacer)
val n = assets.size
Text(
text = pluralStringResource(R.plurals.nip82_assets_count, n, n),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(Modifier.height(6.dp))
BundledAssetsList(
assetIds = assets.map { it.eventId },
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun BundledAssetsList(
assetIds: List<String>,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
assetIds.forEach { id ->
key(id) {
LoadAssetNote(id, accountViewModel) { assetNote ->
if (assetNote != null) {
SoftwareAssetRow(assetNote, accountViewModel)
}
}
}
}
}
}
@Composable
private fun LoadAssetNote(
eventId: String,
accountViewModel: AccountViewModel,
content: @Composable (Note?) -> Unit,
) {
val note by produceState<Note?>(initialValue = accountViewModel.getNoteIfExists(eventId), eventId) {
if (value == null) {
value = accountViewModel.checkGetOrCreateNote(eventId)
}
}
content(note)
}
@Composable
private fun SoftwareAssetRow(
note: Note,
accountViewModel: AccountViewModel,
) {
// Subscribe so a missing asset event is fetched from the relay and we recompose when it arrives.
val eventState = observeNoteEvent<SoftwareAssetEvent>(note, accountViewModel)
val event = eventState.value ?: return
val uri = LocalUriHandler.current
val version = remember(event) { event.version() }
val mimeType = remember(event) { event.mimeType() }
val sizeBytes = remember(event) { event.sizeInBytes() }
val platforms = remember(event) { event.platforms() }
val downloadUrl = remember(event) { event.url() }
val variant = remember(event) { event.variant() }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(8.dp))
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
mimeType?.let {
Text(
text = prettyMime(it),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.width(6.dp))
}
version?.let {
Text(
text = stringRes(R.string.nip82_version_label, it),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
variant?.let {
Spacer(Modifier.width(6.dp))
Text(
text = "· $it",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
sizeBytes?.let {
Spacer(Modifier.width(6.dp))
Text(
text = "· ${formatBytes(it.toLong())}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
}
if (platforms.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
items(platforms) { Chip(it) }
}
}
}
downloadUrl?.let {
Spacer(Modifier.width(8.dp))
ClickableTextPrimary(
text = stringRes(R.string.nip82_download),
onClick = { runCatching { uri.openUri(it) } },
)
}
}
}
/**
* NIP-82 kind 3063 — Software Asset card. A compact descriptor of a single
* install artifact: MIME type, version, size, platforms, and a download link.
*/
@Composable
fun RenderSoftwareAsset(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? SoftwareAssetEvent ?: return
val uri = LocalUriHandler.current
val appId = remember(event) { event.appId() }
val version = remember(event) { event.version() }
val mimeType = remember(event) { event.mimeType() }
val sizeBytes = remember(event) { event.sizeInBytes() }
val platforms = remember(event) { event.platforms() }
val downloadUrl = remember(event) { event.url() }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(top = Size5dp)
.clip(QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
.padding(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
appId?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
version?.let {
Text(
text = stringRes(R.string.nip82_version_label, it),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
sizeBytes?.let {
Spacer(Modifier.width(6.dp))
Text(
text = "· ${formatBytes(it.toLong())}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
}
}
downloadUrl?.let {
ClickableTextPrimary(
text = stringRes(R.string.nip82_download),
onClick = { runCatching { uri.openUri(it) } },
)
}
}
if (mimeType != null || platforms.isNotEmpty()) {
Spacer(StdVertSpacer)
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
mimeType?.let { item { Chip(prettyMime(it)) } }
items(platforms) { Chip(it) }
}
}
}
}
private fun prettyMime(mime: String): String =
when (mime) {
"application/vnd.android.package-archive" -> "APK"
"application/vnd.apple.ipa" -> "IPA"
"application/x-apple-diskimage" -> "DMG"
"application/vnd.apple.installer+xml" -> "PKG"
"application/x-msi" -> "MSI"
"application/vnd.appimage" -> "AppImage"
"application/vnd.flatpak" -> "Flatpak"
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
"application/x-executable" -> "ELF"
"application/x-mach-binary" -> "Mach-O"
"application/vnd.microsoft.portable-executable" -> "EXE"
"application/vsix" -> "VSIX"
"application/x-chrome-extension" -> "CRX"
"application/x-xpinstall" -> "XPI"
"application/wasm" -> "WASM"
"application/webbundle" -> "Web Bundle"
else -> mime
}
private fun formatBytes(bytes: Long): String {
if (bytes < 1024L) return "$bytes B"
val kb = bytes / 1024.0
if (kb < 1024) return "%.1f KB".format(kb)
val mb = kb / 1024
if (mb < 1024) return "%.1f MB".format(mb)
val gb = mb / 1024
return "%.2f GB".format(gb)
}
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.dal.ProductsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.dal.PublicChatsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal.ShortsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.dal.SoftwareAppsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
import kotlinx.coroutines.CoroutineScope
@@ -111,6 +112,7 @@ class AccountFeedContentStates(
val nestsFeed = FeedContentState(NestsFeedFilter(account), scope, LocalCache)
val longsFeed = FeedContentState(LongsFeedFilter(account), scope, LocalCache)
val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache)
val softwareAppsFeed = FeedContentState(SoftwareAppsFeedFilter(account), scope, LocalCache)
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
val notificationsFollowing = CardFeedContentState(NotificationFeedFilter(account, TopFilter.AllFollows), scope)
@@ -199,6 +201,7 @@ class AccountFeedContentStates(
nestsFeed.updateFeedWith(newNotes)
longsFeed.updateFeedWith(newNotes)
articlesFeed.updateFeedWith(newNotes)
softwareAppsFeed.updateFeedWith(newNotes)
calendarAppointmentsFeed.updateFeedWith(newNotes)
calendarCollectionsFeed.updateFeedWith(newNotes)
@@ -254,6 +257,7 @@ class AccountFeedContentStates(
nestsFeed.deleteFromFeed(newNotes)
longsFeed.deleteFromFeed(newNotes)
articlesFeed.deleteFromFeed(newNotes)
softwareAppsFeed.deleteFromFeed(newNotes)
calendarAppointmentsFeed.deleteFromFeed(newNotes)
calendarCollectionsFeed.deleteFromFeed(newNotes)
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilte
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
/**
@@ -86,6 +87,8 @@ private fun PreloadFor(
NavBarItem.PICTURES -> PicturesFilterAssemblerSubscription(accountViewModel)
NavBarItem.SOFTWARE_APPS -> SoftwareAppsFilterAssemblerSubscription(accountViewModel)
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
-> CalendarsFilterAssemblerSubscription(accountViewModel)
@@ -0,0 +1,142 @@
/*
* 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.screen.loggedIn.softwareapps
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
@Composable
fun SoftwareAppsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
SoftwareAppsScreen(
feedContentState = accountViewModel.feedStates.softwareAppsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun SoftwareAppsScreen(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedContentState)
SoftwareAppsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
UserDrawerSearchTopBar(accountViewModel, nav) {}
},
bottomBar = {
AppBottomBar(Route.SoftwareApps, nav, accountViewModel) { route ->
if (route == Route.SoftwareApps) {
feedContentState.sendToTop()
} else {
nav.navBottomBar(route)
}
}
},
accountViewModel = accountViewModel,
) {
RefresheableBox(feedContentState, true) {
SaveableFeedContentState(feedContentState) { listState ->
RenderFeedContentState(
feedContentState = feedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "SoftwareAppsFeed",
onLoaded = { loaded ->
SoftwareAppsFeedLoaded(
loaded = loaded,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
}
}
}
}
@Composable
fun SoftwareAppsFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
if (item.event is SoftwareApplicationEvent) {
RenderSoftwareApplication(
note = item,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(thickness = DividerThickness)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@@ -0,0 +1,56 @@
/*
* 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.screen.loggedIn.softwareapps.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
class SoftwareAppsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun limit() = 200
override fun showHiddenKey(): Boolean = false
override fun feed(): List<Note> {
val notes =
LocalCache.addressables.filterIntoSet(SoftwareApplicationEvent.KIND) { _, it ->
val ev = it.event
ev is SoftwareApplicationEvent && account.isAcceptable(it)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> =
newItems.filterTo(HashSet()) {
val ev = it.event
ev is SoftwareApplicationEvent && account.isAcceptable(it)
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -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.amethyst.ui.screen.loggedIn.softwareapps.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.CoroutineScope
class SoftwareAppsQueryState(
val account: Account,
val scope: CoroutineScope,
)
@Stable
class SoftwareAppsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<SoftwareAppsQueryState>() {
val sub = SoftwareAppsSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = sub.invalidateFilters()
override fun destroy() = sub.destroy()
}
class SoftwareAppsSubAssembler(
client: INostrClient,
allKeys: () -> Set<SoftwareAppsQueryState>,
) : PerUserEoseManager<SoftwareAppsQueryState>(client, allKeys) {
override fun user(key: SoftwareAppsQueryState) = key.account.userProfile()
override fun updateFilter(
key: SoftwareAppsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays = key.account.outboxRelays.flow.value
if (relays.isEmpty()) return emptyList()
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND),
limit = 200,
since = since?.get(relay)?.time,
),
)
}
}
}
@@ -0,0 +1,48 @@
/*
* 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.screen.loggedIn.softwareapps.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun SoftwareAppsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
SoftwareAppsFilterAssemblerSubscription(
accountViewModel.dataSources().softwareApps,
accountViewModel,
)
}
@Composable
fun SoftwareAppsFilterAssemblerSubscription(
dataSource: SoftwareAppsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
SoftwareAppsQueryState(accountViewModel.account, accountViewModel.viewModelScope)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -182,6 +182,9 @@ 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.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent
@@ -225,6 +228,9 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -264,6 +270,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
@@ -725,6 +732,12 @@ private fun FullBleedNoteCompose(
RenderGitIssueEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is AppDefinitionEvent) {
RenderAppDefinition(baseNote, accountViewModel, nav)
} else if (noteEvent is SoftwareApplicationEvent) {
RenderSoftwareApplication(baseNote, accountViewModel, nav)
} else if (noteEvent is SoftwareAssetEvent) {
RenderSoftwareAsset(baseNote, accountViewModel, nav)
} else if (noteEvent is ReleaseArtifactSetEvent && noteEvent.isNip82SoftwareRelease()) {
RenderSoftwareRelease(baseNote, accountViewModel, nav)
} else if (noteEvent is DraftWrapEvent) {
RenderDraft(baseNote, 3, ReplyRenderType.FULL, backgroundColor, accountViewModel, nav)
} else if (noteEvent is HighlightEvent) {
+9
View File
@@ -14,6 +14,10 @@
<item quantity="one">This post has more than %1$d hashtag</item>
<item quantity="other">This post has more than %1$d hashtags</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d asset bundled</item>
<item quantity="other">%1$d assets bundled</item>
</plurals>
<string name="post_not_found">Event is loading or can\'t be found in your relay list</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Channel Image</string>
@@ -583,6 +587,11 @@
<string name="profile_badges_description">Choose which of the badges you\'ve received appear on your profile.</string>
<string name="profile_badges_empty">You haven\'t received any badges yet.</string>
<string name="pictures">Pictures</string>
<string name="software_apps">Apps</string>
<string name="route_software_apps">Apps</string>
<string name="nip82_repository_label">Source: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Download</string>
<string name="calendars">Calendars</string>
<string name="shorts">Shorts</string>
<string name="public_chats">Public Chats</string>
@@ -25,7 +25,9 @@ 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.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -63,6 +65,10 @@ class SoftwareApplicationEvent(
fun license() = tags.license()
fun topics() = tags.mapNotNull(HashtagTag::parse)
fun appLinks() = tags.mapNotNull(ATag::parse)
companion object {
const val KIND = 32267
const val ALT_DESCRIPTION = "Software application"
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.tags.
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.tags.UrlTag
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.shared.PlatformTag
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
fun TagArrayBuilder<SoftwareApplicationEvent>.name(name: String) = addUnique(NameTag.assemble(name))
@@ -49,3 +50,5 @@ fun TagArrayBuilder<SoftwareApplicationEvent>.platform(platform: String) = add(P
fun TagArrayBuilder<SoftwareApplicationEvent>.platforms(platforms: List<String>) = addAll(PlatformTag.assemble(platforms))
fun TagArrayBuilder<SoftwareApplicationEvent>.license(spdxId: String) = addUnique(LicenseTag.assemble(spdxId))
fun TagArrayBuilder<SoftwareApplicationEvent>.appLink(addressTag: String) = add(ATag.assemble(addressTag, null))
@@ -0,0 +1,45 @@
/*
* 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.experimental.nip82SoftwareApps.release
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.tags.AppIdTag
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.tags.VersionTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent
/**
* NIP-82 reuses kind 30063 which was already taken by NIP-51's
* `ReleaseArtifactSetEvent`. Both are valid; they coexist on the wire.
*
* We disambiguate by inspecting the tag set: a NIP-82 Software Release MUST
* carry both `i` (app identifier) and `version` tags. NIP-51 release artifact
* sets do not.
*/
fun Event.isNip82SoftwareRelease() =
kind == SoftwareReleaseEvent.KIND &&
tags.hasTagWithContent(AppIdTag.TAG_NAME) &&
tags.hasTagWithContent(VersionTag.TAG_NAME)
/**
* Reinterprets `this` as a [SoftwareReleaseEvent]. Use [isNip82SoftwareRelease]
* to gate the call.
*/
fun Event.asSoftwareRelease() = SoftwareReleaseEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,228 @@
/*
* 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.experimental.nip82SoftwareApps
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.icon
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.images
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.license
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.platform
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.repository
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.summary
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.url
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.apkCertificateHash
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.platform
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.versionCode
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.asSoftwareRelease
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.shared.Platform
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class SoftwareApplicationEventTest {
@Test
fun buildApplicationEvent_setsRequiredAndOptionalTags() {
val tmpl =
SoftwareApplicationEvent.build(
appId = "com.example.app",
name = "Example",
description = "Demo app",
) {
summary("A short summary")
icon("https://cdn.example.com/icon.png")
url("https://example.com")
repository("https://github.com/example/app")
license("MIT")
platform(Platform.ANDROID_ARM64_V8A)
hashtag("nostr")
}
assertEquals(SoftwareApplicationEvent.KIND, tmpl.kind)
assertEquals("Demo app", tmpl.content)
// Verify some key tags exist
val tagsByName = tmpl.tags.associate { it[0] to it.drop(1) }
assertEquals("com.example.app", tagsByName["d"]?.firstOrNull())
assertEquals("Example", tagsByName["name"]?.firstOrNull())
assertEquals("A short summary", tagsByName["summary"]?.firstOrNull())
assertEquals("MIT", tagsByName["license"]?.firstOrNull())
assertEquals(Platform.ANDROID_ARM64_V8A, tagsByName["f"]?.firstOrNull())
assertEquals("https://github.com/example/app", tagsByName["repository"]?.firstOrNull())
}
@Test
fun buildAssetEvent_setsRequiredTags() {
val tmpl =
SoftwareAssetEvent.build(
appId = "com.example.app",
mimeType = "application/vnd.android.package-archive",
hash = "deadbeef".repeat(8),
version = "1.2.3",
assetUrl = "https://example.com/app.apk",
sizeInBytes = 1024,
) {
versionCode(42L)
platform(Platform.ANDROID_ARM64_V8A)
apkCertificateHash("abc123".repeat(10).take(64))
}
assertEquals(SoftwareAssetEvent.KIND, tmpl.kind)
val tagsByName = tmpl.tags.associate { it[0] to it.drop(1) }
assertEquals("com.example.app", tagsByName["i"]?.firstOrNull())
assertEquals("1.2.3", tagsByName["version"]?.firstOrNull())
assertEquals("42", tagsByName["version_code"]?.firstOrNull())
assertEquals("application/vnd.android.package-archive", tagsByName["m"]?.firstOrNull())
}
@Test
fun isNip82SoftwareRelease_distinguishesFromNip51ReleaseArtifactSet() {
val nip82Release =
Event(
id = "0".repeat(64),
pubKey = "1".repeat(64),
createdAt = 0,
kind = SoftwareReleaseEvent.KIND,
tags =
arrayOf(
arrayOf("d", "com.example.app@1.0.0"),
arrayOf("i", "com.example.app"),
arrayOf("version", "1.0.0"),
arrayOf("c", "main"),
),
content = "",
sig = "",
)
assertTrue(nip82Release.isNip82SoftwareRelease())
// NIP-51 release artifact set: same kind, no `i`/`version` tags
val nip51Set =
Event(
id = "0".repeat(64),
pubKey = "1".repeat(64),
createdAt = 0,
kind = SoftwareReleaseEvent.KIND,
tags = arrayOf(arrayOf("d", "some-uuid"), arrayOf("title", "My Release")),
content = "",
sig = "",
)
assertFalse(nip51Set.isNip82SoftwareRelease())
}
@Test
fun asSoftwareRelease_exposesParsedFields() {
val event =
Event(
id = "0".repeat(64),
pubKey = "1".repeat(64),
createdAt = 0,
kind = SoftwareReleaseEvent.KIND,
tags =
arrayOf(
arrayOf("d", "com.example.app@1.0.0"),
arrayOf("i", "com.example.app"),
arrayOf("version", "1.0.0"),
arrayOf("c", "main"),
),
content = "Initial release",
sig = "",
)
val release = event.asSoftwareRelease()
assertEquals("com.example.app", release.appId())
assertEquals("1.0.0", release.version())
assertEquals("main", release.channel())
}
@Test
fun parses_realWorldAmethystEvent() {
// Real NIP-82 event published for Amethyst — used as a regression fixture so renderer
// changes are caught against a known-good event seen on the wire.
val event =
SoftwareApplicationEvent(
id = "1c59e61478a39a963a6e513b8483b2d429ce1d9d98d1c7dfcd46b6a03dbfc6ea",
pubKey = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b",
createdAt = 1778966309L,
tags =
arrayOf(
arrayOf("d", "com.vitorpamplona.amethyst"),
arrayOf("name", "Amethyst"),
arrayOf("summary", "The all-in-one Nostr client"),
arrayOf("icon", "https://cdn.zapstore.dev/8064b9cb04395b8039390c51b13095b54f1958fcfe7a2515f6ff202a7567d049"),
arrayOf("image", "https://cdn.zapstore.dev/a1dfb3c5fada19c39c85049d6cd97fbf5813feca62b52839a269a2c459c726d2"),
arrayOf("image", "https://cdn.zapstore.dev/ff224b4887cba187238bbf10428523006374dea6692eb6f1dd7256aeff322a78"),
arrayOf("image", "https://cdn.zapstore.dev/9b088b467b2f217f0b3eee6f12afa907cd3fce54b63766ff93e03fb1f891e8b5"),
arrayOf("image", "https://cdn.zapstore.dev/d4580b70f4e741be2d95e6f51c608aac133db77868e8060c2d12dd6350168320"),
arrayOf("t", "social-network"),
arrayOf("t", "nostr"),
arrayOf("url", "https://amethyst.social/"),
arrayOf("repository", "https://github.com/vitorpamplona/amethyst"),
arrayOf("f", "android-arm64-v8a"),
arrayOf("license", "MIT"),
arrayOf("h", "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84"),
),
content = "A privacy-focused Nostr client for Android. Built-in TOR support, the most configurable relay system,\nencrypted messaging, zaps, marketplaces, live streams and complete data sovereignty.\n",
sig = "c209c6d97a7b94acc827755e615f729ff8c9bc6c9eacd9c92e35685b5a89ba95f24cbbc7f5696683bc19f813cd7443c60900acf13ba6b621834e804b1079d130",
)
assertEquals("com.vitorpamplona.amethyst", event.appId())
assertEquals("Amethyst", event.name())
assertEquals("The all-in-one Nostr client", event.summary())
assertEquals("https://cdn.zapstore.dev/8064b9cb04395b8039390c51b13095b54f1958fcfe7a2515f6ff202a7567d049", event.icon())
assertEquals(4, event.images().size)
assertEquals(listOf("social-network", "nostr"), event.topics())
assertEquals("https://amethyst.social/", event.url())
assertEquals("https://github.com/vitorpamplona/amethyst", event.repository())
assertEquals(listOf(Platform.ANDROID_ARM64_V8A), event.platforms())
assertEquals("MIT", event.license())
}
@Test
fun applicationParses_imagesAndPlatforms() {
val tmpl =
SoftwareApplicationEvent.build(
appId = "com.example.app",
name = "Example",
) {
images(listOf("https://example.com/a.png", "https://example.com/b.png"))
platform(Platform.LINUX_X86_64)
platform(Platform.DARWIN_ARM64)
}
val event =
SoftwareApplicationEvent(
id = "0".repeat(64),
pubKey = "1".repeat(64),
createdAt = 0,
tags = tmpl.tags,
content = tmpl.content,
sig = "",
)
assertEquals(2, event.images().size)
assertEquals(2, event.platforms().size)
assertNotNull(event.platforms().firstOrNull { it == Platform.LINUX_X86_64 })
}
}