Moves NIP-55 calls to be suspending functions.

Moves NIP-55 calls to include an ID per call, not per event.
Adds error handling facilities to the Signer functions.
Moves the indexing of the decrypted objects to outside the LocalCache
Migrates Signers to become suspending functions.
Migrates Decryption caching systems to outside the Events themselves.
Migrates all NIP-51 lists to the new structure.
Migrates Drafts and NIP-04 and NIP-17 DMs to the new structure
Migrates Bookmarks to the new structure.
Changes the Room route to avoid using hashcode.
This commit is contained in:
Vitor Pamplona
2025-07-18 17:17:27 -04:00
parent 7bdb3a1a18
commit 58e353fb40
374 changed files with 9807 additions and 8720 deletions
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Test
import java.util.concurrent.CountDownLatch
@@ -74,17 +75,13 @@ class OtsTest {
val signer = NostrSignerInternal(KeyPair())
var newOts: OtsEvent? = null
val countDownLatch = CountDownLatch(1)
signer.sign(OtsEvent.build(eventId, upgraded!!)) {
newOts = it
countDownLatch.countDown()
}
val newOts = runBlocking { signer.sign(OtsEvent.build(eventId, upgraded!!)) }
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
println(newOts!!.toJson())
println(newOts.toJson())
println(resolver.info(newOts.otsByteArray()))
assertEquals(1708879025L, newOts.verify(resolver))
@@ -93,18 +90,17 @@ class OtsTest {
@Test
fun createOTSEventAndVerify() {
val signer = NostrSignerInternal(KeyPair())
var ots: OtsEvent? = null
val countDownLatch = CountDownLatch(1)
signer.sign(OtsEvent.build(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest, resolver))) {
ots = it
countDownLatch.countDown()
}
val ots =
runBlocking {
signer.sign(OtsEvent.build(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest, resolver)))
}
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
println(ots!!.toJson())
println(ots.toJson())
println(resolver.info(ots.otsByteArray()))
assertEquals(null, ots.verify(resolver))
@@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip02FollowList.ReadWrite
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.runBlocking
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
@RunWith(AndroidJUnit4::class)
internal class Nip46Test {
@@ -49,206 +49,205 @@ internal class Nip46Test {
sig = "",
)
fun <T : BunkerMessage> encodeDecodeEvent(req: T): T {
var countDownLatch = CountDownLatch(1)
var eventStr: String? = null
suspend fun <T : BunkerMessage> encodeDecodeEvent(req: T): T {
val eventStr = NostrConnectEvent.create(req, remoteKey.pubKey, signer).toJson()
NostrConnectEvent.create(req, remoteKey.pubKey, signer) {
eventStr = it.toJson()
countDownLatch.countDown()
return (Event.fromJson(eventStr) as NostrConnectEvent).decryptMessage(signer) as T
}
@Test
fun signEncoder() =
runBlocking {
val expected = BunkerRequestSign(event = dummyEvent)
val actual = encodeDecodeEvent(expected)
assertEquals(BunkerRequestSign.METHOD_NAME, actual.method)
assertEquals(expected.id, actual.id)
assertEquals(dummyEvent.id, actual.event.id)
}
countDownLatch.await()
@Test
fun connectEncoder() =
runBlocking {
val expected = BunkerRequestConnect(remoteKey = remoteKey.pubKey)
val actual = encodeDecodeEvent(expected)
countDownLatch = CountDownLatch(1)
var innerMessage: T? = null
(Event.fromJson(eventStr!!) as NostrConnectEvent).plainContent(signer) {
innerMessage = it as T
countDownLatch.countDown()
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
}
countDownLatch.await()
@Test
fun pingEncoder() =
runBlocking {
val expected = BunkerRequestPing()
val actual = encodeDecodeEvent(expected)
return innerMessage!!
}
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
}
@Test
fun signEncoder() {
val expected = BunkerRequestSign(event = dummyEvent)
val actual = encodeDecodeEvent(expected)
fun getPubkeyEncoder() =
runBlocking {
val expected = BunkerRequestGetPublicKey()
val actual = encodeDecodeEvent(expected)
assertEquals(BunkerRequestSign.METHOD_NAME, actual.method)
assertEquals(expected.id, actual.id)
assertEquals(dummyEvent.id, actual.event.id)
}
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
}
@Test
fun connectEncoder() {
val expected = BunkerRequestConnect(remoteKey = remoteKey.pubKey)
val actual = encodeDecodeEvent(expected)
fun getRelaysEncoder() =
runBlocking {
val expected = BunkerRequestGetRelays()
val actual = encodeDecodeEvent(expected)
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
}
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
}
@Test
fun pingEncoder() {
val expected = BunkerRequestPing()
val actual = encodeDecodeEvent(expected)
fun testNip04Encrypt() =
runBlocking {
val expected = BunkerRequestNip04Encrypt(pubKey = peer.pubKey, message = "Test")
val actual = encodeDecodeEvent(expected)
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
}
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
assertEquals(expected.pubKey, actual.pubKey)
assertEquals(expected.message, actual.message)
}
@Test
fun getPubkeyEncoder() {
val expected = BunkerRequestGetPublicKey()
val actual = encodeDecodeEvent(expected)
fun testNip44Encrypt() =
runBlocking {
val expected = BunkerRequestNip44Encrypt(pubKey = peer.pubKey, message = "Test")
val actual = encodeDecodeEvent(expected)
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
}
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
assertEquals(expected.pubKey, actual.pubKey)
assertEquals(expected.message, actual.message)
}
@Test
fun getRelaysEncoder() {
val expected = BunkerRequestGetRelays()
val actual = encodeDecodeEvent(expected)
fun testNip04Decrypt() =
runBlocking {
val expected = BunkerRequestNip04Decrypt(pubKey = peer.pubKey, ciphertext = "Test")
val actual = encodeDecodeEvent(expected)
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
}
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
assertEquals(expected.pubKey, actual.pubKey)
assertEquals(expected.ciphertext, actual.ciphertext)
}
@Test
fun testNip04Encrypt() {
val expected = BunkerRequestNip04Encrypt(pubKey = peer.pubKey, message = "Test")
val actual = encodeDecodeEvent(expected)
fun testNip44Decrypt() =
runBlocking {
val expected = BunkerRequestNip44Decrypt(pubKey = peer.pubKey, ciphertext = "Test")
val actual = encodeDecodeEvent(expected)
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
assertEquals(expected.pubKey, actual.pubKey)
assertEquals(expected.message, actual.message)
}
@Test
fun testNip44Encrypt() {
val expected = BunkerRequestNip44Encrypt(pubKey = peer.pubKey, message = "Test")
val actual = encodeDecodeEvent(expected)
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
assertEquals(expected.pubKey, actual.pubKey)
assertEquals(expected.message, actual.message)
}
@Test
fun testNip04Decrypt() {
val expected = BunkerRequestNip04Decrypt(pubKey = peer.pubKey, ciphertext = "Test")
val actual = encodeDecodeEvent(expected)
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
assertEquals(expected.pubKey, actual.pubKey)
assertEquals(expected.ciphertext, actual.ciphertext)
}
@Test
fun testNip44Decrypt() {
val expected = BunkerRequestNip44Decrypt(pubKey = peer.pubKey, ciphertext = "Test")
val actual = encodeDecodeEvent(expected)
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
assertEquals(expected.pubKey, actual.pubKey)
assertEquals(expected.ciphertext, actual.ciphertext)
}
assertEquals(expected.method, actual.method)
assertEquals(expected.id, actual.id)
assertEquals(expected.pubKey, actual.pubKey)
assertEquals(expected.ciphertext, actual.ciphertext)
}
// Responses
@Test
fun testAckResponse() {
val expected = BunkerResponseAck()
val actual = encodeDecodeEvent(expected)
fun testAckResponse() =
runBlocking {
val expected = BunkerResponseAck()
val actual = encodeDecodeEvent(expected)
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
}
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
}
@Test
fun testPongResponse() {
val expected = BunkerResponsePong()
val actual = encodeDecodeEvent(expected)
fun testPongResponse() =
runBlocking {
val expected = BunkerResponsePong()
val actual = encodeDecodeEvent(expected)
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
}
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
}
@Test
fun testErrorResponse() {
val expected = BunkerResponseError(error = "Error")
val actual = encodeDecodeEvent(expected)
fun testErrorResponse() =
runBlocking {
val expected = BunkerResponseError(error = "Error")
val actual = encodeDecodeEvent(expected)
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
}
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
}
@Test
fun testEventResponse() {
val expected = BunkerResponseEvent(event = dummyEvent)
val actual = encodeDecodeEvent(expected)
fun testEventResponse() =
runBlocking {
val expected = BunkerResponseEvent(event = dummyEvent)
val actual = encodeDecodeEvent(expected)
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
assertEquals(dummyEvent.id, actual.event.id)
}
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
assertEquals(dummyEvent.id, actual.event.id)
}
@Test
fun testPubkeyResponse() {
val expected = BunkerResponsePublicKey(pubkey = peer.pubKey)
val actual = encodeDecodeEvent(expected)
fun testPubkeyResponse() =
runBlocking {
val expected = BunkerResponsePublicKey(pubkey = peer.pubKey)
val actual = encodeDecodeEvent(expected)
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
assertEquals(expected.pubkey, actual.pubkey)
}
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
assertEquals(expected.pubkey, actual.pubkey)
}
@Test
fun testRelaysResponse() {
val expected = BunkerResponseGetRelays(relays = mapOf("url" to ReadWrite(true, false)))
val actual = encodeDecodeEvent(expected)
fun testRelaysResponse() =
runBlocking {
val expected = BunkerResponseGetRelays(relays = mapOf("url" to ReadWrite(true, false)))
val actual = encodeDecodeEvent(expected)
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
assertEquals(expected.relays["url"], actual.relays["url"])
}
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
assertEquals(expected.relays["url"], actual.relays["url"])
}
@Test
@Ignore("Impossible to recreate the class since there are no hints on the json")
fun testDecryptResponse() {
val expected = BunkerResponseDecrypt(plaintext = "Test")
val actual = encodeDecodeEvent(expected)
fun testDecryptResponse() =
runBlocking {
val expected = BunkerResponseDecrypt(plaintext = "Test")
val actual = encodeDecodeEvent(expected)
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
assertEquals(expected.plaintext, actual.plaintext)
}
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
assertEquals(expected.plaintext, actual.plaintext)
}
@Test
@Ignore("Impossible to recreate the class since there are no hints on the json")
fun testEncryptResponse() {
val expected = BunkerResponseEncrypt(ciphertext = "Test")
val actual = encodeDecodeEvent(expected)
fun testEncryptResponse() =
runBlocking {
val expected = BunkerResponseEncrypt(ciphertext = "Test")
val actual = encodeDecodeEvent(expected)
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
assertEquals(expected.ciphertext, actual.ciphertext)
}
assertEquals(expected.id, actual.id)
assertEquals(expected.result, actual.result)
assertEquals(expected.error, actual.error)
assertEquals(expected.ciphertext, actual.ciphertext)
}
}
@@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEncryption.Companion.createEncryptionPrivateKey
import com.vitorpamplona.quartz.nip59GiftWraps.wait1SecondForResult
import com.vitorpamplona.quartz.utils.Hex
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
@@ -89,42 +89,35 @@ class PrivateZapTests {
),
)
var resultPrivateZap: Event? = null
wait1SecondForResult { onDone ->
LnZapRequestEvent.create(
originalNote = poll,
relays = setOf("wss://relay.damus.io/"),
signer = loggedIn,
pollOption = 0,
message = "",
zapType = LnZapEvent.ZapType.PRIVATE,
toUserPubHex = null,
) { privateZapRequest ->
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey =
createEncryptionPrivateKey(
loggedIn.keyPair.privKey!!.toHexKey(),
recepientPost,
privateZapRequest.createdAt,
)
val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
resultPrivateZap = decodedPrivateZap
onDone()
} else {
fail("Should not be null")
}
val privateZapRequest =
runBlocking {
LnZapRequestEvent.create(
originalNote = poll,
relays = setOf("wss://relay.damus.io/"),
signer = loggedIn,
pollOption = 0,
message = "",
zapType = LnZapEvent.ZapType.PRIVATE,
toUserPubHex = null,
)
}
}
assertNotNull(resultPrivateZap)
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey =
createEncryptionPrivateKey(
loggedIn.keyPair.privKey!!.toHexKey(),
recepientPost,
privateZapRequest.createdAt,
)
val decodedPrivateZap = PrivateZapRequestBuilder().decryptAnonTag(privateZapRequest.getAnonTag(), privateKey, recepientPK)
assertNotNull(decodedPrivateZap)
} else {
fail("Should not be null")
}
}
@Test
@@ -156,41 +149,34 @@ class PrivateZapTests {
KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")),
)
var resultPrivateZap: Event? = null
wait1SecondForResult { onDone ->
LnZapRequestEvent.create(
originalNote = textNote,
relays = setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"),
signer = loggedIn,
pollOption = null,
message = "test",
zapType = LnZapEvent.ZapType.PRIVATE,
toUserPubHex = null,
) { privateZapRequest ->
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey =
createEncryptionPrivateKey(
loggedIn.keyPair.privKey!!.toHexKey(),
recepientPost,
privateZapRequest.createdAt,
)
val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
resultPrivateZap = decodedPrivateZap
onDone()
} else {
fail("Should not be null")
}
val privateZapRequest =
runBlocking {
LnZapRequestEvent.create(
originalNote = textNote,
relays = setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"),
signer = loggedIn,
pollOption = null,
message = "test",
zapType = LnZapEvent.ZapType.PRIVATE,
toUserPubHex = null,
)
}
}
assertNotNull(resultPrivateZap)
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey =
createEncryptionPrivateKey(
loggedIn.keyPair.privKey!!.toHexKey(),
recepientPost,
privateZapRequest.createdAt,
)
val decodedPrivateZap = PrivateZapRequestBuilder().decryptAnonTag(privateZapRequest.getAnonTag(), privateKey, recepientPK)
assertNotNull(decodedPrivateZap)
} else {
fail("Should not be null")
}
}
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
@@ -41,104 +42,87 @@ import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class GiftWrapEventTest {
@Test()
fun testNip17Utils() {
val sender = NostrSignerInternal(KeyPair())
val receiver = NostrSignerInternal(KeyPair())
val message = "Hola, que tal?"
fun testNip17Utils() =
runBlocking {
val sender = NostrSignerInternal(KeyPair())
val receiver = NostrSignerInternal(KeyPair())
val message = "Hola, que tal?"
// Requires 3 tests
val countDownLatch = CountDownLatch(3)
NIP17Factory().createMessageNIP17(
ChatMessageEvent.build(
message,
listOf(PTag(receiver.pubKey, null)),
),
sender,
) { events ->
countDownLatch.countDown()
val events =
NIP17Factory().createMessageNIP17(
ChatMessageEvent.build(
message,
listOf(PTag(receiver.pubKey, null)),
),
sender,
)
// Simulate Receiver
val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) }
eventsReceiverGets.forEach {
it.unwrap(receiver) { event ->
if (event is SealedRumorEvent) {
event.unseal(receiver) { innerData ->
countDownLatch.countDown()
assertEquals(message, innerData.content)
}
} else {
fail("Wrong Event")
}
val event = it.unwrapThrowing(receiver)
if (event is SealedRumorEvent) {
val innerData = event.unsealThrowing(receiver)
assertEquals(message, innerData.content)
} else {
fail("Wrong Event")
}
}
// Simulate Sender
val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) }
eventsSenderGets.forEach {
it.unwrap(sender) { event ->
if (event is SealedRumorEvent) {
event.unseal(sender) { innerData ->
countDownLatch.countDown()
assertEquals(message, innerData.content)
}
} else {
fail("Wrong Event")
}
val event = it.unwrapThrowing(sender)
if (event is SealedRumorEvent) {
val innerData = event.unsealThrowing(sender)
assertEquals(message, innerData.content)
} else {
fail("Wrong Event")
}
}
}
assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
}
@Test()
fun testNip17UtilsForGroups() {
val sender = NostrSignerInternal(KeyPair())
val receiver1 = NostrSignerInternal(KeyPair())
val receiver2 = NostrSignerInternal(KeyPair())
val receiver3 = NostrSignerInternal(KeyPair())
val receiver4 = NostrSignerInternal(KeyPair())
val message = "Hola, que tal?"
fun testNip17UtilsForGroups() =
runBlocking {
val sender = NostrSignerInternal(KeyPair())
val receiver1 = NostrSignerInternal(KeyPair())
val receiver2 = NostrSignerInternal(KeyPair())
val receiver3 = NostrSignerInternal(KeyPair())
val receiver4 = NostrSignerInternal(KeyPair())
val message = "Hola, que tal?"
val receivers =
listOf(
receiver1,
receiver2,
receiver3,
receiver4,
)
val receivers =
listOf(
receiver1,
receiver2,
receiver3,
receiver4,
)
val countDownLatch = CountDownLatch(receivers.size + 2)
NIP17Factory().createMessageNIP17(
ChatMessageEvent.build(
message,
receivers.map { PTag(it.pubKey, null) },
),
sender,
) { events ->
countDownLatch.countDown()
val events =
NIP17Factory().createMessageNIP17(
ChatMessageEvent.build(
message,
receivers.map { PTag(it.pubKey, null) },
),
sender,
)
// Simulate Receiver
receivers.forEach { receiver ->
val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) }
eventsReceiverGets.forEach {
it.unwrap(receiver) { event ->
if (event is SealedRumorEvent) {
event.unseal(receiver) { innerData ->
countDownLatch.countDown()
assertEquals(message, innerData.content)
}
} else {
fail("Wrong Event")
}
val event = it.unwrapThrowing(receiver)
if (event is SealedRumorEvent) {
val innerData = event.unsealThrowing(receiver)
assertEquals(message, innerData.content)
} else {
fail("Wrong Event")
}
}
}
@@ -146,149 +130,126 @@ class GiftWrapEventTest {
// Simulate Sender
val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) }
eventsSenderGets.forEach {
it.unwrap(sender) { event ->
if (event is SealedRumorEvent) {
event.unseal(sender) { innerData ->
countDownLatch.countDown()
assertEquals(message, innerData.content)
}
} else {
fail("Wrong Event")
}
val event = it.unwrapThrowing(sender)
if (event is SealedRumorEvent) {
val innerData = event.unsealThrowing(sender)
assertEquals(message, innerData.content)
} else {
fail("Wrong Event")
}
}
}
assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
}
@Test()
fun testInternalsSimpleMessage() {
val sender = NostrSignerInternal(KeyPair())
val receiver = NostrSignerInternal(KeyPair())
fun testInternalsSimpleMessage() =
runBlocking {
val sender = NostrSignerInternal(KeyPair())
val receiver = NostrSignerInternal(KeyPair())
val countDownLatch = CountDownLatch(2)
var giftWrapEventToSender: GiftWrapEvent? = null
var giftWrapEventToReceiver: GiftWrapEvent? = null
sender.sign(
ChatMessageEvent.build(
msg = "Hi There!",
to = listOf(PTag(receiver.pubKey, null)),
),
) { senderMessage ->
val senderMessage =
sender.sign(
ChatMessageEvent.build(
msg = "Hi There!",
to = listOf(PTag(receiver.pubKey, null)),
),
)
// MsgFor the Receiver
val encMsgFromSenderToReceiver =
SealedRumorEvent.create(
event = senderMessage,
encryptTo = receiver.pubKey,
signer = sender,
)
SealedRumorEvent.create(
event = senderMessage,
encryptTo = receiver.pubKey,
signer = sender,
) { encMsgFromSenderToReceiver ->
// Should expose sender
assertEquals(encMsgFromSenderToReceiver.pubKey, sender.pubKey)
// Should not expose receiver
assertTrue(encMsgFromSenderToReceiver.tags.isEmpty())
// Should expose sender
assertEquals(encMsgFromSenderToReceiver.pubKey, sender.pubKey)
// Should not expose receiver
assertTrue(encMsgFromSenderToReceiver.tags.isEmpty())
val giftWrapToReceiver =
GiftWrapEvent.create(
event = encMsgFromSenderToReceiver,
recipientPubKey = receiver.pubKey,
) { giftWrapToReceiver ->
// Should not be signed by neither sender nor receiver
assertNotEquals(giftWrapToReceiver.pubKey, sender.pubKey)
assertNotEquals(giftWrapToReceiver.pubKey, receiver.pubKey)
)
// Should not include sender as recipient
assertNotEquals(giftWrapToReceiver.recipientPubKey(), sender.pubKey)
// Should not be signed by neither sender nor receiver
assertNotEquals(giftWrapToReceiver.pubKey, sender.pubKey)
assertNotEquals(giftWrapToReceiver.pubKey, receiver.pubKey)
// Should be addressed to the receiver
assertEquals(giftWrapToReceiver.recipientPubKey(), receiver.pubKey)
// Should not include sender as recipient
assertNotEquals(giftWrapToReceiver.recipientPubKey(), sender.pubKey)
giftWrapEventToReceiver = giftWrapToReceiver
countDownLatch.countDown()
}
}
// Should be addressed to the receiver
assertEquals(giftWrapToReceiver.recipientPubKey(), receiver.pubKey)
// MsgFor the Sender
SealedRumorEvent.create(
event = senderMessage,
encryptTo = sender.pubKey,
signer = sender,
) { encMsgFromSenderToSender ->
// Should expose sender
assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey)
// Should not expose receiver
assertTrue(encMsgFromSenderToSender.tags.isEmpty())
val encMsgFromSenderToSender =
SealedRumorEvent.create(
event = senderMessage,
encryptTo = sender.pubKey,
signer = sender,
)
// Should expose sender
assertEquals(encMsgFromSenderToSender.pubKey, sender.pubKey)
// Should not expose receiver
assertTrue(encMsgFromSenderToSender.tags.isEmpty())
val giftWrapToSender =
GiftWrapEvent.create(
event = encMsgFromSenderToSender,
recipientPubKey = sender.pubKey,
) { giftWrapToSender ->
// Should not be signed by neither the sender, not the receiver
assertNotEquals(giftWrapToSender.pubKey, sender.pubKey)
assertNotEquals(giftWrapToSender.pubKey, receiver.pubKey)
)
// Should not be addressed to the receiver
assertNotEquals(giftWrapToSender.recipientPubKey(), receiver.pubKey)
// Should be addressed to the sender
assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey)
// Should not be signed by neither the sender, not the receiver
assertNotEquals(giftWrapToSender.pubKey, sender.pubKey)
assertNotEquals(giftWrapToSender.pubKey, receiver.pubKey)
giftWrapEventToSender = giftWrapToSender
// Should not be addressed to the receiver
assertNotEquals(giftWrapToSender.recipientPubKey(), receiver.pubKey)
// Should be addressed to the sender
assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey)
countDownLatch.countDown()
}
}
}
// Done
// -----
// Start receiving
// Done
assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
// Receiver's side
// Makes sure it can only be decrypted by the target user
// Receiver's side
// Makes sure it can only be decrypted by the target user
assertNotNull(giftWrapToSender)
assertNotNull(giftWrapToReceiver)
assertNotNull(giftWrapEventToSender)
assertNotNull(giftWrapEventToReceiver)
val countDownDecryptLatch = CountDownLatch(2)
giftWrapEventToSender!!.unwrap(sender) { unwrappedMsgForSenderBySender ->
val unwrappedMsgForSenderBySender = giftWrapToSender.unwrapThrowing(sender)
assertEquals(SealedRumorEvent.KIND, unwrappedMsgForSenderBySender.kind)
assertTrue(unwrappedMsgForSenderBySender is SealedRumorEvent)
if (unwrappedMsgForSenderBySender is SealedRumorEvent) {
unwrappedMsgForSenderBySender.unseal(sender) { unwrappedRumorToSenderBySender ->
assertEquals("Hi There!", unwrappedRumorToSenderBySender.content)
countDownDecryptLatch.countDown()
}
val unwrappedRumorToSenderBySender = unwrappedMsgForSenderBySender.unsealThrowing(sender)
assertEquals("Hi There!", unwrappedRumorToSenderBySender.content)
unwrappedMsgForSenderBySender.unseal(receiver) { _ ->
fail(
"Should not be able to decrypt msg for the sender by the sender but decrypted with receiver",
)
unwrappedMsgForSenderBySender.unsealOrNull(receiver)?.let { _ ->
fail("Should not be able to decrypt msg for the sender by the sender but decrypted with receiver")
}
}
}
giftWrapEventToReceiver!!.unwrap(sender) { _ ->
fail("Should not be able to decrypt msg for the receiver decrypted by the sender")
}
giftWrapToReceiver.unwrapOrNull(sender)?.let { _ ->
fail("Should not be able to decrypt msg for the receiver decrypted by the sender")
}
giftWrapEventToSender!!.unwrap(receiver) { _ ->
fail("Should not be able to decrypt msg for the sender decrypted by the receiver")
}
giftWrapToReceiver.unwrapOrNull(receiver)?.let { _ ->
fail("Should not be able to decrypt msg for the sender decrypted by the receiver")
}
giftWrapEventToReceiver!!.unwrap(receiver) { unwrappedMsgForReceiverByReceiver ->
val unwrappedMsgForReceiverByReceiver = giftWrapToReceiver.unwrapThrowing(receiver)
assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverByReceiver.kind)
assertTrue(unwrappedMsgForReceiverByReceiver is SealedRumorEvent)
if (unwrappedMsgForReceiverByReceiver is SealedRumorEvent) {
unwrappedMsgForReceiverByReceiver.unseal(receiver) { unwrappedRumorToReceiverByReceiver ->
assertEquals("Hi There!", unwrappedRumorToReceiverByReceiver?.content)
countDownDecryptLatch.countDown()
}
val unwrappedRumorToReceiverByReceiver = unwrappedMsgForReceiverByReceiver.unsealThrowing(receiver)
assertEquals("Hi There!", unwrappedRumorToReceiverByReceiver.content)
unwrappedMsgForReceiverByReceiver.unseal(sender) { unwrappedRumorToReceiverBySender ->
unwrappedMsgForReceiverByReceiver.unsealOrNull(sender)?.let { _ ->
fail(
"Should not be able to decrypt msg for the receiver by the receiver but decrypted with the sender",
)
@@ -296,227 +257,194 @@ class GiftWrapEventTest {
}
}
assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS))
}
@Test()
fun testInternalsGroupMessage() {
val sender = NostrSignerInternal(KeyPair())
val receiverA = NostrSignerInternal(KeyPair())
val receiverB = NostrSignerInternal(KeyPair())
fun testInternalsGroupMessage() =
runBlocking {
val sender = NostrSignerInternal(KeyPair())
val receiverA = NostrSignerInternal(KeyPair())
val receiverB = NostrSignerInternal(KeyPair())
val countDownLatch = CountDownLatch(3)
val senderMessage =
sender.sign(
ChatMessageEvent.build(
msg = "Who is going to the party tonight?",
to = listOf(PTag(receiverA.pubKey), PTag(receiverB.pubKey)),
),
)
var giftWrapEventToSender: GiftWrapEvent? = null
var giftWrapEventToReceiverA: GiftWrapEvent? = null
var giftWrapEventToReceiverB: GiftWrapEvent? = null
val msgFromSenderToReceiverA =
SealedRumorEvent.create(
event = senderMessage,
encryptTo = receiverA.pubKey,
signer = sender,
)
sender.sign(
ChatMessageEvent.build(
msg = "Who is going to the party tonight?",
to = listOf(PTag(receiverA.pubKey), PTag(receiverB.pubKey)),
),
) { senderMessage ->
SealedRumorEvent.create(
event = senderMessage,
encryptTo = receiverA.pubKey,
signer = sender,
) { msgFromSenderToReceiverA ->
// Should expose sender
assertEquals(msgFromSenderToReceiverA.pubKey, sender.pubKey)
// Should not expose receiver
assertTrue(msgFromSenderToReceiverA.tags.isEmpty())
// Should expose sender
assertEquals(msgFromSenderToReceiverA.pubKey, sender.pubKey)
// Should not expose receiver
assertTrue(msgFromSenderToReceiverA.tags.isEmpty())
val giftWrapForReceiverA =
GiftWrapEvent.create(
event = msgFromSenderToReceiverA,
recipientPubKey = receiverA.pubKey,
) { giftWrapForReceiverA ->
// Should not be signed by neither sender nor receiver
assertNotEquals(giftWrapForReceiverA.pubKey, sender.pubKey)
assertNotEquals(giftWrapForReceiverA.pubKey, receiverA.pubKey)
assertNotEquals(giftWrapForReceiverA.pubKey, receiverB.pubKey)
)
// Should not include sender as recipient
assertNotEquals(giftWrapForReceiverA.recipientPubKey(), sender.pubKey)
// Should not be signed by neither sender nor receiver
assertNotEquals(giftWrapForReceiverA.pubKey, sender.pubKey)
assertNotEquals(giftWrapForReceiverA.pubKey, receiverA.pubKey)
assertNotEquals(giftWrapForReceiverA.pubKey, receiverB.pubKey)
// Should be addressed to the receiver
assertEquals(giftWrapForReceiverA.recipientPubKey(), receiverA.pubKey)
// Should not include sender as recipient
assertNotEquals(giftWrapForReceiverA.recipientPubKey(), sender.pubKey)
giftWrapEventToReceiverA = giftWrapForReceiverA
// Should be addressed to the receiver
assertEquals(giftWrapForReceiverA.recipientPubKey(), receiverA.pubKey)
countDownLatch.countDown()
}
}
val msgFromSenderToReceiverB =
SealedRumorEvent.create(
event = senderMessage,
encryptTo = receiverB.pubKey,
signer = sender,
)
SealedRumorEvent.create(
event = senderMessage,
encryptTo = receiverB.pubKey,
signer = sender,
) { msgFromSenderToReceiverB ->
// Should expose sender
assertEquals(msgFromSenderToReceiverB.pubKey, sender.pubKey)
// Should not expose receiver
assertTrue(msgFromSenderToReceiverB.tags.isEmpty())
// Should expose sender
assertEquals(msgFromSenderToReceiverB.pubKey, sender.pubKey)
// Should not expose receiver
assertTrue(msgFromSenderToReceiverB.tags.isEmpty())
val giftWrapForReceiverB =
GiftWrapEvent.create(
event = msgFromSenderToReceiverB,
recipientPubKey = receiverB.pubKey,
) { giftWrapForReceiverB ->
// Should not be signed by neither sender nor receiver
assertNotEquals(giftWrapForReceiverB.pubKey, sender.pubKey)
assertNotEquals(giftWrapForReceiverB.pubKey, receiverA.pubKey)
assertNotEquals(giftWrapForReceiverB.pubKey, receiverB.pubKey)
)
// Should not include sender as recipient
assertNotEquals(giftWrapForReceiverB.recipientPubKey(), sender.pubKey)
// Should not be signed by neither sender nor receiver
assertNotEquals(giftWrapForReceiverB.pubKey, sender.pubKey)
assertNotEquals(giftWrapForReceiverB.pubKey, receiverA.pubKey)
assertNotEquals(giftWrapForReceiverB.pubKey, receiverB.pubKey)
// Should be addressed to the receiver
assertEquals(giftWrapForReceiverB.recipientPubKey(), receiverB.pubKey)
// Should not include sender as recipient
assertNotEquals(giftWrapForReceiverB.recipientPubKey(), sender.pubKey)
giftWrapEventToReceiverB = giftWrapForReceiverB
// Should be addressed to the receiver
assertEquals(giftWrapForReceiverB.recipientPubKey(), receiverB.pubKey)
countDownLatch.countDown()
}
}
val msgFromSenderToSender =
SealedRumorEvent.create(
event = senderMessage,
encryptTo = sender.pubKey,
signer = sender,
)
SealedRumorEvent.create(
event = senderMessage,
encryptTo = sender.pubKey,
signer = sender,
) { msgFromSenderToSender ->
// Should expose sender
assertEquals(msgFromSenderToSender.pubKey, sender.pubKey)
// Should not expose receiver
assertTrue(msgFromSenderToSender.tags.isEmpty())
// Should expose sender
assertEquals(msgFromSenderToSender.pubKey, sender.pubKey)
// Should not expose receiver
assertTrue(msgFromSenderToSender.tags.isEmpty())
val giftWrapToSender =
GiftWrapEvent.create(
event = msgFromSenderToSender,
recipientPubKey = sender.pubKey,
) { giftWrapToSender ->
// Should not be signed by neither the sender, not the receiver
assertNotEquals(giftWrapToSender.pubKey, sender.pubKey)
assertNotEquals(giftWrapToSender.pubKey, receiverA.pubKey)
assertNotEquals(giftWrapToSender.pubKey, receiverB.pubKey)
)
// Should not be addressed to the receiver
assertNotEquals(giftWrapToSender.recipientPubKey(), receiverA.pubKey)
assertNotEquals(giftWrapToSender.recipientPubKey(), receiverB.pubKey)
// Should be addressed to the sender
assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey)
// Should not be signed by neither the sender, not the receiver
assertNotEquals(giftWrapToSender.pubKey, sender.pubKey)
assertNotEquals(giftWrapToSender.pubKey, receiverA.pubKey)
assertNotEquals(giftWrapToSender.pubKey, receiverB.pubKey)
giftWrapEventToSender = giftWrapToSender
// Should not be addressed to the receiver
assertNotEquals(giftWrapToSender.recipientPubKey(), receiverA.pubKey)
assertNotEquals(giftWrapToSender.recipientPubKey(), receiverB.pubKey)
// Should be addressed to the sender
assertEquals(giftWrapToSender.recipientPubKey(), sender.pubKey)
countDownLatch.countDown()
}
}
}
// Done
// -----
// Decrypting
// Done
assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
// Receiver's side
// Makes sure it can only be decrypted by the target user
// Receiver's side
// Makes sure it can only be decrypted by the target user
assertNotNull(giftWrapToSender)
assertNotNull(giftWrapForReceiverA)
assertNotNull(giftWrapForReceiverB)
assertNotNull(giftWrapEventToSender)
assertNotNull(giftWrapEventToReceiverA)
assertNotNull(giftWrapEventToReceiverB)
val countDownDecryptLatch = CountDownLatch(3)
giftWrapEventToSender?.unwrap(sender) { unwrappedMsgForSenderBySender ->
val unwrappedMsgForSenderBySender = giftWrapToSender.unwrapThrowing(sender)
assertEquals(SealedRumorEvent.KIND, unwrappedMsgForSenderBySender.kind)
if (unwrappedMsgForSenderBySender is SealedRumorEvent) {
unwrappedMsgForSenderBySender.unseal(receiverA) { unwrappedRumorToSenderByReceiverA ->
unwrappedMsgForSenderBySender.unsealOrNull(receiverA)?.let { _ ->
fail()
}
unwrappedMsgForSenderBySender.unseal(receiverB) { unwrappedRumorToSenderByReceiverB ->
unwrappedMsgForSenderBySender.unsealOrNull(receiverB)?.let { _ ->
fail()
}
unwrappedMsgForSenderBySender.unseal(sender) { unwrappedRumorToSenderBySender ->
assertEquals(
"Who is going to the party tonight?",
unwrappedRumorToSenderBySender.content,
)
}
val unwrappedRumorToSenderBySender = unwrappedMsgForSenderBySender.unsealThrowing(sender)
assertEquals("Who is going to the party tonight?", unwrappedRumorToSenderBySender.content)
}
countDownDecryptLatch.countDown()
}
giftWrapForReceiverA.unwrapOrNull(sender)?.let { unwrappedMsgForReceiverBySenderA ->
fail("Should not be able to decode msg to the receiver A with the sender's key")
}
giftWrapEventToReceiverA!!.unwrap(sender) { unwrappedMsgForReceiverBySenderA ->
fail("Should not be able to decode msg to the receiver A with the sender's key")
}
giftWrapForReceiverB.unwrapOrNull(sender)?.let { unwrappedMsgForReceiverBySenderB ->
fail("Should not be able to decode msg to the receiver B with the sender's key")
}
giftWrapEventToReceiverB!!.unwrap(sender) { unwrappedMsgForReceiverBySenderB ->
fail("Should not be able to decode msg to the receiver B with the sender's key")
}
giftWrapToSender.unwrapOrNull(receiverA)?.let {
fail("Should not be able to decode msg to sender with the receiver A's key")
}
giftWrapEventToSender!!.unwrap(receiverA) {
fail("Should not be able to decode msg to sender with the receiver A's key")
}
giftWrapEventToReceiverA!!.unwrap(receiverA) { unwrappedMsgForReceiverAByReceiverA ->
val unwrappedMsgForReceiverAByReceiverA = giftWrapForReceiverA.unwrapThrowing(receiverA)
assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverAByReceiverA.kind)
if (unwrappedMsgForReceiverAByReceiverA is SealedRumorEvent) {
unwrappedMsgForReceiverAByReceiverA.unseal(receiverA) { unwrappedRumorToReceiverAByReceiverA ->
assertEquals(
"Who is going to the party tonight?",
unwrappedRumorToReceiverAByReceiverA.content,
)
}
val unwrappedRumorToReceiverAByReceiverA = unwrappedMsgForReceiverAByReceiverA.unsealThrowing(receiverA)
assertEquals("Who is going to the party tonight?", unwrappedRumorToReceiverAByReceiverA.content)
unwrappedMsgForReceiverAByReceiverA.unseal(sender) { unwrappedRumorToReceiverABySender ->
unwrappedMsgForReceiverAByReceiverA.unsealOrNull(sender)?.let { unwrappedRumorToReceiverABySender ->
fail()
}
unwrappedMsgForReceiverAByReceiverA.unseal(receiverB) { unwrappedRumorToReceiverAByReceiverB ->
unwrappedMsgForReceiverAByReceiverA.unsealOrNull(receiverB)?.let { unwrappedRumorToReceiverAByReceiverB ->
fail()
}
}
countDownDecryptLatch.countDown()
}
giftWrapForReceiverB.unwrapOrNull(receiverA)?.let {
fail("Should not be able to decode msg to sender with the receiver A's key")
}
giftWrapEventToReceiverB!!.unwrap(receiverA) {
fail("Should not be able to decode msg to sender with the receiver A's key")
}
giftWrapToSender.unwrapOrNull(receiverB)?.let { unwrappedMsgForSenderByReceiverB ->
fail("Should not be able to decode msg to sender with the receiver B's key")
}
giftWrapForReceiverA.unwrapOrNull(receiverB)?.let { unwrappedMsgForReceiverAByReceiverB ->
fail("Should not be able to decode msg to receiver A with the receiver B's key")
}
giftWrapEventToSender!!.unwrap(receiverB) { unwrappedMsgForSenderByReceiverB ->
fail("Should not be able to decode msg to sender with the receiver B's key")
}
giftWrapEventToReceiverA!!.unwrap(receiverB) { unwrappedMsgForReceiverAByReceiverB ->
fail("Should not be able to decode msg to receiver A with the receiver B's key")
}
giftWrapEventToReceiverB!!.unwrap(receiverB) { unwrappedMsgForReceiverBByReceiverB ->
val unwrappedMsgForReceiverBByReceiverB = giftWrapForReceiverB.unwrapThrowing(receiverB)
assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverBByReceiverB.kind)
if (unwrappedMsgForReceiverBByReceiverB is SealedRumorEvent) {
unwrappedMsgForReceiverBByReceiverB.unseal(receiverA) { unwrappedRumorToReceiverBByReceiverA ->
unwrappedMsgForReceiverBByReceiverB.unsealOrNull(receiverA)?.let { unwrappedRumorToReceiverBByReceiverA ->
fail()
}
unwrappedMsgForReceiverBByReceiverB.unseal(receiverB) { unwrappedRumorToReceiverBByReceiverB ->
assertEquals(
"Who is going to the party tonight?",
unwrappedRumorToReceiverBByReceiverB.content,
)
val unwrappedRumorToReceiverBByReceiverB = unwrappedMsgForReceiverBByReceiverB.unsealThrowing(receiverB)
assertEquals(
"Who is going to the party tonight?",
unwrappedRumorToReceiverBByReceiverB.content,
)
countDownDecryptLatch.countDown()
}
unwrappedMsgForReceiverBByReceiverB.unseal(sender) { unwrappedRumorToReceiverBBySender ->
unwrappedMsgForReceiverBByReceiverB.unsealOrNull(sender)?.let { unwrappedRumorToReceiverBBySender ->
fail()
}
}
}
assertTrue(countDownDecryptLatch.await(1, TimeUnit.SECONDS))
}
@Test
fun testCaseFromAmethyst1() {
val json =
@@ -537,18 +465,14 @@ class GiftWrapEventTest {
}
""".trimIndent()
var rumor: Event? = null
wait1SecondForResult { onDone ->
val privateKey = "de6152a85a0dea3b09a08a6f8139a314d498a7b52f7e5c28858b64270abd4c70"
unwrapUnsealRumor(json, privateKey) {
rumor = it
onDone()
val rumor: Event =
runBlocking {
val privateKey = "de6152a85a0dea3b09a08a6f8139a314d498a7b52f7e5c28858b64270abd4c70"
unwrapUnsealRumor(json, privateKey)
}
}
assertNotNull(rumor)
assertEquals("Hola, que tal?", rumor?.content)
assertEquals("Hola, que tal?", rumor.content)
}
@Test
@@ -573,17 +497,13 @@ class GiftWrapEventTest {
val privateKey = "409ff7654141eaa16cd2161fe5bd127aeaef71f270c67587474b78998a8e3533"
var rumor: Event? = null
wait1SecondForResult { onDone ->
unwrapUnsealRumor(json, privateKey) {
rumor = it
onDone()
val rumor: Event =
runBlocking {
unwrapUnsealRumor(json, privateKey)
}
}
assertNotNull(rumor)
assertEquals("Hola, que tal?", rumor?.content)
assertEquals("Hola, que tal?", rumor.content)
}
@Test
@@ -610,17 +530,14 @@ class GiftWrapEventTest {
""".trimIndent()
val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771"
var rumor: Event? = null
wait1SecondForResult { onDone ->
unwrapUnsealRumor(json, privateKey) {
rumor = it
onDone()
val rumor: Event =
runBlocking {
unwrapUnsealRumor(json, privateKey)
}
}
assertNotNull(rumor)
assertEquals("test", rumor?.content)
assertEquals("test", rumor.content)
}
@Test
@@ -645,21 +562,17 @@ class GiftWrapEventTest {
val privateKey = "09e0051fdf5fdd9dd7a54713583006442cbdbf87bdcdab1a402f26e527d56771"
var rumor: Event? = null
wait1SecondForResult { onDone ->
unwrapUnsealRumor(json, privateKey) {
rumor = it
onDone()
val rumor =
runBlocking {
unwrapUnsealRumor(json, privateKey)
}
}
assertEquals("asdfasdfasdf", rumor?.content)
assertEquals(1690659269L, rumor?.createdAt)
assertEquals("827ba09d32ab81d62c60f657b350198c8aaba84372dab9ad3f4f6b8b7274b707", rumor?.id)
assertEquals(14, rumor?.kind)
assertEquals("subject", rumor?.tags?.firstOrNull()?.get(0))
assertEquals("test", rumor?.tags?.firstOrNull()?.get(1))
assertEquals("asdfasdfasdf", rumor.content)
assertEquals(1690659269L, rumor.createdAt)
assertEquals("827ba09d32ab81d62c60f657b350198c8aaba84372dab9ad3f4f6b8b7274b707", rumor.id)
assertEquals(14, rumor.kind)
assertEquals("subject", rumor.tags.firstOrNull()?.get(0))
assertEquals("test", rumor.tags.firstOrNull()?.get(1))
}
@Test
@@ -684,33 +597,28 @@ class GiftWrapEventTest {
val privateKey = "7dd22cafc512c0bc363a259f6dcda515b13ae3351066d7976fd0bb79cbd0d700"
var rumor: Event? = null
wait1SecondForResult { onDone ->
unwrapUnsealRumor(json, privateKey) {
rumor = it
onDone()
val rumor =
runBlocking {
unwrapUnsealRumor(json, privateKey)
}
}
assertEquals("8d1a56008d4e31dae2fb8bef36b3efea519eff75f57033107e2aa16702466ef2", rumor?.id)
assertEquals("Howdy", rumor?.content)
assertEquals(1690833960L, rumor?.createdAt)
assertEquals(14, rumor?.kind)
assertEquals("p", rumor?.tags?.firstOrNull()?.get(0))
assertEquals("8d1a56008d4e31dae2fb8bef36b3efea519eff75f57033107e2aa16702466ef2", rumor.id)
assertEquals("Howdy", rumor.content)
assertEquals(1690833960L, rumor.createdAt)
assertEquals(14, rumor.kind)
assertEquals("p", rumor.tags.firstOrNull()?.get(0))
assertEquals(
"b08d8857a92b4d6aa580ff55cc3c18c4edf313c83388c34abc118621f74f1a78",
rumor?.tags?.firstOrNull()?.get(1),
rumor.tags.firstOrNull()?.get(1),
)
assertEquals("subject", rumor?.tags?.getOrNull(1)?.get(0))
assertEquals("Stuff", rumor?.tags?.getOrNull(1)?.get(1))
assertEquals("subject", rumor.tags.getOrNull(1)?.get(0))
assertEquals("Stuff", rumor.tags.getOrNull(1)?.get(1))
}
fun unwrapUnsealRumor(
suspend fun unwrapUnsealRumor(
json: String,
privateKey: HexKey,
onReady: (Event) -> Unit,
) {
): Event {
val pkBytes = NostrSignerInternal(KeyPair(privateKey.hexToByteArray()))
val wrap = Event.fromJson(json) as GiftWrapEvent
@@ -718,13 +626,13 @@ class GiftWrapEventTest {
assertEquals(pkBytes.pubKey, wrap.recipientPubKey())
wrap.unwrap(pkBytes) { event ->
if (event is SealedRumorEvent) {
event.unseal(pkBytes, onReady)
} else {
println(event.toJson())
fail("Event is not a Sealed Rumor")
}
val event = wrap.unwrapThrowing(pkBytes)
return if (event is SealedRumorEvent) {
event.unsealThrowing(pkBytes)
} else {
println(event.toJson())
fail("Event is not a Sealed Rumor")
throw Exception("Event is not a Sealed Rumor")
}
}
@@ -750,23 +658,11 @@ class GiftWrapEventTest {
val wrap = Event.fromJson(msg) as GiftWrapEvent
wrap.checkSignature()
var event: Event? = null
wait1SecondForResult { onDone ->
wrap.unwrap(receiversPrivateKey) {
event = it
onDone()
val event =
runBlocking {
wrap.unwrapThrowing(receiversPrivateKey)
}
}
assertNotNull(event)
}
}
fun wait1SecondForResult(run: (onDone: () -> Unit) -> Unit) {
val countDownLatch = CountDownLatch(1)
run { countDownLatch.countDown() }
assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
}
@@ -22,7 +22,6 @@ package com.vitorpamplona.quartz
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
@@ -68,22 +67,23 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent
@@ -124,19 +124,30 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
interface EventBuilder {
fun build(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
): Event
}
class EventFactory {
companion object {
val factories: MutableMap<Int, (HexKey, HexKey, Long, Array<Array<String>>, String, HexKey) -> Event> = mutableMapOf()
val factories: MutableMap<Int, EventBuilder> = mutableMapOf()
fun create(
id: String,
pubKey: String,
fun <T : Event> create(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
sig: String,
): Event =
sig: HexKey,
): T =
when (kind) {
AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig)
AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
@@ -265,12 +276,9 @@ class EventFactory {
VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig)
WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig)
else -> {
factories[kind]?.let {
return it(id, pubKey, createdAt, tags, content, sig)
}
Event(id, pubKey, createdAt, kind, tags, content, sig)
factories[kind]?.build(id, pubKey, createdAt, tags, content, sig)
?: Event(id, pubKey, createdAt, kind, tags, content, sig)
}
}
} as T
}
}
@@ -45,68 +45,62 @@ class DecoupledCipher {
pubKey = fromPublicKey.hexToByteArray(),
)
fun encrypt(
suspend fun encrypt(
decryptedContent: String,
toPublicKey: HexKey,
fromKeyList: EncryptionKeyListEvent,
toKeyList: EncryptionKeyListEvent,
signer: NostrSigner,
onReady: (String) -> Unit,
) {
): String? {
val toKeys = toKeyList.keys()
val sendToKey = if (toKeys.isEmpty()) toKeyList.pubKey else toKeys.random().pubkey
val fromKeys = fromKeyList.keys()
// uses the main key
if (fromKeys.isEmpty()) {
signer.nip44Encrypt(decryptedContent, sendToKey, onReady)
return if (fromKeys.isEmpty()) {
signer.nip44Encrypt(decryptedContent, sendToKey)
} else {
val keyToUse = fromKeys.random()
EncryptionKeyCache.getOrLoad(
deriveFromPubKey = signer.pubKey,
nonce = keyToUse.nonce,
load = { onLoaded ->
signer.deriveKey(keyToUse.nonce) { newPrivKey ->
onLoaded(newPrivKey.hexToByteArray())
}
},
) { derivedPrivKey ->
onReady(innerEncrypt(decryptedContent, derivedPrivKey, sendToKey))
}
EncryptionKeyCache
.getOrLoad(
deriveFromPubKey = signer.pubKey,
nonce = keyToUse.nonce,
load = { signer.deriveKey(keyToUse.nonce).hexToByteArray() },
)?.let { derivedPrivKey ->
return innerEncrypt(decryptedContent, derivedPrivKey, sendToKey)
}
}
}
fun decrypt(
suspend fun decrypt(
encryptedContent: String,
fromPublicKey: HexKey,
toPublicKey: HexKey,
fromKeyList: EncryptionKeyListEvent,
toEncryptedKeyList: EncryptionKeyListEvent,
signer: NostrSigner,
onReady: (String) -> Unit,
) {
): String? {
val fromKeys = fromKeyList.keys()
val sentFromKey = if (fromKeys.isEmpty()) fromKeyList.pubKey else fromKeys.random().pubkey
val keyToUse = toEncryptedKeyList.keys().firstOrNull { it.pubkey == toPublicKey }
// uses the main key
if (signer.pubKey == toPublicKey) {
signer.nip44Decrypt(encryptedContent, sentFromKey, onReady)
return if (signer.pubKey == toPublicKey) {
signer.nip44Decrypt(encryptedContent, sentFromKey)
} else if (keyToUse != null) {
EncryptionKeyCache.getOrLoad(
deriveFromPubKey = signer.pubKey,
nonce = keyToUse.nonce,
load = { onLoaded ->
signer.deriveKey(keyToUse.nonce) { newPrivKey ->
onLoaded(newPrivKey.hexToByteArray())
}
},
) { derivedPrivKey ->
innerDecrypt(encryptedContent, derivedPrivKey, sentFromKey)?.let { onReady(it) }
}
EncryptionKeyCache
.getOrLoad(
deriveFromPubKey = signer.pubKey,
nonce = keyToUse.nonce,
load = { signer.deriveKey(keyToUse.nonce).hexToByteArray() },
)?.let { derivedPrivKey ->
innerDecrypt(encryptedContent, derivedPrivKey, sentFromKey)
}
} else {
null
}
}
}
@@ -35,29 +35,29 @@ object EncryptionKeyCache {
deriveFromPubKey: HexKey,
nonce: HexKey,
privKey: ByteArray,
) = sharedNonceKeyCache.put(idx(deriveFromPubKey, nonce), privKey)
): ByteArray? = sharedNonceKeyCache.put(idx(deriveFromPubKey, nonce), privKey)
fun get(
deriveFromPubKey: HexKey,
nonce: HexKey,
) = sharedNonceKeyCache.get(idx(deriveFromPubKey, nonce))
): ByteArray? = sharedNonceKeyCache.get(idx(deriveFromPubKey, nonce))
inline fun getOrLoad(
deriveFromPubKey: HexKey,
nonce: HexKey,
load: (onLoaded: (privKey: ByteArray) -> Unit) -> Unit,
crossinline whenReady: (privKey: ByteArray) -> Unit,
) {
load: () -> ByteArray?,
): ByteArray? {
val cachedPrivKey = get(deriveFromPubKey, nonce)
if (cachedPrivKey != null) {
whenReady(cachedPrivKey)
return
return cachedPrivKey
}
load { newPrivKey ->
val newPrivKey = load()
newPrivKey?.let {
put(deriveFromPubKey, nonce, newPrivKey)
whenReady(newPrivKey)
newPrivKey
}
return newPrivKey
}
}
@@ -1,173 +0,0 @@
/**
* 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.edits
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.experimental.edits.tags.RelayTag
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class PrivateOutboxRelayListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var privateTagsCache: Array<Array<String>>? = null
override fun isContentEncoded() = true
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0)
fun relays(): List<NormalizedRelayUrl>? =
tags
.mapNotNull(RelayTag::parse)
.plus(
privateTagsCache?.mapNotNull(RelayTag::parse) ?: emptyList(),
).ifEmpty { null }
fun cachedPrivateTags(): Array<Array<String>>? = privateTagsCache
fun privateTags(
signer: NostrSigner,
onReady: (Array<Array<String>>) -> Unit,
) {
if (content.isEmpty()) {
onReady(emptyArray())
return
}
privateTagsCache?.let {
onReady(it)
return
}
try {
signer.nip44Decrypt(content, pubKey) {
try {
privateTagsCache = JsonMapper.mapper.readValue<TagArray>(it)
privateTagsCache?.let { onReady(it) }
} catch (e: Throwable) {
Log.w("PrivateOutboxRelayListEvent", "Error parsing the JSON: ${e.message}. Json `$it` from event `${toNostrUri()}`")
}
}
} catch (e: Throwable) {
Log.w("PrivateOutboxRelayListEvent", "Error decrypting content: ${e.message}. Event: `${toNostrUri()}`")
}
}
companion object {
const val KIND = 10013
val TAGS = arrayOf(AltTag.assemble("Relay list to store private content from this author"))
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
fun encryptTags(
privateTags: Array<Array<String>>? = null,
signer: NostrSigner,
onReady: (String) -> Unit,
) {
val msg = JsonMapper.mapper.writeValueAsString(privateTags)
signer.nip44Encrypt(
msg,
signer.pubKey,
onReady,
)
}
fun createTagArray(relays: List<NormalizedRelayUrl>): Array<Array<String>> =
relays
.map {
RelayTag.assemble(it)
}.toTypedArray()
fun updateRelayList(
earlierVersion: PrivateOutboxRelayListEvent,
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PrivateOutboxRelayListEvent) -> Unit,
) {
val tags =
earlierVersion.privateTagsCache
?.filter(RelayTag::notMatch)
?.plus(
relays.map {
RelayTag.assemble(it)
},
)?.toTypedArray() ?: emptyArray()
encryptTags(tags, signer) {
signer.sign<PrivateOutboxRelayListEvent>(createdAt, KIND, TAGS, it) {
it.privateTagsCache = tags
onReady(it)
}
}
}
fun createFromScratch(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PrivateOutboxRelayListEvent) -> Unit,
) {
create(relays, signer, createdAt, onReady)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PrivateOutboxRelayListEvent) -> Unit,
) {
val privateTagArray = createTagArray(relays)
encryptTags(privateTagArray, signer) { privateTags ->
signer.sign<PrivateOutboxRelayListEvent>(createdAt, KIND, TAGS, privateTags) {
it.privateTagsCache = privateTagArray
onReady(it)
}
}
}
}
}
@@ -45,15 +45,14 @@ class TextNoteModificationEvent(
const val KIND = 1010
const val ALT = "Content Change Event"
fun create(
suspend fun create(
content: String,
eventId: HexKey,
notify: HexKey?,
summary: String?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (TextNoteModificationEvent) -> Unit,
) {
): TextNoteModificationEvent {
val tags = mutableListOf(arrayOf("e", eventId))
notify?.let {
@@ -66,7 +65,7 @@ class TextNoteModificationEvent(
tags.add(AltTag.assemble(ALT))
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
return signer.sign(createdAt, KIND, tags.toTypedArray(), content)
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.experimental.ephemChat.chat
import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@@ -39,4 +40,6 @@ data class RoomId(
result
}
}
fun toTagArray() = RoomIdTag.assemble(id, relayUrl)
}
@@ -22,15 +22,22 @@ package com.vitorpamplona.quartz.experimental.ephemChat.list
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag
import com.vitorpamplona.quartz.experimental.ephemChat.list.rooms
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.utils.TimeUtils
import java.lang.reflect.Modifier.isPrivate
@Immutable
class EphemeralChatListEvent(
@@ -41,31 +48,6 @@ class EphemeralChatListEvent(
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient var publicAndPrivateEventCache: Set<RoomId>? = null
fun publicAndPrivateRoomIds(
signer: NostrSigner,
onReady: (Set<RoomId>) -> Unit,
) {
publicAndPrivateEventCache?.let { eventList ->
onReady(eventList)
return
}
privateTags(signer) {
val set = filterRooms(it)
publicAndPrivateEventCache = set
onReady(set)
}
}
fun filterRooms(privateTags: Array<Array<String>>): Set<RoomId> {
val privateRooms = privateTags.mapNotNull(RoomIdTag::parse)
val publicRooms = tags.mapNotNull(RoomIdTag::parse)
return (privateRooms + publicRooms).toSet()
}
companion object {
const val KIND = 10023
const val ALT = "Ephemeral Chat List"
@@ -73,76 +55,120 @@ class EphemeralChatListEvent(
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
fun createRoom(
suspend fun create(
room: RoomId,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (EphemeralChatListEvent) -> Unit,
) {
val tags = arrayOf(RoomIdTag.assemble(room))
): EphemeralChatListEvent =
if (isPrivate) {
PrivateTagsInContent.encryptNip04(
privateTags = tags,
create(
publicRooms = emptyList(),
privateRooms = listOf(room),
signer = signer,
) { encryptedTags ->
create(encryptedTags, emptyArray(), signer, createdAt, onReady)
}
createdAt = createdAt,
)
} else {
create("", tags, signer, createdAt, onReady)
create(
publicRooms = listOf(room),
privateRooms = emptyList(),
signer = signer,
createdAt = createdAt,
)
}
}
fun removeRoom(
suspend fun add(
earlierVersion: EphemeralChatListEvent,
room: RoomId,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (EphemeralChatListEvent) -> Unit,
) {
PrivateTagArrayBuilder.removeAll(
earlierVersion,
RoomIdTag.assemble(room.id, room.relayUrl),
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
): EphemeralChatListEvent =
if (isPrivate) {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
resign(
tags = earlierVersion.tags,
privateTags = privateTags.plus(room.toTagArray()),
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(room.toTagArray()),
signer = signer,
createdAt = createdAt,
)
}
}
fun addRoom(
suspend fun remove(
earlierVersion: EphemeralChatListEvent,
room: RoomId,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (EphemeralChatListEvent) -> Unit,
) {
PrivateTagArrayBuilder.add(
earlierVersion,
RoomIdTag.assemble(room.id, room.relayUrl.url),
isPrivate,
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
): EphemeralChatListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
return resign(
privateTags = privateTags.remove(room.toTagArray()),
tags = earlierVersion.tags.remove(room.toTagArray()),
signer = signer,
createdAt = createdAt,
)
}
fun create(
suspend fun resign(
tags: TagArray,
privateTags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = resign(
content = PrivateTagsInContent.encryptNip04(privateTags, signer),
tags = tags,
signer = signer,
createdAt = createdAt,
)
suspend fun resign(
content: String,
tags: Array<Array<String>>,
tags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (EphemeralChatListEvent) -> Unit,
) {
): EphemeralChatListEvent {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
if (tags.fastAny(AltTag::match)) {
tags
} else {
tags + AltTag.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
return signer.sign(createdAt, KIND, newTags, content)
}
suspend fun create(
publicRooms: List<RoomId> = emptyList(),
privateRooms: List<RoomId> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): EphemeralChatListEvent {
val template = build(publicRooms, privateRooms, signer, createdAt)
return signer.sign(template)
}
suspend fun build(
publicRooms: List<RoomId> = emptyList(),
privateRooms: List<RoomId> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EphemeralChatListEvent>.() -> Unit = {},
) = eventTemplate<EphemeralChatListEvent>(
kind = KIND,
description = PrivateTagsInContent.encryptNip04(privateRooms.map { it.toTagArray() }.toTypedArray(), signer),
createdAt = createdAt,
) {
alt(ALT)
rooms(publicRooms)
initializer()
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.experimental.ephemChat.list
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
@@ -27,3 +28,5 @@ fun TagArrayBuilder<EphemeralChatListEvent>.roomId(
id: String,
relayUrl: String,
) = addUnique(RoomIdTag.assemble(id, relayUrl))
fun TagArrayBuilder<EphemeralChatListEvent>.rooms(rooms: List<RoomId>) = addAll(rooms.map { it.toTagArray() })
@@ -0,0 +1,28 @@
/**
* 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.ephemChat.list
import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.rooms() = mapNotNull(RoomIdTag::parse)
fun TagArray.roomSet() = mapNotNullTo(mutableSetOf(), RoomIdTag::parse)
@@ -25,9 +25,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@Deprecated("Replaced by NIP-68")
@Immutable
class GalleryListEvent(
id: HexKey,
@@ -36,108 +37,96 @@ class GalleryListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 10011
const val ALT = "Profile Gallery"
const val GALLERYTAGNAME = "url"
const val GALLERY_TAG_NAME = "url"
fun addEvent(
suspend fun addEvent(
earlierVersion: GalleryListEvent?,
eventId: HexKey,
url: String,
relay: String?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) = addTag(earlierVersion, GALLERYTAGNAME, eventId, url, relay, signer, createdAt, onReady)
) = addTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, relay, signer, createdAt)
fun addTag(
suspend fun addTag(
earlierVersion: GalleryListEvent?,
tagName: String,
eventid: HexKey,
eventId: HexKey,
url: String,
relay: String?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
val tags = arrayOf(tagName, url, eventid)
): GalleryListEvent {
val tags = arrayOf(tagName, url, eventId)
if (relay != null) {
tags + relay
}
add(
return add(
earlierVersion,
arrayOf(tags),
signer,
createdAt,
onReady,
)
}
fun add(
suspend fun add(
earlierVersion: GalleryListEvent?,
listNewTags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
): GalleryListEvent =
create(
content = earlierVersion?.content ?: "",
tags = listNewTags.plus(earlierVersion?.tags ?: arrayOf()),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun removeEvent(
suspend fun removeEvent(
earlierVersion: GalleryListEvent,
eventId: HexKey,
url: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) = removeTag(earlierVersion, GALLERYTAGNAME, eventId, url, signer, createdAt, onReady)
) = removeTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, signer, createdAt)
fun removeReplaceable(
suspend fun removeReplaceable(
earlierVersion: GalleryListEvent,
aTag: ATag,
url: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) = removeTag(earlierVersion, GALLERYTAGNAME, aTag.toTag(), url, signer, createdAt, onReady)
) = removeTag(earlierVersion, GALLERY_TAG_NAME, aTag.toTag(), url, signer, createdAt)
private fun removeTag(
private suspend fun removeTag(
earlierVersion: GalleryListEvent,
tagName: String,
eventid: HexKey,
eventId: HexKey,
url: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
): GalleryListEvent =
create(
content = earlierVersion.content,
tags =
earlierVersion.tags
.filter { it.size <= 1 || !(it[0] == tagName && it[1] == url && it[2] == eventid) }
.filter { it.size <= 1 || !(it[0] == tagName && it[1] == url && it[2] == eventId) }
.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun create(
suspend fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
): GalleryListEvent {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
@@ -145,7 +134,7 @@ class GalleryListEvent(
tags + AltTag.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
return signer.sign(createdAt, KIND, newTags, content)
}
}
@@ -21,7 +21,7 @@
package com.vitorpamplona.quartz.experimental.relationshipStatus
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetnameTag
import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetNameTag
import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.RankTag
import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.SummaryTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -45,7 +45,7 @@ class RelationshipStatusEvent(
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun rank() = tags.firstNotNullOfOrNull(RankTag::parse)
fun petname() = tags.firstNotNullOfOrNull(PetnameTag::parse)
fun petName() = tags.firstNotNullOfOrNull(PetNameTag::parse)
fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse)
@@ -53,16 +53,15 @@ class RelationshipStatusEvent(
const val KIND = 30382
const val ALT = "Relationship Status"
fun create(
suspend fun create(
targetUser: HexKey,
petname: String? = null,
petName: String? = null,
summary: String? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
publicInitializer: TagArrayBuilder<RelationshipStatusEvent>.() -> Unit = {},
privateInitializer: TagArrayBuilder<RelationshipStatusEvent>.() -> Unit = {},
onReady: (RelationshipStatusEvent) -> Unit,
) {
): RelationshipStatusEvent {
val publicTags =
tagArray {
alt(ALT)
@@ -72,14 +71,13 @@ class RelationshipStatusEvent(
val privateTags =
tagArray {
petname?.let { petname(it) }
petName?.let { petName(it) }
summary?.let { summary(it) }
privateInitializer()
}
PrivateTagsInContent.encryptNip44(privateTags, signer) { content ->
signer.sign(createdAt, KIND, publicTags, content, onReady)
}
val encryptedContent = PrivateTagsInContent.encryptNip44(privateTags, signer)
return signer.sign(createdAt, KIND, publicTags, encryptedContent)
}
}
}
@@ -20,13 +20,13 @@
*/
package com.vitorpamplona.quartz.experimental.relationshipStatus
import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetnameTag
import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetNameTag
import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.RankTag
import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.SummaryTag
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun TagArrayBuilder<RelationshipStatusEvent>.rank(rank: Int) = add(RankTag.assemble(rank))
fun TagArrayBuilder<RelationshipStatusEvent>.petname(name: String) = add(PetnameTag.assemble(name))
fun TagArrayBuilder<RelationshipStatusEvent>.petName(name: String) = add(PetNameTag.assemble(name))
fun TagArrayBuilder<RelationshipStatusEvent>.summary(summary: String) = add(SummaryTag.assemble(summary))
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.experimental.relationshipStatus.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class PetnameTag {
class PetNameTag {
companion object {
const val TAG_NAME = "petname"
@@ -36,6 +36,6 @@ class PetnameTag {
}
@JvmStatic
fun assemble(petname: String) = arrayOf(TAG_NAME, petname)
fun assemble(petName: String) = arrayOf(TAG_NAME, petName)
}
}
@@ -41,17 +41,10 @@ fun Event.verifySignature(): Boolean {
/** Checks if the ID is correct and then if the pubKey's secret key signed the event. */
fun Event.checkSignature() {
if (!verifyId()) {
throw Exception(
"""
|Unexpected ID.
| Event: ${toJson()}
| Actual ID: $id
| Generated: ${generateId()}
""".trimIndent(),
)
throw Exception("ID mismatch: our ID is ${generateId()} for event ${toJson()}")
}
if (!verifySignature()) {
throw Exception("""Bad signature!""")
throw Exception("Bad signature!")
}
}
@@ -48,7 +48,7 @@ class EventAssembler {
tags,
content,
sig,
) as T
)
}
}
}
@@ -43,7 +43,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorSerializer
class JsonMapper {
companion object Companion {
companion object {
val defaultPrettyPrinter = InliningTagArrayPrettyPrinter()
val mapper =
@@ -24,10 +24,13 @@ import android.util.Log
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class RelayAuthenticator(
val client: NostrClient,
val authenticate: (challenge: String, relay: IRelayClient) -> Unit,
val scope: CoroutineScope,
val authenticate: suspend (challenge: String, relay: IRelayClient) -> Unit,
) {
private val clientListener =
object : IRelayClientListener {
@@ -35,7 +38,9 @@ class RelayAuthenticator(
relay: IRelayClient,
challenge: String,
) {
authenticate(challenge, relay)
scope.launch {
authenticate(challenge, relay)
}
}
}
@@ -144,7 +144,6 @@ open class BasicRelayClient(
stats.newError(e.message ?: "Error trying to connect: ${e.javaClass.simpleName}")
markConnectionAsClosed()
e.printStackTrace()
} finally {
connectingMutex.set(false)
}
@@ -224,7 +223,6 @@ open class BasicRelayClient(
markConnectionAsClosed()
Log.w(logTag, "OnFailure $code $response ${t.message} $socket")
t.printStackTrace()
listener.onError(
this@BasicRelayClient,
"",
@@ -20,10 +20,8 @@
*/
package com.vitorpamplona.quartz.nip01Core.signers
import com.vitorpamplona.quartz.EventFactory
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
@@ -33,104 +31,49 @@ abstract class NostrSigner(
) {
abstract fun isWriteable(): Boolean
fun <T : Event> sign(
ev: EventTemplate<T>,
onReady: (T) -> Unit,
) = sign(ev.createdAt, ev.kind, ev.tags, ev.content, onReady)
suspend fun <T : Event> sign(ev: EventTemplate<T>): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content)
abstract fun <T : Event> sign(
abstract suspend fun <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
onReady: (T) -> Unit,
)
): T
abstract fun nip04Encrypt(
abstract suspend fun nip04Encrypt(
plaintext: String,
toPublicKey: HexKey,
onReady: (String) -> Unit,
)
): String
abstract fun nip04Decrypt(
abstract suspend fun nip04Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
onReady: (String) -> Unit,
)
): String
abstract fun nip44Encrypt(
abstract suspend fun nip44Encrypt(
plaintext: String,
toPublicKey: HexKey,
onReady: (String) -> Unit,
)
): String
abstract fun nip44Decrypt(
abstract suspend fun nip44Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
onReady: (String) -> Unit,
)
): String
abstract fun decryptZapEvent(
event: LnZapRequestEvent,
onReady: (LnZapPrivateEvent) -> Unit,
)
abstract suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent
abstract fun deriveKey(
nonce: HexKey,
onReady: (HexKey) -> Unit,
)
abstract suspend fun deriveKey(nonce: HexKey): HexKey
fun decrypt(
suspend fun decrypt(
encryptedContent: String,
fromPublicKey: HexKey,
onReady: (String) -> Unit,
) {
if (EncryptedInfo.isNIP04(encryptedContent)) {
nip04Decrypt(encryptedContent, fromPublicKey, onReady)
): String {
if (encryptedContent.isBlank()) throw SignerExceptions.NothingToDecrypt()
return if (EncryptedInfo.isNIP04(encryptedContent)) {
nip04Decrypt(encryptedContent, fromPublicKey)
} else {
nip44Decrypt(encryptedContent, fromPublicKey, onReady)
nip44Decrypt(encryptedContent, fromPublicKey)
}
}
fun <T : Event> assembleRumor(
ev: EventTemplate<T>,
onReady: (T) -> Unit,
) = assembleRumor(ev.createdAt, ev.kind, ev.tags, ev.content, onReady)
fun <T : Event> assembleRumor(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
onReady: (T) -> Unit,
) {
onReady(
EventFactory.create(
id = EventHasher.hashId(pubKey, createdAt, kind, tags, content),
pubKey = pubKey,
createdAt = createdAt,
kind = kind,
tags = tags,
content = content,
sig = "",
) as T,
)
}
fun <T : Event> assembleRumor(ev: EventTemplate<T>) = assembleRumor<T>(ev.createdAt, ev.kind, ev.tags, ev.content)
fun <T : Event> assembleRumor(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
) = EventFactory.create(
id = EventHasher.hashId(pubKey, createdAt, kind, tags, content),
pubKey = pubKey,
createdAt = createdAt,
kind = kind,
tags = tags,
content = content,
sig = "",
) as T
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import kotlinx.coroutines.CancellationException
class NostrSignerInternal(
val keyPair: KeyPair,
@@ -34,59 +35,68 @@ class NostrSignerInternal(
override fun isWriteable(): Boolean = keyPair.privKey != null
override fun <T : Event> sign(
inline fun <T> runWrapErrors(action: () -> T): T =
try {
action()
} catch (e: SignerExceptions) {
throw e
} catch (e: Exception) {
if (e is CancellationException) throw e
throw SignerExceptions.CouldNotPerformException("Could not sign event.", e)
}
override suspend fun <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
onReady: (T) -> Unit,
) {
signerSync.sign<T>(createdAt, kind, tags, content)?.let { onReady(it) }
}
): T =
runWrapErrors {
signerSync.sign<T>(createdAt, kind, tags, content)
}
override fun nip04Encrypt(
override suspend fun nip04Encrypt(
plaintext: String,
toPublicKey: HexKey,
onReady: (String) -> Unit,
) {
signerSync.nip04Encrypt(plaintext, toPublicKey)?.let { onReady(it) }
}
): String =
runWrapErrors {
signerSync.nip04Encrypt(plaintext, toPublicKey)
}
override fun nip04Decrypt(
override suspend fun nip04Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
onReady: (String) -> Unit,
) {
signerSync.nip04Decrypt(ciphertext, fromPublicKey)?.let { onReady(it) }
}
): String =
runWrapErrors {
signerSync.nip04Decrypt(ciphertext, fromPublicKey)
}
override fun nip44Encrypt(
override suspend fun nip44Encrypt(
plaintext: String,
toPublicKey: HexKey,
onReady: (String) -> Unit,
) {
signerSync.nip44Encrypt(plaintext, toPublicKey)?.let { onReady(it) }
}
): String =
runWrapErrors {
signerSync.nip44Encrypt(plaintext, toPublicKey)
}
override fun nip44Decrypt(
override suspend fun nip44Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
onReady: (String) -> Unit,
) {
signerSync.nip44Decrypt(ciphertext, fromPublicKey)?.let { onReady(it) }
): String =
runWrapErrors {
signerSync.nip44Decrypt(ciphertext, fromPublicKey)
}
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent {
if (!event.isPrivateZap()) throw SignerExceptions.NothingToDecrypt()
return runWrapErrors {
signerSync.decryptZapEvent(event)
}
}
override fun decryptZapEvent(
event: LnZapRequestEvent,
onReady: (LnZapPrivateEvent) -> Unit,
) {
signerSync.decryptZapEvent(event)?.let { onReady(it) }
}
override fun deriveKey(
nonce: HexKey,
onReady: (HexKey) -> Unit,
) {
signerSync.deriveKey(nonce)?.let { onReady(it) }
}
override suspend fun deriveKey(nonce: HexKey): HexKey =
runWrapErrors {
signerSync.deriveKey(nonce)
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.quartz.nip01Core.signers
import android.util.Log
import com.vitorpamplona.quartz.experimental.decoupling.EncryptionKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -28,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventAssembler
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04
import com.vitorpamplona.quartz.nip44Encryption.Nip44
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
@@ -45,8 +45,8 @@ class NostrSignerSync(
kind: Int,
tags: Array<Array<String>>,
content: String,
): T? {
if (keyPair.privKey == null) return null
): T {
if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException()
return if (isUnsignedPrivateZapEvent(kind, tags)) {
// this is a private zap
@@ -68,71 +68,78 @@ class NostrSignerSync(
kind: Int,
tags: Array<Array<String>>,
content: String,
): T? {
if (keyPair.privKey == null) return null
): T {
if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException()
return EventAssembler.hashAndSign<T>(pubKey, createdAt, kind, tags, content, keyPair.privKey)
}
fun nip04Encrypt(
decryptedContent: String,
plaintext: String,
toPublicKey: HexKey,
): String? {
if (keyPair.privKey == null) return null
): String {
if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException()
return Nip04.encrypt(
decryptedContent,
plaintext,
keyPair.privKey,
toPublicKey.hexToByteArray(),
)
}
fun nip04Decrypt(
encryptedContent: String,
ciphertext: String,
fromPublicKey: HexKey,
): String? {
if (keyPair.privKey == null) return null
): String {
if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException()
if (ciphertext.isBlank()) throw SignerExceptions.NothingToDecrypt()
return try {
Nip04.decrypt(encryptedContent, keyPair.privKey, fromPublicKey.hexToByteArray())
} catch (e: Exception) {
Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on $encryptedContent")
null
}
return Nip04.decrypt(ciphertext, keyPair.privKey, fromPublicKey.hexToByteArray())
}
fun nip44Encrypt(
decryptedContent: String,
plaintext: String,
toPublicKey: HexKey,
): String? {
if (keyPair.privKey == null) return null
): String {
if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException()
return Nip44
.encrypt(
decryptedContent,
plaintext,
keyPair.privKey,
toPublicKey.hexToByteArray(),
).encodePayload()
}
fun nip44Decrypt(
encryptedContent: String,
ciphertext: String,
fromPublicKey: HexKey,
): String? {
if (keyPair.privKey == null) return null
): String {
if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException()
if (ciphertext.isBlank()) throw SignerExceptions.NothingToDecrypt()
return Nip44.decrypt(
payload = encryptedContent,
payload = ciphertext,
privateKey = keyPair.privKey,
pubKey = fromPublicKey.hexToByteArray(),
)
}
fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent? = PrivateZapRequestBuilder().decryptZapEvent(event, this)
fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = PrivateZapRequestBuilder().decryptZapEvent(event, this)
fun deriveKey(nonce: HexKey): HexKey? {
if (keyPair.privKey == null) return null
fun deriveKey(nonce: HexKey): HexKey {
if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException()
return EncryptionKeyDerivation.derivePrivateKey(keyPair.privKey, nonce.hexToByteArray()).toHexKey()
}
suspend fun decrypt(
encryptedContent: String,
fromPublicKey: HexKey,
): String =
if (EncryptedInfo.isNIP04(encryptedContent)) {
nip04Decrypt(encryptedContent, fromPublicKey)
} else {
nip44Decrypt(encryptedContent, fromPublicKey)
}
}
@@ -0,0 +1,62 @@
/**
* 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.nip01Core.signers
sealed class SignerExceptions(
msg: String,
cause: Throwable? = null,
) : Exception(msg, cause) {
class ReadOnlyException : SignerExceptions("Signer is read-only")
class UnauthorizedDecryptionException : SignerExceptions("Couldn't not decrypt the contents of this event.")
class NothingToDecrypt : SignerExceptions("Ciphertext is Empty")
class AutomaticallyUnauthorizedException(
msg: String,
cause: Throwable? = null,
) : SignerExceptions(msg)
class ManuallyUnauthorizedException(
msg: String,
cause: Throwable? = null,
) : SignerExceptions(msg)
class TimedOutException(
msg: String,
cause: Throwable? = null,
) : SignerExceptions(msg)
class CouldNotPerformException(
msg: String,
cause: Throwable? = null,
) : SignerExceptions(msg, cause)
class RunningOnBackgroundWithoutAutomaticPermissionException(
msg: String,
cause: Throwable? = null,
) : SignerExceptions(msg)
class SignerNotFoundException(
msg: String,
cause: Throwable? = null,
) : SignerExceptions(msg)
}
@@ -0,0 +1,37 @@
/**
* 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.nip01Core.signers.caches
sealed interface CacheResults<T> {
class Success<T>(
val value: T,
) : CacheResults<T>
class DontTryAgain<T> : CacheResults<T>
class NeedsForegroundActivityToTryAgain<T>(
val after: Long,
) : CacheResults<T>
class CanTryAgain<T>(
val after: Long,
) : CacheResults<T>
}
@@ -0,0 +1,121 @@
/**
* 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.nip01Core.signers.caches
import android.util.Log
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
import com.vitorpamplona.quartz.utils.TimeUtils
abstract class DecryptCache<I : Any, T : Any>(
val signer: NostrSigner,
) {
var cache: CacheResults<T> = CacheResults.CanTryAgain(0)
fun preload(result: T) {
cache = CacheResults.Success(result)
}
abstract suspend fun decryptAndParse(
event: I,
signer: NostrSigner,
): T
private suspend fun performDecrypt(input: I): T? {
try {
val response = decryptAndParse(input, signer)
cache = CacheResults.Success(response)
return response
} catch (e: SignerExceptions.NothingToDecrypt) {
Log.w("DecryptCache", "Nothing to decrypt", e)
// ciphertext is blank. Cancels everything.
cache = CacheResults.DontTryAgain()
} catch (e: SignerExceptions.AutomaticallyUnauthorizedException) {
Log.w("DecryptCache", "NothAutomaticallyUnauthorizedException", e)
// User has rejected this permission. Don't try again.
cache = CacheResults.DontTryAgain<T>()
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
Log.w("DecryptCache", "ManuallyUnauthorizedException", e)
// User has rejected this permission. Don't try again.
cache = CacheResults.CanTryAgain<T>(TimeUtils.tenSecondsFromNow())
} catch (e: SignerExceptions.TimedOutException) {
Log.w("DecryptCache", "TimedOutException", e)
// User has did not reply to the approval request. Ignore until later time.
cache = CacheResults.CanTryAgain<T>(TimeUtils.tenSecondsFromNow())
} catch (e: SignerExceptions.CouldNotPerformException) {
Log.w("DecryptCache", "CouldNotPerformException", e)
// Decryption failed. This key might not be able to decrypt anything. Don't try again.
cache = CacheResults.DontTryAgain<T>()
} catch (e: SignerExceptions.SignerNotFoundException) {
Log.w("DecryptCache", "SignerNotFoundException", e)
// Signer app was deleted. Not sure what to to. It should probably log off.
cache = CacheResults.DontTryAgain<T>()
} catch (e: SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException) {
Log.w("DecryptCache", "RunningOnBackgroundWithoutAutomaticPermissionException", e)
// App received a notifications, asked the signer to decrypt but the permission was not automatic.
// It needs the interface but does not have it. It needs to wait for an activity.
cache = CacheResults.NeedsForegroundActivityToTryAgain<T>(TimeUtils.tenSecondsFromNow())
} catch (e: com.fasterxml.jackson.core.JsonParseException) {
Log.w("DecryptCache", "JsonParseException", e)
// Decryption failed. This key might not be able to decrypt anything. Don't try again.
cache = CacheResults.DontTryAgain()
} catch (e: IllegalStateException) {
Log.w("DecryptCache", "IllegalStateException", e)
cache = CacheResults.DontTryAgain()
} catch (e: IllegalArgumentException) {
Log.w("DecryptCache", "IllegalArgumentException", e)
cache = CacheResults.DontTryAgain()
}
return null
}
fun cached(): T? {
val cachedResult = cache
return if (cachedResult is CacheResults.Success<T>) {
cachedResult.value
} else {
null
}
}
suspend fun decrypt(input: I): T? {
val cachedResult = cache
return when (cachedResult) {
is CacheResults.Success<T> -> cachedResult.value
is CacheResults.DontTryAgain -> null
is CacheResults.CanTryAgain -> {
if (TimeUtils.now() > cachedResult.after) {
performDecrypt(input)
} else {
null
}
}
is CacheResults.NeedsForegroundActivityToTryAgain<*> -> {
if (TimeUtils.now() > cachedResult.after && (signer !is NostrSignerExternal || signer.hasForegroundActivity())) {
performDecrypt(input)
} else {
null
}
}
}
}
}
@@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes
data class ATag(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
val dTag: String = "",
val relay: NormalizedRelayUrl? = null,
) {
constructor(address: Address, relayHint: NormalizedRelayUrl? = null) : this(address.kind, address.pubKeyHex, address.dTag, relayHint)
@@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes
data class Address(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
val dTag: String = "",
) : Comparable<Address> {
fun toValue() = assemble(kind, pubKeyHex, dTag)
@@ -59,7 +59,7 @@ data class Address(
fun assemble(
kind: Int,
pubKeyHex: HexKey,
dTag: String,
dTag: String = "",
) = "$kind:$pubKeyHex:$dTag"
@JvmStatic
@@ -94,12 +94,12 @@ class ContactListEvent(
fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:"
fun createFromScratch(
suspend fun createFromScratch(
followUsers: List<ContactTag> = emptyList(),
relayUse: Map<String, ReadWrite>? = emptyMap(),
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): ContactListEvent? {
): ContactListEvent {
val content = relayUse?.let { RelaySet.assemble(it) } ?: ""
val tags =
@@ -109,13 +109,12 @@ class ContactListEvent(
return signer.sign(createdAt, KIND, tags.toTypedArray(), content)
}
fun createFromScratch(
suspend fun createFromScratch(
followUsers: List<ContactTag>,
relayUse: Map<String, ReadWrite>?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ContactListEvent) -> Unit,
) {
): ContactListEvent {
val content = relayUse?.let { RelaySet.assemble(it) } ?: ""
val tags = followUsers.map { it.toTagArray() }
@@ -124,53 +123,47 @@ class ContactListEvent(
tags = tags.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun followUser(
suspend fun followUser(
earlierVersion: ContactListEvent,
pubKeyHex: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ContactListEvent) -> Unit,
) {
if (earlierVersion.isTaggedUser(pubKeyHex)) return
): ContactListEvent {
if (earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = arrayOf("p", pubKeyHex)),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun unfollowUser(
suspend fun unfollowUser(
earlierVersion: ContactListEvent,
pubKeyHex: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ContactListEvent) -> Unit,
) {
if (!earlierVersion.isTaggedUser(pubKeyHex)) return
): ContactListEvent {
if (!earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex }.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun updateRelayList(
suspend fun updateRelayList(
earlierVersion: ContactListEvent,
relayUse: Map<String, ReadWrite>?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ContactListEvent) -> Unit,
) {
): ContactListEvent {
val content = relayUse?.let { RelaySet.assemble(it) } ?: ""
return create(
@@ -178,17 +171,15 @@ class ContactListEvent(
tags = earlierVersion.tags,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun create(
suspend fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ContactListEvent) -> Unit,
) {
): ContactListEvent {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
@@ -196,7 +187,7 @@ class ContactListEvent(
tags + AltTag.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
return signer.sign(createdAt, KIND, newTags, content)
}
}
}
@@ -0,0 +1,55 @@
/**
* 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.nip04Dm
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
class PrivateDMCache(
signer: NostrSigner,
) {
private val decryptionCache =
object : LruCache<PrivateDmEvent, PrivateDMDecryptCache>(10000) {
override fun create(key: PrivateDmEvent): PrivateDMDecryptCache? {
val canDecrypt = key.canDecrypt(signer.pubKey)
return if (key.content.isNotBlank() && canDecrypt) {
PrivateDMDecryptCache(signer)
} else {
null
}
}
}
fun cachedDM(event: PrivateDmEvent): String? = decryptionCache[event]?.cached()
suspend fun decryptDM(event: PrivateDmEvent) = decryptionCache[event]?.decrypt(event)
}
class PrivateDMDecryptCache(
signer: NostrSigner,
) : DecryptCache<PrivateDmEvent, String>(signer) {
override suspend fun decryptAndParse(
event: PrivateDmEvent,
signer: NostrSigner,
) = event.decryptContent(signer)
}
@@ -26,7 +26,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.any
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
@@ -37,8 +39,6 @@ import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlinx.collections.immutable.persistentSetOf
@Immutable
@@ -50,21 +50,35 @@ class PrivateDmEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
ChatroomKeyable {
@Transient private var decryptedContent: Map<HexKey, String> = mapOf()
ChatroomKeyable,
PubKeyHintProvider {
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (decryptedContent.values.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() })
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
override fun isContentEncoded() = true
fun canDecrypt(signer: NostrSigner) = canDecrypt(signer.pubKey)
fun canDecrypt(signerPubKey: HexKey) = pubKey == signerPubKey || recipientPubKey() == signerPubKey
suspend fun decryptContent(signer: NostrSigner): String {
if (!canDecrypt(signer.pubKey)) throw SignerExceptions.UnauthorizedDecryptionException()
val retVal = signer.decrypt(content, talkingWith(signer.pubKey))
return if (retVal.startsWith(NIP_18_ADVERTISEMENT)) {
retVal.substring(16)
} else {
retVal
}
}
/**
* This may or may not be the actual recipient's pub key. The event is intended to look like a
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used for
* initial messages.
*/
private fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey)
fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey)
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull()
@@ -79,43 +93,12 @@ class PrivateDmEvent(
fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(persistentSetOf(talkingWith(toRemove)))
override fun chatroomKey(toRemove: HexKey): ChatroomKey = ChatroomKey(persistentSetOf(talkingWith(toRemove)))
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
*
* Nip-18 messages should refer to other events by inline references in the content like
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
*/
fun replyTo() = tags.firstNotNullOfOrNull(MarkedETag::parseId)
fun with(pubkeyHex: HexKey): Boolean = pubkeyHex == pubKey || tags.any(PTag::isTagged, pubkeyHex)
fun cachedContentFor(signer: NostrSigner): String? = decryptedContent[signer.pubKey]
fun plainContent(
signer: NostrSigner,
onReady: (String) -> Unit,
) {
decryptedContent[signer.pubKey]?.let {
onReady(it)
return
}
signer.decrypt(content, talkingWith(signer.pubKey)) { retVal ->
val content =
if (retVal.startsWith(NIP_18_ADVERTISEMENT)) {
retVal.substring(16)
} else {
retVal
}
decryptedContent = decryptedContent + Pair(signer.pubKey, content)
onReady(content)
}
}
companion object {
const val KIND = 4
const val ALT = "Private Message"
@@ -123,11 +106,11 @@ class PrivateDmEvent(
fun prepareMessageToEncrypt(
msg: String,
imetas: List<IMetaTag>? = null,
iMetas: List<IMetaTag>? = null,
advertiseNip18: Boolean = true,
): String {
var message = msg
imetas?.forEach {
iMetas?.forEach {
message = message.replace(it.url, Nip54InlineMetadata().createUrl(it.url, it.properties))
}
@@ -48,7 +48,9 @@ class Nip11RelayInformation(
) {
companion object {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)
fun fromJson(json: String): Nip11RelayInformation = mapper.readValue(json, Nip11RelayInformation::class.java)
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.mapNotNullAsync
class NIP17Factory {
data class Result(
@@ -38,122 +39,82 @@ class NIP17Factory {
val wraps: List<GiftWrapEvent>,
)
private fun recursiveGiftWrapCreation(
event: Event,
remainingTos: List<HexKey>,
signer: NostrSigner,
output: MutableList<GiftWrapEvent>,
onReady: (List<GiftWrapEvent>) -> Unit,
) {
if (remainingTos.isEmpty()) {
onReady(output)
return
}
val next = remainingTos.first()
SealedRumorEvent.create(
event = event,
encryptTo = next,
signer = signer,
) { seal ->
GiftWrapEvent.create(
event = seal,
recipientPubKey = next,
) { giftWrap ->
output.add(giftWrap)
recursiveGiftWrapCreation(event, remainingTos.minus(next), signer, output, onReady)
}
}
}
private fun createWraps(
private suspend fun createWraps(
event: Event,
to: Set<HexKey>,
signer: NostrSigner,
onReady: (List<GiftWrapEvent>) -> Unit,
) {
val wraps = mutableListOf<GiftWrapEvent>()
recursiveGiftWrapCreation(event, to.toList(), signer, wraps, onReady)
}
): List<GiftWrapEvent> =
mapNotNullAsync(
to.toList(),
) { next ->
GiftWrapEvent.create(
event =
SealedRumorEvent.create(
event = event,
encryptTo = next,
signer = signer,
),
recipientPubKey = next,
)
}
fun createMessageNIP17(
suspend fun createMessageNIP17(
template: EventTemplate<ChatMessageEvent>,
signer: NostrSigner,
onReady: (Result) -> Unit,
) {
signer.sign(template) { senderMessage ->
createWraps(senderMessage, senderMessage.groupMembers(), signer) { wraps ->
onReady(
Result(
msg = senderMessage,
wraps = wraps,
),
)
}
}
): Result {
val senderMessage = signer.sign(template)
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer)
return Result(
msg = senderMessage,
wraps = wraps,
)
}
fun createEncryptedFileNIP17(
suspend fun createEncryptedFileNIP17(
template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>,
signer: NostrSigner,
onReady: (Result) -> Unit,
) {
signer.sign(template) { senderMessage ->
createWraps(senderMessage, senderMessage.groupMembers(), signer) { wraps ->
onReady(
Result(
msg = senderMessage,
wraps = wraps,
),
)
}
}
): Result {
val senderMessage = signer.sign(template)
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer)
return Result(
msg = senderMessage,
wraps = wraps,
)
}
fun createReactionWithinGroup(
suspend fun createReactionWithinGroup(
content: String,
originalNote: EventHintBundle<Event>,
to: List<HexKey>,
signer: NostrSigner,
onReady: (Result) -> Unit,
) {
): Result {
val senderPublicKey = signer.pubKey
val template = ReactionEvent.build(content, originalNote)
signer.sign(
ReactionEvent.build(content, originalNote),
) { senderReaction ->
createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps ->
onReady(
Result(
msg = senderReaction,
wraps = wraps,
),
)
}
}
val senderReaction = signer.sign(template)
val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer)
return Result(
msg = senderReaction,
wraps = wraps,
)
}
fun createReactionWithinGroup(
suspend fun createReactionWithinGroup(
emojiUrl: EmojiUrlTag,
originalNote: EventHintBundle<Event>,
to: List<HexKey>,
signer: NostrSigner,
onReady: (Result) -> Unit,
) {
): Result {
val senderPublicKey = signer.pubKey
val template = ReactionEvent.build(emojiUrl, originalNote)
signer.sign(
ReactionEvent.build(emojiUrl, originalNote),
) { senderReaction ->
createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) { wraps ->
onReady(
Result(
msg = senderReaction,
wraps = wraps,
),
)
}
}
val senderReaction = signer.sign(template)
val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer)
return Result(
msg = senderReaction,
wraps = wraps,
)
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip17Dm.base
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.any
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
@@ -68,6 +69,8 @@ open class BaseDMGroupEvent(
return result
}
override fun isIncluded(pubKey: HexKey) = tags.any(PTag::isTagged, pubKey)
override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet()
override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet())
@@ -26,4 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Stable
data class ChatroomKey(
val users: Set<HexKey>,
)
) : Comparable<ChatroomKey> {
override fun compareTo(other: ChatroomKey): Int = users.hashCode().compareTo(other.users.hashCode())
}
@@ -23,5 +23,7 @@ package com.vitorpamplona.quartz.nip17Dm.base
import com.vitorpamplona.quartz.nip01Core.core.HexKey
interface NIP17Group {
fun isIncluded(pubKey: HexKey): Boolean
fun groupMembers(): Set<HexKey>
}
@@ -54,19 +54,17 @@ class ChatMessageRelayListEvent(
fun createTagArray(relays: List<NormalizedRelayUrl>): Array<Array<String>> =
relays
.map {
RelayTag.assemble(it)
}.plusElement(
.map { RelayTag.assemble(it) }
.plusElement(
AltTag.assemble("Relay list to receive private messages"),
).toTypedArray()
fun updateRelayList(
suspend fun updateRelayList(
earlierVersion: ChatMessageRelayListEvent,
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChatMessageRelayListEvent) -> Unit,
) {
): ChatMessageRelayListEvent {
val tags =
earlierVersion.tags
.filter(RelayTag::notMatch)
@@ -76,31 +74,19 @@ class ChatMessageRelayListEvent(
},
).toTypedArray()
signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady)
return signer.sign(createdAt, KIND, tags, earlierVersion.content)
}
fun createFromScratch(
suspend fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChatMessageRelayListEvent) -> Unit,
) {
create(relays, signer, createdAt, onReady)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChatMessageRelayListEvent) -> Unit,
) {
signer.sign(createdAt, KIND, createTagArray(relays), "", onReady)
}
): ChatMessageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "")
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): ChatMessageRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "")
): ChatMessageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "")
}
}
@@ -110,12 +110,11 @@ class GenericRepostEvent(
initializer()
}
fun create(
suspend fun create(
boostedPost: Event,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GenericRepostEvent) -> Unit,
) {
): GenericRepostEvent {
val content = boostedPost.toJson()
val tags =
@@ -131,7 +130,7 @@ class GenericRepostEvent(
tags.add(arrayOf("k", "${boostedPost.kind}"))
tags.add(AltTag.assemble(ALT))
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
return signer.sign(createdAt, KIND, tags.toTypedArray(), content)
}
}
}
@@ -56,7 +56,6 @@ data class NAddress(
}
} catch (e: Throwable) {
Log.w("NAddress", "Issue trying to Decode NIP19 $this: ${e.message}")
// e.printStackTrace()
}
return null
@@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNote
data class NNote(
val hex: String,
) : Entity {
companion object Companion {
companion object {
fun parse(bytes: ByteArray): NNote? {
if (bytes.isEmpty()) return null
return NNote(bytes.toHexKey())
@@ -59,7 +59,7 @@ class ReplyIdentifierTag {
fun matchOrNull(
tag: Array<String>,
encodedScope: Set<String>,
) = if (tag.has(1) && tag[0] == RootIdentifierTag.Companion.TAG_NAME && tag[1] in encodedScope) {
) = if (tag.has(1) && tag[0] == TAG_NAME && tag[1] in encodedScope) {
tag[1]
} else {
null
@@ -93,7 +93,7 @@ class ReplyIdentifierTag {
@JvmStatic
fun parseExternalId(tag: Tag): ExternalId? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == RootIdentifierTag.Companion.TAG_NAME) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val value = tag[1]
@@ -22,17 +22,23 @@ package com.vitorpamplona.quartz.nip28PublicChat.list
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHintOptional
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.removeAny
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -43,24 +49,11 @@ class ChannelListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient var publicAndPrivateEventCache: Set<EventIdHint>? = null
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider {
override fun eventHints() = tags.mapNotNull(ChannelTag::parseAsHint)
fun publicAndPrivateChannels(
signer: NostrSigner,
onReady: (Set<EventIdHint>) -> Unit,
) {
publicAndPrivateEventCache?.let { eventList ->
onReady(eventList)
return
}
mergeTagList(signer) {
val set = it.mapNotNull(ETag::parseAsHint).toSet()
publicAndPrivateEventCache = set
onReady(set)
}
}
override fun linkedEventIds() = tags.mapNotNull(ChannelTag::parseId)
companion object {
const val KIND = 10005
@@ -69,170 +62,157 @@ class ChannelListEvent(
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
private fun createChannelBase(
tags: Array<Array<String>>,
suspend fun create(
channel: ChannelTag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
PrivateTagArrayBuilder.create(
tags,
isPrivate,
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
fun createChannel(
channel: EventHintBundle<ChannelCreateEvent>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = createChannelBase(
tags = arrayOf(ETag.assemble(channel.event.id, channel.relay, channel.event.pubKey)),
) = create(
channels = listOf(channel),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
fun createChannel(
channel: EventIdHintOptional,
suspend fun create(
channels: List<ChannelTag>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = createChannelBase(
tags = arrayOf(ETag.assemble(channel.eventId, channel.relay, null)),
): ChannelListEvent =
if (isPrivate) {
create(
publicChannels = emptyList(),
privateChannels = channels,
signer = signer,
createdAt = createdAt,
)
} else {
create(
publicChannels = channels,
privateChannels = emptyList(),
signer = signer,
createdAt = createdAt,
)
}
suspend fun add(
earlierVersion: ChannelListEvent,
channel: ChannelTag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = add(
earlierVersion = earlierVersion,
channels = listOf(channel),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
fun createChannels(
channels: List<EventIdHintOptional>,
suspend fun add(
earlierVersion: ChannelListEvent,
channels: List<ChannelTag>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = createChannelBase(
tags = channels.map { ETag.assemble(it.eventId, it.relay, null) }.toTypedArray(),
isPrivate = isPrivate,
): ChannelListEvent =
if (isPrivate) {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
resign(
tags = earlierVersion.tags,
privateTags = privateTags.removeAny(channels.map { it.toTagIdOnly() }) + channels.map { it.toTagArray() },
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.removeAny(channels.map { it.toTagIdOnly() }) + channels.map { it.toTagArray() },
signer = signer,
createdAt = createdAt,
)
}
suspend fun remove(
earlierVersion: ChannelListEvent,
channel: ChannelTag,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): ChannelListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
return resign(
privateTags = privateTags.remove(channel.toTagArray()),
tags = earlierVersion.tags.remove(channel.toTagArray()),
signer = signer,
createdAt = createdAt,
)
}
suspend fun resign(
tags: TagArray,
privateTags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = resign(
content = PrivateTagsInContent.encryptNip04(privateTags, signer),
tags = tags,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
fun removeChannel(
earlierVersion: ChannelListEvent,
channel: HexKey,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
PrivateTagArrayBuilder.removeAll(
earlierVersion,
ETag.assemble(channel, null, null),
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
private fun addChannelBase(
earlierVersion: ChannelListEvent,
newTags: Array<Array<String>>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
PrivateTagArrayBuilder.addAll(
earlierVersion,
newTags,
isPrivate,
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
fun addChannel(
earlierVersion: ChannelListEvent,
channel: EventHintBundle<ChannelCreateEvent>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = addChannelBase(
earlierVersion,
arrayOf(ETag.assemble(channel.event.id, channel.relay, channel.event.pubKey)),
isPrivate,
signer,
createdAt,
onReady,
)
fun addChannel(
earlierVersion: ChannelListEvent,
channel: EventIdHintOptional,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = addChannelBase(
earlierVersion,
arrayOf(ETag.assemble(channel.eventId, channel.relay, null)),
isPrivate,
signer,
createdAt,
onReady,
)
fun addChannels(
earlierVersion: ChannelListEvent,
channels: List<EventIdHintOptional>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = addChannelBase(
earlierVersion,
channels.map { ETag.assemble(it.eventId, it.relay, null) }.toTypedArray(),
isPrivate,
signer,
createdAt,
onReady,
)
private fun create(
suspend fun resign(
content: String,
tags: Array<Array<String>>,
tags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
): ChannelListEvent {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
if (tags.fastAny(AltTag::match)) {
tags
} else {
tags + AltTag.Companion.assemble(ALT)
tags + AltTag.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
return signer.sign(createdAt, KIND, newTags, content)
}
suspend fun create(
publicChannels: List<ChannelTag> = emptyList(),
privateChannels: List<ChannelTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): ChannelListEvent {
val template = build(publicChannels, privateChannels, signer, createdAt)
return signer.sign(template)
}
fun create(
list: List<EventIdHint>,
publicChannels: List<ChannelTag> = emptyList(),
privateChannels: List<ChannelTag> = emptyList(),
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): ChannelListEvent? {
val tags = list.map { ETag.assemble(it.eventId, it.relay, null) }.toTypedArray()
return signer.sign(createdAt, KIND, tags, "")
): ChannelListEvent {
val privateTagArray = privateChannels.map { it.toTagArray() }.toTypedArray()
val publicTagArray = publicChannels.map { it.toTagArray() }.toTypedArray() + AltTag.assemble(ALT)
return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray)
}
suspend fun build(
publicChannels: List<ChannelTag> = emptyList(),
privateChannels: List<ChannelTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ChannelListEvent>.() -> Unit = {},
) = eventTemplate<ChannelListEvent>(
kind = KIND,
description = PrivateTagsInContent.encryptNip04(privateChannels.map { it.toTagArray() }.toTypedArray(), signer),
createdAt = createdAt,
) {
alt(ALT)
channels(publicChannels)
initializer()
}
}
}
@@ -24,8 +24,11 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
fun TagArrayBuilder<ChannelListEvent>.followChat(
eventId: HexKey,
relayUrl: NormalizedRelayUrl,
) = addUnique(ETag.assemble(eventId, relayUrl, null))
fun TagArrayBuilder<ChannelListEvent>.channels(rooms: List<ChannelTag>) = addAll(rooms.map { it.toTagArray() })
@@ -0,0 +1,28 @@
/**
* 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.nip28PublicChat.list
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
fun TagArray.channels() = mapNotNull(ChannelTag::parse)
fun TagArray.channelSet() = mapNotNullTo(mutableSetOf(), ChannelTag::parse)
@@ -0,0 +1,119 @@
/**
* 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.nip28PublicChat.list.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.ensure
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class ChannelTag(
val eventId: HexKey,
val relay: NormalizedRelayUrl? = null,
val author: HexKey? = null,
) {
fun countMemory(): Long =
3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit)
eventId.bytesUsedInMemory() +
(relay?.url?.bytesUsedInMemory() ?: 0) +
(author?.bytesUsedInMemory() ?: 0)
fun toNEvent(): String = NEvent.create(eventId, author, null, relay)
fun toTagArray() = assemble(eventId, relay, author)
fun toTagIdOnly() = assemble(eventId, null, null)
companion object {
const val TAG_NAME = "e"
@JvmStatic
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
@JvmStatic
fun isTagged(
tag: Array<String>,
eventId: HexKey,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId
@JvmStatic
fun parse(tag: Array<String>): ChannelTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return ChannelTag(tag[1], pickRelayHint(tag), pickAuthor(tag))
}
@JvmStatic
fun parseId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
@JvmStatic
private fun pickRelayHint(tag: Array<String>): NormalizedRelayUrl? {
if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2])
if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3])
if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4])
return null
}
@JvmStatic
private fun pickAuthor(tag: Array<String>): HexKey? {
if (tag.has(2) && tag[2].length == 64) return tag[2]
if (tag.has(3) && tag[3].length == 64) return tag[3]
if (tag.has(4) && tag[4].length == 64) return tag[4]
return null
}
@JvmStatic
fun parseAsHint(tag: Array<String>): EventIdHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val hint = pickRelayHint(tag)
ensure(hint != null) { return null }
return EventIdHint(tag[1], hint)
}
@JvmStatic
fun assemble(
eventId: HexKey,
relay: NormalizedRelayUrl?,
author: HexKey?,
) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author)
}
}
@@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.repository.name
import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import java.util.UUID
@@ -40,7 +40,7 @@ class EmojiPackEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30030
const val ALT_DESCRIPTION = "Emoji pack"
@@ -27,6 +27,9 @@ class AltTag {
companion object {
const val TAG_NAME = "alt"
@JvmStatic
fun match(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@JvmStatic
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
@@ -107,12 +107,11 @@ class GitPatchEvent(
const val KIND = 1617
const val ALT = "A Git Patch"
fun create(
suspend fun create(
patch: String,
createdAt: Long = TimeUtils.now(),
signer: NostrSigner,
onReady: (GitPatchEvent) -> Unit,
) {
): GitPatchEvent {
val content = patch
val tags =
mutableListOf(
@@ -121,7 +120,7 @@ class GitPatchEvent(
tags.add(AltTag.assemble(ALT))
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
return signer.sign(createdAt, KIND, tags.toTypedArray(), content)
}
}
}
@@ -30,30 +30,25 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class DraftBuilder {
companion object {
fun <T : Event> encryptAndSign(
suspend fun <T : Event> encryptAndSign(
dTag: String,
draft: T,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
signer.nip44Encrypt(draft.toJson(), signer.pubKey) { encryptedContent ->
val template =
eventTemplate<DraftEvent>(DraftEvent.KIND, encryptedContent, createdAt) {
alt(DraftEvent.ALT_DESCRIPTION)
dTag(dTag)
kind(draft.kind)
): DraftEvent {
val encryptedContent = signer.nip44Encrypt(draft.toJson(), signer.pubKey)
val template =
eventTemplate<DraftEvent>(DraftEvent.KIND, encryptedContent, createdAt) {
alt(DraftEvent.ALT_DESCRIPTION)
dTag(dTag)
kind(draft.kind)
if (draft is ExposeInDraft) {
addAll(draft.exposeInDraft())
}
if (draft is ExposeInDraft) {
addAll(draft.exposeInDraft())
}
signer.sign(template) {
it.addToCache(signer.pubKey, draft)
onReady(it)
}
}
return signer.sign(template)
}
}
}
@@ -20,7 +20,9 @@
*/
package com.vitorpamplona.quartz.nip37Drafts
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.core.JsonParseException
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
@@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
@@ -41,7 +44,6 @@ import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class DraftEvent(
@@ -67,76 +69,23 @@ class DraftEvent(
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
@Transient private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
override fun countMemory(): Long =
super.countMemory() +
32 + (cachedInnerEvent.values.sumOf { pointerSizeInBytes + (it?.countMemory() ?: 0) })
override fun isContentEncoded() = true
fun isDeleted() = content == ""
fun preCachedDraft(signer: NostrSigner): Event? = cachedInnerEvent[signer.pubKey]
fun canDecrypt(signer: NostrSigner) = signer.pubKey == pubKey
fun preCachedDraft(pubKey: HexKey): Event? = cachedInnerEvent[pubKey]
suspend fun createDeletedEvent(signer: NostrSigner): DraftEvent = signer.sign(createdAt, KIND, tags, "")
fun allCache() = cachedInnerEvent.values
suspend fun decryptInnerEvent(signer: NostrSigner): Event {
if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException()
fun addToCache(
pubKey: HexKey,
innerEvent: Event,
) {
cachedInnerEvent = cachedInnerEvent + Pair(pubKey, innerEvent)
}
fun cachedDraft(
signer: NostrSigner,
onReady: (Event) -> Unit,
) {
cachedInnerEvent[signer.pubKey]?.let {
onReady(it)
return
}
decrypt(signer) { draft ->
addToCache(signer.pubKey, draft)
onReady(draft)
}
}
private fun decrypt(
signer: NostrSigner,
onReady: (Event) -> Unit,
) {
try {
plainContent(signer) {
try {
onReady(fromJson(it))
} catch (e: Exception) {
// Log.e("UnwrapError", "Couldn't Decrypt the content", e)
}
}
} catch (e: Exception) {
// Log.e("UnwrapError", "Couldn't Decrypt the content", e)
}
}
private fun plainContent(
signer: NostrSigner,
onReady: (String) -> Unit,
) {
if (content.isEmpty()) return
signer.nip44Decrypt(content, pubKey, onReady)
}
fun createDeletedEvent(
signer: NostrSigner,
onReady: (DraftEvent) -> Unit,
) {
signer.sign<DraftEvent>(createdAt, KIND, tags, "") {
onReady(it)
val json = signer.nip44Decrypt(content, pubKey)
return try {
fromJson(json)
} catch (e: JsonParseException) {
Log.w("DraftEvent", "Unable to parse inner event of a draft: $json")
throw e
}
}
@@ -149,123 +98,116 @@ class DraftEvent(
dTag: String,
): String = Address.assemble(KIND, pubKey, dTag)
fun create(
@Suppress("DEPRECATION")
suspend fun create(
dTag: String,
originalNote: TorrentCommentEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
): DraftEvent {
val tagsWithMarkers =
originalNote.tags.filter {
it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply")
}
create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady)
return create(dTag, originalNote, tagsWithMarkers, signer, createdAt)
}
fun create(
suspend fun create(
dTag: String,
originalNote: InteractiveStoryBaseEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
): DraftEvent {
val tags = mutableListOf<Array<String>>()
create(dTag, originalNote, tags, signer, createdAt, onReady)
return create(dTag, originalNote, tags, signer, createdAt)
}
fun create(
suspend fun create(
dTag: String,
originalNote: LiveActivitiesChatMessageEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
): DraftEvent {
val tags = mutableListOf<Array<String>>()
originalNote.activity()?.let { tags.add(arrayOf("a", it.toTag(), "", "root")) }
originalNote.replyingTo()?.let { tags.add(arrayOf("e", it, "", "reply")) }
create(dTag, originalNote, tags, signer, createdAt, onReady)
return create(dTag, originalNote, tags, signer, createdAt)
}
fun create(
suspend fun create(
dTag: String,
originalNote: ChannelMessageEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
): DraftEvent {
val tags = mutableListOf<Array<String>>()
originalNote.channelId()?.let { tags.add(arrayOf("e", it)) }
create(dTag, originalNote, tags, signer, createdAt, onReady)
return create(dTag, originalNote, tags, signer, createdAt)
}
fun create(
@Suppress("DEPRECATION")
suspend fun create(
dTag: String,
originalNote: GitReplyEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
): DraftEvent {
val tags = mutableListOf<Array<String>>()
originalNote.repository()?.let { tags.add(arrayOf("a", it.toTag())) }
originalNote.replyingTo()?.let { tags.add(arrayOf("e", it)) }
create(dTag, originalNote, tags, signer, createdAt, onReady)
return create(dTag, originalNote, tags, signer, createdAt)
}
fun create(
suspend fun create(
dTag: String,
originalNote: PollNoteEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
): DraftEvent {
val tagsWithMarkers =
originalNote.tags.filter {
it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply")
}
create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady)
return create(dTag, originalNote, tagsWithMarkers, signer, createdAt)
}
fun create(
suspend fun create(
dTag: String,
originalNote: CommentEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
): DraftEvent {
val tagsWithMarkers = originalNote.rootScopes() + originalNote.directReplies()
create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady)
return create(dTag, originalNote, tagsWithMarkers, signer, createdAt)
}
fun create(
suspend fun create(
dTag: String,
originalNote: TextNoteEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
): DraftEvent {
val tagsWithMarkers =
originalNote.tags.filter {
it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply")
}
create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady)
return create(dTag, originalNote, tagsWithMarkers, signer, createdAt)
}
fun create(
suspend fun create(
dTag: String,
innerEvent: Event,
anchorTagArray: List<Array<String>> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
): DraftEvent {
val tags = mutableListOf<Array<String>>()
tags.add(arrayOf("d", dTag))
tags.add(arrayOf("k", "${innerEvent.kind}"))
@@ -274,12 +216,15 @@ class DraftEvent(
tags.addAll(anchorTagArray)
}
signer.nip44Encrypt(innerEvent.toJson(), signer.pubKey) { encryptedContent ->
signer.sign<DraftEvent>(createdAt, KIND, tags.toTypedArray(), encryptedContent) {
it.addToCache(signer.pubKey, innerEvent)
onReady(it)
}
}
val draft =
signer.sign<DraftEvent>(
createdAt = createdAt,
kind = KIND,
tags = tags.toTypedArray(),
content = signer.nip44Encrypt(innerEvent.toJson(), signer.pubKey),
)
return draft
}
}
}
@@ -0,0 +1,60 @@
/**
* 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.nip37Drafts
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache
class DraftEventCache(
signer: NostrSigner,
) {
private val decryptionCache =
object : LruCache<DraftEvent, DraftEventDecryptCache>(1000) {
override fun create(key: DraftEvent): DraftEventDecryptCache? =
if (!key.isDeleted() && key.pubKey == signer.pubKey) {
DraftEventDecryptCache(signer)
} else {
null
}
}
fun delete(event: DraftEvent) = decryptionCache.remove(event)
fun preload(
event: DraftEvent,
result: Event,
) = decryptionCache[event]?.preload(result)
fun preCachedDraft(event: DraftEvent): Event? = decryptionCache[event]?.cached()
suspend fun cachedDraft(event: DraftEvent) = decryptionCache[event]?.decrypt(event)
}
class DraftEventDecryptCache(
signer: NostrSigner,
) : DecryptCache<DraftEvent, Event>(signer) {
override suspend fun decryptAndParse(
event: DraftEvent,
signer: NostrSigner,
): Event = event.decryptInnerEvent(signer)
}
@@ -0,0 +1,121 @@
/**
* 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.nip37Drafts.privateOutbox
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.privateRelays
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class PrivateOutboxRelayListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun publicRelays() = tags.mapNotNull(RelayTag::parse)
suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.mapNotNull(RelayTag::parse)
suspend fun relays(signer: NostrSigner): List<NormalizedRelayUrl> = publicRelays() + (privateRelays(signer) ?: emptyList())
companion object {
const val KIND = 10013
const val FIXED_D_TAG = ""
val ALT = "Relay list to store private content from this author"
val TAGS = arrayOf(AltTag.assemble(ALT))
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
suspend fun updateRelayList(
earlierVersion: PrivateOutboxRelayListEvent,
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): PrivateOutboxRelayListEvent {
val newRelayList = relays.map { RelayTag.assemble(it) }
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val publicTags = earlierVersion.tags.remove(RelayTag::match)
val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList)
return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags)
}
suspend fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): PrivateOutboxRelayListEvent {
val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray()
return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray)
}
suspend fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): PrivateOutboxRelayListEvent {
val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray()
return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray)
}
suspend fun build(
publicRelays: List<NormalizedRelayUrl> = emptyList(),
privateRelays: List<NormalizedRelayUrl> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PrivateOutboxRelayListEvent>.() -> Unit = {},
) = eventTemplate<PrivateOutboxRelayListEvent>(
kind = KIND,
description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer),
createdAt = createdAt,
) {
alt(ALT)
privateRelays(publicRelays)
initializer()
}
}
}
@@ -41,42 +41,39 @@ class StatusEvent(
companion object {
const val KIND = 30315
fun create(
suspend fun create(
msg: String,
type: String,
expiration: Long?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (StatusEvent) -> Unit,
) {
): StatusEvent {
val tags = mutableListOf<Array<String>>()
tags.add(arrayOf("d", type))
expiration?.let { tags.add(arrayOf("expiration", it.toString())) }
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady)
return signer.sign(createdAt, KIND, tags.toTypedArray(), msg)
}
fun update(
suspend fun update(
event: StatusEvent,
newStatus: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (StatusEvent) -> Unit,
) {
): StatusEvent {
val tags = event.tags
signer.sign(createdAt, KIND, tags, newStatus, onReady)
return signer.sign(createdAt, KIND, tags, newStatus)
}
fun clear(
suspend fun clear(
event: StatusEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (StatusEvent) -> Unit,
) {
): StatusEvent {
val msg = ""
val tags = event.tags.filter { it.size > 1 && it[0] == "d" }
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady)
return signer.sign(createdAt, KIND, tags.toTypedArray(), msg)
}
}
}
@@ -45,36 +45,34 @@ class RelayAuthEvent(
companion object {
const val KIND = 22242
fun create(
suspend fun create(
relay: NormalizedRelayUrl,
challenge: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (RelayAuthEvent) -> Unit,
) {
): RelayAuthEvent {
val content = ""
val tags =
arrayOf(
RelayTag.assemble(relay),
ChallengeTag.assemble(challenge),
)
signer.sign(createdAt, KIND, tags, content, onReady)
return signer.sign(createdAt, KIND, tags, content)
}
fun create(
suspend fun create(
relays: List<NormalizedRelayUrl>,
challenge: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (RelayAuthEvent) -> Unit,
) {
): RelayAuthEvent {
val content = ""
val tags =
relays
.map { RelayTag.assemble(it) }
.plusElement(ChallengeTag.assemble(challenge))
.toTypedArray()
signer.sign(createdAt, KIND, tags, content, onReady)
return signer.sign(createdAt, KIND, tags, content)
}
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.quartz.nip44Encryption
import android.util.Log
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04
@@ -49,8 +48,8 @@ object Nip44 {
payload: String,
privateKey: ByteArray,
pubKey: ByteArray,
): String? {
if (payload.isEmpty()) return null
): String {
require(payload.isNotBlank()) { "Payload must not be blank" }
return if (payload[0] == '{') {
decryptNIP44FromJackson(payload, privateKey, pubKey)
} else {
@@ -62,14 +61,13 @@ object Nip44 {
json: String,
privateKey: ByteArray,
pubKey: ByteArray,
): String? {
): String {
// Ignores if it is not a valid json
val info =
try {
JsonMapper.mapper.readValue(json, EncryptedInfoString::class.java)
} catch (e: Exception) {
Log.e("NIP44", "Unable to parse json $json")
return null
throw IllegalArgumentException("Unable to parse NIP-44 JSON: $json")
}
return when (info.v) {
@@ -101,31 +99,25 @@ object Nip44 {
v2.decrypt(encryptedInfo, privateKey, pubKey)
}
else -> null
else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code ${info.v}")
}
}
private fun decryptNIP44FromBase64(
payload: String,
ciphertext: String,
privateKey: ByteArray,
pubKey: ByteArray,
): String? {
if (payload.isEmpty()) return null
): String {
require(ciphertext.isNotBlank()) { "ciphertext must not be blank" }
// Ignores if it is not base64
val byteArray =
try {
Base64.getDecoder().decode(payload)
} catch (e: Exception) {
Log.e("NIP44", "Unable to parse base64 $payload")
return null
}
val byteArray = Base64.getDecoder().decode(ciphertext)
return when (byteArray[0].toInt()) {
EncryptedInfo.V -> Nip04.decrypt(payload, privateKey, pubKey)
Nip44v1.EncryptedInfo.V -> v1.decrypt(payload, privateKey, pubKey)
Nip44v2.EncryptedInfo.V -> v2.decrypt(payload, privateKey, pubKey)
else -> null
EncryptedInfo.V -> Nip04.decrypt(ciphertext, privateKey, pubKey)
Nip44v1.EncryptedInfo.V -> v1.decrypt(ciphertext, privateKey, pubKey)
Nip44v2.EncryptedInfo.V -> v2.decrypt(ciphertext, privateKey, pubKey)
else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code ${byteArray[0].toInt()}")
}
}
}
@@ -20,11 +20,11 @@
*/
package com.vitorpamplona.quartz.nip44Encryption
import android.util.Log
import com.vitorpamplona.quartz.utils.LibSodiumInstance
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.CancellationException
import java.util.Base64
class Nip44v1 {
@@ -57,7 +57,7 @@ class Nip44v1 {
)
return EncryptedInfo(
ciphertext = cipher ?: ByteArray(0),
ciphertext = cipher,
nonce = nonce,
)
}
@@ -66,7 +66,7 @@ class Nip44v1 {
payload: String,
privateKey: ByteArray,
pubKey: ByteArray,
): String? {
): String {
val sharedSecret = getSharedSecret(privateKey, pubKey)
return decrypt(payload, sharedSecret)
}
@@ -75,7 +75,7 @@ class Nip44v1 {
encryptedInfo: EncryptedInfo,
privateKey: ByteArray,
pubKey: ByteArray,
): String? {
): String {
val sharedSecret = getSharedSecret(privateKey, pubKey)
return decrypt(encryptedInfo, sharedSecret)
}
@@ -83,21 +83,21 @@ class Nip44v1 {
fun decrypt(
payload: String,
sharedSecret: ByteArray,
): String? {
val encryptedInfo = EncryptedInfo.decodePayload(payload) ?: return null
): String {
val encryptedInfo = EncryptedInfo.decodePayload(payload)
return decrypt(encryptedInfo, sharedSecret)
}
fun decrypt(
encryptedInfo: EncryptedInfo,
sharedSecret: ByteArray,
): String? =
): String =
LibSodiumInstance
.cryptoStreamXChaCha20Xor(
messageBytes = encryptedInfo.ciphertext,
nonce = encryptedInfo.nonce,
key = sharedSecret,
)?.decodeToString()
).decodeToString()
fun getSharedSecret(
privateKey: ByteArray,
@@ -127,19 +127,18 @@ class Nip44v1 {
companion object {
const val V: Int = 1
fun decodePayload(payload: String): EncryptedInfo? {
return try {
fun decodePayload(payload: String): EncryptedInfo =
try {
val byteArray = Base64.getDecoder().decode(payload)
check(byteArray[0].toInt() == V)
return EncryptedInfo(
EncryptedInfo(
nonce = byteArray.copyOfRange(1, 25),
ciphertext = byteArray.copyOfRange(25, byteArray.size),
)
} catch (e: Exception) {
Log.w("NIP44v1", "Unable to Parse encrypted payload: $payload")
null
if (e is CancellationException) throw e
throw IllegalStateException("NIP-44v1 Unable to Parse encrypted payload: $payload", e)
}
}
}
fun encodePayload(): String =
@@ -20,12 +20,12 @@
*/
package com.vitorpamplona.quartz.nip44Encryption
import android.util.Log
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf
import com.vitorpamplona.quartz.utils.LibSodiumInstance
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.CancellationException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Base64
@@ -88,26 +88,26 @@ class Nip44v2 {
payload: String,
privateKey: ByteArray,
pubKey: ByteArray,
): String? = decrypt(payload, getConversationKey(privateKey, pubKey))
): String = decrypt(payload, getConversationKey(privateKey, pubKey))
fun decrypt(
decoded: EncryptedInfo,
privateKey: ByteArray,
pubKey: ByteArray,
): String? = decrypt(decoded, getConversationKey(privateKey, pubKey))
): String = decrypt(decoded, getConversationKey(privateKey, pubKey))
fun decrypt(
payload: String,
conversationKey: ByteArray,
): String? {
val decoded = EncryptedInfo.decodePayload(payload) ?: return null
): String {
val decoded = EncryptedInfo.decodePayload(payload)
return decrypt(decoded, conversationKey)
}
fun decrypt(
decoded: EncryptedInfo,
conversationKey: ByteArray,
): String? {
): String {
val messageKey = getMessageKeys(conversationKey, decoded.nonce)
val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce)
@@ -236,7 +236,7 @@ class Nip44v2 {
companion object {
const val V: Int = 2
fun decodePayload(payload: String): EncryptedInfo? {
fun decodePayload(payload: String): EncryptedInfo {
check(payload.length >= 132 || payload.length <= 87472) {
"Invalid payload length ${payload.length} for $payload"
}
@@ -245,14 +245,14 @@ class Nip44v2 {
return try {
val byteArray = Base64.getDecoder().decode(payload)
check(byteArray[0].toInt() == V)
return EncryptedInfo(
EncryptedInfo(
nonce = byteArray.copyOfRange(1, 33),
ciphertext = byteArray.copyOfRange(33, byteArray.size - 32),
mac = byteArray.copyOfRange(byteArray.size - 32, byteArray.size),
)
} catch (e: Exception) {
Log.w("NIP44v2", "Unable to Parse encrypted payload: $payload")
null
if (e is CancellationException) throw e
throw IllegalStateException("NIP-44v2 Unable to Parse encrypted payload: $payload", e)
}
}
}
@@ -25,10 +25,10 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class NostrConnectEvent(
@@ -39,17 +39,18 @@ class NostrConnectEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var decryptedContent: Map<HexKey, BunkerMessage> = mapOf()
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (decryptedContent.values.sumOf { pointerSizeInBytes + it.countMemory() })
override fun isContentEncoded() = true
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || recipientPubKey() == signer.pubKey
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull()
suspend fun decryptMessage(signer: NostrSigner): BunkerMessage {
if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException()
val retVal = signer.decrypt(content, talkingWith(signer.pubKey))
return JsonMapper.mapper.readValue(retVal, BunkerMessage::class.java)
}
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
fun verifiedRecipientPubKey(): HexKey? {
val recipient = recipientPubKey()
@@ -62,47 +63,29 @@ class NostrConnectEvent(
fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
fun plainContent(
signer: NostrSigner,
onReady: (BunkerMessage) -> Unit,
) {
decryptedContent[signer.pubKey]?.let {
onReady(it)
return
}
// decrypts using NIP-04 or NIP-44
signer.decrypt(content, talkingWith(signer.pubKey)) { retVal ->
val content = JsonMapper.mapper.readValue(retVal, BunkerMessage::class.java)
decryptedContent = decryptedContent + Pair(signer.pubKey, content)
onReady(content)
}
}
companion object {
const val KIND = 24133
const val ALT = "Nostr Connect Event"
fun create(
suspend fun create(
message: BunkerMessage,
remoteKey: HexKey,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (NostrConnectEvent) -> Unit,
) {
val tags =
arrayOf(
AltTag.assemble(ALT),
arrayOf("p", remoteKey),
)
val encrypted = JsonMapper.mapper.writeValueAsString(message)
signer.nip44Encrypt(encrypted, remoteKey) { content ->
signer.sign(createdAt, KIND, tags, content, onReady)
}
}
): NostrConnectEvent =
signer.sign(
createdAt = createdAt,
kind = KIND,
tags =
arrayOf(
AltTag.assemble(ALT),
arrayOf("p", remoteKey),
),
content =
signer.nip44Encrypt(
plaintext = JsonMapper.mapper.writeValueAsString(message),
toPublicKey = remoteKey,
),
)
}
}
@@ -20,16 +20,14 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class LnZapPaymentRequestEvent(
@@ -40,60 +38,41 @@ class LnZapPaymentRequestEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
// Once one of an app user decrypts the payment, all users else can see it.
@Transient private var lnInvoice: String? = null
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (lnInvoice?.bytesUsedInMemory() ?: 0) // rough calculation
override fun isContentEncoded() = true
fun walletServicePubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) walletServicePubKey() ?: pubKey else pubKey
fun lnInvoice(
signer: NostrSigner,
onReady: (String) -> Unit,
) {
lnInvoice?.let {
onReady(it)
return
}
fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || walletServicePubKey() == signer.pubKey
try {
signer.decrypt(content, talkingWith(signer.pubKey)) { jsonText ->
val payInvoiceMethod = JsonMapper.mapper.readValue(jsonText, Request::class.java)
lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
lnInvoice?.let { onReady(it) }
}
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
}
suspend fun decryptRequest(signer: NostrSigner): Request {
if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException()
val jsonText = signer.decrypt(content, talkingWith(signer.pubKey))
return JsonMapper.mapper.readValue(jsonText, Request::class.java)
}
companion object {
const val KIND = 23194
const val ALT = "Zap payment request"
fun create(
suspend fun create(
lnInvoice: String,
walletServicePubkey: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (LnZapPaymentRequestEvent) -> Unit,
) {
): LnZapPaymentRequestEvent {
val serializedRequest = JsonMapper.mapper.writeValueAsString(PayInvoiceMethod.create(lnInvoice))
val tags = arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT))
signer.nip04Encrypt(
serializedRequest,
walletServicePubkey,
) { content ->
signer.sign(createdAt, KIND, tags, content, onReady)
}
val encrypted =
signer.nip04Encrypt(
serializedRequest,
walletServicePubkey,
)
return signer.sign(createdAt, KIND, tags, encrypted)
}
}
}
@@ -20,13 +20,12 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@Immutable
class LnZapPaymentResponseEvent(
@@ -37,10 +36,7 @@ class LnZapPaymentResponseEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
// Once one of an app user decrypts the payment, all users else can see it.
@Transient private var response: Response? = null
override fun countMemory(): Long = super.countMemory() + pointerSizeInBytes + (response?.countMemory() ?: 0)
override fun isContentEncoded() = true
fun requestAuthor() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
@@ -48,38 +44,13 @@ class LnZapPaymentResponseEvent(
fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) requestAuthor() ?: pubKey else pubKey
private fun plainContent(
signer: NostrSigner,
onReady: (String) -> Unit,
) {
try {
signer.decrypt(content, talkingWith(signer.pubKey)) { content -> onReady(content) }
} catch (e: Exception) {
Log.w("PrivateDM", "Error decrypting the message ${e.message}")
}
}
fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || requestAuthor() == signer.pubKey
fun response(
signer: NostrSigner,
onReady: (Response) -> Unit,
) {
response?.let {
onReady(it)
return
}
suspend fun decrypt(signer: NostrSigner): Response {
if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException()
try {
if (content.isNotEmpty()) {
plainContent(signer) {
JsonMapper.mapper.readValue(it, Response::class.java)?.let {
response = it
onReady(it)
}
}
}
} catch (e: Exception) {
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
}
val json = signer.decrypt(content, talkingWith(signer.pubKey))
return JsonMapper.mapper.readValue(json, Response::class.java)
}
companion object {
@@ -63,7 +63,7 @@ class Nip47WalletConnect {
val relayUri: String,
val secret: HexKey?,
) {
fun normalize(): Nip47WalletConnect.Nip47URINorm? =
fun normalize(): Nip47URINorm? =
RelayUrlNormalizer.normalizeOrNull(relayUri)?.let {
Nip47URINorm(
pubKeyHex,
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache
class NostrWalletConnectRequestCache(
signer: NostrSigner,
) {
private val decryptionCache =
object : LruCache<LnZapPaymentRequestEvent, NWCRequestDecryptCache>(50) {
override fun create(key: LnZapPaymentRequestEvent): NWCRequestDecryptCache? =
if (key.content.isNotBlank() && key.canDecrypt(signer)) {
NWCRequestDecryptCache(signer)
} else {
null
}
}
fun cachedRequest(event: LnZapPaymentRequestEvent): Request? = decryptionCache[event]?.cached()
suspend fun decryptRequest(event: LnZapPaymentRequestEvent) = decryptionCache[event]?.decrypt(event)
}
class NWCRequestDecryptCache(
signer: NostrSigner,
) : DecryptCache<LnZapPaymentRequestEvent, Request>(signer) {
override suspend fun decryptAndParse(
event: LnZapPaymentRequestEvent,
signer: NostrSigner,
) = event.decryptRequest(signer)
}
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache
class NostrWalletConnectResponseCache(
signer: NostrSigner,
) {
private val decryptionCache =
object : LruCache<LnZapPaymentResponseEvent, NWCResponseDecryptCache>(50) {
override fun create(key: LnZapPaymentResponseEvent): NWCResponseDecryptCache? =
if (key.content.isNotBlank() && key.canDecrypt(signer)) {
NWCResponseDecryptCache(signer)
} else {
null
}
}
fun cachedResponse(event: LnZapPaymentResponseEvent): Response? = decryptionCache[event]?.cached()
suspend fun decryptResponse(event: LnZapPaymentResponseEvent) = decryptionCache[event]?.decrypt(event)
}
class NWCResponseDecryptCache(
signer: NostrSigner,
) : DecryptCache<LnZapPaymentResponseEvent, Response>(signer) {
override suspend fun decryptAndParse(
event: LnZapPaymentResponseEvent,
signer: NostrSigner,
) = event.decrypt(signer)
}
@@ -21,16 +21,27 @@
package com.vitorpamplona.quartz.nip50Search
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip50Search.tags.RelayTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.searchRelays
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.collections.plus
import kotlin.collections.toTypedArray
@Immutable
class SearchRelayListEvent(
@@ -40,66 +51,72 @@ class SearchRelayListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun relays(): List<NormalizedRelayUrl> = tags.mapNotNull(RelayTag::parse)
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun publicRelays() = tags.relays()
suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
suspend fun relays(signer: NostrSigner): List<NormalizedRelayUrl> = publicRelays() + (privateRelays(signer) ?: emptyList())
companion object {
const val KIND = 10007
val ALT = "Relay list to use for Search"
val ALT_TAG = arrayOf(AltTag.assemble(ALT))
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey)
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey)
fun createTagArray(relays: List<NormalizedRelayUrl>): Array<Array<String>> =
relays
.map {
RelayTag.assemble(it)
}.plusElement(AltTag.assemble("Relay list to use for Search"))
.toTypedArray()
fun updateRelayList(
suspend fun updateRelayList(
earlierVersion: SearchRelayListEvent,
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (SearchRelayListEvent) -> Unit,
) {
val tags =
earlierVersion.tags
.filter(RelayTag::notMatch)
.plus(
relays.map {
RelayTag.assemble(it)
},
).toTypedArray()
): SearchRelayListEvent {
val newRelayList = relays.map { RelayTag.assemble(it) }
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady)
val publicTags = earlierVersion.tags.remove(RelayTag::match)
val newPrivateTags = privateTags.remove(RelayTag::notMatch).plus(newRelayList)
return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags)
}
fun createFromScratch(
suspend fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (SearchRelayListEvent) -> Unit,
) {
create(relays, signer, createdAt, onReady)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (SearchRelayListEvent) -> Unit,
) {
signer.sign(createdAt, KIND, createTagArray(relays), "", onReady)
): SearchRelayListEvent {
val publicTagArray = relays.map { RelayTag.assemble(it) }.plus(ALT_TAG).toTypedArray()
return signer.signNip51List(createdAt, KIND, publicTagArray, emptyArray())
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): SearchRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "")
): SearchRelayListEvent {
val publicTagArray = relays.map { RelayTag.assemble(it) }.plus(ALT_TAG).toTypedArray()
return signer.signNip51List(createdAt, KIND, publicTagArray, emptyArray())
}
suspend fun build(
publicRelays: List<NormalizedRelayUrl> = emptyList(),
privateRelays: List<NormalizedRelayUrl> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<SearchRelayListEvent>.() -> Unit = {},
) = eventTemplate<SearchRelayListEvent>(
kind = KIND,
description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer),
createdAt = createdAt,
) {
alt(ALT)
searchRelays(publicRelays)
initializer()
}
}
}
@@ -1,105 +0,0 @@
/**
* 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.nip51Lists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip50Search.tags.RelayTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class BlockedRelayListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun relays(): List<NormalizedRelayUrl> = tags.mapNotNull(RelayTag::parse)
companion object {
const val KIND = 10006
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
fun createTagArray(relays: List<NormalizedRelayUrl>): Array<Array<String>> =
relays
.map {
RelayTag.assemble(it)
}.plusElement(AltTag.assemble("Relay list to use for trusted connections"))
.toTypedArray()
fun updateRelayList(
earlierVersion: BlockedRelayListEvent,
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlockedRelayListEvent) -> Unit,
) {
val tags =
earlierVersion.tags
.filter(RelayTag::notMatch)
.plus(
relays.map {
RelayTag.assemble(it)
},
).toTypedArray()
signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady)
}
fun createFromScratch(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlockedRelayListEvent) -> Unit,
) {
create(relays, signer, createdAt, onReady)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlockedRelayListEvent) -> Unit,
) {
signer.sign(createdAt, KIND, createTagArray(relays), "", onReady)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): BlockedRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "")
}
}
@@ -1,237 +0,0 @@
/**
* 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.nip51Lists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class BookmarkListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun countBookmarks() = tags.count(ETag::isTagged) + tags.count(ATag::isTagged)
companion object {
const val KIND = 30001
const val ALT = "List of bookmarks"
const val DEFAULT_D_TAG_BOOKMARKS = "bookmark"
fun addEvent(
earlierVersion: BookmarkListEvent?,
eventId: HexKey,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BookmarkListEvent) -> Unit,
) = addTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady)
fun addReplaceable(
earlierVersion: BookmarkListEvent?,
aTag: ATag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BookmarkListEvent) -> Unit,
) = addTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady)
fun addTag(
earlierVersion: BookmarkListEvent?,
tagName: String,
tagValue: HexKey,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BookmarkListEvent) -> Unit,
) {
add(
earlierVersion,
arrayOf(arrayOf(tagName, tagValue)),
isPrivate,
signer,
createdAt,
onReady,
)
}
fun add(
earlierVersion: BookmarkListEvent?,
listNewTags: Array<Array<String>>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BookmarkListEvent) -> Unit,
) {
if (isPrivate) {
if (earlierVersion != null) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags = privateTags.plus(listNewTags),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags = earlierVersion.tags,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
} else {
encryptTags(
privateTags = listNewTags,
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags = arrayOf(arrayOf("d", DEFAULT_D_TAG_BOOKMARKS)),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
} else {
create(
content = earlierVersion?.content ?: "",
tags = (earlierVersion?.tags ?: arrayOf(arrayOf("d", DEFAULT_D_TAG_BOOKMARKS))).plus(listNewTags),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
fun removeEvent(
earlierVersion: BookmarkListEvent,
eventId: HexKey,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BookmarkListEvent) -> Unit,
) = removeTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady)
fun removeReplaceable(
earlierVersion: BookmarkListEvent,
aTag: ATag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BookmarkListEvent) -> Unit,
) = removeTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady)
private fun removeTag(
earlierVersion: BookmarkListEvent,
tagName: String,
tagValue: HexKey,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BookmarkListEvent) -> Unit,
) {
if (isPrivate) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags =
privateTags
.filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) }
.toTypedArray(),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags =
earlierVersion.tags
.filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) }
.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
} else {
create(
content = earlierVersion.content,
tags =
earlierVersion.tags
.filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) }
.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BookmarkListEvent) -> Unit,
) {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
} else {
tags + AltTag.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
}
fun create(
name: String = "",
events: List<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BookmarkListEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
tags.add(arrayOf("d", name))
events?.forEach { tags.add(arrayOf("e", it)) }
users?.forEach { tags.add(arrayOf("p", it)) }
addresses?.forEach { tags.add(arrayOf("a", it.toTag())) }
tags.add(AltTag.assemble(ALT))
createPrivateTags(privEvents, privUsers, privAddresses, signer) { content ->
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
}
}
}
}
@@ -1,188 +0,0 @@
/**
* 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.nip51Lists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isTagged
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
@Immutable
abstract class GeneralListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, kind, tags, content, sig),
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider {
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(ETag::parseAsHint) ?: emptyList())
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + (cachedPrivateTags()?.mapNotNull(ETag::parseId) ?: emptyList())
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(ATag::parseAsHint) ?: emptyList())
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + (cachedPrivateTags()?.mapNotNull(ATag::parseAddressId) ?: emptyList())
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + (cachedPrivateTags()?.mapNotNull(PTag::parseAsHint) ?: emptyList())
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + (cachedPrivateTags()?.mapNotNull(PTag::parseKey) ?: emptyList())
fun name() = tags.firstNotNullOfOrNull(NameTag::parse)
@Deprecated("NIP-51 has deprecated Title. Use name instead", ReplaceWith("name()"))
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun nameOrTitle() = name() ?: title()
fun filterTagList(
key: String,
privateTags: Array<Array<String>>?,
): ImmutableSet<String> {
val result = HashSet<String>(tags.size + (privateTags?.size ?: 0))
privateTags?.let { it.filter { it.size > 1 && it[0] == key }.mapTo(result) { it[1] } }
tags.filter { it.size > 1 && it[0] == key }.mapTo(result) { it[1] }
return result.toImmutableSet()
}
fun isTagged(
key: String,
tag: String,
isPrivate: Boolean,
signer: NostrSigner,
onReady: (Boolean) -> Unit,
) = if (isPrivate) {
privateTagsOrEmpty(signer = signer) {
onReady(
it.any { it.size > 1 && it[0] == key && it[1] == tag },
)
}
} else {
onReady(tags.isTagged(key, tag))
}
fun privateTagsOrEmpty(
signer: NostrSigner,
onReady: (Array<Array<String>>) -> Unit,
) {
privateTags(signer, onReady)
}
fun privateTaggedUsers(
signer: NostrSigner,
onReady: (List<String>) -> Unit,
) = privateTags(signer) { onReady(filterUsers(it)) }
fun privateHashtags(
signer: NostrSigner,
onReady: (List<String>) -> Unit,
) = privateTags(signer) { onReady(filterHashtags(it)) }
fun privateGeohashes(
signer: NostrSigner,
onReady: (List<String>) -> Unit,
) = privateTags(signer) { onReady(filterGeohashes(it)) }
fun privateTaggedEvents(
signer: NostrSigner,
onReady: (List<String>) -> Unit,
) = privateTags(signer) { onReady(filterEvents(it)) }
fun privateATags(
signer: NostrSigner,
onReady: (List<ATag>) -> Unit,
) = privateTags(signer) { onReady(filterATags(it)) }
fun privateAddress(
signer: NostrSigner,
onReady: (List<Address>) -> Unit,
) = privateTags(signer) { onReady(filterAddresses(it)) }
fun filterUsers(tags: Array<Array<String>>): List<String> = tags.mapNotNull(PTag::parseKey)
fun filterHashtags(tags: Array<Array<String>>): List<String> = tags.mapNotNull(HashtagTag::parse)
fun filterGeohashes(tags: Array<Array<String>>): List<String> = tags.geohashes()
fun filterEvents(tags: Array<Array<String>>): List<String> = tags.mapNotNull(ETag::parseId)
fun filterATags(tags: Array<Array<String>>): List<ATag> = tags.mapNotNull(ATag::parse)
fun filterAddresses(tags: Array<Array<String>>): List<Address> = tags.mapNotNull(ATag::parseAddress)
companion object {
fun createPrivateTags(
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
signer: NostrSigner,
onReady: (String) -> Unit,
) {
val privTags = mutableListOf<Array<String>>()
privEvents?.forEach { privTags.add(arrayOf("e", it)) }
privUsers?.forEach { privTags.add(arrayOf("p", it)) }
privAddresses?.forEach { privTags.add(arrayOf("a", it.toTag())) }
return encryptTags(privTags.toTypedArray(), signer, onReady)
}
fun encryptTags(
privateTags: Array<Array<String>>? = null,
signer: NostrSigner,
onReady: (String) -> Unit,
) {
val msg = JsonMapper.mapper.writeValueAsString(privateTags)
signer.nip04Encrypt(
msg,
signer.pubKey,
onReady,
)
}
}
}
@@ -1,285 +0,0 @@
/**
* 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.nip51Lists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent.UsersAndWords
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlin.coroutines.resume
@Immutable
class MuteListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun dTag() = FIXED_D_TAG
fun publicAndCachedUsersAndWords() =
UsersAndWords(
filterTagList("p", cachedPrivateTags()),
filterTagList("word", cachedPrivateTags()),
)
fun publicAndPrivateUsersAndWords(
signer: NostrSigner,
onReady: (UsersAndWords) -> Unit,
) {
privateTagsOrEmpty(signer) {
onReady(
UsersAndWords(
filterTagList("p", it),
filterTagList("word", it),
),
)
}
}
suspend fun publicAndPrivateUsersAndWords(signer: NostrSigner): UsersAndWords? =
tryAndWait { continuation ->
publicAndPrivateUsersAndWords(signer) { privateTagList ->
continuation.resume(privateTagList)
}
}
companion object {
const val KIND = 10000
const val FIXED_D_TAG = ""
const val ALT = "Mute List"
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
fun blockListFor(pubKeyHex: HexKey): String = "10000:$pubKeyHex:"
fun createListWithTag(
key: String,
tag: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MuteListEvent) -> Unit,
) {
if (isPrivate) {
encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags ->
create(
content = encryptedTags,
tags = emptyArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
} else {
create(
content = "",
tags = arrayOf(arrayOf(key, tag)),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
fun createListWithUser(
pubKeyHex: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MuteListEvent) -> Unit,
) = createListWithTag("p", pubKeyHex, isPrivate, signer, createdAt, onReady)
fun createListWithWord(
word: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MuteListEvent) -> Unit,
) = createListWithTag("word", word, isPrivate, signer, createdAt, onReady)
fun addUsers(
earlierVersion: MuteListEvent,
listPubKeyHex: List<String>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MuteListEvent) -> Unit,
) {
if (isPrivate) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags =
privateTags.plus(
listPubKeyHex.map { arrayOf("p", it) },
),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags = earlierVersion.tags,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
} else {
create(
content = earlierVersion.content,
tags =
earlierVersion.tags.plus(
listPubKeyHex.map { arrayOf("p", it) },
),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
fun addWord(
earlierVersion: MuteListEvent,
word: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MuteListEvent) -> Unit,
) = addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady)
fun addUser(
earlierVersion: MuteListEvent,
pubKeyHex: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MuteListEvent) -> Unit,
) = addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady)
fun addTag(
earlierVersion: MuteListEvent,
key: String,
tag: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MuteListEvent) -> Unit,
) {
earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged ->
if (!isTagged) {
if (isPrivate) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags = privateTags.plus(element = arrayOf(key, tag)),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags = earlierVersion.tags,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
} else {
create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = arrayOf(key, tag)),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
}
}
fun removeWord(
earlierVersion: MuteListEvent,
word: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MuteListEvent) -> Unit,
) = removeTag(earlierVersion, "word", word, signer, createdAt, onReady)
fun removeUser(
earlierVersion: MuteListEvent,
pubKeyHex: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MuteListEvent) -> Unit,
) = removeTag(earlierVersion, "p", pubKeyHex, signer, createdAt, onReady)
fun removeTag(
earlierVersion: MuteListEvent,
key: String,
tag: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MuteListEvent) -> Unit,
) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags =
privateTags
.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }
.toTypedArray(),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags =
earlierVersion.tags
.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }
.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
}
fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MuteListEvent) -> Unit,
) {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
} else {
tags + AltTag.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
}
}
}
@@ -1,305 +0,0 @@
/**
* 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.nip51Lists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlin.coroutines.resume
@Immutable
class PeopleListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Immutable
class UsersAndWords(
val users: Set<String> = setOf(),
val words: Set<String> = setOf(),
)
fun publicAndCachedPrivateUsersAndWords() =
UsersAndWords(
filterTagList("p", cachedPrivateTags()),
filterTagList("word", cachedPrivateTags()),
)
fun publicAndPrivateUsersAndWords(
signer: NostrSigner,
onReady: (UsersAndWords) -> Unit,
) {
privateTagsOrEmpty(signer) {
onReady(
UsersAndWords(
filterTagList("p", it),
filterTagList("word", it),
),
)
}
}
suspend fun publicAndPrivateUsersAndWords(signer: NostrSigner): UsersAndWords? =
tryAndWait { continuation ->
publicAndPrivateUsersAndWords(signer) { privateTagList ->
continuation.resume(privateTagList)
}
}
fun isTaggedWord(
word: String,
isPrivate: Boolean,
signer: NostrSigner,
onReady: (Boolean) -> Unit,
) = isTagged("word", word, isPrivate, signer, onReady)
fun isTaggedUser(
idHex: String,
isPrivate: Boolean,
signer: NostrSigner,
onReady: (Boolean) -> Unit,
) = isTagged("p", idHex, isPrivate, signer, onReady)
companion object {
const val KIND = 30000
const val BLOCK_LIST_D_TAG = "mute"
const val ALT = "List of people"
fun createBlockAddress(pubKey: HexKey) = Address(KIND, pubKey, BLOCK_LIST_D_TAG)
fun blockListFor(pubKeyHex: HexKey): String = "30000:$pubKeyHex:$BLOCK_LIST_D_TAG"
fun createListWithTag(
name: String,
key: String,
tag: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) {
if (isPrivate) {
encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags ->
create(
content = encryptedTags,
tags = arrayOf(arrayOf("d", name)),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
} else {
create(
content = "",
tags = arrayOf(arrayOf("d", name), arrayOf(key, tag)),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
fun createListWithUser(
name: String,
pubKeyHex: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) = createListWithTag(name, "p", pubKeyHex, isPrivate, signer, createdAt, onReady)
fun createListWithWord(
name: String,
word: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) = createListWithTag(name, "word", word, isPrivate, signer, createdAt, onReady)
fun addUsers(
earlierVersion: PeopleListEvent,
listPubKeyHex: List<String>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) {
if (isPrivate) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags =
privateTags.plus(
listPubKeyHex.map { arrayOf("p", it) },
),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags = earlierVersion.tags,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
} else {
create(
content = earlierVersion.content,
tags =
earlierVersion.tags.plus(
listPubKeyHex.map { arrayOf("p", it) },
),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
fun addWord(
earlierVersion: PeopleListEvent,
word: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) = addTag(earlierVersion, "word", word, isPrivate, signer, createdAt, onReady)
fun addUser(
earlierVersion: PeopleListEvent,
pubKeyHex: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) = addTag(earlierVersion, "p", pubKeyHex, isPrivate, signer, createdAt, onReady)
fun addTag(
earlierVersion: PeopleListEvent,
key: String,
tag: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) {
earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged ->
if (!isTagged) {
if (isPrivate) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags = privateTags.plus(element = arrayOf(key, tag)),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags = earlierVersion.tags,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
} else {
create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = arrayOf(key, tag)),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
}
}
fun removeWord(
earlierVersion: PeopleListEvent,
word: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) = removeTag(earlierVersion, "word", word, signer, createdAt, onReady)
fun removeUser(
earlierVersion: PeopleListEvent,
pubKeyHex: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) = removeTag(earlierVersion, "p", pubKeyHex, signer, createdAt, onReady)
fun removeTag(
earlierVersion: PeopleListEvent,
key: String,
tag: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags =
privateTags
.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }
.toTypedArray(),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags =
earlierVersion.tags
.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }
.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
}
fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
} else {
tags + AltTag.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
}
}
}
@@ -42,17 +42,16 @@ class PinListEvent(
const val KIND = 33888
const val ALT = "Pinned Posts"
fun create(
suspend fun create(
pins: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PinListEvent) -> Unit,
) {
): PinListEvent {
val tags = mutableListOf<Array<String>>()
pins.forEach { tags.add(arrayOf("pin", it)) }
tags.add(AltTag.assemble(ALT))
signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady)
return signer.sign(createdAt, KIND, tags.toTypedArray(), "")
}
}
}
@@ -21,194 +21,170 @@
package com.vitorpamplona.quartz.nip51Lists
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
class PrivateTagArrayBuilder {
companion object {
fun create(
suspend fun create(
tags: Array<Array<String>>,
toPrivate: Boolean,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
): Pair<String, Array<Array<String>>> =
if (toPrivate) {
PrivateTagsInContent.encryptNip04(
privateTags = tags,
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, arrayOf())
}
val encryptedTags =
PrivateTagsInContent.encryptNip04(
privateTags = tags,
signer = signer,
)
Pair(encryptedTags, arrayOf())
} else {
onReady("", tags)
Pair("", tags)
}
}
fun add(
suspend fun add(
current: PrivateTagArrayEvent,
newTag: Array<String>,
toPrivate: Boolean,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
): Pair<String, Array<Array<String>>> =
if (toPrivate) {
current.privateTags(signer) { privateTags ->
val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val encryptedTags =
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.plus(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
)
Pair(encryptedTags, current.tags)
} else {
onReady(current.content, current.tags.plus(newTag))
Pair(current.content, current.tags.plus(newTag))
}
}
fun addAll(
suspend fun addAll(
current: PrivateTagArrayEvent,
newTag: Array<Array<String>>,
toPrivate: Boolean,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
): Pair<String, Array<Array<String>>> =
if (toPrivate) {
current.privateTags(signer) { privateTags ->
val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val encryptedTags =
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.plus(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
)
Pair(encryptedTags, current.tags)
} else {
onReady(current.content, current.tags.plus(newTag))
Pair(current.content, current.tags.plus(newTag))
}
}
fun replaceAllToPrivateNewTag(
suspend fun replaceAllToPrivateNewTag(
dTag: String,
current: PrivateTagArrayEvent?,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
): Pair<String, Array<Array<String>>> =
if (current == null) {
createPrivate(dTag, newTag, signer, onReady)
createPrivate(dTag, newTag, signer)
} else {
replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer, onReady)
replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer)
}
}
fun replaceAllToPublicNewTag(
suspend fun replaceAllToPublicNewTag(
dTag: String,
current: PrivateTagArrayEvent?,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
): Pair<String, Array<Array<String>>> =
if (current == null) {
createPublic(dTag, newTag, signer, onReady)
createPublic(dTag, newTag, signer)
} else {
replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer, onReady)
replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer)
}
}
fun replaceAllToPrivateNewTag(
suspend fun replaceAllToPrivateNewTag(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
): Pair<String, Array<Array<String>>> {
val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val encryptedTags =
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.replaceAll(oldTagStartsWith, newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith))
}
}
)
return Pair(encryptedTags, current.tags.remove(oldTagStartsWith))
}
fun replaceAllToPublicNewTag(
suspend fun replaceAllToPublicNewTag(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
): Pair<String, Array<Array<String>>> {
val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val encryptedTags =
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith).plus(newTag))
}
}
)
return Pair(encryptedTags, current.tags.remove(oldTagStartsWith).plus(newTag))
}
fun removeAllFromPrivate(
suspend fun removeAllFromPrivate(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
): Pair<String, Array<Array<String>>> {
val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val encryptedTags =
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
)
return Pair(encryptedTags, current.tags)
}
fun removeAllFromPublic(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) = onReady(current.content, current.tags.remove(oldTagStartsWith))
): Pair<String, Array<Array<String>>> = Pair(current.content, current.tags.remove(oldTagStartsWith))
fun removeAll(
suspend fun removeAll(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
): Pair<String, Array<Array<String>>> {
val privateTags = current.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val encryptedTags =
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith))
}
}
)
return Pair(encryptedTags, current.tags.remove(oldTagStartsWith))
}
fun createPrivate(
suspend fun createPrivate(
dTag: String,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
PrivateTagsInContent.encryptNip04(
privateTags = arrayOf(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, arrayOf(arrayOf("d", dTag)))
}
): Pair<String, Array<Array<String>>> {
val encryptedTags =
PrivateTagsInContent.encryptNip04(
privateTags = arrayOf(newTag),
signer = signer,
)
return Pair(encryptedTags, arrayOf(arrayOf("d", dTag)))
}
fun createPublic(
dTag: String,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
onReady("", arrayOf(arrayOf("d", dTag), newTag))
}
): Pair<String, Array<Array<String>>> = Pair("", arrayOf(arrayOf("d", dTag), newTag))
}
}
@@ -20,14 +20,14 @@
*/
package com.vitorpamplona.quartz.nip51Lists
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlinx.coroutines.CancellationException
@Immutable
abstract class PrivateTagArrayEvent(
@@ -35,61 +35,28 @@ abstract class PrivateTagArrayEvent(
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
tags: TagArray,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient private var privateTagsCache: Array<Array<String>>? = null
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0)
override fun isContentEncoded() = true
fun cachedPrivateTags(): Array<Array<String>>? = privateTagsCache
suspend fun decrypt(signer: NostrSigner): TagArray {
if (signer.pubKey != pubKey) throw SignerExceptions.UnauthorizedDecryptionException()
fun privateTags(
signer: NostrSigner,
onReady: (Array<Array<String>>) -> Unit,
) {
if (content.isEmpty()) {
onReady(emptyArray())
return
}
privateTagsCache?.let {
onReady(it)
return
}
try {
PrivateTagsInContent.decrypt(content, signer) {
privateTagsCache = it
privateTagsCache?.let { onReady(it) }
}
} catch (e: Throwable) {
onReady(emptyArray())
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
}
return PrivateTagsInContent.decrypt(content, signer)
}
fun mergeTagList(
signer: NostrSigner,
onReady: (Array<Array<String>>) -> Unit,
) {
privateTags(signer) {
onReady(tags + it)
suspend fun privateTags(signer: NostrSigner): TagArray? {
if (signer.pubKey != pubKey) {
return null
}
}
fun <T> mapAllTags(
privateTags: Array<Array<String>>,
mapper: (Array<String>) -> T,
): Set<T> {
val privateRooms = privateTags.mapNotNull(mapper)
val publicRooms = tags.mapNotNull(mapper)
return (privateRooms + publicRooms).toSet()
return try {
PrivateTagsInContent.decrypt(content, signer)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
}
@@ -0,0 +1,61 @@
/**
* 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.nip51Lists
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache
import kotlin.collections.plus
class PrivateTagArrayEventCache<T : PrivateTagArrayEvent>(
signer: NostrSigner,
cacheSize: Int = 10,
) {
private val decryptionCache =
object : LruCache<T, PrivateTagArrayEventDecryptCache<T>>(cacheSize) {
override fun create(key: T): PrivateTagArrayEventDecryptCache<T>? =
if (key.content.isNotBlank() && key.pubKey == signer.pubKey) {
PrivateTagArrayEventDecryptCache<T>(signer)
} else {
null
}
}
fun remove(event: T) = decryptionCache.remove(event)
fun cachedPrivateTags(event: T): TagArray? = decryptionCache[event]?.cached()
suspend fun privateTags(event: T) = decryptionCache[event]?.decrypt(event)
suspend fun mergeTagList(event: T): TagArray = event.tags + (privateTags(event) ?: emptyArray())
fun mergeTagListPrecached(event: T): TagArray = event.tags + (cachedPrivateTags(event) ?: emptyArray())
}
class PrivateTagArrayEventDecryptCache<T : PrivateTagArrayEvent>(
signer: NostrSigner,
) : DecryptCache<T, TagArray>(signer) {
override suspend fun decryptAndParse(
event: T,
signer: NostrSigner,
): TagArray = event.decrypt(signer)
}
@@ -1,62 +0,0 @@
/**
* 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.nip51Lists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class RelaySetEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun relays(): List<NormalizedRelayUrl> = tags.mapNotNull(RelayTag::parse)
fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
companion object {
const val KIND = 30002
const val ALT = "Relay list"
fun create(
relays: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (RelaySetEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
relays.forEach { tags.add(arrayOf("r", it)) }
tags.add(AltTag.assemble(ALT))
signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady)
}
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip51Lists
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.utils.startsWith
import com.vitorpamplona.quartz.utils.startsWithAny
inline fun TagArray.filterToArray(predicate: (Array<String>) -> Boolean): TagArray = filterTo(ArrayList(), predicate).toTypedArray()
@@ -29,6 +30,14 @@ inline fun TagArray.remove(predicate: (Array<String>) -> Boolean): TagArray = fi
fun TagArray.remove(startsWith: Array<String>): TagArray = filterNotTo(ArrayList(this.size), { it.startsWith(startsWith) }).toTypedArray()
fun TagArray.removeAny(startsWith: List<Array<String>>): TagArray =
filterNotTo(
ArrayList(this.size),
{
it.startsWithAny(startsWith)
},
).toTypedArray()
fun TagArray.replaceAll(
startsWith: Array<String>,
newElement: Array<String>,
@@ -1,105 +0,0 @@
/**
* 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.nip51Lists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip50Search.tags.RelayTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class TrustedRelayListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun relays(): List<NormalizedRelayUrl> = tags.mapNotNull(RelayTag::parse)
companion object {
const val KIND = 10089
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
fun createTagArray(relays: List<NormalizedRelayUrl>): Array<Array<String>> =
relays
.map {
RelayTag.assemble(it)
}.plusElement(AltTag.assemble("Relay list to use for trusted connections"))
.toTypedArray()
fun updateRelayList(
earlierVersion: TrustedRelayListEvent,
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (TrustedRelayListEvent) -> Unit,
) {
val tags =
earlierVersion.tags
.filter(RelayTag::notMatch)
.plus(
relays.map {
RelayTag.assemble(it)
},
).toTypedArray()
signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady)
}
fun createFromScratch(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (TrustedRelayListEvent) -> Unit,
) {
create(relays, signer, createdAt, onReady)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (TrustedRelayListEvent) -> Unit,
) {
signer.sign(createdAt, KIND, createTagArray(relays), "", onReady)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): TrustedRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "")
}
}
@@ -0,0 +1,212 @@
/**
* 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.nip51Lists.bookmarkList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class BookmarkListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
AddressHintProvider {
override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId)
override fun addressHints() = tags.mapNotNull(AddressBookmark::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(AddressBookmark::parseAddressId)
fun name() = tags.firstNotNullOfOrNull(NameTag::parse)
fun countBookmarks() = tags.count(BookmarkIdTag::isTagged)
fun publicBookmarks(): List<BookmarkIdTag> = tags.mapNotNull(BookmarkIdTag::parse)
suspend fun privateBookmarks(signer: NostrSigner): List<BookmarkIdTag>? = privateTags(signer)?.mapNotNull(BookmarkIdTag::parse)
companion object {
const val KIND = 30001
const val ALT = "List of bookmarks"
const val DEFAULT_D_TAG_BOOKMARKS = "bookmark"
fun createBookmarkAddress(pubKey: HexKey) = Address(KIND, pubKey, DEFAULT_D_TAG_BOOKMARKS)
suspend fun create(
bookmarkIdTag: BookmarkIdTag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): BookmarkListEvent =
if (isPrivate) {
create(
publicBookmarks = emptyList(),
privateBookmarks = listOf(bookmarkIdTag),
signer = signer,
createdAt = createdAt,
)
} else {
create(
publicBookmarks = listOf(bookmarkIdTag),
privateBookmarks = emptyList(),
signer = signer,
createdAt = createdAt,
)
}
suspend fun add(
earlierVersion: BookmarkListEvent,
bookmarkIdTag: BookmarkIdTag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): BookmarkListEvent =
if (isPrivate) {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
resign(
tags = earlierVersion.tags,
privateTags = privateTags.plus(bookmarkIdTag.toTagArray()),
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(bookmarkIdTag.toTagArray()),
signer = signer,
createdAt = createdAt,
)
}
suspend fun remove(
earlierVersion: BookmarkListEvent,
bookmarkIdTag: BookmarkIdTag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): BookmarkListEvent =
if (isPrivate) {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
resign(
privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()),
tags = earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()),
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags =
earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()),
signer = signer,
createdAt = createdAt,
)
}
suspend fun resign(
tags: TagArray,
privateTags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = resign(
content = PrivateTagsInContent.encryptNip04(privateTags, signer),
tags = tags,
signer = signer,
createdAt = createdAt,
)
suspend fun resign(
content: String,
tags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): BookmarkListEvent {
val newTags =
if (tags.fastAny(AltTag::match)) {
tags
} else {
tags + AltTag.assemble(ALT)
}
return signer.sign(createdAt, KIND, newTags, content)
}
suspend fun create(
name: String = "",
publicBookmarks: List<BookmarkIdTag> = emptyList(),
privateBookmarks: List<BookmarkIdTag> = emptyList(),
dTag: String = DEFAULT_D_TAG_BOOKMARKS,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): BookmarkListEvent {
val template = build(name, publicBookmarks, privateBookmarks, signer, dTag, createdAt)
return signer.sign(template)
}
suspend fun build(
name: String = "",
publicBookmarks: List<BookmarkIdTag> = emptyList(),
privateBookmarks: List<BookmarkIdTag> = emptyList(),
signer: NostrSigner,
dTag: String = DEFAULT_D_TAG_BOOKMARKS,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<BookmarkListEvent>.() -> Unit = {},
) = eventTemplate(
kind = KIND,
description = PrivateTagsInContent.encryptNip04(privateBookmarks.map { it.toTagArray() }.toTypedArray(), signer),
createdAt = createdAt,
) {
dTag(dTag)
alt(ALT)
name(name)
bookmarks(publicBookmarks)
initializer()
}
}
}
@@ -0,0 +1,29 @@
/**
* 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.nip51Lists.bookmarkList
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
fun TagArrayBuilder<BookmarkListEvent>.name(name: String) = addUnique(NameTag.assemble(name))
fun TagArrayBuilder<BookmarkListEvent>.bookmarks(bookmarks: List<BookmarkIdTag>) = addAll(bookmarks.map { it.toTagArray() })
@@ -0,0 +1,150 @@
/**
* 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.nip51Lists.bookmarkList.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.ensure
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
class AddressBookmark(
val address: Address,
val relayHint: NormalizedRelayUrl? = null,
) : BookmarkIdTag {
fun countMemory(): Long = 2 * pointerSizeInBytes + address.countMemory() + (relayHint?.url?.bytesUsedInMemory() ?: 0)
fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag)
override fun toTagArray() = assemble(address, relayHint)
override fun toTagIdOnly() = assemble(address, null)
companion object {
const val TAG_NAME = "a"
@JvmStatic
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@JvmStatic
fun isTagged(
tag: Array<String>,
addressId: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId
@JvmStatic
fun isTagged(
tag: Array<String>,
address: AddressBookmark,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag()
@JvmStatic
fun isIn(
tag: Array<String>,
addressIds: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds
@JvmStatic
fun isTaggedWithKind(
tag: Array<String>,
kind: String,
) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], kind)
@JvmStatic
fun parse(
aTagId: String,
relay: String?,
) = Address.parse(aTagId)?.let {
AddressBookmark(it, relay?.let { RelayUrlNormalizer.normalizeOrNull(it) })
}
@JvmStatic
fun parse(tag: Array<String>): AddressBookmark? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return parse(tag[1], tag.getOrNull(2))
}
@JvmStatic
fun parseValidAddress(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return Address.parse(tag[1])?.toValue()
}
@JvmStatic
fun parseAddress(tag: Array<String>): Address? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return Address.parse(tag[1])
}
@JvmStatic
fun parseAddressId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@JvmStatic
fun parseAsHint(tag: Array<String>): AddressHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[1].contains(':')) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2])
ensure(relayHint != null) { return null }
return AddressHint(tag[1], relayHint)
}
@JvmStatic
fun assemble(
aTagId: HexKey,
relay: NormalizedRelayUrl?,
) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url)
@JvmStatic
fun assemble(
address: Address,
relay: NormalizedRelayUrl?,
) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url)
@JvmStatic
fun assemble(
kind: Int,
pubKey: String,
dTag: String,
relay: NormalizedRelayUrl?,
) = assemble(Address.assemble(kind, pubKey, dTag), relay)
}
}
@@ -0,0 +1,35 @@
/**
* 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.nip51Lists.bookmarkList.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
sealed interface BookmarkIdTag {
fun toTagArray(): Tag
fun toTagIdOnly(): Tag
companion object {
fun isTagged(tag: Array<String>) = EventBookmark.isTagged(tag) || AddressBookmark.isTagged(tag)
fun parse(tag: Array<String>): BookmarkIdTag? = EventBookmark.parse(tag) ?: AddressBookmark.parse(tag)
}
}
@@ -0,0 +1,119 @@
/**
* 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.nip51Lists.bookmarkList.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.ensure
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class EventBookmark(
val eventId: HexKey,
val relay: NormalizedRelayUrl? = null,
val author: HexKey? = null,
) : BookmarkIdTag {
fun countMemory(): Long =
3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit)
eventId.bytesUsedInMemory() +
(relay?.url?.bytesUsedInMemory() ?: 0) +
(author?.bytesUsedInMemory() ?: 0)
fun toNEvent(): String = NEvent.create(eventId, author, null, relay)
override fun toTagArray() = assemble(eventId, relay, author)
override fun toTagIdOnly() = assemble(eventId, null, null)
companion object {
const val TAG_NAME = "e"
@JvmStatic
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
@JvmStatic
fun isTagged(
tag: Array<String>,
eventId: HexKey,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId
@JvmStatic
fun parse(tag: Array<String>): EventBookmark? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return EventBookmark(tag[1], pickRelayHint(tag), pickAuthor(tag))
}
@JvmStatic
fun parseId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
@JvmStatic
private fun pickRelayHint(tag: Array<String>): NormalizedRelayUrl? {
if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2])
if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3])
if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4])
return null
}
@JvmStatic
private fun pickAuthor(tag: Array<String>): HexKey? {
if (tag.has(2) && tag[2].length == 64) return tag[2]
if (tag.has(3) && tag[3].length == 64) return tag[3]
if (tag.has(4) && tag[4].length == 64) return tag[4]
return null
}
@JvmStatic
fun parseAsHint(tag: Array<String>): EventIdHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val hint = pickRelayHint(tag)
ensure(hint != null) { return null }
return EventIdHint(tag[1], hint)
}
@JvmStatic
fun assemble(
eventId: HexKey,
relay: NormalizedRelayUrl?,
author: HexKey?,
) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author)
}
}
@@ -18,24 +18,32 @@
* 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.nip55AndroidSigner.api.foreground.processors
package com.vitorpamplona.quartz.nip51Lists.encryption
import android.content.Intent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.SignResponse
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
class SignResultProcessor(
val unsignedEvent: Event,
val onReady: (Event) -> Unit,
) : NewResultProcessor {
override fun consume(intent: Intent) {
val foregroundResult = SignResponse.parse(intent, unsignedEvent)
suspend fun <T : Event> NostrSigner.signNip51List(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
privateTags: Array<Array<String>>,
) = sign<T>(
createdAt = createdAt,
kind = kind,
tags = tags,
content = PrivateTagsInContent.encryptNip44(privateTags, this),
)
if (foregroundResult is SignerResult.Successful<SignResult>) {
onReady(foregroundResult.result.event)
}
}
}
fun <T : Event> NostrSignerSync.signNip51List(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
privateTags: Array<Array<String>>,
) = sign<T>(
createdAt = createdAt,
kind = kind,
tags = tags,
content = PrivateTagsInContent.encryptNip44(privateTags, this),
)
@@ -20,44 +20,76 @@
*/
package com.vitorpamplona.quartz.nip51Lists.encryption
import android.util.Log
import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
class PrivateTagsInContent {
companion object {
fun decode(content: String) = JsonMapper.mapper.readValue<Array<Array<String>>>(content)
fun encode(privateTags: Array<Array<String>>) = JsonMapper.mapper.writeValueAsString(privateTags)
fun encode(privateTags: Array<Array<String>>): String = JsonMapper.mapper.writeValueAsString(privateTags)
fun decrypt(
suspend fun decrypt(
content: String,
signer: NostrSigner,
onReady: (Array<Array<String>>) -> Unit,
) {
signer.decrypt(content, signer.pubKey) {
onReady(decode(it))
): TagArray {
if (content.isBlank()) return emptyArray()
val json = signer.decrypt(content, signer.pubKey)
return try {
decode(json)
} catch (e: JsonParseException) {
Log.w("DraftEvent", "Unable to parse inner event of a draft: $json")
throw e
}
}
suspend fun encryptNip04(
privateTags: Array<Array<String>>? = null,
signer: NostrSigner,
): String =
signer.nip04Encrypt(
if (privateTags.isNullOrEmpty()) "" else encode(privateTags),
signer.pubKey,
)
suspend fun encryptNip44(
privateTags: Array<Array<String>>? = null,
signer: NostrSigner,
): String =
signer.nip44Encrypt(
if (privateTags.isNullOrEmpty()) "" else encode(privateTags),
signer.pubKey,
)
suspend fun decrypt(
content: String,
signer: NostrSignerSync,
): TagArray {
val json = signer.decrypt(content, signer.pubKey)
return decode(json)
}
fun encryptNip04(
privateTags: Array<Array<String>>? = null,
signer: NostrSigner,
onReady: (String) -> Unit,
) = signer.nip04Encrypt(
if (privateTags.isNullOrEmpty()) "" else encode(privateTags),
signer.pubKey,
onReady,
)
signer: NostrSignerSync,
): String =
signer.nip04Encrypt(
if (privateTags.isNullOrEmpty()) "" else encode(privateTags),
signer.pubKey,
)
fun encryptNip44(
privateTags: Array<Array<String>>? = null,
signer: NostrSigner,
onReady: (String) -> Unit,
) = signer.nip44Encrypt(
if (privateTags.isNullOrEmpty()) "" else encode(privateTags),
signer.pubKey,
onReady,
)
signer: NostrSignerSync,
): String =
signer.nip44Encrypt(
if (privateTags.isNullOrEmpty()) "" else encode(privateTags),
signer.pubKey,
)
}
}
@@ -18,19 +18,27 @@
* 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.nip51Lists
package com.vitorpamplona.quartz.nip51Lists.followList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
import com.vitorpamplona.quartz.utils.TimeUtils
import java.util.UUID
import kotlin.collections.plus
@Immutable
class FollowListEvent(
@@ -40,102 +48,120 @@ class FollowListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun pubKeys() = tags.mapNotNull(PTag::parseKey)
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PubKeyHintProvider {
override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint)
fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
fun name() = tags.firstNotNullOfOrNull(NameTag::parse)
override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey)
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun nameOrTitle() = name() ?: title()
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun follows() = tags.follows()
fun followIds() = tags.followIds()
fun followIdSet() = tags.followIdSet()
companion object {
const val KIND = 39089
const val ALT = "List of people to follow"
fun createListWithUser(
suspend fun create(
name: String,
pubKeyHex: String,
person: UserTag,
signer: NostrSigner,
dTag: String = UUID.randomUUID().toString(),
createdAt: Long = TimeUtils.now(),
onReady: (FollowListEvent) -> Unit,
) {
): FollowListEvent =
create(
content = "",
tags = arrayOf(arrayOf("d", name), arrayOf("p", pubKeyHex)),
name = name,
people = listOf(person),
signer = signer,
dTag = dTag,
createdAt = createdAt,
onReady = onReady,
)
}
fun addUsers(
suspend fun addUsers(
earlierVersion: FollowListEvent,
listPubKeyHex: List<String>,
people: List<UserTag>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (FollowListEvent) -> Unit,
) {
create(
): FollowListEvent =
resign(
content = earlierVersion.content,
tags =
earlierVersion.tags.plus(
listPubKeyHex.map { arrayOf("p", it) },
),
tags = earlierVersion.tags.plus(people.map { it.toTagArray() }),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun addUser(
suspend fun add(
earlierVersion: FollowListEvent,
pubKeyHex: String,
person: UserTag,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (FollowListEvent) -> Unit,
) = addUsers(earlierVersion, listOf(pubKeyHex), signer, createdAt, onReady)
) = addUsers(earlierVersion, listOf(person), signer, createdAt)
fun removeUser(
suspend fun remove(
earlierVersion: FollowListEvent,
pubKeyHex: String,
person: UserTag,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (FollowListEvent) -> Unit,
) {
create(
): FollowListEvent =
resign(
content = earlierVersion.content,
tags =
earlierVersion.tags
.filter { it.size > 1 && !(it[0] == "p" && it[1] == pubKeyHex) }
.toTypedArray(),
tags = earlierVersion.tags.remove(person.toTagIdOnly()),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun create(
suspend fun resign(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (FollowListEvent) -> Unit,
) {
): FollowListEvent {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
if (tags.fastAny(AltTag::match)) {
tags
} else {
tags + AltTag.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
return signer.sign(createdAt, KIND, newTags, content)
}
suspend fun create(
name: String,
people: List<UserTag> = emptyList(),
signer: NostrSigner,
dTag: String = UUID.randomUUID().toString(),
createdAt: Long = TimeUtils.now(),
): FollowListEvent {
val template = build(name, people, dTag, createdAt)
return signer.sign(template)
}
fun build(
name: String,
people: List<UserTag> = emptyList(),
dTag: String = UUID.randomUUID().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<FollowListEvent>.() -> Unit = {},
) = eventTemplate(
kind = KIND,
description = "",
createdAt = createdAt,
) {
dTag(dTag)
alt(ALT)
name(name)
people(people)
initializer()
}
}
}
@@ -0,0 +1,29 @@
/**
* 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.nip51Lists.followList
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
fun TagArrayBuilder<FollowListEvent>.name(name: String) = addUnique(NameTag.assemble(name))
fun TagArrayBuilder<FollowListEvent>.people(peoples: List<UserTag>) = addAll(peoples.map { it.toTagArray() })
@@ -0,0 +1,30 @@
/**
* 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.nip51Lists.followList
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
fun TagArray.follows() = mapNotNull(UserTag::parse)
fun TagArray.followIds() = mapNotNull(UserTag::parseKey)
fun TagArray.followIdSet() = mapNotNullTo(mutableSetOf(), UserTag::parseKey)
@@ -0,0 +1,220 @@
/**
* 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.nip51Lists.geohashList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.removeAny
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class GeohashListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun publicGeohashes() = tags.geohashList()
suspend fun decryptPrivateGeohashes(signer: NostrSigner) = privateTags(signer)?.geohashList()
suspend fun decryptGeohashes(signer: NostrSigner): List<String> = publicGeohashes() + (decryptPrivateGeohashes(signer) ?: emptyList())
companion object {
const val KIND = 10081
const val ALT = "Geohash List"
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
suspend fun create(
geohash: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = create(
geohashes = listOf(geohash),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
)
suspend fun create(
geohashes: List<String>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): GeohashListEvent =
if (isPrivate) {
create(
publicGeohashes = emptyList(),
privateGeohashes = geohashes,
signer = signer,
createdAt = createdAt,
)
} else {
create(
publicGeohashes = geohashes,
privateGeohashes = emptyList(),
signer = signer,
createdAt = createdAt,
)
}
suspend fun add(
earlierVersion: GeohashListEvent,
geohash: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = add(
earlierVersion = earlierVersion,
geohashes = listOf(geohash),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
)
suspend fun add(
earlierVersion: GeohashListEvent,
geohashes: List<String>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): GeohashListEvent {
val geohashTags = geohashes.map { GeoHashTag.assembleSingle(it) }
return if (isPrivate) {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
resign(
tags = earlierVersion.tags,
privateTags = privateTags.removeAny(geohashTags) + geohashTags,
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.removeAny(geohashTags) + geohashTags,
signer = signer,
createdAt = createdAt,
)
}
}
suspend fun remove(
earlierVersion: GeohashListEvent,
geohash: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): GeohashListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
return resign(
privateTags = privateTags.remove(GeoHashTag.assembleSingle(geohash)),
tags = earlierVersion.tags.remove(GeoHashTag.assembleSingle(geohash)),
signer = signer,
createdAt = createdAt,
)
}
suspend fun resign(
tags: TagArray,
privateTags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = resign(
content = PrivateTagsInContent.encryptNip04(privateTags, signer),
tags = tags,
signer = signer,
createdAt = createdAt,
)
suspend fun resign(
content: String,
tags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): GeohashListEvent {
val newTags =
if (tags.fastAny(AltTag::match)) {
tags
} else {
tags + AltTag.assemble(ALT)
}
return signer.sign(createdAt, KIND, newTags, content)
}
suspend fun create(
publicGeohashes: List<String> = emptyList(),
privateGeohashes: List<String> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): GeohashListEvent {
val template = build(publicGeohashes, privateGeohashes, signer, createdAt)
return signer.sign(template)
}
fun create(
publicGeohashes: List<String> = emptyList(),
privateGeohashes: List<String> = emptyList(),
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): GeohashListEvent {
val privateTagArray = publicGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray()
val publicTagArray = privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() + AltTag.assemble(ALT)
return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray)
}
suspend fun build(
publicGeohashes: List<String> = emptyList(),
privateGeohashes: List<String> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GeohashListEvent>.() -> Unit = {},
) = eventTemplate<GeohashListEvent>(
kind = KIND,
description = PrivateTagsInContent.encryptNip04(privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray(), signer),
createdAt = createdAt,
) {
alt(ALT)
geohashes(publicGeohashes)
initializer()
}
}
}
@@ -18,9 +18,11 @@
* 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.nip51Lists.locations
package com.vitorpamplona.quartz.nip51Lists.geohashList
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
fun TagArrayBuilder<GeohashListEvent>.followGeohash(geohash: String) = add(GeoHashTag.assembleSingle(geohash))
fun TagArrayBuilder<GeohashListEvent>.geohashes(geohash: List<String>) = addAll(geohash.map { GeoHashTag.assembleSingle(it) })
@@ -0,0 +1,28 @@
/**
* 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.nip51Lists.geohashList
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
fun TagArray.geohashList() = mapNotNull(GeoHashTag::parse)
fun TagArray.geohashSet() = mapNotNullTo(mutableSetOf(), GeoHashTag::parse)
@@ -0,0 +1,217 @@
/**
* 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.nip51Lists.hashtagList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.removeAny
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.collections.map
@Immutable
class HashtagListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun publicHashtags() = tags.mapNotNull(HashtagTag::parse)
companion object {
const val KIND = 10015
const val ALT = "Hashtag List"
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
suspend fun create(
hashtag: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = create(
hashtags = listOf(hashtag),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
)
suspend fun create(
hashtags: List<String>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): HashtagListEvent =
if (isPrivate) {
create(
publicHashtags = emptyList(),
privateHashtags = hashtags,
signer = signer,
createdAt = createdAt,
)
} else {
create(
publicHashtags = hashtags,
privateHashtags = emptyList(),
signer = signer,
createdAt = createdAt,
)
}
suspend fun add(
earlierVersion: HashtagListEvent,
hashtag: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = add(
earlierVersion = earlierVersion,
hashtags = listOf(hashtag),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
)
suspend fun add(
earlierVersion: HashtagListEvent,
hashtags: List<String>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): HashtagListEvent {
val hashtags = hashtags.map { HashtagTag.assemble(it) }
return if (isPrivate) {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
resign(
tags = earlierVersion.tags,
privateTags = privateTags.removeAny(hashtags) + hashtags,
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.removeAny(hashtags) + hashtags,
signer = signer,
createdAt = createdAt,
)
}
}
suspend fun remove(
earlierVersion: HashtagListEvent,
hashtag: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): HashtagListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
return resign(
privateTags = privateTags.remove(HashtagTag.assemble(hashtag)),
tags = earlierVersion.tags.remove(HashtagTag.assemble(hashtag)),
signer = signer,
createdAt = createdAt,
)
}
suspend fun resign(
tags: TagArray,
privateTags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = resign(
content = PrivateTagsInContent.encryptNip04(privateTags, signer),
tags = tags,
signer = signer,
createdAt = createdAt,
)
suspend fun resign(
content: String,
tags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): HashtagListEvent {
val newTags =
if (tags.fastAny(AltTag::match)) {
tags
} else {
tags + AltTag.assemble(ALT)
}
return signer.sign(createdAt, KIND, newTags, content)
}
suspend fun create(
publicHashtags: List<String> = emptyList(),
privateHashtags: List<String> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): HashtagListEvent {
val template = build(publicHashtags, privateHashtags, signer, createdAt)
return signer.sign(template)
}
fun create(
publicHashtags: List<String> = emptyList(),
privateHashtags: List<String> = emptyList(),
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): HashtagListEvent {
val privateTagArray = publicHashtags.map { HashtagTag.assemble(it) }.toTypedArray()
val publicTagArray = privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray() + AltTag.assemble(ALT)
return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray)
}
suspend fun build(
publicHashtags: List<String> = emptyList(),
privateHashtags: List<String> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<HashtagListEvent>.() -> Unit = {},
) = eventTemplate<HashtagListEvent>(
kind = KIND,
description = PrivateTagsInContent.encryptNip04(privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray(), signer),
createdAt = createdAt,
) {
alt(ALT)
hashtags(publicHashtags)
initializer()
}
}
}
@@ -18,9 +18,12 @@
* 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.nip51Lists.interests
package com.vitorpamplona.quartz.nip51Lists.hashtagList
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
fun TagArrayBuilder<HashtagListEvent>.followHashTag(hashtag: String) = add(HashtagTag.assemble(hashtag))
fun TagArrayBuilder<HashtagListEvent>.hashtags(hashtags: List<String>) = addAll(hashtags.map { HashtagTag.assemble(it) })
@@ -0,0 +1,28 @@
/**
* 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.nip51Lists.hashtagList
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
fun TagArray.hashtagList() = mapNotNull(HashtagTag::parse)
fun TagArray.hashtagSet() = mapNotNullTo(mutableSetOf(), HashtagTag::parse)
@@ -1,220 +0,0 @@
/**
* 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.nip51Lists.interests
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlin.coroutines.resume
@Immutable
class HashtagListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient var publicAndPrivateHashtagCache: Set<String>? = null
fun publicHashtags() = tags.mapNotNull(HashtagTag::parse)
fun publicAndCachedPrivateHashtags() = publicHashtags().toSet() + (publicAndPrivateHashtagCache ?: emptySet())
fun publicAndPrivateHashtag(
signer: NostrSigner,
onReady: (Set<String>) -> Unit,
) {
publicAndPrivateHashtagCache?.let { eventList ->
onReady(eventList)
return
}
mergeTagList(signer) {
val set = it.mapNotNull(HashtagTag::parse).toSet()
publicAndPrivateHashtagCache = set
onReady(set)
}
}
suspend fun publicAndPrivateHashtag(signer: NostrSigner): Set<String>? {
publicAndPrivateHashtagCache?.let { return it }
return tryAndWait { continuation ->
publicAndPrivateHashtag(signer) { privateTagList ->
continuation.resume(privateTagList)
}
}
}
companion object {
const val KIND = 10015
const val ALT = "Hashtag List"
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
private fun createHashtagBase(
tags: Array<Array<String>>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (HashtagListEvent) -> Unit,
) {
PrivateTagArrayBuilder.create(
tags,
isPrivate,
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
fun createHashtag(
hashtag: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (HashtagListEvent) -> Unit,
) = createHashtagBase(
tags = arrayOf(HashtagTag.assemble(hashtag)),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
fun createHashtags(
hashtags: List<String>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (HashtagListEvent) -> Unit,
) = createHashtagBase(
tags = HashtagTag.assemble(hashtags).toTypedArray(),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
fun removeHashtag(
earlierVersion: HashtagListEvent,
hashtag: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (HashtagListEvent) -> Unit,
) {
PrivateTagArrayBuilder.removeAll(
earlierVersion,
HashtagTag.assemble(hashtag),
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
private fun addHashtagBase(
earlierVersion: HashtagListEvent,
newTags: Array<Array<String>>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (HashtagListEvent) -> Unit,
) {
PrivateTagArrayBuilder.addAll(
earlierVersion,
newTags,
isPrivate,
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
fun addHashtag(
earlierVersion: HashtagListEvent,
hashtag: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (HashtagListEvent) -> Unit,
) = addHashtagBase(
earlierVersion,
arrayOf(HashtagTag.assemble(hashtag)),
isPrivate,
signer,
createdAt,
onReady,
)
fun addHashtags(
earlierVersion: HashtagListEvent,
hashtags: List<String>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (HashtagListEvent) -> Unit,
) = addHashtagBase(
earlierVersion,
HashtagTag.assemble(hashtags).toTypedArray(),
isPrivate,
signer,
createdAt,
onReady,
)
private fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (HashtagListEvent) -> Unit,
) {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
} else {
tags + AltTag.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
}
fun create(
hashtags: List<String>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): HashtagListEvent? {
val tags = HashtagTag.assemble(hashtags).toTypedArray()
return signer.sign(createdAt, KIND, tags, "")
}
}
}
@@ -1,220 +0,0 @@
/**
* 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.nip51Lists.locations
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlin.coroutines.resume
@Immutable
class GeohashListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient var publicAndPrivateGeohashCache: Set<String>? = null
fun publicGeohashes() = tags.mapNotNull(GeoHashTag::parse)
fun publicAndCachedPrivateGeohash() = publicGeohashes().toSet() + (publicAndPrivateGeohashCache ?: emptySet())
fun publicAndPrivateGeohash(
signer: NostrSigner,
onReady: (Set<String>) -> Unit,
) {
publicAndPrivateGeohashCache?.let { eventList ->
onReady(eventList)
return
}
mergeTagList(signer) {
val set = it.mapNotNull(GeoHashTag::parse).toSet()
publicAndPrivateGeohashCache = set
onReady(set)
}
}
suspend fun publicAndPrivateGeohash(signer: NostrSigner): Set<String>? {
publicAndPrivateGeohashCache?.let { return it }
return tryAndWait { continuation ->
publicAndPrivateGeohash(signer) { privateTagList ->
continuation.resume(privateTagList)
}
}
}
companion object {
const val KIND = 10081
const val ALT = "Geohash List"
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
private fun createGeohashBase(
tags: Array<Array<String>>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GeohashListEvent) -> Unit,
) {
PrivateTagArrayBuilder.create(
tags,
isPrivate,
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
fun createGeohash(
geohash: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GeohashListEvent) -> Unit,
) = createGeohashBase(
tags = arrayOf(GeoHashTag.assembleSingle(geohash)),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
fun createGeohashs(
geohashs: List<String>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GeohashListEvent) -> Unit,
) = createGeohashBase(
tags = geohashs.map { GeoHashTag.assembleSingle(it) }.toTypedArray(),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
fun removeGeohash(
earlierVersion: GeohashListEvent,
geohash: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GeohashListEvent) -> Unit,
) {
PrivateTagArrayBuilder.removeAll(
earlierVersion,
GeoHashTag.assembleSingle(geohash),
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
private fun addGeohashBase(
earlierVersion: GeohashListEvent,
newTags: Array<Array<String>>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GeohashListEvent) -> Unit,
) {
PrivateTagArrayBuilder.addAll(
earlierVersion,
newTags,
isPrivate,
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
fun addGeohash(
earlierVersion: GeohashListEvent,
geohash: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GeohashListEvent) -> Unit,
) = addGeohashBase(
earlierVersion,
arrayOf(GeoHashTag.assembleSingle(geohash)),
isPrivate,
signer,
createdAt,
onReady,
)
fun addGeohashs(
earlierVersion: GeohashListEvent,
geohashs: List<String>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GeohashListEvent) -> Unit,
) = addGeohashBase(
earlierVersion,
geohashs.map { GeoHashTag.assembleSingle(it) }.toTypedArray(),
isPrivate,
signer,
createdAt,
onReady,
)
private fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GeohashListEvent) -> Unit,
) {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
} else {
tags + AltTag.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
}
fun create(
geohashs: List<String>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): GeohashListEvent? {
val tags = geohashs.map { GeoHashTag.assembleSingle(it) }.toTypedArray()
return signer.sign(createdAt, KIND, tags, "")
}
}
}
@@ -0,0 +1,191 @@
/**
* 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.nip51Lists.muteList
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.collections.plus
@Immutable
class MuteListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PubKeyHintProvider {
override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey)
fun countMutes() = tags.count(MuteTag::isTagged)
fun publicMutes(): List<MuteTag> = tags.mapNotNull(MuteTag::parse)
suspend fun privateMutes(signer: NostrSigner): List<MuteTag>? = privateTags(signer)?.mapNotNull(MuteTag::parse)
override fun dTag() = FIXED_D_TAG
companion object {
const val KIND = 10000
const val FIXED_D_TAG = ""
const val ALT = "Mute List"
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
fun blockListFor(pubKeyHex: HexKey): String = "10000:$pubKeyHex:"
suspend fun create(
mute: MuteTag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): MuteListEvent =
if (isPrivate) {
create(
publicMutes = emptyList(),
privateMutes = listOf(mute),
signer = signer,
createdAt = createdAt,
)
} else {
create(
publicMutes = listOf(mute),
privateMutes = emptyList(),
signer = signer,
createdAt = createdAt,
)
}
suspend fun add(
earlierVersion: MuteListEvent,
mute: MuteTag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): MuteListEvent =
if (isPrivate) {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
resign(
publicTags = earlierVersion.tags,
privateTags = privateTags.plus(mute.toTagArray()),
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(mute.toTagArray()),
signer = signer,
createdAt = createdAt,
)
}
suspend fun remove(
earlierVersion: MuteListEvent,
mute: MuteTag,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): MuteListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
return resign(
privateTags = privateTags.remove(mute.toTagIdOnly()),
publicTags = earlierVersion.tags.remove(mute.toTagIdOnly()),
signer = signer,
createdAt = createdAt,
)
}
suspend fun resign(
publicTags: TagArray,
privateTags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = resign(
content = PrivateTagsInContent.encryptNip04(privateTags, signer),
tags = publicTags,
signer = signer,
createdAt = createdAt,
)
suspend fun resign(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): MuteListEvent {
val newTags =
if (tags.fastAny(AltTag::match)) {
tags
} else {
tags + AltTag.assemble(ALT)
}
return signer.sign(createdAt, KIND, newTags, content)
}
suspend fun create(
publicMutes: List<MuteTag> = emptyList(),
privateMutes: List<MuteTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): MuteListEvent {
val template = build(publicMutes, privateMutes, signer, createdAt)
return signer.sign(template)
}
suspend fun build(
publicMutes: List<MuteTag> = emptyList(),
privateMutes: List<MuteTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<MuteListEvent>.() -> Unit = {},
) = eventTemplate<MuteListEvent>(
kind = KIND,
description = PrivateTagsInContent.encryptNip04(privateMutes.map { it.toTagArray() }.toTypedArray(), signer),
createdAt = createdAt,
) {
alt(ALT)
mutes(publicMutes)
initializer()
}
}
}
@@ -0,0 +1,26 @@
/**
* 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.nip51Lists.muteList
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
fun TagArrayBuilder<MuteListEvent>.mutes(mutes: List<MuteTag>) = addAll(mutes.map { it.toTagArray() })
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.muteList
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
fun TagArray.mutedUsersAndWords() = mapNotNull(MuteTag::parse)
fun TagArray.mutedUsers() = mapNotNull(UserTag::parse)
fun TagArray.mutedUserIds() = mapNotNull(UserTag::parseKey)
fun TagArray.mutedUserIdSet() = mapNotNullTo(mutableSetOf(), UserTag::parseKey)
fun TagArray.mutedWords() = mapNotNull(WordTag::parse)
fun TagArray.mutedWordSet() = mapNotNullTo(mutableSetOf(), WordTag::parse)
@@ -0,0 +1,35 @@
/**
* 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.nip51Lists.muteList.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
sealed interface MuteTag {
fun toTagArray(): Tag
fun toTagIdOnly(): Tag
companion object {
fun isTagged(tag: Array<String>) = WordTag.isTagged(tag) || UserTag.isTagged(tag)
fun parse(tag: Array<String>): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag)
}
}

Some files were not shown because too many files have changed in this diff Show More