From 82e500eee1546917e7382d29269842be0e73c932 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 03:06:22 +0000 Subject: [PATCH] feat: complete NIP-38 implementation with p tag, emoji, and expiration support - Add optional r/p/e/a/emoji tags to StatusEvent.create() for full spec compliance - Add GENERAL and MUSIC type constants to StatusEvent - Filter expired statuses in UserStatusCache.addStatus() and add removeExpired() - Add p tag (profile reference) display in DisplayStatusInner - Add NIP-30 custom emoji rendering in status display via CreateTextWithEmoji - Allow status type parameter in UserStatusAction.create() https://claude.ai/code/session_0117asp4mDiR65dxbAGKi2hg --- .../ui/note/NIP05VerificationDisplay.kt | 66 ++++++++++++++++--- .../nip38UserStatuses/UserStatusAction.kt | 3 +- .../nip38UserStatuses/UserStatusCache.kt | 15 +++++ .../quartz/nip38UserStatus/StatusEvent.kt | 21 +++++- 4 files changed, 94 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index eaee4475c..7e1593543 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -59,9 +59,12 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.routes.routeForUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -77,8 +80,13 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.tags.aTag.firstTaggedAddress import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent +import com.vitorpamplona.quartz.nip01Core.tags.people.firstTaggedUserId +import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.seconds @@ -220,12 +228,24 @@ fun DisplayStatus( accountViewModel: AccountViewModel, nav: INav, ) { + val emojis = + remember(event) { + val emojiList = event.taggedEmojis() + if (emojiList.isEmpty()) { + persistentMapOf() + } else { + emojiList.associate { it.code to it.url }.toImmutableMap() + } + } + DisplayStatusInner( event.content, event.dTag(), event.firstTaggedUrl()?.ifBlank { null }, event.firstTaggedAddress(), event.firstTaggedEvent(), + event.firstTaggedUserId(), + emojis, accountViewModel, nav, ) @@ -238,11 +258,13 @@ fun DisplayStatusInner( url: String?, nostrATag: Address?, nostrETag: ETag?, + nostrPTag: String?, + emojis: ImmutableMap, accountViewModel: AccountViewModel, nav: INav, ) { when (type) { - "music" -> { + StatusEvent.MUSIC -> { Icon( imageVector = CustomHashTagIcons.Tunestr, null, @@ -254,13 +276,24 @@ fun DisplayStatusInner( else -> {} } - Text( - text = content, - fontSize = Font14SP, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (emojis.isNotEmpty()) { + CreateTextWithEmoji( + text = content, + emojis = emojis, + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font14SP, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = content, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } if (url != null) { val uri = LocalUriHandler.current @@ -320,6 +353,23 @@ fun DisplayStatusInner( } } } + } else if (nostrPTag != null) { + LoadUser(baseUserHex = nostrPTag, accountViewModel) { user -> + if (user != null) { + Spacer(modifier = StdHorzSpacer) + IconButton( + modifier = Size15Modifier, + onClick = { nav.nav(routeForUser(nostrPTag)) }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + null, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.lessImportantLink, + ) + } + } + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt index 1d0acdac7..0040e9e0d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt @@ -31,7 +31,8 @@ class UserStatusAction { suspend fun create( newStatus: String, signer: NostrSigner, - ): StatusEvent = StatusEvent.create(newStatus, "general", expiration = null, signer) + type: String = StatusEvent.GENERAL, + ): StatusEvent = StatusEvent.create(newStatus, type, signer = signer) suspend fun update( oldStatus: AddressableNote, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt index 477653eb5..2298cc18e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.UserDependencies import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip40Expiration.isExpired import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -43,6 +44,9 @@ class UserStatusCache : UserDependencies { // if it's already there, quick exit if (statuses.value.contains(note) || note.event?.content.isNullOrBlank()) return + // don't add expired statuses + if (note.event?.isExpired() == true) return + statuses.update { (it + note).sortedWith(sortModel).toImmutableList() } @@ -56,4 +60,15 @@ class UserStatusCache : UserDependencies { (it - deleteNote).toImmutableList() } } + + fun removeExpired() { + statuses.update { list -> + val filtered = list.filter { it.event?.isExpired() != true } + if (filtered.size != list.size) { + filtered.toImmutableList() + } else { + list + } + } + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt index 28485f34d..cc3a80b17 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt @@ -25,6 +25,10 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -41,10 +45,18 @@ class StatusEvent( companion object { const val KIND = 30315 + const val GENERAL = "general" + const val MUSIC = "music" + suspend fun create( msg: String, - type: String, - expiration: Long?, + type: String = GENERAL, + expiration: Long? = null, + url: String? = null, + profileId: HexKey? = null, + eventId: HexKey? = null, + addressableId: String? = null, + emojiTags: List? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): StatusEvent { @@ -52,6 +64,11 @@ class StatusEvent( tags.add(arrayOf("d", type)) expiration?.let { tags.add(arrayOf("expiration", it.toString())) } + url?.let { tags.add(arrayOf("r", it)) } + profileId?.let { tags.add(PTag.assemble(it, null)) } + eventId?.let { tags.add(ETag.assemble(it, null, null)) } + addressableId?.let { tags.add(ATag.assemble(it, null)) } + emojiTags?.forEach { tags.add(it.toTagArray()) } return signer.sign(createdAt, KIND, tags.toTypedArray(), msg) }