mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge pull request #3036 from vitorpamplona/claude/affectionate-gauss-UWDJ4
Add NIP-82 Software Applications support with dedicated feed
This commit is contained in:
+3
@@ -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) }
|
||||
|
||||
+9
@@ -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)
|
||||
}
|
||||
+4
@@ -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)
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+142
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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)
|
||||
}
|
||||
+78
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+13
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+6
@@ -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"
|
||||
|
||||
+3
@@ -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))
|
||||
|
||||
+45
@@ -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)
|
||||
+228
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user