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
This commit is contained in:
+58
-8
@@ -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.service.relayClient.reqCommand.user.observeUserStatuses
|
||||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||||
import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary
|
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.components.LoadNote
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
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.painterRes
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
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.aTag.firstTaggedAddress
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent
|
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 com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
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 kotlinx.coroutines.delay
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -220,12 +228,24 @@ fun DisplayStatus(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
|
val emojis =
|
||||||
|
remember(event) {
|
||||||
|
val emojiList = event.taggedEmojis()
|
||||||
|
if (emojiList.isEmpty()) {
|
||||||
|
persistentMapOf()
|
||||||
|
} else {
|
||||||
|
emojiList.associate { it.code to it.url }.toImmutableMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DisplayStatusInner(
|
DisplayStatusInner(
|
||||||
event.content,
|
event.content,
|
||||||
event.dTag(),
|
event.dTag(),
|
||||||
event.firstTaggedUrl()?.ifBlank { null },
|
event.firstTaggedUrl()?.ifBlank { null },
|
||||||
event.firstTaggedAddress(),
|
event.firstTaggedAddress(),
|
||||||
event.firstTaggedEvent(),
|
event.firstTaggedEvent(),
|
||||||
|
event.firstTaggedUserId(),
|
||||||
|
emojis,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
@@ -238,11 +258,13 @@ fun DisplayStatusInner(
|
|||||||
url: String?,
|
url: String?,
|
||||||
nostrATag: Address?,
|
nostrATag: Address?,
|
||||||
nostrETag: ETag?,
|
nostrETag: ETag?,
|
||||||
|
nostrPTag: String?,
|
||||||
|
emojis: ImmutableMap<String, String>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
when (type) {
|
when (type) {
|
||||||
"music" -> {
|
StatusEvent.MUSIC -> {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = CustomHashTagIcons.Tunestr,
|
imageVector = CustomHashTagIcons.Tunestr,
|
||||||
null,
|
null,
|
||||||
@@ -254,13 +276,24 @@ fun DisplayStatusInner(
|
|||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
if (emojis.isNotEmpty()) {
|
||||||
text = content,
|
CreateTextWithEmoji(
|
||||||
fontSize = Font14SP,
|
text = content,
|
||||||
color = MaterialTheme.colorScheme.placeholderText,
|
emojis = emojis,
|
||||||
maxLines = 1,
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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) {
|
if (url != null) {
|
||||||
val uri = LocalUriHandler.current
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@ class UserStatusAction {
|
|||||||
suspend fun create(
|
suspend fun create(
|
||||||
newStatus: String,
|
newStatus: String,
|
||||||
signer: NostrSigner,
|
signer: NostrSigner,
|
||||||
): StatusEvent = StatusEvent.create(newStatus, "general", expiration = null, signer)
|
type: String = StatusEvent.GENERAL,
|
||||||
|
): StatusEvent = StatusEvent.create(newStatus, type, signer = signer)
|
||||||
|
|
||||||
suspend fun update(
|
suspend fun update(
|
||||||
oldStatus: AddressableNote,
|
oldStatus: AddressableNote,
|
||||||
|
|||||||
+15
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
|||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.UserDependencies
|
import com.vitorpamplona.amethyst.commons.model.UserDependencies
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
|
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
@@ -43,6 +44,9 @@ class UserStatusCache : UserDependencies {
|
|||||||
// if it's already there, quick exit
|
// if it's already there, quick exit
|
||||||
if (statuses.value.contains(note) || note.event?.content.isNullOrBlank()) return
|
if (statuses.value.contains(note) || note.event?.content.isNullOrBlank()) return
|
||||||
|
|
||||||
|
// don't add expired statuses
|
||||||
|
if (note.event?.isExpired() == true) return
|
||||||
|
|
||||||
statuses.update {
|
statuses.update {
|
||||||
(it + note).sortedWith(sortModel).toImmutableList()
|
(it + note).sortedWith(sortModel).toImmutableList()
|
||||||
}
|
}
|
||||||
@@ -56,4 +60,15 @@ class UserStatusCache : UserDependencies {
|
|||||||
(it - deleteNote).toImmutableList()
|
(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-2
@@ -25,6 +25,10 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
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
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
@@ -41,10 +45,18 @@ class StatusEvent(
|
|||||||
companion object {
|
companion object {
|
||||||
const val KIND = 30315
|
const val KIND = 30315
|
||||||
|
|
||||||
|
const val GENERAL = "general"
|
||||||
|
const val MUSIC = "music"
|
||||||
|
|
||||||
suspend fun create(
|
suspend fun create(
|
||||||
msg: String,
|
msg: String,
|
||||||
type: String,
|
type: String = GENERAL,
|
||||||
expiration: Long?,
|
expiration: Long? = null,
|
||||||
|
url: String? = null,
|
||||||
|
profileId: HexKey? = null,
|
||||||
|
eventId: HexKey? = null,
|
||||||
|
addressableId: String? = null,
|
||||||
|
emojiTags: List<EmojiUrlTag>? = null,
|
||||||
signer: NostrSigner,
|
signer: NostrSigner,
|
||||||
createdAt: Long = TimeUtils.now(),
|
createdAt: Long = TimeUtils.now(),
|
||||||
): StatusEvent {
|
): StatusEvent {
|
||||||
@@ -52,6 +64,11 @@ class StatusEvent(
|
|||||||
|
|
||||||
tags.add(arrayOf("d", type))
|
tags.add(arrayOf("d", type))
|
||||||
expiration?.let { tags.add(arrayOf("expiration", it.toString())) }
|
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)
|
return signer.sign(createdAt, KIND, tags.toTypedArray(), msg)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user